diff --git a/Cargo.lock b/Cargo.lock index f3b3819a53..3b56fefadf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8875,6 +8875,7 @@ dependencies = [ "async-walkdir", "async_zip", "base64 0.22.1", + "bytemuck", "bytes", "chrono", "daedalus", @@ -8895,11 +8896,13 @@ dependencies = [ "notify-debouncer-mini", "p256", "paste", + "png", "quartz_nbt", "quick-xml 0.37.5", "rand 0.8.5", "regex", "reqwest", + "rgb", "serde", "serde_ini", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 92f312fe88..2c629527a4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,7 @@ async-walkdir = "2.1.0" async_zip = "0.0.17" base64 = "0.22.1" bitflags = "2.9.0" +bytemuck = "1.23.0" bytes = "1.10.1" censor = "0.3.0" chrono = "0.4.41" @@ -86,6 +87,7 @@ notify = { version = "8.0.0", default-features = false } notify-debouncer-mini = { version = "0.6.0", default-features = false } p256 = "0.13.2" paste = "1.0.15" +png = "0.17.16" prometheus = "0.14.0" quartz_nbt = "0.2.9" quick-xml = "0.37.5" @@ -94,6 +96,7 @@ rand_chacha = "=0.3.1" # Locked on 0.3 until we can update rand to 0.9 redis = "=0.29.5" # Locked on 0.29 until deadpool-redis updates to 0.30 regex = "1.11.1" reqwest = { version = "0.12.15", default-features = false } +rgb = "0.8.50" rust-s3 = { version = "0.35.1", default-features = false, features = [ "fail-on-err", "tags", diff --git a/apps/app-frontend/.prettierignore b/apps/app-frontend/.prettierignore index 581edad3d9..0cb3e84e5d 100644 --- a/apps/app-frontend/.prettierignore +++ b/apps/app-frontend/.prettierignore @@ -1 +1,2 @@ **/dist +*.gltf diff --git a/apps/app-frontend/package.json b/apps/app-frontend/package.json index fa6d8f9262..5d80a3cc05 100644 --- a/apps/app-frontend/package.json +++ b/apps/app-frontend/package.json @@ -12,15 +12,15 @@ "intl:extract": "formatjs extract \"{,src/components,src/composables,src/helpers,src/pages,src/store}/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" --ignore '**/*.d.ts' --ignore 'node_modules' --out-file src/locales/en-US/index.json --format crowdin --preserve-whitespace" }, "dependencies": { + "@geometrically/minecraft-motd-parser": "^1.1.4", "@modrinth/assets": "workspace:*", "@modrinth/ui": "workspace:*", "@modrinth/utils": "workspace:*", "@sentry/vue": "^8.27.0", - "@geometrically/minecraft-motd-parser": "^1.1.4", "@tauri-apps/api": "^2.5.0", "@tauri-apps/plugin-dialog": "^2.2.1", - "@tauri-apps/plugin-os": "^2.2.1", "@tauri-apps/plugin-opener": "^2.2.6", + "@tauri-apps/plugin-os": "^2.2.1", "@tauri-apps/plugin-updater": "^2.7.1", "@tauri-apps/plugin-window-state": "^2.2.2", "@vintl/vintl": "^4.4.1", @@ -29,6 +29,7 @@ "ofetch": "^1.3.4", "pinia": "^2.1.7", "posthog-js": "^1.158.2", + "three": "^0.172.0", "vite-svg-loader": "^5.1.0", "vue": "^3.5.13", "vue-multiselect": "3.0.0", @@ -39,6 +40,7 @@ "@eslint/compat": "^1.1.1", "@formatjs/cli": "^6.2.12", "@nuxt/eslint-config": "^0.5.6", + "@taijased/vue-render-tracker": "^1.0.7", "@vitejs/plugin-vue": "^5.0.4", "autoprefixer": "^10.4.19", "eslint": "^9.9.1", @@ -51,8 +53,7 @@ "tsconfig": "workspace:*", "typescript": "^5.5.4", "vite": "^5.4.6", - "vue-tsc": "^2.1.6", - "@taijased/vue-render-tracker": "^1.0.7" + "vue-tsc": "^2.1.6" }, "packageManager": "pnpm@9.4.0", "web-types": "../../web-types.json" diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue index caed5e872d..8a86df9711 100644 --- a/apps/app-frontend/src/App.vue +++ b/apps/app-frontend/src/App.vue @@ -1,5 +1,5 @@ - diff --git a/apps/app-frontend/src/components/ui/skin/SelectCapeModal.vue b/apps/app-frontend/src/components/ui/skin/SelectCapeModal.vue index 01ce31944d..3da3b9d33a 100644 --- a/apps/app-frontend/src/components/ui/skin/SelectCapeModal.vue +++ b/apps/app-frontend/src/components/ui/skin/SelectCapeModal.vue @@ -1,61 +1,71 @@ diff --git a/apps/app-frontend/src/components/ui/skin/SkinButton.vue b/apps/app-frontend/src/components/ui/skin/SkinButton.vue deleted file mode 100644 index 13c1622729..0000000000 --- a/apps/app-frontend/src/components/ui/skin/SkinButton.vue +++ /dev/null @@ -1,181 +0,0 @@ - - - - - diff --git a/apps/app-frontend/src/components/ui/skin/UploadSkinModal.vue b/apps/app-frontend/src/components/ui/skin/UploadSkinModal.vue new file mode 100644 index 0000000000..713aaf7c2a --- /dev/null +++ b/apps/app-frontend/src/components/ui/skin/UploadSkinModal.vue @@ -0,0 +1,108 @@ + + + diff --git a/apps/app-frontend/src/helpers/rendering/batch-skin-renderer.ts b/apps/app-frontend/src/helpers/rendering/batch-skin-renderer.ts new file mode 100644 index 0000000000..5b4f06a198 --- /dev/null +++ b/apps/app-frontend/src/helpers/rendering/batch-skin-renderer.ts @@ -0,0 +1,206 @@ +import * as THREE from 'three' +import type { Skin, Cape } from '../skins' +import { determineModelType } from '../skins' +import { reactive } from 'vue' +import { setupSkinModel, disposeCaches } from '@modrinth/utils' +import { skinPreviewStorage } from '../storage/skin-preview-storage' + +export interface RenderResult { + forwards: string + backwards: string +} + +class BatchSkinRenderer { + private renderer: THREE.WebGLRenderer + private readonly scene: THREE.Scene + private readonly camera: THREE.PerspectiveCamera + private currentModel: THREE.Group | null = null + + constructor(width: number = 360, height: number = 504) { + const canvas = document.createElement('canvas') + canvas.width = width + canvas.height = height + + this.renderer = new THREE.WebGLRenderer({ + canvas: canvas, + antialias: true, + alpha: true, + preserveDrawingBuffer: true, + }) + + this.renderer.outputColorSpace = THREE.SRGBColorSpace + this.renderer.toneMapping = THREE.NoToneMapping + this.renderer.setClearColor(0x000000, 0) + this.renderer.setSize(width, height) + + this.scene = new THREE.Scene() + this.camera = new THREE.PerspectiveCamera(20, width / height, 0.4, 1000) + + const ambientLight = new THREE.AmbientLight(0xffffff, 2) + this.scene.add(ambientLight) + } + + public async renderSkin( + textureUrl: string, + modelUrl: string, + capeUrl?: string, + capeModelUrl?: string, + ): Promise { + await this.setupModel(modelUrl, textureUrl, capeModelUrl, capeUrl) + + const headPart = this.currentModel!.getObjectByName('Head') + let lookAtTarget: [number, number, number] + + if (headPart) { + const headPosition = new THREE.Vector3() + headPart.getWorldPosition(headPosition) + lookAtTarget = [headPosition.x, headPosition.y - 0.3, headPosition.z] + } else { + throw new Error("Failed to find 'Head' object in model.") + } + + const frontCameraPos: [number, number, number] = [2, 1, -2.5] + const backCameraPos: [number, number, number] = [2, 1, 6.0] + + const forwards = await this.renderView(frontCameraPos, lookAtTarget) + const backwards = await this.renderView(backCameraPos, lookAtTarget) + + return { forwards, backwards } + } + + private async renderView( + cameraPosition: [number, number, number], + lookAtPosition: [number, number, number], + ): Promise { + this.camera.position.set(...cameraPosition) + this.camera.lookAt(...lookAtPosition) + + this.renderer.render(this.scene, this.camera) + + return new Promise((resolve, reject) => { + this.renderer.domElement.toBlob((blob) => { + if (blob) { + const url = URL.createObjectURL(blob) + resolve(url) + } else { + reject(new Error('Failed to create blob from canvas')) + } + }, 'image/png') + }) + } + + private async setupModel( + modelUrl: string, + textureUrl: string, + capeModelUrl?: string, + capeUrl?: string, + ): Promise { + if (this.currentModel) { + this.scene.remove(this.currentModel) + } + + const { model } = await setupSkinModel(modelUrl, textureUrl, capeModelUrl, capeUrl) + + const group = new THREE.Group() + group.add(model) + group.position.set(0, 0.3, 1.95) + group.scale.set(0.8, 0.8, 0.8) + + this.scene.add(group) + this.currentModel = group + } + + public dispose(): void { + this.renderer.dispose() + disposeCaches() + } +} + +function getModelUrlForVariant(variant: string): string { + switch (variant) { + case 'SLIM': + return '/src/assets/models/slim_player.gltf' + case 'CLASSIC': + case 'UNKNOWN': + default: + return '/src/assets/models/classic_player.gltf' + } +} + +export const map = reactive(new Map()) +const DEBUG_MODE = false + +export async function cleanupUnusedPreviews(skins: Skin[]): Promise { + const validKeys = new Set() + + for (const skin of skins) { + const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}` + validKeys.add(key) + } + + try { + await skinPreviewStorage.cleanupInvalidKeys(validKeys) + } catch (error) { + console.warn('Failed to cleanup unused skin previews:', error) + } +} + +export async function generateSkinPreviews(skins: Skin[], capes: Cape[]): Promise { + const renderer = new BatchSkinRenderer() + const capeModelUrl = '/src/assets/models/cape.gltf' + + try { + for (const skin of skins) { + const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}` + + if (map.has(key)) { + if (DEBUG_MODE) { + const result = map.get(key)! + URL.revokeObjectURL(result.forwards) + URL.revokeObjectURL(result.backwards) + map.delete(key) + } else continue + } + + try { + const cached = await skinPreviewStorage.retrieve(key) + if (cached) { + map.set(key, cached) + continue + } + } catch (error) { + console.warn('Failed to retrieve cached skin preview:', error) + } + + let variant = skin.variant + if (variant === 'UNKNOWN') { + try { + variant = await determineModelType(skin.texture) + } catch (error) { + console.error(`Failed to determine model type for skin ${key}:`, error) + variant = 'CLASSIC' + } + } + + const modelUrl = getModelUrlForVariant(variant) + const cape: Cape | undefined = capes.find((_cape) => _cape.id === skin.cape_id) + const renderResult = await renderer.renderSkin( + skin.texture, + modelUrl, + cape?.texture, + capeModelUrl, + ) + + map.set(key, renderResult) + + try { + await skinPreviewStorage.store(key, renderResult) + } catch (error) { + console.warn('Failed to store skin preview in persistent storage:', error) + } + } + } finally { + renderer.dispose() + await cleanupUnusedPreviews(skins) + } +} diff --git a/apps/app-frontend/src/helpers/settings.ts b/apps/app-frontend/src/helpers/settings.ts index 2988d34d84..c256575a4e 100644 --- a/apps/app-frontend/src/helpers/settings.ts +++ b/apps/app-frontend/src/helpers/settings.ts @@ -37,6 +37,7 @@ export type AppSettings = { theme: ColorTheme default_page: 'home' | 'library' collapsed_navigation: boolean + hide_nametag_skins_page: boolean advanced_rendering: boolean native_decorations: boolean toggle_sidebar: boolean diff --git a/apps/app-frontend/src/helpers/skins.ts b/apps/app-frontend/src/helpers/skins.ts index ded98c531a..83b6f398b3 100644 --- a/apps/app-frontend/src/helpers/skins.ts +++ b/apps/app-frontend/src/helpers/skins.ts @@ -1,4 +1,5 @@ import { invoke } from '@tauri-apps/api/core' +import { handleError } from '@/store/notifications' export interface Cape { id: string @@ -8,8 +9,8 @@ export interface Cape { is_equipped: boolean } -export type SkinModel = 'Classic' | 'Slim' | 'Unknown' -export type SkinSource = 'Default' | 'CustomExternal' | 'Custom' +export type SkinModel = 'CLASSIC' | 'SLIM' | 'UNKNOWN' +export type SkinSource = 'default' | 'custom_external' | 'custom' export interface Skin { texture_key: string @@ -21,23 +22,105 @@ export interface Skin { is_equipped: boolean } +export const DEFAULT_MODEL_SORTING = ['Steve', 'Alex'] as string[] + +export const DEFAULT_MODELS: Record = { + Steve: 'CLASSIC', + Alex: 'SLIM', + Zuri: 'CLASSIC', + Sunny: 'CLASSIC', + Noor: 'SLIM', + Makena: 'SLIM', + Kai: 'CLASSIC', + Efe: 'SLIM', + Ari: 'CLASSIC', +} + +export function filterSavedSkins(list: Skin[]) { + const customSkins = list.filter((s) => s.source !== 'default') + fixUnknownSkins(customSkins).catch(handleError) + return customSkins +} + +export async function determineModelType(texture: string): Promise<'SLIM' | 'CLASSIC'> { + return new Promise((resolve, reject) => { + const canvas = document.createElement('canvas') + const context = canvas.getContext('2d') + + if (!context) { + return reject(new Error('Failed to create canvas rendering context.')) + } + + const image = new Image() + image.crossOrigin = 'anonymous' + image.src = texture + + image.onload = () => { + canvas.width = image.width + canvas.height = image.height + + context.drawImage(image, 0, 0) + + const armX = 44 + const armY = 16 + const armWidth = 4 + const armHeight = 12 + + const imageData = context.getImageData(armX, armY, armWidth, armHeight).data + + for (let y = 0; y < armHeight; y++) { + const alphaIndex = (3 + y * armWidth) * 4 + 3 + if (imageData[alphaIndex] !== 0) { + resolve('CLASSIC') + return + } + } + + canvas.remove() + resolve('SLIM') + } + + image.onerror = () => { + canvas.remove() + reject(new Error('Failed to load the image.')) + } + }) +} + +export async function fixUnknownSkins(list: Skin[]) { + const unknownSkins = list.filter((s) => s.variant === 'UNKNOWN') + for (const unknownSkin of unknownSkins) { + unknownSkin.variant = await determineModelType(unknownSkin.texture) + } +} + +export function filterDefaultSkins(list: Skin[]) { + return list + .filter((s) => s.source === 'default' && (!s.name || s.variant === DEFAULT_MODELS[s.name])) + .sort((a, b) => { + const aIndex = a.name ? DEFAULT_MODEL_SORTING.indexOf(a.name) : -1 + const bIndex = b.name ? DEFAULT_MODEL_SORTING.indexOf(b.name) : -1 + return (aIndex === -1 ? Infinity : aIndex) - (bIndex === -1 ? Infinity : bIndex) + }) +} + export async function get_available_capes(): Promise { - return await invoke('plugin:minecraft-skins|get_available_capes', {}) + return invoke('plugin:minecraft-skins|get_available_capes', {}) } export async function get_available_skins(): Promise { - return await invoke('plugin:minecraft-skins|get_available_skins', {}) + return invoke('plugin:minecraft-skins|get_available_skins', {}) } export async function add_and_equip_custom_skin( - texture_blob: Uint8Array, + textureBlob: Uint8Array, variant: SkinModel, - cape_override?: Cape, + capeOverride?: Cape, ): Promise { await invoke('plugin:minecraft-skins|add_and_equip_custom_skin', { - texture_blob, + textureBlob, variant, - cape_override, + capeOverride, }) } diff --git a/apps/app-frontend/src/helpers/storage/skin-preview-storage.ts b/apps/app-frontend/src/helpers/storage/skin-preview-storage.ts new file mode 100644 index 0000000000..c69e204724 --- /dev/null +++ b/apps/app-frontend/src/helpers/storage/skin-preview-storage.ts @@ -0,0 +1,118 @@ +import type { RenderResult } from '../rendering/batch-skin-renderer' + +interface StoredPreview { + forwards: Blob + backwards: Blob + timestamp: number +} + +export class SkinPreviewStorage { + private dbName = 'skin-previews' + private version = 1 + private db: IDBDatabase | null = null + + async init(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(this.dbName, this.version) + + request.onerror = () => reject(request.error) + request.onsuccess = () => { + this.db = request.result + resolve() + } + + request.onupgradeneeded = () => { + const db = request.result + if (!db.objectStoreNames.contains('previews')) { + db.createObjectStore('previews') + } + } + }) + } + + async store(key: string, result: RenderResult): Promise { + if (!this.db) await this.init() + + const transaction = this.db!.transaction(['previews'], 'readwrite') + const store = transaction.objectStore('previews') + + const forwardsBlob = await fetch(result.forwards).then((r) => r.blob()) + const backwardsBlob = await fetch(result.backwards).then((r) => r.blob()) + + const storedPreview: StoredPreview = { + forwards: forwardsBlob, + backwards: backwardsBlob, + timestamp: Date.now(), + } + + return new Promise((resolve, reject) => { + const request = store.put(storedPreview, key) + + request.onsuccess = () => resolve() + request.onerror = () => reject(request.error) + }) + } + + async retrieve(key: string): Promise { + if (!this.db) await this.init() + + const transaction = this.db!.transaction(['previews'], 'readonly') + const store = transaction.objectStore('previews') + + return new Promise((resolve, reject) => { + const request = store.get(key) + + request.onsuccess = () => { + const result = request.result as StoredPreview | undefined + + if (!result) { + resolve(null) + return + } + + const forwards = URL.createObjectURL(result.forwards) + const backwards = URL.createObjectURL(result.backwards) + resolve({ forwards, backwards }) + } + request.onerror = () => reject(request.error) + }) + } + + async cleanupInvalidKeys(validKeys: Set): Promise { + if (!this.db) await this.init() + + const transaction = this.db!.transaction(['previews'], 'readwrite') + const store = transaction.objectStore('previews') + let deletedCount = 0 + + return new Promise((resolve, reject) => { + const request = store.openCursor() + + request.onsuccess = (event) => { + const cursor = (event.target as IDBRequest).result + + if (cursor) { + const key = cursor.primaryKey as string + + if (!validKeys.has(key)) { + const deleteRequest = cursor.delete() + deleteRequest.onsuccess = () => { + deletedCount++ + } + deleteRequest.onerror = () => { + console.warn('Failed to delete invalid entry:', key) + } + } + + cursor.continue() + } else { + resolve(deletedCount) + } + } + + request.onerror = () => reject(request.error) + }) + } +} + +export const skinPreviewStorage = new SkinPreviewStorage() diff --git a/apps/app-frontend/src/pages/Index.vue b/apps/app-frontend/src/pages/Index.vue index 14776c2bb2..3eba1ba684 100644 --- a/apps/app-frontend/src/pages/Index.vue +++ b/apps/app-frontend/src/pages/Index.vue @@ -83,13 +83,15 @@ async function refreshFeaturedProjects() { await fetchInstances() await refreshFeaturedProjects() -const unlistenProfile = await profile_listener(async (e: { event: string; profile_path_id: string }) => { - await fetchInstances() +const unlistenProfile = await profile_listener( + async (e: { event: string; profile_path_id: string }) => { + await fetchInstances() - if (e.event === 'added' || e.event === 'created' || e.event === 'removed') { - await refreshFeaturedProjects() - } -}) + if (e.event === 'added' || e.event === 'created' || e.event === 'removed') { + await refreshFeaturedProjects() + } + }, +) onUnmounted(() => { unlistenProfile() diff --git a/apps/app-frontend/src/pages/Skins.vue b/apps/app-frontend/src/pages/Skins.vue index 32e6beea7b..0bd27d8b36 100644 --- a/apps/app-frontend/src/pages/Skins.vue +++ b/apps/app-frontend/src/pages/Skins.vue @@ -1,135 +1,447 @@ + - + + diff --git a/apps/app-frontend/tailwind.config.js b/apps/app-frontend/tailwind.config.js index 0d0fab4bfd..b5196b3682 100644 --- a/apps/app-frontend/tailwind.config.js +++ b/apps/app-frontend/tailwind.config.js @@ -41,6 +41,7 @@ export default { green: 'var(--color-green-highlight)', blue: 'var(--color-blue-highlight)', purple: 'var(--color-purple-highlight)', + gray: 'var(--color-gray-highlight)', }, divider: { DEFAULT: 'var(--color-divider)', diff --git a/apps/app/src/api/minecraft_skins.rs b/apps/app/src/api/minecraft_skins.rs index 42ff5ff340..9daf52e847 100644 --- a/apps/app/src/api/minecraft_skins.rs +++ b/apps/app/src/api/minecraft_skins.rs @@ -12,6 +12,7 @@ pub fn init() -> tauri::plugin::TauriPlugin { equip_skin, remove_custom_skin, unequip_skin, + normalize_skin_texture, ]) .build() } @@ -80,3 +81,11 @@ pub async fn remove_custom_skin(skin: Skin) -> Result<()> { pub async fn unequip_skin() -> Result<()> { Ok(minecraft_skins::unequip_skin().await?) } + +/// `invoke('plugin:minecraft-skins|normalize_skin_texture')` +/// +/// See also: [minecraft_skins::normalize_skin_texture] +#[tauri::command] +pub async fn normalize_skin_texture(skin: Skin) -> Result { + Ok(minecraft_skins::normalize_skin_texture(&skin).await?) +} diff --git a/packages/app-lib/.sqlx/query-759e4ffe30ebc4f8602256cb419ef15732d84bcebb9ca15225dbabdc0f46ba2d.json b/packages/app-lib/.sqlx/query-3613473fb4d836ee0fb3c292e6bf5e50912064c29ebf1a1e5ead79c44c37e64c.json similarity index 88% rename from packages/app-lib/.sqlx/query-759e4ffe30ebc4f8602256cb419ef15732d84bcebb9ca15225dbabdc0f46ba2d.json rename to packages/app-lib/.sqlx/query-3613473fb4d836ee0fb3c292e6bf5e50912064c29ebf1a1e5ead79c44c37e64c.json index 0e8fd86131..2fce764bca 100644 --- a/packages/app-lib/.sqlx/query-759e4ffe30ebc4f8602256cb419ef15732d84bcebb9ca15225dbabdc0f46ba2d.json +++ b/packages/app-lib/.sqlx/query-3613473fb4d836ee0fb3c292e6bf5e50912064c29ebf1a1e5ead79c44c37e64c.json @@ -1,12 +1,12 @@ { "db_name": "SQLite", - "query": "\n UPDATE settings\n SET\n max_concurrent_writes = $1,\n max_concurrent_downloads = $2,\n\n theme = $3,\n default_page = $4,\n collapsed_navigation = $5,\n advanced_rendering = $6,\n native_decorations = $7,\n\n discord_rpc = $8,\n developer_mode = $9,\n telemetry = $10,\n personalized_ads = $11,\n\n onboarded = $12,\n\n extra_launch_args = jsonb($13),\n custom_env_vars = jsonb($14),\n mc_memory_max = $15,\n mc_force_fullscreen = $16,\n mc_game_resolution_x = $17,\n mc_game_resolution_y = $18,\n hide_on_process_start = $19,\n\n hook_pre_launch = $20,\n hook_wrapper = $21,\n hook_post_exit = $22,\n\n custom_dir = $23,\n prev_custom_dir = $24,\n migrated = $25,\n\n toggle_sidebar = $26,\n feature_flags = $27\n ", + "query": "\n UPDATE settings\n SET\n max_concurrent_writes = $1,\n max_concurrent_downloads = $2,\n\n theme = $3,\n default_page = $4,\n collapsed_navigation = $5,\n advanced_rendering = $6,\n native_decorations = $7,\n\n discord_rpc = $8,\n developer_mode = $9,\n telemetry = $10,\n personalized_ads = $11,\n\n onboarded = $12,\n\n extra_launch_args = jsonb($13),\n custom_env_vars = jsonb($14),\n mc_memory_max = $15,\n mc_force_fullscreen = $16,\n mc_game_resolution_x = $17,\n mc_game_resolution_y = $18,\n hide_on_process_start = $19,\n\n hook_pre_launch = $20,\n hook_wrapper = $21,\n hook_post_exit = $22,\n\n custom_dir = $23,\n prev_custom_dir = $24,\n migrated = $25,\n\n toggle_sidebar = $26,\n feature_flags = $27,\n hide_nametag_skins_page = $28\n ", "describe": { "columns": [], "parameters": { - "Right": 27 + "Right": 28 }, "nullable": [] }, - "hash": "759e4ffe30ebc4f8602256cb419ef15732d84bcebb9ca15225dbabdc0f46ba2d" + "hash": "3613473fb4d836ee0fb3c292e6bf5e50912064c29ebf1a1e5ead79c44c37e64c" } diff --git a/packages/app-lib/.sqlx/query-d90a2f2f823fc546661a94af07249758c5ca82db396268bca5087bac88f733d9.json b/packages/app-lib/.sqlx/query-5193f519f021b2e7013cdb67a6e1a31ae4bd7532d02f8b00b43d5645351941ca.json similarity index 80% rename from packages/app-lib/.sqlx/query-d90a2f2f823fc546661a94af07249758c5ca82db396268bca5087bac88f733d9.json rename to packages/app-lib/.sqlx/query-5193f519f021b2e7013cdb67a6e1a31ae4bd7532d02f8b00b43d5645351941ca.json index 72b34a9572..5dc714e293 100644 --- a/packages/app-lib/.sqlx/query-d90a2f2f823fc546661a94af07249758c5ca82db396268bca5087bac88f733d9.json +++ b/packages/app-lib/.sqlx/query-5193f519f021b2e7013cdb67a6e1a31ae4bd7532d02f8b00b43d5645351941ca.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "\n SELECT\n max_concurrent_writes, max_concurrent_downloads,\n theme, default_page, collapsed_navigation, advanced_rendering, native_decorations,\n discord_rpc, developer_mode, telemetry, personalized_ads,\n onboarded,\n json(extra_launch_args) extra_launch_args, json(custom_env_vars) custom_env_vars,\n mc_memory_max, mc_force_fullscreen, mc_game_resolution_x, mc_game_resolution_y, hide_on_process_start,\n hook_pre_launch, hook_wrapper, hook_post_exit,\n custom_dir, prev_custom_dir, migrated, json(feature_flags) feature_flags, toggle_sidebar\n FROM settings\n ", + "query": "\n SELECT\n max_concurrent_writes, max_concurrent_downloads,\n theme, default_page, collapsed_navigation, hide_nametag_skins_page, advanced_rendering, native_decorations,\n discord_rpc, developer_mode, telemetry, personalized_ads,\n onboarded,\n json(extra_launch_args) extra_launch_args, json(custom_env_vars) custom_env_vars,\n mc_memory_max, mc_force_fullscreen, mc_game_resolution_x, mc_game_resolution_y, hide_on_process_start,\n hook_pre_launch, hook_wrapper, hook_post_exit,\n custom_dir, prev_custom_dir, migrated, json(feature_flags) feature_flags, toggle_sidebar\n FROM settings\n ", "describe": { "columns": [ { @@ -29,113 +29,118 @@ "type_info": "Integer" }, { - "name": "advanced_rendering", + "name": "hide_nametag_skins_page", "ordinal": 5, "type_info": "Integer" }, { - "name": "native_decorations", + "name": "advanced_rendering", "ordinal": 6, "type_info": "Integer" }, { - "name": "discord_rpc", + "name": "native_decorations", "ordinal": 7, "type_info": "Integer" }, { - "name": "developer_mode", + "name": "discord_rpc", "ordinal": 8, "type_info": "Integer" }, { - "name": "telemetry", + "name": "developer_mode", "ordinal": 9, "type_info": "Integer" }, { - "name": "personalized_ads", + "name": "telemetry", "ordinal": 10, "type_info": "Integer" }, { - "name": "onboarded", + "name": "personalized_ads", "ordinal": 11, "type_info": "Integer" }, { - "name": "extra_launch_args", + "name": "onboarded", "ordinal": 12, + "type_info": "Integer" + }, + { + "name": "extra_launch_args", + "ordinal": 13, "type_info": "Text" }, { "name": "custom_env_vars", - "ordinal": 13, + "ordinal": 14, "type_info": "Text" }, { "name": "mc_memory_max", - "ordinal": 14, + "ordinal": 15, "type_info": "Integer" }, { "name": "mc_force_fullscreen", - "ordinal": 15, + "ordinal": 16, "type_info": "Integer" }, { "name": "mc_game_resolution_x", - "ordinal": 16, + "ordinal": 17, "type_info": "Integer" }, { "name": "mc_game_resolution_y", - "ordinal": 17, + "ordinal": 18, "type_info": "Integer" }, { "name": "hide_on_process_start", - "ordinal": 18, + "ordinal": 19, "type_info": "Integer" }, { "name": "hook_pre_launch", - "ordinal": 19, + "ordinal": 20, "type_info": "Text" }, { "name": "hook_wrapper", - "ordinal": 20, + "ordinal": 21, "type_info": "Text" }, { "name": "hook_post_exit", - "ordinal": 21, + "ordinal": 22, "type_info": "Text" }, { "name": "custom_dir", - "ordinal": 22, + "ordinal": 23, "type_info": "Text" }, { "name": "prev_custom_dir", - "ordinal": 23, + "ordinal": 24, "type_info": "Text" }, { "name": "migrated", - "ordinal": 24, + "ordinal": 25, "type_info": "Integer" }, { "name": "feature_flags", - "ordinal": 25, + "ordinal": 26, "type_info": "Text" }, { "name": "toggle_sidebar", - "ordinal": 26, + "ordinal": 27, "type_info": "Integer" } ], @@ -155,6 +160,7 @@ false, false, false, + false, null, null, false, @@ -172,5 +178,5 @@ false ] }, - "hash": "d90a2f2f823fc546661a94af07249758c5ca82db396268bca5087bac88f733d9" + "hash": "5193f519f021b2e7013cdb67a6e1a31ae4bd7532d02f8b00b43d5645351941ca" } diff --git a/packages/app-lib/.sqlx/query-e9449930a74c6a6151c3d868042b878b6789927df5adf50986fe642c8afcb681.json b/packages/app-lib/.sqlx/query-e9449930a74c6a6151c3d868042b878b6789927df5adf50986fe642c8afcb681.json new file mode 100644 index 0000000000..a09ac2ff77 --- /dev/null +++ b/packages/app-lib/.sqlx/query-e9449930a74c6a6151c3d868042b878b6789927df5adf50986fe642c8afcb681.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM default_minecraft_capes WHERE minecraft_user_uuid NOT IN (SELECT uuid FROM minecraft_users)", + "describe": { + "columns": [], + "parameters": { + "Right": 0 + }, + "nullable": [] + }, + "hash": "e9449930a74c6a6151c3d868042b878b6789927df5adf50986fe642c8afcb681" +} diff --git a/packages/app-lib/.sqlx/query-fd494269d944b179ade61876669d1821b46d60f5cb79685c489aaebf13b35d24.json b/packages/app-lib/.sqlx/query-fd494269d944b179ade61876669d1821b46d60f5cb79685c489aaebf13b35d24.json new file mode 100644 index 0000000000..ee41aad88b --- /dev/null +++ b/packages/app-lib/.sqlx/query-fd494269d944b179ade61876669d1821b46d60f5cb79685c489aaebf13b35d24.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM custom_minecraft_skins WHERE minecraft_user_uuid NOT IN (SELECT uuid FROM minecraft_users)", + "describe": { + "columns": [], + "parameters": { + "Right": 0 + }, + "nullable": [] + }, + "hash": "fd494269d944b179ade61876669d1821b46d60f5cb79685c489aaebf13b35d24" +} diff --git a/packages/app-lib/Cargo.toml b/packages/app-lib/Cargo.toml index fa7742a49f..32adfca2e2 100644 --- a/packages/app-lib/Cargo.toml +++ b/packages/app-lib/Cargo.toml @@ -20,6 +20,9 @@ tempfile.workspace = true dashmap = { workspace = true, features = ["serde"] } quick-xml = { workspace = true, features = ["async-tokio"] } enumset.workspace = true +png.workspace = true +bytemuck.workspace = true +rgb.workspace = true chrono = { workspace = true, features = ["serde"] } daedalus.workspace = true @@ -45,7 +48,7 @@ async-tungstenite = { workspace = true, features = ["tokio-runtime", "tokio-rust futures = { workspace = true, features = ["async-await", "alloc"] } reqwest = { workspace = true, features = ["json", "stream", "deflate", "gzip", "brotli", "rustls-tls-webpki-roots", "charset", "http2", "macos-system-configuration", "multipart"] } tokio = { workspace = true, features = ["time", "io-util", "net", "sync", "fs", "macros", "process"] } -tokio-util = { workspace = true, features = ["compat"] } +tokio-util = { workspace = true, features = ["compat", "io", "io-util"] } async-recursion.workspace = true fs4 = { workspace = true, features = ["tokio"] } async-walkdir.workspace = true diff --git a/packages/app-lib/migrations/20250413162050_skin-selector.sql b/packages/app-lib/migrations/20250413162050_skin-selector.sql index f76e667b46..615318f5c6 100644 --- a/packages/app-lib/migrations/20250413162050_skin-selector.sql +++ b/packages/app-lib/migrations/20250413162050_skin-selector.sql @@ -2,11 +2,34 @@ CREATE TABLE default_minecraft_capes ( minecraft_user_uuid TEXT NOT NULL, id TEXT NOT NULL, - PRIMARY KEY (minecraft_user_uuid, id), - FOREIGN KEY (minecraft_user_uuid) REFERENCES minecraft_users(uuid) - ON DELETE CASCADE ON UPDATE CASCADE + PRIMARY KEY (minecraft_user_uuid, id) ); +-- Emulate a ON UPDATE CASCADE foreign key constraint for the user UUID on the default_minecraft_capes table, +-- but allowing deletion of the user UUID in the minecraft_users table. This allows the application to temporarily +-- keep skin state around for logged-out users, allowing them to retain their skins under the right conditions +CREATE TRIGGER default_minecraft_capes_user_uuid_insert_check + BEFORE INSERT ON default_minecraft_capes FOR EACH ROW + BEGIN + SELECT CASE WHEN NOT EXISTS ( + SELECT 1 FROM minecraft_users WHERE uuid = NEW.minecraft_user_uuid + ) THEN RAISE(ABORT, 'Cannot add a default cape for an unknown Minecraft user UUID') END; + END; + +CREATE TRIGGER default_minecraft_capes_user_uuid_update_check + BEFORE UPDATE ON default_minecraft_capes FOR EACH ROW + BEGIN + SELECT CASE WHEN NOT EXISTS ( + SELECT 1 FROM minecraft_users WHERE uuid = NEW.minecraft_user_uuid + ) THEN RAISE(ABORT, 'Cannot change a default cape to refer to an unknown Minecraft user UUID') END; + END; + +CREATE TRIGGER default_minecraft_capes_user_uuid_update_cascade + AFTER UPDATE OF uuid ON minecraft_users FOR EACH ROW + BEGIN + UPDATE default_minecraft_capes SET minecraft_user_uuid = NEW.uuid WHERE minecraft_user_uuid = OLD.uuid; + END; + CREATE TABLE custom_minecraft_skins ( minecraft_user_uuid TEXT NOT NULL, texture_key TEXT NOT NULL, @@ -14,12 +37,33 @@ CREATE TABLE custom_minecraft_skins ( cape_id TEXT, PRIMARY KEY (minecraft_user_uuid, texture_key, variant, cape_id), - FOREIGN KEY (minecraft_user_uuid) REFERENCES minecraft_users(uuid) - ON DELETE CASCADE ON UPDATE CASCADE, FOREIGN KEY (texture_key) REFERENCES custom_minecraft_skin_textures(texture_key) ON DELETE CASCADE ON UPDATE CASCADE DEFERRABLE INITIALLY DEFERRED ); +-- Similar partial foreign key emulation as above +CREATE TRIGGER custom_minecraft_skins_user_uuid_insert_check + BEFORE INSERT ON custom_minecraft_skins FOR EACH ROW + BEGIN + SELECT CASE WHEN NOT EXISTS ( + SELECT 1 FROM minecraft_users WHERE uuid = NEW.minecraft_user_uuid + ) THEN RAISE(ABORT, 'Cannot add a custom skin for an unknown Minecraft user UUID') END; + END; + +CREATE TRIGGER custom_minecraft_skins_user_uuid_update_check + BEFORE UPDATE ON custom_minecraft_skins FOR EACH ROW + BEGIN + SELECT CASE WHEN NOT EXISTS ( + SELECT 1 FROM minecraft_users WHERE uuid = NEW.minecraft_user_uuid + ) THEN RAISE(ABORT, 'Cannot change a custom skin to refer to an unknown Minecraft user UUID') END; + END; + +CREATE TRIGGER custom_minecraft_skins_user_uuid_update_cascade + AFTER UPDATE OF uuid ON minecraft_users FOR EACH ROW + BEGIN + UPDATE custom_minecraft_skins SET minecraft_user_uuid = NEW.uuid WHERE minecraft_user_uuid = OLD.uuid; + END; + CREATE TABLE custom_minecraft_skin_textures ( texture_key TEXT NOT NULL, texture PNG BLOB NOT NULL, diff --git a/packages/app-lib/migrations/20250514181748_skin_nametag_setting.sql b/packages/app-lib/migrations/20250514181748_skin_nametag_setting.sql new file mode 100644 index 0000000000..faba8e36f8 --- /dev/null +++ b/packages/app-lib/migrations/20250514181748_skin_nametag_setting.sql @@ -0,0 +1 @@ +ALTER TABLE settings ADD COLUMN hide_nametag_skins_page INTEGER NOT NULL DEFAULT 0 CHECK (hide_nametag_skins_page IN (0, 1)); diff --git a/packages/app-lib/src/api/minecraft_skins.rs b/packages/app-lib/src/api/minecraft_skins.rs index 199515166f..08f0341c52 100644 --- a/packages/app-lib/src/api/minecraft_skins.rs +++ b/packages/app-lib/src/api/minecraft_skins.rs @@ -1,14 +1,10 @@ //! Theseus skin management interface -use std::{ - borrow::Cow, - sync::{ - Arc, - atomic::{AtomicBool, Ordering}, - }, +use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, }; -use base64::Engine; pub use bytes::Bytes; use data_url::DataUrl; use futures::{Stream, StreamExt, TryStreamExt, future::Either, stream}; @@ -38,6 +34,8 @@ mod assets { pub use default::DEFAULT_SKINS; } +mod png_util; + #[derive(Deserialize, Serialize, Debug)] pub struct Cape { /// An identifier for this cape, potentially unique to the owning player. @@ -102,7 +100,7 @@ impl Skin { } } -#[derive(Deserialize, Serialize, Debug)] +#[derive(Deserialize, Serialize, Debug, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum SkinSource { /// A default Minecraft skin, which may be assigned to players at random by default. @@ -202,9 +200,16 @@ pub async fn get_available_skins() -> crate::Result> { name: None, variant: custom_skin.variant, cape_id: custom_skin.cape_id, - texture: texture_blob_to_data_url( + texture: png_util::blob_to_data_url( custom_skin.texture_blob(&state.pool).await?, - ), + ) + .or_else(|| { + // Fall back to a placeholder texture if the DB somehow contains corrupt data + png_util::blob_to_data_url(include_bytes!( + "minecraft_skins/assets/default/MissingNo.png" + )) + }) + .unwrap(), source: SkinSource::Custom, is_equipped, texture_key: custom_skin.texture_key.into(), @@ -256,14 +261,16 @@ pub async fn get_available_skins() -> crate::Result> { } /// Adds a custom skin to the app database and equips it for the currently selected -/// Minecraft profile. +/// Minecraft profile. If the currently equipped skin is custom but not managed by +/// the app (i.e., it was set externally by another launcher, the Minecraft website, +/// etc.), that skin will be added as a custom skin to the app database as well. #[tracing::instrument] pub async fn add_and_equip_custom_skin( texture_blob: Bytes, variant: MinecraftSkinVariant, cape_override: Option, ) -> crate::Result<()> { - let (skin_width, skin_height) = png_dimensions(&texture_blob)?; + let (skin_width, skin_height) = png_util::dimensions(&texture_blob)?; if skin_width != 64 || ![32, 64].contains(&skin_height) { return Err(ErrorKind::InvalidSkinTexture)?; } @@ -275,9 +282,11 @@ pub async fn add_and_equip_custom_skin( .await? .ok_or(ErrorKind::NoCredentialsError)?; - // We have to equip the skin first, as it's the Mojang API backend who knows - // how to compute the texture key we require, which we can then read from the - // updated player profile + save_current_custom_external_skin(&state, &selected_credentials).await?; + + // We have to equip the new skin before storing it, as it's the Mojang API backend + // who knows how to compute the texture key we require, which we can then read from + // the updated player profile mojang_api::MinecraftSkinOperation::equip( &selected_credentials, stream::iter([Ok::<_, String>(Bytes::clone(&texture_blob))]), @@ -366,6 +375,10 @@ pub async fn set_default_cape(cape: Option) -> crate::Result<()> { /// /// This function does not check that the passed skin, if custom, exists in the app database, /// giving the caller complete freedom to equip any skin at any time. +/// +/// If the currently equipped skin is a custom skin that is not managed by the app (i.e., it was +/// set externally by another launcher, the Minecraft website, etc.), that skin will be added as +/// a custom skin to the app database, in order to allow the app to manage it later. #[tracing::instrument] pub async fn equip_skin(skin: Skin) -> crate::Result<()> { let state = State::get().await?; @@ -374,6 +387,8 @@ pub async fn equip_skin(skin: Skin) -> crate::Result<()> { .await? .ok_or(ErrorKind::NoCredentialsError)?; + save_current_custom_external_skin(&state, &selected_credentials).await?; + let profile = selected_credentials.online_profile().await.ok_or_else(|| { ErrorKind::OnlineMinecraftProfileUnavailable { @@ -447,6 +462,17 @@ pub async fn unequip_skin() -> crate::Result<()> { Ok(()) } +/// Normalizes the texture of a Minecraft skin to the modern 64x64 format, handling +/// legacy 64x32 skins as the vanilla game client does. This function prioritizes +/// PNG encoding speed over compression density, so the resulting textures are better +/// suited for display purposes, not persistent storage or transmission. +/// +/// Returns the normalized, processed texture as a byte array in PNG format. +#[tracing::instrument] +pub async fn normalize_skin_texture(skin: &Skin) -> crate::Result { + png_util::normalize_skin_texture(skin).await +} + /// Synchronizes the equipped cape with the selected cape if necessary, taking into /// account the currently equipped cape, the default cape for the player, and if a /// cape override is provided. @@ -485,56 +511,35 @@ async fn sync_cape( Ok(()) } -fn texture_blob_to_data_url(texture_blob: Vec) -> Arc { - let data = if is_png(&texture_blob) { - Cow::Owned(texture_blob) - } else { - // Fall back to a placeholder texture if the DB somehow contains corrupt data - Cow::Borrowed( - &include_bytes!("minecraft_skins/assets/default/MissingNo.png")[..], +/// Stores the currently equipped skin as a custom skin, if it is a custom skin that is not +/// managed by the app (i.e., it was externally set). +async fn save_current_custom_external_skin( + state: &State, + selected_credentials: &Credentials, +) -> crate::Result<()> { + if let Some(current_external_skin) = get_available_skins() + .await? + .into_iter() + .find(|skin| skin.is_equipped) + .filter(|skin| skin.source == SkinSource::CustomExternal) + { + CustomMinecraftSkin::add( + selected_credentials.offline_profile.id, + ¤t_external_skin.texture_key, + ¤t_external_skin + .resolve_texture() + .await? + .try_fold(vec![], async |mut texture_blob, chunk| { + texture_blob.extend_from_slice(&chunk); + Ok(texture_blob) + }) + .await?, + current_external_skin.variant, + current_external_skin.cape_id, + &state.pool, ) - }; - - Url::parse(&format!( - "data:image/png;base64,{}", - base64::engine::general_purpose::STANDARD.encode(data) - )) - .unwrap() - .into() -} - -fn is_png(data: &[u8]) -> bool { - /// The initial 8 bytes of a PNG file, used to identify it as such. - /// - /// Reference: - const PNG_SIGNATURE: &[u8] = - &[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; - - data.starts_with(PNG_SIGNATURE) -} - -fn png_dimensions(data: &[u8]) -> crate::Result<(u32, u32)> { - if !is_png(data) { - Err(ErrorKind::InvalidPng)?; + .await?; } - // Read the width and height fields from the IHDR chunk, which the - // PNG specification mandates to be the first in the file, just after - // the 8 signature bytes. See: - // https://www.w3.org/TR/png-3/#5DataRep - // https://www.w3.org/TR/png-3/#11IHDR - let width = u32::from_be_bytes( - data.get(16..20) - .ok_or(ErrorKind::InvalidPng)? - .try_into() - .unwrap(), - ); - let height = u32::from_be_bytes( - data.get(20..24) - .ok_or(ErrorKind::InvalidPng)? - .try_into() - .unwrap(), - ); - - Ok((width, height)) + Ok(()) } diff --git a/packages/app-lib/src/api/minecraft_skins/assets/test/MissingNo_normalized.png b/packages/app-lib/src/api/minecraft_skins/assets/test/MissingNo_normalized.png new file mode 100644 index 0000000000..639b3fe159 Binary files /dev/null and b/packages/app-lib/src/api/minecraft_skins/assets/test/MissingNo_normalized.png differ diff --git a/packages/app-lib/src/api/minecraft_skins/png_util.rs b/packages/app-lib/src/api/minecraft_skins/png_util.rs new file mode 100644 index 0000000000..3129e64f2c --- /dev/null +++ b/packages/app-lib/src/api/minecraft_skins/png_util.rs @@ -0,0 +1,299 @@ +//! Miscellaneous PNG utilities for Minecraft skins. + +use std::sync::Arc; + +use base64::Engine; +use bytemuck::{AnyBitPattern, NoUninit}; +use bytes::Bytes; +use futures::TryStreamExt; +use tokio_util::{compat::FuturesAsyncReadCompatExt, io::SyncIoBridge}; +use url::Url; + +use crate::ErrorKind; + +use super::Skin; + +pub fn blob_to_data_url(png_data: impl AsRef<[u8]>) -> Option> { + let png_data = png_data.as_ref(); + + is_png(png_data).then(|| { + Url::parse(&format!( + "data:image/png;base64,{}", + base64::engine::general_purpose::STANDARD.encode(png_data) + )) + .unwrap() + .into() + }) +} + +pub fn is_png(png_data: &[u8]) -> bool { + /// The initial 8 bytes of a PNG file, used to identify it as such. + /// + /// Reference: + const PNG_SIGNATURE: &[u8] = + &[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; + + png_data.starts_with(PNG_SIGNATURE) +} + +pub fn dimensions(png_data: &[u8]) -> crate::Result<(u32, u32)> { + if !is_png(png_data) { + Err(ErrorKind::InvalidPng)?; + } + + // Read the width and height fields from the IHDR chunk, which the + // PNG specification mandates to be the first in the file, just after + // the 8 signature bytes. See: + // https://www.w3.org/TR/png-3/#5DataRep + // https://www.w3.org/TR/png-3/#11IHDR + let width = u32::from_be_bytes( + png_data + .get(16..20) + .ok_or(ErrorKind::InvalidPng)? + .try_into() + .unwrap(), + ); + let height = u32::from_be_bytes( + png_data + .get(20..24) + .ok_or(ErrorKind::InvalidPng)? + .try_into() + .unwrap(), + ); + + Ok((width, height)) +} + +/// Normalizes the texture of a Minecraft skin to the modern 64x64 format, handling +/// legacy 64x32 skins as the vanilla game client does. This function prioritizes +/// PNG encoding speed over compression density, so the resulting textures are better +/// suited for display purposes, not persistent storage or transmission. +/// +/// Returns the normalized, processed texture as a byte array in PNG format. +pub async fn normalize_skin_texture(skin: &Skin) -> crate::Result { + let texture_stream = SyncIoBridge::new(Box::pin( + skin.resolve_texture() + .await? + .map_err(std::io::Error::other) + .into_async_read() + .compat(), + )); + + tokio::task::spawn_blocking(|| { + let mut png_reader = { + let mut decoder = png::Decoder::new(texture_stream); + decoder.set_transformations( + png::Transformations::normalize_to_color8(), + ); + decoder.read_info() + }?; + + // The code below assumes that the skin texture has valid dimensions. + // This also serves as a way to bail out early for obviously invalid or + // adversarial textures + if png_reader.info().width != 64 + || ![64, 32].contains(&png_reader.info().height) + { + Err(ErrorKind::InvalidSkinTexture)?; + } + + let is_legacy_skin = png_reader.info().height == 32; + + let mut texture_buf = if is_legacy_skin { + // Legacy skins have half the height, so duplicate the rows to + // turn them into a 64x64 texture + vec![0; png_reader.output_buffer_size() * 2] + } else { + // Modern skins are left as-is + vec![0; png_reader.output_buffer_size()] + }; + + let texture_buf_color_type = png_reader.output_color_type().0; + png_reader.next_frame(&mut texture_buf)?; + + if is_legacy_skin { + convert_legacy_skin_texture( + &mut texture_buf, + texture_buf_color_type, + png_reader.info(), + )?; + } + + let mut encoded_png = vec![]; + + let mut png_encoder = png::Encoder::new(&mut encoded_png, 64, 64); + png_encoder.set_color(texture_buf_color_type); + png_encoder.set_depth(png::BitDepth::Eight); + png_encoder.set_filter(png::FilterType::NoFilter); + png_encoder.set_compression(png::Compression::Fast); + + // Keeping color space information properly set, to handle the occasional + // strange PNG with non-sRGB chromacities and/or different grayscale spaces + // that keeps most people wondering, is what sets a carefully crafted image + // manipulation routine apart :) + if let Some(source_chromacities) = + png_reader.info().source_chromaticities.as_ref().cloned() + { + png_encoder.set_source_chromaticities(source_chromacities); + } + if let Some(source_gamma) = + png_reader.info().source_gamma.as_ref().cloned() + { + png_encoder.set_source_gamma(source_gamma); + } + if let Some(source_srgb) = png_reader.info().srgb.as_ref().cloned() { + png_encoder.set_source_srgb(source_srgb); + } + + let mut png_writer = png_encoder.write_header()?; + png_writer.write_image_data(&texture_buf)?; + png_writer.finish()?; + + Ok(encoded_png.into()) + }) + .await? +} + +/// Converts a legacy skin texture (32x64 pixels) within a 64x64 buffer to the +/// native 64x64 format used by modern Minecraft clients. +/// +/// See also 25w16a's `SkinTextureDownloader#processLegacySkin` method. +#[inline] +fn convert_legacy_skin_texture( + texture_buf: &mut [u8], + texture_color_type: png::ColorType, + texture_info: &png::Info, +) -> crate::Result<()> { + /// The skin faces the game client copies around, in order, when converting a + /// legacy skin to the native 64x64 format. + const FACE_COPY_PARAMETERS: &[( + usize, + usize, + isize, + isize, + usize, + usize, + )] = &[ + (4, 16, 16, 32, 4, 4), + (8, 16, 16, 32, 4, 4), + (0, 20, 24, 32, 4, 12), + (4, 20, 16, 32, 4, 12), + (8, 20, 8, 32, 4, 12), + (12, 20, 16, 32, 4, 12), + (44, 16, -8, 32, 4, 4), + (48, 16, -8, 32, 4, 4), + (40, 20, 0, 32, 4, 12), + (44, 20, -8, 32, 4, 12), + (48, 20, -16, 32, 4, 12), + (52, 20, -8, 32, 4, 12), + ]; + + for (x, y, off_x, off_y, width, height) in FACE_COPY_PARAMETERS { + macro_rules! do_copy { + ($pixel_type:ty) => { + copy_rect_mirror_horizontally::<$pixel_type>( + // This cast should never fail because all pixels have a depth of 8 bits + // after the transformations applied during decoding + ::bytemuck::try_cast_slice_mut(texture_buf).map_err(|_| ErrorKind::InvalidPng)?, + &texture_info, + *x, + *y, + *off_x, + *off_y, + *width, + *height, + ) + }; + } + + match texture_color_type.samples() { + 1 => do_copy!(rgb::Gray), + 2 => do_copy!(rgb::GrayAlpha), + 3 => do_copy!(rgb::Rgb), + 4 => do_copy!(rgb::Rgba), + _ => Err(ErrorKind::InvalidPng)?, // Cannot happen by PNG spec after transformations + }; + } + + Ok(()) +} + +/// Copies a `width` pixels wide, `height` pixels tall rectangle of pixels within `texture_buf` +/// whose top-left corner is at coordinates `(x, y)` to a destination rectangle whose top-left +/// corner is at coordinates `(x + off_x, y + off_y)`, while mirroring (i.e., flipping) the +/// pixels horizontally. +/// +/// Equivalent to Mojang's Blaze3D `NativeImage#copyRect(int, int, int, int, int, int, +/// boolean, boolean)` method, but with the last two parameters fixed to `true` and `false`, +/// respectively. +#[allow(clippy::too_many_arguments)] +fn copy_rect_mirror_horizontally( + texture_buf: &mut [PixelType], + texture_info: &png::Info, + x: usize, + y: usize, + off_x: isize, + off_y: isize, + width: usize, + height: usize, +) { + for row in 0..height { + for col in 0..width { + let src_x = x + col; + let src_y = y + row; + let dst_x = (x as isize + off_x) as usize + (width - 1 - col); + let dst_y = (y as isize + off_y) as usize + row; + + texture_buf[dst_x + dst_y * texture_info.width as usize] = + texture_buf[src_x + src_y * texture_info.width as usize]; + } + } +} + +#[cfg(test)] +#[tokio::test] +async fn normalize_skin_texture_works() { + use crate::{minecraft_skins::SkinSource, state::MinecraftSkinVariant}; + + let legacy_png_data = &include_bytes!("assets/default/MissingNo.png")[..]; + let expected_normalized_png_data = + &include_bytes!("assets/test/MissingNo_normalized.png")[..]; + + let normalized_png_data = normalize_skin_texture(&Skin { + texture_key: "missingno".into(), + name: None, + variant: MinecraftSkinVariant::Classic, + cape_id: None, + texture: blob_to_data_url(legacy_png_data).unwrap(), + source: SkinSource::Default, + is_equipped: false, + }) + .await + .expect("Failed to normalize skin texture"); + + let decode_to_pixels = |png_data: &[u8]| { + let decoder = png::Decoder::new(png_data); + let mut reader = decoder.read_info().expect("Failed to read PNG info"); + let mut buffer = vec![0; reader.output_buffer_size()]; + reader + .next_frame(&mut buffer) + .expect("Failed to decode PNG"); + (buffer, reader.info().clone()) + }; + + let (normalized_pixels, normalized_info) = + decode_to_pixels(&normalized_png_data); + let (expected_pixels, expected_info) = + decode_to_pixels(expected_normalized_png_data); + + // Check that dimensions match + assert_eq!(normalized_info.width, expected_info.width); + assert_eq!(normalized_info.height, expected_info.height); + assert_eq!(normalized_info.color_type, expected_info.color_type); + + // Check that pixel data matches + assert_eq!( + normalized_pixels, expected_pixels, + "Pixel data doesn't match" + ); +} diff --git a/packages/app-lib/src/api/settings.rs b/packages/app-lib/src/api/settings.rs index 75e34d33c2..7619596832 100644 --- a/packages/app-lib/src/api/settings.rs +++ b/packages/app-lib/src/api/settings.rs @@ -24,6 +24,8 @@ pub async fn set(settings: Settings) -> crate::Result<()> { #[tracing::instrument] pub async fn cancel_directory_change() -> crate::Result<()> { + // This is called to handle state initialization errors due to folder migrations + // failing, so fetching a DB connection pool from `State::get` is not reliable here let pool = crate::state::db::connect().await?; let mut settings = Settings::get(&pool).await?; diff --git a/packages/app-lib/src/error.rs b/packages/app-lib/src/error.rs index d38c10c3fd..75c144f554 100644 --- a/packages/app-lib/src/error.rs +++ b/packages/app-lib/src/error.rs @@ -141,6 +141,12 @@ pub enum ErrorKind { #[error("Invalid PNG")] InvalidPng, + #[error("Invalid PNG: {0}")] + PngDecodingError(#[from] png::DecodingError), + + #[error("PNG encoding error: {0}")] + PngEncodingError(#[from] png::EncodingError), + #[error( "A skin texture must have a dimension of either 64x64 or 64x32 pixels" )] diff --git a/packages/app-lib/src/state/db.rs b/packages/app-lib/src/state/db.rs index 387d381f2e..f386eaeb9d 100644 --- a/packages/app-lib/src/state/db.rs +++ b/packages/app-lib/src/state/db.rs @@ -36,5 +36,33 @@ pub(crate) async fn connect() -> crate::Result> { sqlx::migrate!().run(&pool).await?; + if let Err(err) = stale_data_cleanup(&pool).await { + tracing::warn!( + "Failed to clean up stale data from state database: {err}" + ); + } + Ok(pool) } + +/// Cleans up data from the database that is no longer referenced, but must be +/// kept around for a little while to allow users to recover from accidental +/// deletions. +async fn stale_data_cleanup(pool: &Pool) -> crate::Result<()> { + let mut tx = pool.begin().await?; + + sqlx::query!( + "DELETE FROM default_minecraft_capes WHERE minecraft_user_uuid NOT IN (SELECT uuid FROM minecraft_users)" + ) + .execute(&mut *tx) + .await?; + sqlx::query!( + "DELETE FROM custom_minecraft_skins WHERE minecraft_user_uuid NOT IN (SELECT uuid FROM minecraft_users)" + ) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + + Ok(()) +} diff --git a/packages/app-lib/src/state/settings.rs b/packages/app-lib/src/state/settings.rs index 89d7bc0445..1219fc9366 100644 --- a/packages/app-lib/src/state/settings.rs +++ b/packages/app-lib/src/state/settings.rs @@ -13,6 +13,7 @@ pub struct Settings { pub theme: Theme, pub default_page: DefaultPage, pub collapsed_navigation: bool, + pub hide_nametag_skins_page: bool, pub advanced_rendering: bool, pub native_decorations: bool, pub toggle_sidebar: bool, @@ -56,7 +57,7 @@ impl Settings { " SELECT max_concurrent_writes, max_concurrent_downloads, - theme, default_page, collapsed_navigation, advanced_rendering, native_decorations, + theme, default_page, collapsed_navigation, hide_nametag_skins_page, advanced_rendering, native_decorations, discord_rpc, developer_mode, telemetry, personalized_ads, onboarded, json(extra_launch_args) extra_launch_args, json(custom_env_vars) custom_env_vars, @@ -75,6 +76,7 @@ impl Settings { theme: Theme::from_string(&res.theme), default_page: DefaultPage::from_string(&res.default_page), collapsed_navigation: res.collapsed_navigation == 1, + hide_nametag_skins_page: res.hide_nametag_skins_page == 1, advanced_rendering: res.advanced_rendering == 1, native_decorations: res.native_decorations == 1, toggle_sidebar: res.toggle_sidebar == 1, @@ -167,7 +169,8 @@ impl Settings { migrated = $25, toggle_sidebar = $26, - feature_flags = $27 + feature_flags = $27, + hide_nametag_skins_page = $28 ", max_concurrent_writes, max_concurrent_downloads, @@ -195,7 +198,8 @@ impl Settings { self.prev_custom_dir, self.migrated, self.toggle_sidebar, - feature_flags + feature_flags, + self.hide_nametag_skins_page ) .execute(exec) .await?; diff --git a/packages/assets/styles/variables.scss b/packages/assets/styles/variables.scss index 315c989893..942014bd28 100644 --- a/packages/assets/styles/variables.scss +++ b/packages/assets/styles/variables.scss @@ -68,6 +68,8 @@ --color-button-bg-selected: var(--color-brand); --color-button-text-selected: var(--color-accent-contrast); + --color-gradient-button-bg: linear-gradient(180deg, #f8f9fa 0%, #dce0e6 100%); + --loading-bar-gradient: linear-gradient(to right, var(--color-brand) 0%, #00af5c 100%); --color-platform-fabric: #8a7b71; @@ -182,6 +184,8 @@ html { --color-button-bg-selected: var(--color-brand-highlight); --color-button-text-selected: var(--color-brand); + --color-gradient-button-bg: linear-gradient(180deg, #3a3d47 0%, #33363d 100%); + --loading-bar-gradient: linear-gradient(to right, var(--color-brand) 0%, #1ffa9a 100%); --color-platform-fabric: #dbb69b; @@ -220,6 +224,8 @@ html { rgba(9, 18, 14, 0.6) 10%, rgba(19, 31, 23, 0.5) 100% ); + + --color-gradient-button-bg: linear-gradient(180deg, #1b1b20 0%, #25262b 100%); } .retro-mode { diff --git a/packages/ui/package.json b/packages/ui/package.json index 0599a80779..9330493951 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -28,7 +28,10 @@ "@codemirror/view": "^6.22.1", "@modrinth/assets": "workspace:*", "@modrinth/utils": "workspace:*", + "@tresjs/cientos": "^4.3.0", + "@tresjs/core": "^4.3.4", "@types/markdown-it": "^14.1.1", + "@types/three": "^0.172.0", "@vintl/how-ago": "^3.0.1", "apexcharts": "^3.44.0", "dayjs": "^1.11.10", @@ -36,6 +39,7 @@ "highlight.js": "^11.9.0", "markdown-it": "^13.0.2", "qrcode.vue": "^3.4.1", + "three": "^0.172.0", "vue-multiselect": "3.0.0", "vue-select": "4.0.0-beta.6", "vue-typed-virtual-list": "^1.0.10", diff --git a/packages/ui/src/components/base/ScrollablePanel.vue b/packages/ui/src/components/base/ScrollablePanel.vue index f6bb387158..cc4cdff8d1 100644 --- a/packages/ui/src/components/base/ScrollablePanel.vue +++ b/packages/ui/src/components/base/ScrollablePanel.vue @@ -59,13 +59,13 @@ function onScroll({ target: { scrollTop, offsetHeight, scrollHeight } }) { diff --git a/packages/ui/src/components/skin/CapeLikeTextButton.vue b/packages/ui/src/components/skin/CapeLikeTextButton.vue new file mode 100644 index 0000000000..ef8a9cad9b --- /dev/null +++ b/packages/ui/src/components/skin/CapeLikeTextButton.vue @@ -0,0 +1,62 @@ + + + + + diff --git a/packages/ui/src/components/skin/SkinButton.vue b/packages/ui/src/components/skin/SkinButton.vue new file mode 100644 index 0000000000..75a9279aac --- /dev/null +++ b/packages/ui/src/components/skin/SkinButton.vue @@ -0,0 +1,143 @@ + + + + + diff --git a/packages/ui/src/components/skin/SkinLikeTextButton.vue b/packages/ui/src/components/skin/SkinLikeTextButton.vue new file mode 100644 index 0000000000..b7ab8765e0 --- /dev/null +++ b/packages/ui/src/components/skin/SkinLikeTextButton.vue @@ -0,0 +1,70 @@ + + + + + diff --git a/packages/ui/src/components/skin/SkinPreviewRenderer.vue b/packages/ui/src/components/skin/SkinPreviewRenderer.vue new file mode 100644 index 0000000000..fbc183dc9f --- /dev/null +++ b/packages/ui/src/components/skin/SkinPreviewRenderer.vue @@ -0,0 +1,587 @@ + + + + + diff --git a/packages/ui/src/vue-shims.d.ts b/packages/ui/src/vue-shims.d.ts index 41c2ecce61..aae8c737a0 100644 --- a/packages/ui/src/vue-shims.d.ts +++ b/packages/ui/src/vue-shims.d.ts @@ -4,3 +4,8 @@ declare module '*.vue' { const component: ReturnType export default component } + +declare module '*.glsl' { + const value: string + export default value +} diff --git a/packages/utils/index.ts b/packages/utils/index.ts index 8cac239532..6782679eed 100644 --- a/packages/utils/index.ts +++ b/packages/utils/index.ts @@ -7,3 +7,4 @@ export * from './projects' export * from './types' export * from './users' export * from './utils' +export * from './three/skin-rendering' diff --git a/packages/utils/package.json b/packages/utils/package.json index 8a4141b055..674983603d 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -20,9 +20,11 @@ "@codemirror/state": "^6.3.2", "@codemirror/view": "^6.22.1", "@types/markdown-it": "^14.1.1", + "@types/three": "^0.172.0", "dayjs": "^1.11.10", "highlight.js": "^11.9.0", "markdown-it": "^14.1.0", + "three": "^0.172.0", "xss": "^1.0.14" } } diff --git a/packages/utils/three/skin-rendering.ts b/packages/utils/three/skin-rendering.ts new file mode 100644 index 0000000000..1ebd9c1d8a --- /dev/null +++ b/packages/utils/three/skin-rendering.ts @@ -0,0 +1,198 @@ +import * as THREE from 'three' +import type { GLTF } from 'three/examples/jsm/loaders/GLTFLoader.js' +import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js' + +export interface SkinRendererConfig { + textureColorSpace?: THREE.ColorSpace + textureFlipY?: boolean + textureMagFilter?: THREE.MagnificationTextureFilter + textureMinFilter?: THREE.MinificationTextureFilter +} + +const modelCache: Map = new Map() +const textureCache: Map = new Map() + +export async function loadModel(modelUrl: string): Promise { + if (modelCache.has(modelUrl)) { + return modelCache.get(modelUrl)! + } + + const loader = new GLTFLoader() + return new Promise((resolve, reject) => { + loader.load( + modelUrl, + (gltf) => { + modelCache.set(modelUrl, gltf) + resolve(gltf) + }, + undefined, + reject, + ) + }) +} + +export async function loadTexture( + textureUrl: string, + config: SkinRendererConfig = {}, +): Promise { + const cacheKey = `${textureUrl}_${JSON.stringify(config)}` + + if (textureCache.has(cacheKey)) { + return textureCache.get(cacheKey)! + } + + return new Promise((resolve) => { + const textureLoader = new THREE.TextureLoader() + textureLoader.load(textureUrl, (texture) => { + texture.colorSpace = config.textureColorSpace ?? THREE.SRGBColorSpace + texture.flipY = config.textureFlipY ?? false + texture.magFilter = config.textureMagFilter ?? THREE.NearestFilter + texture.minFilter = config.textureMinFilter ?? THREE.NearestFilter + + textureCache.set(cacheKey, texture) + resolve(texture) + }) + }) +} + +export function applyTexture(model: THREE.Object3D, texture: THREE.Texture): void { + model.traverse((child) => { + if ((child as THREE.Mesh).isMesh) { + const mesh = child as THREE.Mesh + + // Skip cape meshes + if (mesh.name === 'Cape') return + + const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material] + + materials.forEach((mat: THREE.Material) => { + if (mat instanceof THREE.MeshStandardMaterial) { + mat.map = texture + mat.metalness = 0 + mat.color.set(0xffffff) + mat.toneMapped = false + mat.roughness = 1 + mat.needsUpdate = true + } + }) + } + }) +} + +export function applyCapeTexture( + model: THREE.Object3D, + texture: THREE.Texture | null, + transparentTexture?: THREE.Texture, +): void { + model.traverse((child) => { + if ((child as THREE.Mesh).isMesh) { + const mesh = child as THREE.Mesh + const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material] + + materials.forEach((mat: THREE.Material) => { + if (mat instanceof THREE.MeshStandardMaterial) { + mat.map = texture || transparentTexture || null + mat.transparent = transparentTexture ? true : false + mat.metalness = 0 + mat.color.set(0xffffff) + mat.toneMapped = false + mat.roughness = 1 + mat.side = THREE.DoubleSide + mat.needsUpdate = true + } + }) + } + }) +} + +export function attachCapeToBody( + bodyNode: THREE.Object3D, + capeModel: THREE.Object3D, + position = { x: 0, y: -1, z: -0.01 }, + rotation = { x: 0, y: -Math.PI / 2, z: 0 }, +): void { + if (!bodyNode || !capeModel) return + + if (capeModel.parent) { + capeModel.parent.remove(capeModel) + } + + capeModel.position.set(position.x, position.y, position.z) + capeModel.rotation.set(rotation.x, rotation.y, rotation.z) + bodyNode.add(capeModel) +} + +export function findBodyNode(model: THREE.Object3D): THREE.Object3D | null { + let bodyNode: THREE.Object3D | null = null + + model.traverse((node) => { + if (node.name === 'Body') { + bodyNode = node + } + }) + + return bodyNode +} + +export function createTransparentTexture(): THREE.Texture { + const canvas = document.createElement('canvas') + canvas.width = canvas.height = 1 + const ctx = canvas.getContext('2d') as CanvasRenderingContext2D + ctx.clearRect(0, 0, 1, 1) + + const texture = new THREE.CanvasTexture(canvas) + texture.needsUpdate = true + texture.colorSpace = THREE.SRGBColorSpace + texture.flipY = false + texture.magFilter = THREE.NearestFilter + texture.minFilter = THREE.NearestFilter + + return texture +} + +export async function setupSkinModel( + modelUrl: string, + textureUrl: string, + capeModelUrl?: string, + capeTextureUrl?: string, + config: SkinRendererConfig = {}, +): Promise<{ + model: THREE.Object3D + bodyNode: THREE.Object3D | null + capeModel: THREE.Object3D | null +}> { + // Load model and texture in parallel + const [gltf, texture] = await Promise.all([loadModel(modelUrl), loadTexture(textureUrl, config)]) + + const model = gltf.scene.clone() + applyTexture(model, texture) + + const bodyNode = findBodyNode(model) + let capeModel: THREE.Object3D | null = null + + // Load cape if provided + if (capeModelUrl && capeTextureUrl) { + const [capeGltf, capeTexture] = await Promise.all([ + loadModel(capeModelUrl), + loadTexture(capeTextureUrl, config), + ]) + + capeModel = capeGltf.scene.clone() + applyCapeTexture(capeModel, capeTexture) + + if (bodyNode && capeModel) { + attachCapeToBody(bodyNode, capeModel) + } + } + + return { model, bodyNode, capeModel } +} + +export function disposeCaches(): void { + Array.from(textureCache.values()).forEach((texture) => { + texture.dispose() + }) + + textureCache.clear() + modelCache.clear() +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 70096d6d20..27e56b672f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -82,7 +82,7 @@ importers: version: 1.11.11 floating-vue: specifier: ^5.2.2 - version: 5.2.2(@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@4.34.9))(vue@3.5.13(typescript@5.5.4)) + version: 5.2.2(@nuxt/kit@3.14.1592(magicast@0.3.5))(vue@3.5.13(typescript@5.5.4)) ofetch: specifier: ^1.3.4 version: 1.4.1 @@ -92,6 +92,9 @@ importers: posthog-js: specifier: ^1.158.2 version: 1.158.2 + three: + specifier: ^0.172.0 + version: 0.172.0 vite-svg-loader: specifier: ^5.1.0 version: 5.1.0(vue@3.5.13(typescript@5.5.4)) @@ -417,9 +420,18 @@ importers: '@modrinth/utils': specifier: workspace:* version: link:../utils + '@tresjs/cientos': + specifier: ^4.3.0 + version: 4.3.0(@tresjs/core@4.3.4(three@0.172.0)(typescript@5.5.4)(vue@3.5.13(typescript@5.5.4)))(@types/three@0.172.0)(three@0.172.0)(typescript@5.5.4)(vue@3.5.13(typescript@5.5.4)) + '@tresjs/core': + specifier: ^4.3.4 + version: 4.3.4(three@0.172.0)(typescript@5.5.4)(vue@3.5.13(typescript@5.5.4)) '@types/markdown-it': specifier: ^14.1.1 version: 14.1.1 + '@types/three': + specifier: ^0.172.0 + version: 0.172.0 '@vintl/how-ago': specifier: ^3.0.1 version: 3.0.1(@formatjs/intl@2.10.4(typescript@5.5.4)) @@ -441,6 +453,9 @@ importers: qrcode.vue: specifier: ^3.4.1 version: 3.4.1(vue@3.5.13(typescript@5.5.4)) + three: + specifier: ^0.172.0 + version: 0.172.0 vue-multiselect: specifier: 3.0.0 version: 3.0.0 @@ -505,6 +520,9 @@ importers: '@types/markdown-it': specifier: ^14.1.1 version: 14.1.1 + '@types/three': + specifier: ^0.172.0 + version: 0.172.0 dayjs: specifier: ^1.11.10 version: 1.11.11 @@ -514,6 +532,9 @@ importers: markdown-it: specifier: ^14.1.0 version: 14.1.0 + three: + specifier: ^0.172.0 + version: 0.172.0 xss: specifier: ^1.0.14 version: 1.0.15 @@ -534,6 +555,9 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} + '@alvarosabu/utils@3.2.0': + resolution: {integrity: sha512-aoGWRfaQjOo9TUwrBA6W0zwTHktgrXy69GIFNILT4gHsqscw6+X8P6uoSlZVQFr887SPm8x3aDin5EBVq8y4pw==} + '@ampproject/remapping@2.3.0': resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} @@ -2438,6 +2462,19 @@ packages: '@tauri-apps/plugin-window-state@2.2.2': resolution: {integrity: sha512-7pFwmMtGhhhE/WgmM7PUrj0BSSWVAQMfDdYbRalphIqqF1tWBvxtlxclx8bTutpXHLJTQoCpIeWtBEIXsoAlGw==} + '@tresjs/cientos@4.3.0': + resolution: {integrity: sha512-8YvzgqHab1lsRjTX2KAOg+glQfS+YeqF3wB8XB/+I7/IUconYetSiCJHwLb2FArI9iF7as0x3QRhx8Y2msiysg==} + peerDependencies: + '@tresjs/core': '>=4.2.1' + three: '>=0.133' + vue: '>=3.3' + + '@tresjs/core@4.3.4': + resolution: {integrity: sha512-1wK8aWGTJTnB4ClTXC+yQ9FZvZsFW5sAVmhW2G83tedMXJvhKRilhFn3EBWOJSJ/9kGvb/8iRLlaCBqwF5b12g==} + peerDependencies: + three: '>=0.133' + vue: '>=3.4' + '@trysound/sax@0.2.0': resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==} engines: {node: '>=10.13.0'} @@ -2457,6 +2494,9 @@ packages: '@types/dompurify@3.0.5': resolution: {integrity: sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==} + '@types/draco3d@1.4.10': + resolution: {integrity: sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw==} + '@types/eslint-scope@3.7.7': resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} @@ -2523,6 +2563,9 @@ packages: '@types/normalize-package-data@2.4.4': resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} + '@types/offscreencanvas@2019.7.3': + resolution: {integrity: sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==} + '@types/resolve@1.20.2': resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} @@ -2553,6 +2596,9 @@ packages: '@types/web-bluetooth@0.0.20': resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==} + '@types/web-bluetooth@0.0.21': + resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} + '@types/webxr@0.5.21': resolution: {integrity: sha512-geZIAtLzjGmgY2JUi6VxXdCrTb99A7yP49lxLr2Nm/uIK0PkkxcEi4OGhoGDO4pxCf3JwGz2GiJL2Ej4K2bKaA==} @@ -2947,18 +2993,27 @@ packages: '@vueuse/core@11.1.0': resolution: {integrity: sha512-P6dk79QYA6sKQnghrUz/1tHi0n9mrb/iO1WTMk/ElLmTyNqgDeSZ3wcDf6fRBGzRJbeG1dxzEOvLENMjr+E3fg==} + '@vueuse/core@12.8.2': + resolution: {integrity: sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==} + '@vueuse/core@9.13.0': resolution: {integrity: sha512-pujnclbeHWxxPRqXWmdkKV5OX4Wk4YeK7wusHqRwU0Q7EFusHoqNA/aPhB6KCh9hEqJkLAJo7bb0Lh9b+OIVzw==} '@vueuse/metadata@11.1.0': resolution: {integrity: sha512-l9Q502TBTaPYGanl1G+hPgd3QX5s4CGnpXriVBR5fEZ/goI6fvDaVmIl3Td8oKFurOxTmbXvBPSsgrd6eu6HYg==} + '@vueuse/metadata@12.8.2': + resolution: {integrity: sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==} + '@vueuse/metadata@9.13.0': resolution: {integrity: sha512-gdU7TKNAUVlXXLbaF+ZCfte8BjRJQWPCa2J55+7/h+yDtzw3vOoGQDRXzI6pyKyo6bXFT5/QoPE4hAknExjRLQ==} '@vueuse/shared@11.1.0': resolution: {integrity: sha512-YUtIpY122q7osj+zsNMFAfMTubGz0sn5QzE5gPzAIiCmtt2ha3uQUY1+JPyL4gRCTsLPX82Y9brNbo/aqlA91w==} + '@vueuse/shared@12.8.2': + resolution: {integrity: sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==} + '@vueuse/shared@9.13.0': resolution: {integrity: sha512-UrnhU+Cnufu4S6JLCPZnkWh0WwZGUp72ktOF2DFptMlOs3TOdVv8xJN53zhHGARmVOsz5KqOls09+J1NR6sBKw==} @@ -3332,6 +3387,11 @@ packages: resolution: {integrity: sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==} engines: {node: '>=16'} + camera-controls@2.10.1: + resolution: {integrity: sha512-KnaKdcvkBJ1Irbrzl8XD6WtZltkRjp869Jx8c0ujs9K+9WD+1D7ryBsCiVqJYUqt6i/HR5FxT7RLASieUD+Q5w==} + peerDependencies: + three: '>=0.126.1' + caniuse-api@3.0.0: resolution: {integrity: sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==} @@ -3824,6 +3884,9 @@ packages: resolution: {integrity: sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==} engines: {node: '>=12'} + draco3d@1.5.7: + resolution: {integrity: sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==} + dset@3.1.4: resolution: {integrity: sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==} engines: {node: '>=4'} @@ -4320,6 +4383,9 @@ packages: fflate@0.4.8: resolution: {integrity: sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==} + fflate@0.6.10: + resolution: {integrity: sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg==} + fflate@0.8.2: resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} @@ -4533,6 +4599,15 @@ packages: resolution: {integrity: sha512-s3Fq41ZVh7vbbe2PN3nrW7yC7U7MFVc5c98/iTl9c2GawNMKx/J648KQRW6WKkuU8GIbbh2IXfIRQjOZnXcTnw==} engines: {node: '>=18'} + glsl-token-functions@1.0.1: + resolution: {integrity: sha512-EigGhp1g+aUVeUNY7H1o5tL/bnwIB3/FcRREPr2E7Du+/UDXN24hDkaZ3e4aWHDjHr9lJ6YHXMISkwhUYg9UOg==} + + glsl-token-string@1.0.1: + resolution: {integrity: sha512-1mtQ47Uxd47wrovl+T6RshKGkRRCYWhnELmkEcUAPALWGTFe2XZpH3r45XAwL2B6v+l0KNsCnoaZCSnhzKEksg==} + + glsl-tokenizer@2.1.5: + resolution: {integrity: sha512-XSZEJ/i4dmz3Pmbnpsy3cKh7cotvFlBiZnDOwnj/05EwNp2XrhQ4XKJxT7/pDt4kp4YcpRSKz8eTV7S+mwV6MA==} + gopd@1.0.1: resolution: {integrity: sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==} @@ -4950,6 +5025,9 @@ packages: resolution: {integrity: sha512-jv+8jaWCl0g2lSBkNSVXdzfBA0npK1HGC2KtWM9FumFRoGS94g3NbCCLVnCYHLjp4GrW2KZeeSTMo5ddtznmGw==} engines: {node: '>=18'} + isarray@0.0.1: + resolution: {integrity: sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==} + isarray@1.0.0: resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} @@ -6091,6 +6169,9 @@ packages: posthog-js@1.158.2: resolution: {integrity: sha512-ovb7GHHRNDf6vmuL+8lbDukewzDzQlLZXg3d475hrfHSBgidYeTxtLGtoBcUz4x6558BLDFjnSip+f3m4rV9LA==} + potpack@1.0.2: + resolution: {integrity: sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==} + preact@10.23.2: resolution: {integrity: sha512-kKYfePf9rzKnxOAKDpsWhg/ysrHPqT+yQ7UW4JjdnqjFIeNUnNcEJvhuA8fDenxAGWzUqtd51DfVg7xp/8T9NA==} @@ -6239,6 +6320,9 @@ packages: resolution: {integrity: sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==} engines: {node: '>=8'} + readable-stream@1.0.34: + resolution: {integrity: sha512-ok1qVCJuRkNmvebYikljxJA/UEsKwLl2nI1OmaqAu4/UE+h0wKCHok4XkL/gvi39OacXvw59RJUOFUkDib2rHg==} + readable-stream@2.3.8: resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} @@ -6664,6 +6748,15 @@ packages: '@astrojs/starlight': '>=0.30.0' astro: '>=5.1.5' + stats-gl@2.4.2: + resolution: {integrity: sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ==} + peerDependencies: + '@types/three': '*' + three: '*' + + stats.js@0.17.0: + resolution: {integrity: sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw==} + statuses@2.0.1: resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} engines: {node: '>= 0.8'} @@ -6700,6 +6793,9 @@ packages: resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} engines: {node: '>= 0.4'} + string_decoder@0.10.31: + resolution: {integrity: sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==} + string_decoder@1.1.1: resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} @@ -6873,9 +6969,29 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + three-custom-shader-material@5.4.0: + resolution: {integrity: sha512-Yn1lFlKOk3Vul3npEGAmbbFUZ5S2+yjPgM2XqJEZEYRSUUH2vk+WVYrtTB6Bcq15wa7hLUXAKoctAvbRmBmbYA==} + peerDependencies: + '@react-three/fiber': '>=8.0' + react: '>=18.0' + three: '>=0.154' + peerDependenciesMeta: + '@react-three/fiber': + optional: true + react: + optional: true + + three-stdlib@2.36.0: + resolution: {integrity: sha512-kv0Byb++AXztEGsULgMAs8U2jgUdz6HPpAB/wDJnLiLlaWQX2APHhiTJIN7rqW+Of0eRgcp7jn05U1BsCP3xBA==} + peerDependencies: + three: '>=0.128.0' + three@0.172.0: resolution: {integrity: sha512-6HMgMlzU97MsV7D/tY8Va38b83kz8YJX+BefKjspMNAv0Vx6dxMogHOrnRl/sbMIs3BPUKijPqDqJ/+UwJbIow==} + through2@0.6.5: + resolution: {integrity: sha512-RkK/CCESdTKQZHdmKICijdKKsCRVHs5KsLZ6pACAmF/1GPUQhonHSXWNERctxEp7RmvjdNbZTL5z9V7nSCXKcg==} + tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} @@ -7757,6 +7873,10 @@ packages: engines: {node: '>= 0.10.0'} hasBin: true + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + xxhash-wasm@1.1.0: resolution: {integrity: sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==} @@ -7841,6 +7961,8 @@ snapshots: '@alloc/quick-lru@5.2.0': {} + '@alvarosabu/utils@3.2.0': {} + '@ampproject/remapping@2.3.0': dependencies: '@jridgewell/gen-mapping': 0.3.5 @@ -9142,9 +9264,9 @@ snapshots: - supports-color - typescript - '@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@3.29.4)': + '@nuxt/kit@3.14.1592(magicast@0.3.5)': dependencies: - '@nuxt/schema': 3.14.1592(magicast@0.3.5)(rollup@3.29.4) + '@nuxt/schema': 3.14.1592(magicast@0.3.5) c12: 2.0.1(magicast@0.3.5) consola: 3.2.3 defu: 6.1.4 @@ -9162,7 +9284,7 @@ snapshots: semver: 7.7.1 ufo: 1.5.4 unctx: 2.3.1 - unimport: 3.14.4(rollup@3.29.4) + unimport: 3.14.4 untyped: 1.5.1 transitivePeerDependencies: - magicast @@ -9170,9 +9292,9 @@ snapshots: - supports-color optional: true - '@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@4.28.1)': + '@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@3.29.4)': dependencies: - '@nuxt/schema': 3.14.1592(magicast@0.3.5)(rollup@4.28.1) + '@nuxt/schema': 3.14.1592(magicast@0.3.5)(rollup@3.29.4) c12: 2.0.1(magicast@0.3.5) consola: 3.2.3 defu: 6.1.4 @@ -9190,16 +9312,17 @@ snapshots: semver: 7.7.1 ufo: 1.5.4 unctx: 2.3.1 - unimport: 3.14.4(rollup@4.28.1) + unimport: 3.14.4(rollup@3.29.4) untyped: 1.5.1 transitivePeerDependencies: - magicast - rollup - supports-color + optional: true - '@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@4.34.9)': + '@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@4.28.1)': dependencies: - '@nuxt/schema': 3.14.1592(magicast@0.3.5)(rollup@4.34.9) + '@nuxt/schema': 3.14.1592(magicast@0.3.5)(rollup@4.28.1) c12: 2.0.1(magicast@0.3.5) consola: 3.2.3 defu: 6.1.4 @@ -9217,15 +9340,14 @@ snapshots: semver: 7.7.1 ufo: 1.5.4 unctx: 2.3.1 - unimport: 3.14.4(rollup@4.34.9) + unimport: 3.14.4(rollup@4.28.1) untyped: 1.5.1 transitivePeerDependencies: - magicast - rollup - supports-color - optional: true - '@nuxt/schema@3.14.1592(magicast@0.3.5)(rollup@3.29.4)': + '@nuxt/schema@3.14.1592(magicast@0.3.5)': dependencies: c12: 2.0.1(magicast@0.3.5) compatx: 0.1.8 @@ -9238,7 +9360,7 @@ snapshots: std-env: 3.8.0 ufo: 1.5.4 uncrypto: 0.1.3 - unimport: 3.14.4(rollup@3.29.4) + unimport: 3.14.4 untyped: 1.5.1 transitivePeerDependencies: - magicast @@ -9246,7 +9368,7 @@ snapshots: - supports-color optional: true - '@nuxt/schema@3.14.1592(magicast@0.3.5)(rollup@4.28.1)': + '@nuxt/schema@3.14.1592(magicast@0.3.5)(rollup@3.29.4)': dependencies: c12: 2.0.1(magicast@0.3.5) compatx: 0.1.8 @@ -9259,14 +9381,15 @@ snapshots: std-env: 3.8.0 ufo: 1.5.4 uncrypto: 0.1.3 - unimport: 3.14.4(rollup@4.28.1) + unimport: 3.14.4(rollup@3.29.4) untyped: 1.5.1 transitivePeerDependencies: - magicast - rollup - supports-color + optional: true - '@nuxt/schema@3.14.1592(magicast@0.3.5)(rollup@4.34.9)': + '@nuxt/schema@3.14.1592(magicast@0.3.5)(rollup@4.28.1)': dependencies: c12: 2.0.1(magicast@0.3.5) compatx: 0.1.8 @@ -9279,13 +9402,12 @@ snapshots: std-env: 3.8.0 ufo: 1.5.4 uncrypto: 0.1.3 - unimport: 3.14.4(rollup@4.34.9) + unimport: 3.14.4(rollup@4.28.1) untyped: 1.5.1 transitivePeerDependencies: - magicast - rollup - supports-color - optional: true '@nuxt/telemetry@2.6.0(magicast@0.3.5)(rollup@4.28.1)': dependencies: @@ -9629,15 +9751,6 @@ snapshots: optionalDependencies: rollup: 4.28.1 - '@rollup/pluginutils@5.1.3(rollup@4.34.9)': - dependencies: - '@types/estree': 1.0.6 - estree-walker: 2.0.2 - picomatch: 4.0.2 - optionalDependencies: - rollup: 4.34.9 - optional: true - '@rollup/pluginutils@5.1.4(rollup@4.34.9)': dependencies: '@types/estree': 1.0.6 @@ -9937,6 +10050,33 @@ snapshots: dependencies: '@tauri-apps/api': 2.5.0 + '@tresjs/cientos@4.3.0(@tresjs/core@4.3.4(three@0.172.0)(typescript@5.5.4)(vue@3.5.13(typescript@5.5.4)))(@types/three@0.172.0)(three@0.172.0)(typescript@5.5.4)(vue@3.5.13(typescript@5.5.4))': + dependencies: + '@tresjs/core': 4.3.4(three@0.172.0)(typescript@5.5.4)(vue@3.5.13(typescript@5.5.4)) + '@vueuse/core': 12.8.2(typescript@5.5.4) + camera-controls: 2.10.1(three@0.172.0) + stats-gl: 2.4.2(@types/three@0.172.0)(three@0.172.0) + stats.js: 0.17.0 + three: 0.172.0 + three-custom-shader-material: 5.4.0(three@0.172.0) + three-stdlib: 2.36.0(three@0.172.0) + vue: 3.5.13(typescript@5.5.4) + transitivePeerDependencies: + - '@react-three/fiber' + - '@types/three' + - react + - typescript + + '@tresjs/core@4.3.4(three@0.172.0)(typescript@5.5.4)(vue@3.5.13(typescript@5.5.4))': + dependencies: + '@alvarosabu/utils': 3.2.0 + '@vue/devtools-api': 6.6.4 + '@vueuse/core': 12.8.2(typescript@5.5.4) + three: 0.172.0 + vue: 3.5.13(typescript@5.5.4) + transitivePeerDependencies: + - typescript + '@trysound/sax@0.2.0': {} '@tweenjs/tween.js@23.1.3': {} @@ -9955,6 +10095,8 @@ snapshots: dependencies: '@types/trusted-types': 2.0.7 + '@types/draco3d@1.4.10': {} + '@types/eslint-scope@3.7.7': dependencies: '@types/eslint': 9.6.0 @@ -10027,6 +10169,8 @@ snapshots: '@types/normalize-package-data@2.4.4': {} + '@types/offscreencanvas@2019.7.3': {} + '@types/resolve@1.20.2': {} '@types/sax@1.2.7': @@ -10056,6 +10200,8 @@ snapshots: '@types/web-bluetooth@0.0.20': {} + '@types/web-bluetooth@0.0.21': {} + '@types/webxr@0.5.21': {} '@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4))(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4)': @@ -10713,6 +10859,15 @@ snapshots: - '@vue/composition-api' - vue + '@vueuse/core@12.8.2(typescript@5.5.4)': + dependencies: + '@types/web-bluetooth': 0.0.21 + '@vueuse/metadata': 12.8.2 + '@vueuse/shared': 12.8.2(typescript@5.5.4) + vue: 3.5.13(typescript@5.5.4) + transitivePeerDependencies: + - typescript + '@vueuse/core@9.13.0(vue@3.5.13(typescript@5.5.4))': dependencies: '@types/web-bluetooth': 0.0.16 @@ -10725,6 +10880,8 @@ snapshots: '@vueuse/metadata@11.1.0': {} + '@vueuse/metadata@12.8.2': {} + '@vueuse/metadata@9.13.0': {} '@vueuse/shared@11.1.0(vue@3.5.13(typescript@5.5.4))': @@ -10734,6 +10891,12 @@ snapshots: - '@vue/composition-api' - vue + '@vueuse/shared@12.8.2(typescript@5.5.4)': + dependencies: + vue: 3.5.13(typescript@5.5.4) + transitivePeerDependencies: + - typescript + '@vueuse/shared@9.13.0(vue@3.5.13(typescript@5.5.4))': dependencies: vue-demi: 0.14.10(vue@3.5.13(typescript@5.5.4)) @@ -11275,6 +11438,10 @@ snapshots: camelcase@8.0.0: {} + camera-controls@2.10.1(three@0.172.0): + dependencies: + three: 0.172.0 + caniuse-api@3.0.0: dependencies: browserslist: 4.24.2 @@ -11702,6 +11869,8 @@ snapshots: dotenv@16.4.5: {} + draco3d@1.5.7: {} + dset@3.1.4: {} duplexer@0.1.2: {} @@ -12527,6 +12696,8 @@ snapshots: fflate@0.4.8: {} + fflate@0.6.10: {} + fflate@0.8.2: {} file-entry-cache@6.0.1: @@ -12591,13 +12762,13 @@ snapshots: optionalDependencies: '@nuxt/kit': 3.14.1592(magicast@0.3.5)(rollup@4.28.1) - floating-vue@5.2.2(@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@4.34.9))(vue@3.5.13(typescript@5.5.4)): + floating-vue@5.2.2(@nuxt/kit@3.14.1592(magicast@0.3.5))(vue@3.5.13(typescript@5.5.4)): dependencies: '@floating-ui/dom': 1.1.1 vue: 3.5.13(typescript@5.5.4) vue-resize: 2.0.0-alpha.1(vue@3.5.13(typescript@5.5.4)) optionalDependencies: - '@nuxt/kit': 3.14.1592(magicast@0.3.5)(rollup@4.34.9) + '@nuxt/kit': 3.14.1592(magicast@0.3.5) for-each@0.3.3: dependencies: @@ -12778,6 +12949,14 @@ snapshots: slash: 5.1.0 unicorn-magic: 0.1.0 + glsl-token-functions@1.0.1: {} + + glsl-token-string@1.0.1: {} + + glsl-tokenizer@2.1.5: + dependencies: + through2: 0.6.5 + gopd@1.0.1: dependencies: get-intrinsic: 1.2.4 @@ -13314,6 +13493,8 @@ snapshots: dependencies: system-architecture: 0.1.0 + isarray@0.0.1: {} + isarray@1.0.0: {} isarray@2.0.5: {} @@ -14882,6 +15063,8 @@ snapshots: preact: 10.23.2 web-vitals: 4.2.3 + potpack@1.0.2: {} + preact@10.23.2: {} preferred-pm@4.1.1: @@ -14966,6 +15149,13 @@ snapshots: parse-json: 5.2.0 type-fest: 0.6.0 + readable-stream@1.0.34: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 0.0.1 + string_decoder: 0.10.31 + readable-stream@2.3.8(patch_hash=h52dazg37p4h3yox67pw36akse): dependencies: core-util-is: 1.0.3 @@ -15585,6 +15775,13 @@ snapshots: transitivePeerDependencies: - openapi-types + stats-gl@2.4.2(@types/three@0.172.0)(three@0.172.0): + dependencies: + '@types/three': 0.172.0 + three: 0.172.0 + + stats.js@0.17.0: {} + statuses@2.0.1: {} std-env@3.8.0: {} @@ -15636,6 +15833,8 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.0.0 + string_decoder@0.10.31: {} + string_decoder@1.1.1: dependencies: safe-buffer: 5.1.2 @@ -15839,8 +16038,31 @@ snapshots: dependencies: any-promise: 1.3.0 + three-custom-shader-material@5.4.0(three@0.172.0): + dependencies: + glsl-token-functions: 1.0.1 + glsl-token-string: 1.0.1 + glsl-tokenizer: 2.1.5 + object-hash: 3.0.0 + three: 0.172.0 + + three-stdlib@2.36.0(three@0.172.0): + dependencies: + '@types/draco3d': 1.4.10 + '@types/offscreencanvas': 2019.7.3 + '@types/webxr': 0.5.21 + draco3d: 1.5.7 + fflate: 0.6.10 + potpack: 1.0.2 + three: 0.172.0 + three@0.172.0: {} + through2@0.6.5: + dependencies: + readable-stream: 1.0.34 + xtend: 4.0.2 + tiny-invariant@1.3.3: {} tinyexec@0.3.1: {} @@ -16029,9 +16251,9 @@ snapshots: trough: 2.2.0 vfile: 6.0.3 - unimport@3.14.4(rollup@3.29.4): + unimport@3.14.4: dependencies: - '@rollup/pluginutils': 5.1.3(rollup@3.29.4) + '@rollup/pluginutils': 5.1.3(rollup@4.28.1) acorn: 8.14.0 escape-string-regexp: 5.0.0 estree-walker: 3.0.3 @@ -16049,9 +16271,9 @@ snapshots: - rollup optional: true - unimport@3.14.4(rollup@4.28.1): + unimport@3.14.4(rollup@3.29.4): dependencies: - '@rollup/pluginutils': 5.1.3(rollup@4.28.1) + '@rollup/pluginutils': 5.1.3(rollup@3.29.4) acorn: 8.14.0 escape-string-regexp: 5.0.0 estree-walker: 3.0.3 @@ -16067,10 +16289,11 @@ snapshots: unplugin: 1.16.0 transitivePeerDependencies: - rollup + optional: true - unimport@3.14.4(rollup@4.34.9): + unimport@3.14.4(rollup@4.28.1): dependencies: - '@rollup/pluginutils': 5.1.3(rollup@4.34.9) + '@rollup/pluginutils': 5.1.3(rollup@4.28.1) acorn: 8.14.0 escape-string-regexp: 5.0.0 estree-walker: 3.0.3 @@ -16086,7 +16309,6 @@ snapshots: unplugin: 1.16.0 transitivePeerDependencies: - rollup - optional: true unist-util-find-after@5.0.0: dependencies: @@ -16742,6 +16964,8 @@ snapshots: commander: 2.20.3 cssfilter: 0.0.10 + xtend@4.0.2: {} + xxhash-wasm@1.1.0: {} y18n@5.0.8: {}