diff --git a/src/ImportBootstrap.ts b/src/ImportBootstrap.ts new file mode 100644 index 0000000..94b55c0 --- /dev/null +++ b/src/ImportBootstrap.ts @@ -0,0 +1,230 @@ +import * as THREE from 'three' +import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js' +import { BVHImporter } from './lib/processes/import/BVHImporter' +import { skeletonStorage } from './lib/services/SkeletonStorage' +import { add_preview_skeleton_from_bvh, remove_preview_skeleton } from './lib/processes/load-skeleton/PreviewSkeletonManager' +import { ThemeManager } from './lib/ThemeManager' +import tippy from 'tippy.js' +import 'tippy.js/dist/tippy.css' +import './environment.js' + +class ImportBootstrap { + private readonly camera = this.createCamera() + private readonly renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }) + private controls: OrbitControls | undefined + private readonly scene = new THREE.Scene() + private readonly bvhImporter = new BVHImporter() + private readonly themeManager = new ThemeManager() + private currentPreviewArmature: THREE.Object3D | null = null + + constructor () { + this.setupEnvironment() + this.addEventListeners() + this.setupTooltips() + this.injectBuildVersion() + this.animate() + this.updateStoredSkeletonsList() + } + + private createCamera (): THREE.PerspectiveCamera { + const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000) + camera.position.set(0, 1.7, 5) + return camera + } + + private setupEnvironment (): void { + this.renderer.setSize(window.innerWidth, window.innerHeight) + this.renderer.shadowMap.enabled = true + this.renderer.toneMapping = THREE.ACESFilmicToneMapping + this.renderer.toneMappingExposure = 2.0 + document.body.appendChild(this.renderer.domElement) + + this.controls = new OrbitControls(this.camera, this.renderer.domElement) + this.controls.target.set(0, 0.9, 0) + this.controls.minDistance = 0.5 + this.controls.maxDistance = 30 + this.controls.update() + + // Add lights + const ambientLight = new THREE.AmbientLight(0xffffff, 0.6) + this.scene.add(ambientLight) + + const directionalLight = new THREE.DirectionalLight(0xffffff, 1) + directionalLight.position.set(5, 10, 7) + directionalLight.castShadow = true + this.scene.add(directionalLight) + + // Add grid + const gridHelper = new THREE.GridHelper(20, 20, 0x4f6f6f, 0x2d4353) + this.scene.add(gridHelper) + + // Handle window resize + window.addEventListener('resize', () => { + this.camera.aspect = window.innerWidth / window.innerHeight + this.camera.updateProjectionMatrix() + this.renderer.setSize(window.innerWidth, window.innerHeight) + }) + } + + private addEventListeners (): void { + // Theme changes + this.themeManager.addEventListener('theme-changed', () => { + this.updateGridColor() + }) + + // BVH file upload + const fileInput = document.getElementById('import-bvh-upload') as HTMLInputElement + fileInput?.addEventListener('change', async (event) => { + const files = (event.target as HTMLInputElement).files + if (files && files.length > 0) { + await this.handleBVHImport(files[0]) + } + }) + + // Attribution link + document.getElementById('attribution-link')?.addEventListener('click', (event) => { + event.preventDefault() + this.showContributorsDialog() + }) + + // Theme toggle buttons + document.querySelectorAll('#theme-toggle').forEach(button => { + button.addEventListener('click', () => { + this.themeManager.toggle_theme() + }) + }) + } + + private async handleBVHImport (file: File): Promise { + try { + console.log('Importing BVH file:', file.name) + + // Read file content as text + const bvhContent = await this.readFileAsText(file) + + // Parse to preview + const result = await this.bvhImporter.importFromFile(file) + + if (!result) { + console.error('Failed to import BVH') + return + } + + // Remove previous preview + if (this.currentPreviewArmature) { + remove_preview_skeleton(this.scene) + } + + // Store the skeleton with BVH content for persistence + const skeletonName = file.name.replace(/\.bvh$/i, '') + await skeletonStorage.storeSkeletonFromBVH(skeletonName, bvhContent) + + // Show preview + this.currentPreviewArmature = result.armature.clone() + await add_preview_skeleton_from_bvh(this.scene, this.currentPreviewArmature) + + // Update UI + const nameElement = document.getElementById('imported-skeleton-name') + if (nameElement) { + nameElement.textContent = skeletonName + } + + const infoElement = document.getElementById('imported-skeleton-info') + if (infoElement) { + infoElement.style.display = 'block' + } + + // Update stored skeletons list + this.updateStoredSkeletonsList() + + console.log('BVH imported successfully:', { + name: skeletonName, + boneCount: result.skeleton.bones.length, + animationCount: result.animations.length + }) + + } catch (error) { + console.error('Error importing BVH:', error) + alert('Failed to import BVH file. Please ensure it is a valid BVH format.') + } + } + + private updateStoredSkeletonsList (): void { + const listElement = document.getElementById('stored-skeletons-list') + if (!listElement) return + + const skeletons = skeletonStorage.getAllSkeletonsInfo() + + if (skeletons.length === 0) { + listElement.innerHTML = '

No imported skeletons yet.

' + return + } + + listElement.innerHTML = skeletons.map(skeleton => ` +
+ ${skeleton.name} + ${skeleton.animationCount} animation${skeleton.animationCount !== 1 ? 's' : ''} +
+ `).join('') + } + + private readFileAsText (file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = (event) => { + const text = event.target?.result as string + if (text) { + resolve(text) + } else { + reject(new Error('Failed to read file')) + } + } + reader.onerror = () => reject(new Error('File read error')) + reader.readAsText(file) + }) + } + + private updateGridColor (): void { + // Remove old grid + const oldGrid = this.scene.getObjectByName('GridHelper') + if (oldGrid) { + this.scene.remove(oldGrid) + } + + // Add new grid with appropriate color + const isLight = this.themeManager.get_current_theme() === 'light' + const gridColor = isLight ? 0xcccccc : 0x4f6f6f + const gridHelper = new THREE.GridHelper(20, 20, gridColor, isLight ? 0xecf0f1 : 0x2d4353) + gridHelper.name = 'GridHelper' + this.scene.add(gridHelper) + } + + private setupTooltips (): void { + tippy('[data-tippy-content]', { theme: 'mesh2motion' }) + } + + private injectBuildVersion (): void { + const buildVersionElement = document.getElementById('build-version') + const commitSha = (window as unknown as { CLOUDFLARE_COMMIT_SHA?: string }).CLOUDFLARE_COMMIT_SHA + const branch = (window as unknown as { CLOUDFLARE_BRANCH?: string }).CLOUDFLARE_BRANCH + if (buildVersionElement && commitSha) { + buildVersionElement.textContent = `git:${commitSha.slice(0, 9)}-${branch ?? 'unknown'}` + } + } + + private showContributorsDialog (): void { + // Simple alert for now - could be a proper modal + alert('Contributors: Mesh2Motion Team') + } + + private animate (): void { + requestAnimationFrame(() => this.animate()) + this.controls?.update() + this.renderer.render(this.scene, this.camera) + } +} + +// Initialize when DOM is ready +document.addEventListener('DOMContentLoaded', () => { + new ImportBootstrap() +}) diff --git a/src/Mesh2MotionEngine.ts b/src/Mesh2MotionEngine.ts index 608d619..e7997df 100644 --- a/src/Mesh2MotionEngine.ts +++ b/src/Mesh2MotionEngine.ts @@ -370,7 +370,11 @@ export class Mesh2MotionEngine { } else if (this.process_step === ProcessStep.AnimationsListing) { this.process_step = ProcessStep.AnimationsListing - this.animations_listing_step.begin(this.load_skeleton_step.skeleton_type(), this.load_skeleton_step.skeleton_scale()) + this.animations_listing_step.begin( + this.load_skeleton_step.skeleton_type(), + this.load_skeleton_step.skeleton_scale(), + this.load_skeleton_step.get_selected_custom_skeleton_id() + ) // update reference of skeleton helper to use the final skinned mesh this.regenerate_skeleton_helper(this.weight_skin_step.skeleton()) @@ -385,6 +389,9 @@ export class Mesh2MotionEngine { this.update_a_pose_options_visibility() this.animations_listing_step.load_and_apply_default_animation_to_skinned_mesh(this.weight_skin_step.final_skinned_meshes()) + .catch((err) => { + console.error('Error loading animations for custom skeleton:', err) + }) if (this.skeleton_helper !== undefined) { this.skeleton_helper.hide() // hide skeleton helper in animations listing step diff --git a/src/create.html b/src/create.html index 97dc885..b4d65b3 100644 --- a/src/create.html +++ b/src/create.html @@ -22,6 +22,7 @@ Explore Use Your Model Use Your Rigged Model + Import
@@ -189,7 +190,7 @@
100% - +
@@ -202,7 +203,6 @@ - @@ -428,4 +428,4 @@ - \ No newline at end of file + diff --git a/src/import.html b/src/import.html new file mode 100644 index 0000000..9c9956a --- /dev/null +++ b/src/import.html @@ -0,0 +1,104 @@ + + + + Mesh2Motion - Import Skeleton + + + + + + + + + + + + + +
+
+ Rotate +
+
+ Pan +
+
+ Zoom +
+
+ + +
+ +
+ +
+
+
+ 1 + Import Skeleton +
+ +
+

Import a custom skeleton with animation from a BVH file. The skeleton will be available in the Create page.

+ +
+ + + + +
+ +
+ +
+ Stored Skeletons +
+

No imported skeletons yet.

+
+
+ +
+ + +
+
+
+ +
+ + diff --git a/src/index.html b/src/index.html index f01d817..d2a41bf 100644 --- a/src/index.html +++ b/src/index.html @@ -26,6 +26,7 @@ Explore Use Your Model Use Your Rigged Model + Import
@@ -200,4 +201,4 @@ - \ No newline at end of file + diff --git a/src/lib/EventListeners.ts b/src/lib/EventListeners.ts index 4b2738f..5a4362e 100644 --- a/src/lib/EventListeners.ts +++ b/src/lib/EventListeners.ts @@ -4,6 +4,7 @@ import { ProcessStep } from './enums/ProcessStep' import { TransformSpace } from './enums/TransformSpace' import { Utility } from './Utilities' import { ModelCleanupUtility } from './processes/load-model/ModelCleanupUtility' +import { skeletonStorage } from './services/SkeletonStorage.ts' export class EventListeners { constructor (private readonly bootstrap: Mesh2MotionEngine) {} @@ -41,7 +42,7 @@ export class EventListeners { // listen for view helper changes document.getElementById('view-control-hitbox')?.addEventListener('pointerdown', (event: PointerEvent) => { - if (this.bootstrap.view_helper.handleClick(event)) { + if (this.bootstrap.view_helper !== undefined && this.bootstrap.view_helper.handleClick(event)) { event.stopPropagation() event.preventDefault() } @@ -62,10 +63,9 @@ export class EventListeners { this.bootstrap.handle_transform_controls_mouse_down(event) // update UI with current bone name - if (this.bootstrap.ui.dom_selected_bone_label !== null && - this.bootstrap.edit_skeleton_step.get_currently_selected_bone() !== null) { - this.bootstrap.ui.dom_selected_bone_label.innerHTML = - this.bootstrap.edit_skeleton_step.get_currently_selected_bone().name + const selected_bone = this.bootstrap.edit_skeleton_step.get_currently_selected_bone() + if (this.bootstrap.ui.dom_selected_bone_label !== null && selected_bone !== null) { + this.bootstrap.ui.dom_selected_bone_label.innerHTML = selected_bone.name } }, false) @@ -73,7 +73,9 @@ export class EventListeners { // we can know about the "mouseup" event with this this.bootstrap.transform_controls?.addEventListener('dragging-changed', (event: any) => { this.bootstrap.is_transform_controls_dragging = event.value - this.bootstrap.controls.enabled = !event.value + if (this.bootstrap.controls !== undefined) { + this.bootstrap.controls.enabled = !event.value + } // Store undo state when we start dragging (event.value = true) if (event.value && this.bootstrap.process_step === ProcessStep.EditSkeleton) { @@ -93,6 +95,8 @@ export class EventListeners { }) this.bootstrap.ui.dom_bind_pose_button?.addEventListener('click', () => { + const corrections = this.bootstrap.edit_skeleton_step.get_rest_pose_rotation_corrections() + skeletonStorage.setRestPoseRotationCorrections(corrections) this.bootstrap.setup_weight_skinning_config() this.bootstrap.process_step_changed(ProcessStep.BindPose) }) @@ -117,7 +121,11 @@ export class EventListeners { this.bootstrap.ui.dom_show_skeleton_checkbox?.addEventListener('click', (event: MouseEvent) => { if (this.bootstrap.skeleton_helper !== undefined) { - this.bootstrap.skeleton_helper.visible = event.target.checked + const target = event.target as HTMLInputElement | null + if (target === null) { + return + } + this.bootstrap.skeleton_helper.visible = target.checked } else { console.warn('Skeleton helper is undefined, so we cannot show it') } @@ -159,7 +167,8 @@ export class EventListeners { }) this.bootstrap.ui.dom_transform_type_radio_group?.addEventListener('change', (event: Event) => { - const radio_button_selected: string | null = event.target?.value + const target = event.target as HTMLInputElement | null + const radio_button_selected: string | null = target?.value ?? null if (radio_button_selected === null) { console.warn('Null radio button selected for transform type change') @@ -170,7 +179,8 @@ export class EventListeners { }) this.bootstrap.ui.dom_transform_space_radio_group?.addEventListener('change', (event: Event) => { - const radio_button_selected: string | null = event.target?.value + const target = event.target as HTMLInputElement | null + const radio_button_selected: string | null = target?.value ?? null if (radio_button_selected === null) { console.warn('Null radio button selected for transform space change') @@ -182,7 +192,8 @@ export class EventListeners { // changing the 3d model preview while editing the skeleton bones this.bootstrap.ui.dom_mesh_preview_group?.addEventListener('change', (event: Event) => { - const radio_button_selected: string | null = event.target?.value + const target = event.target as HTMLInputElement | null + const radio_button_selected: string | null = target?.value ?? null if (radio_button_selected === null) { console.warn('Null radio button selected for mesh preview type change') diff --git a/src/lib/Utilities.ts b/src/lib/Utilities.ts index ba8bf5c..09487b4 100644 --- a/src/lib/Utilities.ts +++ b/src/lib/Utilities.ts @@ -1,6 +1,6 @@ import { Vector3, Vector2, type Object3D, Mesh, Group, Bone, type Skeleton, Euler, Raycaster, - type PerspectiveCamera, type Scene, type Object3DEventMap, type BufferAttribute, type BufferGeometry, type InterleavedBufferAttribute + type Camera, type Scene, type Object3DEventMap, type BufferAttribute, type BufferGeometry, type InterleavedBufferAttribute } from 'three' import BoneTransformState from './interfaces/BoneTransformState' import type BoneCalculationData from './interfaces/BoneCalculationData' @@ -221,7 +221,7 @@ export class Utility { } // Find the closest bone for raycaster using screen-space distance to account for camera zoom - static raycast_closest_bone_test (camera: PerspectiveCamera, mouse_event: MouseEvent, skeleton: Skeleton): [Bone | null, number, number] { + static raycast_closest_bone_test (camera: Camera, mouse_event: MouseEvent, skeleton: Skeleton): [Bone | null, number, number] { const raycaster: Raycaster = new Raycaster() raycaster.setFromCamera(Utility.normalized_mouse_position(mouse_event), camera) const mouse_position = Utility.normalized_mouse_position(mouse_event) diff --git a/src/lib/enums/ProcessStep.ts b/src/lib/enums/ProcessStep.ts index 92ccb17..ecbd4e6 100644 --- a/src/lib/enums/ProcessStep.ts +++ b/src/lib/enums/ProcessStep.ts @@ -1,5 +1,6 @@ export enum ProcessStep { LoadModel = 'load-model', + Import = 'import', LoadSkeleton = 'load-skeleton', EditSkeleton = 'edit-skeleton', WeightSkin = 'weight-skin', diff --git a/src/lib/enums/SkeletonType.ts b/src/lib/enums/SkeletonType.ts index 93ddfd8..a3b3461 100644 --- a/src/lib/enums/SkeletonType.ts +++ b/src/lib/enums/SkeletonType.ts @@ -6,6 +6,7 @@ export enum SkeletonType { Bird = 'rigs/rig-bird.glb', Dragon = 'rigs/rig-dragon.glb', Kaiju = 'rigs/rig-kaiju.glb', + Custom = 'CUSTOM', Error = 'ERROR', None = 'NONE' } diff --git a/src/lib/processes/animations-listing/AnimationUtility.ts b/src/lib/processes/animations-listing/AnimationUtility.ts index 54deaaf..532685b 100644 --- a/src/lib/processes/animations-listing/AnimationUtility.ts +++ b/src/lib/processes/animations-listing/AnimationUtility.ts @@ -171,6 +171,57 @@ export class AnimationUtility { this.apply_hips_position_mirroring(animation_clips) } + static apply_rest_pose_rotation_corrections (animation_clips: AnimationClip[], corrections: Map): void { + if (corrections.size === 0) { + return + } + + const units_in_quaternions = 4 + + animation_clips.forEach((animation_clip: AnimationClip) => { + animation_clip.tracks.forEach((track: KeyframeTrack) => { + if (!track.name.includes('.quaternion')) { + return + } + + let bone_name: string | null = null + const simple_match = track.name.match(/^([^.]+)\.quaternion$/) + if (simple_match !== null) { + bone_name = simple_match[1] + } else { + const bones_match = track.name.match(/\.bones\[([^\]]+)\]\.quaternion$/) + if (bones_match !== null) { + bone_name = bones_match[1] + } + } + + if (bone_name === null) { + return + } + + const correction = corrections.get(bone_name) + if (correction === undefined) { + return + } + + const values = track.values + for (let i = 0; i < values.length; i += units_in_quaternions) { + const quat = new Quaternion( + values[i], + values[i + 1], + values[i + 2], + values[i + 3] + ) + quat.multiply(correction) + values[i] = quat.x + values[i + 1] = quat.y + values[i + 2] = quat.z + values[i + 3] = quat.w + } + }) + }) + } + /** * Mirrors quaternion values by inverting X and W components for proper reflection. * This creates the mathematical mirror of the rotation. diff --git a/src/lib/processes/animations-listing/StepAnimationsListing.ts b/src/lib/processes/animations-listing/StepAnimationsListing.ts index 3aa9aeb..e9694e9 100644 --- a/src/lib/processes/animations-listing/StepAnimationsListing.ts +++ b/src/lib/processes/animations-listing/StepAnimationsListing.ts @@ -13,6 +13,7 @@ import { Utility } from '../../Utilities.ts' import { type ThemeManager } from '../../ThemeManager.ts' import { AnimationSearch } from './AnimationSearch.ts' import { type TransformedAnimationClipPair } from './interfaces/TransformedAnimationClipPair.ts' +import { skeletonStorage } from '../../services/SkeletonStorage.ts' // Note: EventTarget is a built-ininterface and do not need to import it export class StepAnimationsListing extends EventTarget { @@ -26,6 +27,7 @@ export class StepAnimationsListing extends EventTarget { private skinned_meshes_to_animate: SkinnedMesh[] = [] private current_playing_index: number = 0 private skeleton_type: SkeletonType = SkeletonType.Human + private custom_skeleton_id: string | null = null private animations_file_path: string = 'animations/' @@ -59,8 +61,10 @@ export class StepAnimationsListing extends EventTarget { this.theme_manager = theme_manager } - public begin (skeleton_type: SkeletonType, skeleton_scale: number): void { + public begin (skeleton_type: SkeletonType, skeleton_scale: number, custom_skeleton_id: string | null = null): void { this.skeleton_scale = skeleton_scale + this.custom_skeleton_id = custom_skeleton_id + if (this.ui.dom_current_step_index != null) { this.ui.dom_current_step_index.innerHTML = '4' @@ -91,6 +95,18 @@ export class StepAnimationsListing extends EventTarget { this.update_download_button_enabled() } + private apply_rest_pose_rotation_corrections (): void { + const corrections = skeletonStorage.getRestPoseRotationCorrections() + if (corrections === null || corrections.size === 0) { + return + } + + this.animation_clips_loaded.forEach((clip_pair) => { + AnimationUtility.apply_rest_pose_rotation_corrections([clip_pair.original_animation_clip], corrections) + AnimationUtility.apply_rest_pose_rotation_corrections([clip_pair.display_animation_clip], corrections) + }) + } + public reset_step_data (): void { // reset previous state if we are re-entering this step // this will happen if we are reskinning the mesh after changes @@ -119,12 +135,26 @@ export class StepAnimationsListing extends EventTarget { return this.animation_clips_loaded.map(clip => clip.display_animation_clip) } - public load_and_apply_default_animation_to_skinned_mesh (final_skinned_meshes: SkinnedMesh[]): void { + public async load_and_apply_default_animation_to_skinned_mesh (final_skinned_meshes: SkinnedMesh[]): Promise { this.skinned_meshes_to_animate = final_skinned_meshes // Set the animations file path on the loader this.animation_loader.set_animations_file_path(this.animations_file_path) + // Handle custom skeleton - load animations from storage + if (this.skeleton_type === SkeletonType.Custom && this.custom_skeleton_id) { + const storedSkeleton = await skeletonStorage.getSkeleton(this.custom_skeleton_id) + if (storedSkeleton && storedSkeleton.animations.length > 0) { + this.load_custom_animations(storedSkeleton.animations) + return + } else { + console.warn('No animations found for custom skeleton:', this.custom_skeleton_id) + return + } + } + + // The AnimationLoader handles GLB selection internally now + // Reset the animation clips loaded this.animation_clips_loaded = [] // Create an animation mixer to do the playback. Play the first by default @@ -143,6 +173,7 @@ export class StepAnimationsListing extends EventTarget { } private onAllAnimationsLoaded (): void { + this.apply_rest_pose_rotation_corrections() // sort all animation names alphabetically this.animation_clips_loaded.sort((a: TransformedAnimationClipPair, b: TransformedAnimationClipPair) => { if (a.display_animation_clip.name < b.display_animation_clip.name) { return -1 } @@ -222,6 +253,14 @@ export class StepAnimationsListing extends EventTarget { warped_clip.display_animation_clip = AnimationUtility.deep_clone_animation_clip(warped_clip.original_animation_clip) }) + const corrections = skeletonStorage.getRestPoseRotationCorrections() + if (corrections !== null && corrections.size > 0) { + AnimationUtility.apply_rest_pose_rotation_corrections( + this.animation_clips_loaded.map(clip => clip.display_animation_clip), + corrections + ) + } + if (this.mirror_animations_enabled) { AnimationUtility.apply_animation_mirroring(this.animation_clips_loaded) } @@ -382,4 +421,32 @@ export class StepAnimationsListing extends EventTarget { } return this.animation_search.get_selected_animation_indices() } + + /** + * Load custom animations from imported skeleton storage + */ + private load_custom_animations (animations: AnimationClip[]): void { + this.animation_clips_loaded = [] + this.animation_mixer = new AnimationMixer(new Object3D()) + + // Process the custom animations through the same pipeline + const cloned_anims: AnimationClip[] = AnimationUtility.deep_clone_animation_clips(animations) + + // Clean track data + AnimationUtility.clean_track_data(cloned_anims) + + // Apply skeleton scaling + AnimationUtility.apply_skeleton_scale_to_position_keyframes(cloned_anims, this.skeleton_scale) + + // Add to animation clips loaded + this.animation_clips_loaded.push(...cloned_anims.map(clip => ({ + original_animation_clip: clip, + display_animation_clip: AnimationUtility.deep_clone_animation_clip(clip) + }))) + + console.log(`Loaded ${this.animation_clips_loaded.length} custom animations`) + + this.onAllAnimationsLoaded() + this.play_animation(0) + } } diff --git a/src/lib/processes/edit-skeleton/StepEditSkeleton.ts b/src/lib/processes/edit-skeleton/StepEditSkeleton.ts index 4db8539..be901ae 100644 --- a/src/lib/processes/edit-skeleton/StepEditSkeleton.ts +++ b/src/lib/processes/edit-skeleton/StepEditSkeleton.ts @@ -15,9 +15,12 @@ import { Points, Float32BufferAttribute, TextureLoader, - type Camera + type Camera, + Quaternion, + Matrix4 } from 'three' import { SkeletonType } from '../../enums/SkeletonType.ts' +import type BoneTransformState from '../../interfaces/BoneTransformState.ts' /* * StepEditSkeleton @@ -54,6 +57,8 @@ export class StepEditSkeleton extends EventTarget { private _added_event_listeners: boolean = false private readonly preview_plane_manager: PreviewPlaneManager = PreviewPlaneManager.getInstance() + private original_bone_transforms: BoneTransformState[] | null = null + constructor () { super() this.ui = UI.getInstance() @@ -190,7 +195,7 @@ export class StepEditSkeleton extends EventTarget { * @description This is the bone that is currently selected in the UI while editing * the skeleton. */ - public set_currently_selected_bone (bone: Bone): void { + public set_currently_selected_bone (bone: Bone | null): void { this.currently_selected_bone = bone } @@ -254,13 +259,21 @@ export class StepEditSkeleton extends EventTarget { if (this.ui.dom_mirror_skeleton_checkbox !== null) { this.ui.dom_mirror_skeleton_checkbox.addEventListener('change', (event) => { + const target = event.target as HTMLInputElement | null + if (target === null) { + return + } // mirror skeleton movements along the X axis - this.set_mirror_mode_enabled(event.target.checked) + this.set_mirror_mode_enabled(target.checked) }) } this.ui.dom_enable_skin_debugging?.addEventListener('change', (event) => { - this.show_debug = event.target.checked + const target = event.target as HTMLInputElement | null + if (target === null) { + return + } + this.show_debug = target.checked this.update_bind_button_text() }) @@ -382,12 +395,14 @@ export class StepEditSkeleton extends EventTarget { // Initialize the undo/redo system with the skeleton this.undo_redo_system.set_skeleton(this.threejs_skeleton) + + // Store the original rest pose for correction calculations + this.original_bone_transforms = Utility.store_bone_transforms(this.threejs_skeleton) } private create_threejs_skeleton_object (): Skeleton { // create skeleton and helper to visualize this.threejs_skeleton = Generators.create_skeleton(this.edited_armature.children[0]) - this.threejs_skeleton.name = 'Editing Skeleton' // update the world matrix for the skeleton // without this the skeleton helper won't appear when the bones are first loaded @@ -404,6 +419,140 @@ export class StepEditSkeleton extends EventTarget { return this.threejs_skeleton } + /** + * Compute per-bone rotation corrections from the original rest pose to the edited rest pose. + * These corrections can be applied to animation keyframes to keep rotations aligned. + */ + public get_rest_pose_rotation_corrections (): Map { + const corrections = new Map() + + if (this.original_bone_transforms === null) { + return corrections + } + + const find_bone = (names: string[]): Bone | undefined => { + const name_set = names.map(name => name.toLowerCase()) + return this.threejs_skeleton.bones.find((bone) => { + const bone_name = bone.name.toLowerCase() + return name_set.some(name => bone_name === name || bone_name.includes(name)) + }) + } + + const compute_forward_vector = (): Vector3 => { + const hips = find_bone(['hips', 'pelvis']) + const spine = find_bone(['spine', 'spine1', 'spine2', 'lowerback']) + const left_leg = find_bone(['leftupleg', 'lhip', 'lefthip', 'lhipjoint']) + const right_leg = find_bone(['rightupleg', 'rhip', 'righthip', 'rhipjoint']) + const left_arm = find_bone(['leftarm', 'leftshoulder']) + const right_arm = find_bone(['rightarm', 'rightshoulder']) + + let up = new Vector3(0, 1, 0) + if (hips !== undefined && spine !== undefined) { + const hips_pos = Utility.world_position_from_object(hips) + const spine_pos = Utility.world_position_from_object(spine) + const up_dir = new Vector3().subVectors(spine_pos, hips_pos) + if (up_dir.lengthSq() > 0) { + up = up_dir.normalize() + } + } + + let left_right = new Vector3(1, 0, 0) + if (left_leg !== undefined && right_leg !== undefined) { + const left_pos = Utility.world_position_from_object(left_leg) + const right_pos = Utility.world_position_from_object(right_leg) + const lr_dir = new Vector3().subVectors(right_pos, left_pos) + if (lr_dir.lengthSq() > 0) { + left_right = lr_dir.normalize() + } + } else if (left_arm !== undefined && right_arm !== undefined) { + const left_pos = Utility.world_position_from_object(left_arm) + const right_pos = Utility.world_position_from_object(right_arm) + const lr_dir = new Vector3().subVectors(right_pos, left_pos) + if (lr_dir.lengthSq() > 0) { + left_right = lr_dir.normalize() + } + } + + const forward = new Vector3().crossVectors(left_right, up) + if (forward.lengthSq() === 0) { + return new Vector3(0, 0, 1) + } + return forward.normalize() + } + + const compute_rest_rotation = (bone: Bone, forward: Vector3): Quaternion | null => { + const child = bone.children.find(child_obj => child_obj.type === 'Bone') as Bone | undefined + if (child === undefined) { + return null + } + + const bone_position = Utility.world_position_from_object(bone) + const child_position = Utility.world_position_from_object(child) + const direction = new Vector3().subVectors(child_position, bone_position) + if (direction.lengthSq() === 0) { + return null + } + const y_axis = direction.normalize() + + let z_axis = forward.clone().sub(y_axis.clone().multiplyScalar(forward.dot(y_axis))) + if (z_axis.lengthSq() === 0) { + const parent = bone.parent + if (parent !== null && parent.type === 'Bone') { + const parent_pos = Utility.world_position_from_object(parent as Bone) + const parent_dir = new Vector3().subVectors(bone_position, parent_pos) + z_axis = parent_dir.cross(y_axis) + } + } + + if (z_axis.lengthSq() === 0) { + const fallback_axis = Math.abs(y_axis.y) < 0.99 ? new Vector3(0, 1, 0) : new Vector3(1, 0, 0) + z_axis = fallback_axis.cross(y_axis) + } + + z_axis.normalize() + const x_axis = new Vector3().crossVectors(y_axis, z_axis).normalize() + z_axis.crossVectors(x_axis, y_axis).normalize() + + const basis = new Matrix4().makeBasis(x_axis, y_axis, z_axis) + return new Quaternion().setFromRotationMatrix(basis) + } + + const current_bone_transforms = Utility.store_bone_transforms(this.threejs_skeleton) + Utility.restore_bone_transforms(this.threejs_skeleton, this.original_bone_transforms) + this.threejs_skeleton.bones.forEach((bone) => bone.updateWorldMatrix(true, true)) + + const forward = compute_forward_vector() + const original_rest_rotations = new Map() + this.threejs_skeleton.bones.forEach((bone) => { + const rest_rotation = compute_rest_rotation(bone, forward) + if (rest_rotation !== null) { + original_rest_rotations.set(bone.name, rest_rotation) + } + }) + + Utility.restore_bone_transforms(this.threejs_skeleton, current_bone_transforms) + this.threejs_skeleton.bones.forEach((bone) => bone.updateWorldMatrix(true, true)) + + this.threejs_skeleton.bones.forEach((bone) => { + const original_rest_rotation = original_rest_rotations.get(bone.name) + if (original_rest_rotation === undefined) { + return + } + + const edited_rest_rotation = compute_rest_rotation(bone, forward) + if (edited_rest_rotation === null) { + return + } + + const correction = edited_rest_rotation.clone().invert().multiply(original_rest_rotation) + if (1 - Math.abs(correction.w) > 1e-5) { + corrections.set(bone.name, correction) + } + }) + + return corrections + } + public apply_mirror_mode (selected_bone: Bone, transform_type: string): void { // if we are on the positive side mirror mode is enabled // we need to change the position of the bone on the other side of the mirror diff --git a/src/lib/processes/import/BVHImporter.ts b/src/lib/processes/import/BVHImporter.ts new file mode 100644 index 0000000..ff95c65 --- /dev/null +++ b/src/lib/processes/import/BVHImporter.ts @@ -0,0 +1,112 @@ +import { BVHLoader } from 'three/examples/jsm/loaders/BVHLoader.js' +import { type AnimationClip, type Skeleton, Object3D, Bone } from 'three' +import { ModalDialog } from '../../ModalDialog' + +export interface BVHImportResult { + skeleton: Skeleton + armature: Object3D + animations: AnimationClip[] +} + +export class BVHImporter { + private readonly loader: BVHLoader + + constructor () { + this.loader = new BVHLoader() + } + + public async importFromFile (file: File): Promise { + return await new Promise((resolve, reject) => { + const reader = new FileReader() + + reader.onload = (event) => { + const text = event.target?.result as string + if (!text) { + new ModalDialog('Error reading BVH file', 'Failed to read file content.').show() + reject(new Error('Failed to read file')) + return + } + + try { + const result = this.parseBVHText(text) + resolve(result) + } catch (error) { + console.error('Error parsing BVH:', error) + new ModalDialog( + 'Error parsing BVH file', + 'The BVH file could not be parsed. Please ensure it is a valid BVH format.' + ).show() + reject(error) + } + } + + reader.onerror = () => { + new ModalDialog('Error reading file', 'Failed to read the selected file.').show() + reject(new Error('File read error')) + } + + reader.readAsText(file) + }) + } + + public parseBVHText (text: string): BVHImportResult { + // Use Three.js BVHLoader to parse the BVH data + const result = this.loader.parse(text) + + // The BVHLoader returns { clip: AnimationClip, skeleton: Skeleton } + const skeleton = result.skeleton as Skeleton + const animationClip = result.clip as AnimationClip + + // Create an armature Object3D that wraps the skeleton bones + // This matches the structure expected by the rest of the application + const armature = this.createArmatureFromSkeleton(skeleton) + + // Store animations + const animations: AnimationClip[] = [] + if (animationClip) { + // Rename the clip to the BVH file content or a default name + animationClip.name = animationClip.name || 'Imported Animation' + animations.push(animationClip) + } + + return { + skeleton, + armature, + animations + } + } + + private createArmatureFromSkeleton (skeleton: Skeleton): Object3D { + // Create an armature container (similar to what GLTF returns) + const armature = new Object3D() + armature.name = 'BVH_Armature' + + // Find the root bone (the one with no parent in the skeleton) + const rootBone = this.findRootBone(skeleton) + + if (rootBone) { + // Add the root bone to the armature + armature.add(rootBone) + + // Update the bone's world matrix + rootBone.updateWorldMatrix(true, true) + } + + return armature + } + + private findRootBone (skeleton: Skeleton): Bone | null { + const bones = skeleton.bones + + // Find the bone that doesn't have its parent in the skeleton bones array + for (const bone of bones) { + const parent = bone.parent + if (parent == null || !bones.includes(parent as Bone)) { + return bone + } + } + + // Fallback: return the first bone + return bones[0] ?? null + } +} diff --git a/src/lib/processes/import/StepImport.ts b/src/lib/processes/import/StepImport.ts new file mode 100644 index 0000000..7b67026 --- /dev/null +++ b/src/lib/processes/import/StepImport.ts @@ -0,0 +1,165 @@ +import { UI } from '../../UI.ts' +import { Object3D, type Scene, type AnimationClip } from 'three' +import { BVHImporter, type BVHImportResult } from './BVHImporter' +import { ModalDialog } from '../../ModalDialog' +import { SkeletonType } from '../../enums/SkeletonType' +import { add_preview_skeleton_from_bvh, remove_preview_skeleton } from '../load-skeleton/PreviewSkeletonManager' + +export class StepImport extends EventTarget { + private readonly ui: UI = UI.getInstance() + private readonly _main_scene: Scene + private readonly bvh_importer: BVHImporter = new BVHImporter() + + private _added_event_listeners: boolean = false + private loaded_armature: Object3D = new Object3D() + private loaded_animations: AnimationClip[] = [] + private has_imported_skeleton: boolean = false + + constructor (main_scene: Scene) { + super() + this._main_scene = main_scene + } + + public begin (): void { + if (this.ui.dom_current_step_index !== null) { + this.ui.dom_current_step_index.innerHTML = '2' + } + + if (this.ui.dom_current_step_element !== null) { + this.ui.dom_current_step_element.innerHTML = 'Import Skeleton' + } + + if (this.ui.dom_import_tools !== null) { + this.ui.dom_import_tools.style.display = 'flex' + } + + // if we are navigating back to this step, we don't want to add the event listeners again + if (!this._added_event_listeners) { + this.add_event_listeners() + this._added_event_listeners = true + } + + // Disable proceed button until skeleton is imported + this.allow_proceeding_to_next_step(false) + + // If we already have an imported skeleton, show it + if (this.has_imported_skeleton) { + this.show_preview_skeleton() + this.allow_proceeding_to_next_step(true) + } + } + + public dispose (): void { + remove_preview_skeleton(this._main_scene) + } + + private add_event_listeners (): void { + // BVH file upload event listener + if (this.ui.dom_import_bvh_button !== null) { + this.ui.dom_import_bvh_button.addEventListener('change', async (event: Event) => { + const file_input = event.target as HTMLInputElement + const file = file_input.files?.[0] + + if (!file) { + return + } + + // Check if it's a BVH file + if (!file.name.toLowerCase().endsWith('.bvh')) { + new ModalDialog('Invalid file type', 'Please select a .bvh file.').show() + return + } + + try { + await this.import_bvh_file(file) + } catch (error) { + console.error('Failed to import BVH:', error) + } finally { + // Clear the input so the same file can be imported again if needed + file_input.value = '' + } + }) + } + } + + private async import_bvh_file (file: File): Promise { + console.log('Importing BVH file:', file.name) + + const result = await this.bvh_importer.importFromFile(file) + + if (!result) { + console.error('Failed to import BVH - no result returned') + return + } + + // Store the imported data + this.loaded_armature = result.armature.clone() + this.loaded_armature.name = 'Imported BVH Armature' + this.loaded_animations = result.animations + this.has_imported_skeleton = true + + // Update UI + if (this.ui.dom_imported_skeleton_name !== null) { + this.ui.dom_imported_skeleton_name.textContent = file.name + } + + // Show the preview + this.show_preview_skeleton() + + // Enable proceeding to next step + this.allow_proceeding_to_next_step(true) + + // Dispatch event with the imported data + this.dispatchEvent(new CustomEvent('skeletonImported', { + detail: { + armature: this.loaded_armature, + animations: this.loaded_animations + } + })) + + console.log('BVH imported successfully:', { + boneCount: result.skeleton.bones.length, + animationCount: result.animations.length + }) + } + + private show_preview_skeleton (): void { + if (!this.has_imported_skeleton) { + return + } + + // Use the existing preview skeleton manager to show the imported skeleton + add_preview_skeleton_from_bvh(this._main_scene, this.loaded_armature).catch((err) => { + console.error('Error showing preview skeleton:', err) + }) + } + + private allow_proceeding_to_next_step (allow: boolean): void { + if (this.ui.dom_import_skeleton_button !== null) { + this.ui.dom_import_skeleton_button.disabled = !allow + } + } + + // Public getters for other steps to access the imported data + public armature (): Object3D { + return this.loaded_armature + } + + public animations (): AnimationClip[] { + return this.loaded_animations + } + + public skeleton_type (): SkeletonType { + // Return Custom type for imported skeletons + return SkeletonType.Custom + } + + public has_skeleton (): boolean { + return this.has_imported_skeleton + } + + public skeleton_scale (): number { + // For BVH imports, we use scale of 1.0 (no scaling needed as bones are imported as-is) + return 1.0 + } +} diff --git a/src/lib/processes/load-skeleton/PreviewSkeletonManager.ts b/src/lib/processes/load-skeleton/PreviewSkeletonManager.ts index 5aa477a..f0cbe50 100644 --- a/src/lib/processes/load-skeleton/PreviewSkeletonManager.ts +++ b/src/lib/processes/load-skeleton/PreviewSkeletonManager.ts @@ -1,4 +1,4 @@ -import { Group, type Object3D, type Object3DEventMap, SkeletonHelper, type Scene } from 'three' +import { Group, Object3D, type Object3DEventMap, SkeletonHelper, type Scene } from 'three' import { type GLTF, GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader' import type GLTFResult from './interfaces/GLTFResult' import { type HandSkeletonType, SkeletonType } from '../../enums/SkeletonType' @@ -69,3 +69,40 @@ export function remove_preview_skeleton (root: Scene): void { skeleton_group.parent.remove(skeleton_group) } } + +// Function to add a preview skeleton from an already-loaded BVH armature +export async function add_preview_skeleton_from_bvh ( + root: Scene, + armature: Object3D, + skeleton_scale: number = 1.0 +): Promise { + // Remove existing preview + remove_preview_skeleton(root) + + // Bake the scale into the armature bone positions + if (skeleton_scale !== 1) { + armature.scale.set(1, 1, 1) + armature.traverse((obj) => { + if (obj instanceof Object3D && obj !== armature) { + obj.position.multiplyScalar(skeleton_scale) + } + }) + armature.updateMatrixWorld(true) + } + + // Create new preview skeleton group + const preview_skeleton_group = new Group() + preview_skeleton_group.name = skeleton_group_name + preview_skeleton_group.userData.is_bvh_import = true + // Don't apply scale at group level since we've baked it into positions + preview_skeleton_group.scale.set(1, 1, 1) + root.add(preview_skeleton_group) + + // Create skeleton helper from the armature + const skeleton_helper = new SkeletonHelper(armature) + skeleton_helper.name = 'preview_skeleton' + preview_skeleton_group.add(skeleton_helper) + + // Add the armature itself to the group so it can be animated + preview_skeleton_group.add(armature) +} diff --git a/src/lib/processes/load-skeleton/StepLoadSkeleton.ts b/src/lib/processes/load-skeleton/StepLoadSkeleton.ts index 23d6408..9cb41f5 100644 --- a/src/lib/processes/load-skeleton/StepLoadSkeleton.ts +++ b/src/lib/processes/load-skeleton/StepLoadSkeleton.ts @@ -4,8 +4,9 @@ import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js' import { SkeletonType, type HandSkeletonType } from '../../enums/SkeletonType.js' import type GLTFResult from './interfaces/GLTFResult.ts' import { add_origin_markers, remove_origin_markers } from './OriginMarkerManager' -import { add_preview_skeleton, remove_preview_skeleton } from './PreviewSkeletonManager.ts' +import { add_preview_skeleton, remove_preview_skeleton, add_preview_skeleton_from_bvh } from './PreviewSkeletonManager.ts' import { HandHelper } from './HandHelper.ts' +import { skeletonStorage } from '../../services/SkeletonStorage.ts' // Note: EventTarget is a built-ininterface and do not need to import it export class StepLoadSkeleton extends EventTarget { @@ -26,6 +27,13 @@ export class StepLoadSkeleton extends EventTarget { // probably could refactor this a bit to be cleaner later. private manual_set_skeleton_type: SkeletonType = SkeletonType.None + // Store the ID of the selected custom skeleton from storage + private selected_custom_skeleton_id: string | null = null + + public get_selected_custom_skeleton_id (): string | null { + return this.selected_custom_skeleton_id + } + public skeleton_type (): SkeletonType { if (this.skeleton_file_path() === SkeletonType.None) { return this.manual_set_skeleton_type @@ -62,6 +70,9 @@ export class StepLoadSkeleton extends EventTarget { this.ui.dom_load_skeleton_tools.style.display = 'flex' } + // Populate the dropdown with any imported skeletons from storage + this.populate_skeleton_dropdown() + // if we are navigating back to this step, we don't want to add the event listeners again if (!this._added_event_listeners) { this.add_event_listeners() @@ -71,10 +82,17 @@ export class StepLoadSkeleton extends EventTarget { // when we come back to this step, there is a good chance we already selected a skeleton // so just use that and load the preview right when we enter this step if (!this.has_select_skeleton_ui_option()) { - add_preview_skeleton(this._main_scene, this.skeleton_file_path(), - this.hand_skeleton_type(), this.skeleton_scale_percentage).catch((err) => { - console.error('error loading preview skeleton: ', err) - }) + const selectedValue = this.ui.dom_skeleton_drop_type?.value + if (selectedValue?.startsWith('custom:')) { + // Load custom skeleton from storage + this.load_custom_skeleton_preview(selectedValue) + } else { + // Load standard skeleton + add_preview_skeleton(this._main_scene, this.skeleton_file_path(), + this.hand_skeleton_type(), this.skeleton_scale_percentage).catch((err) => { + console.error('error loading preview skeleton: ', err) + }) + } } // Initialize hand skeleton hand options visibility @@ -92,6 +110,78 @@ export class StepLoadSkeleton extends EventTarget { } } + /** + * Populate the skeleton dropdown with imported skeletons from storage + */ + private populate_skeleton_dropdown (): void { + const dropdown = this.ui.dom_skeleton_drop_type + if (!dropdown) return + + // Remove any existing custom skeleton options (keep the standard ones) + const optionsToRemove: HTMLOptionElement[] = [] + for (let i = 0; i < dropdown.options.length; i++) { + const option = dropdown.options[i] + if (option.value.startsWith('custom:')) { + optionsToRemove.push(option) + } + } + optionsToRemove.forEach(option => dropdown.removeChild(option)) + + // Add imported skeletons from storage (using sync metadata method) + const storedSkeletons = skeletonStorage.getAllSkeletonsInfo() + if (storedSkeletons.length > 0) { + // Add a separator option + const separator = document.createElement('option') + separator.disabled = true + separator.textContent = '--- Imported ---' + dropdown.appendChild(separator) + + // Add each stored skeleton + storedSkeletons.forEach(skeleton => { + const option = document.createElement('option') + option.value = `custom:${skeleton.id}` + option.textContent = skeleton.name + dropdown.appendChild(option) + }) + } + } + + /** + * Load a custom skeleton from storage and show preview + */ + private async load_custom_skeleton_preview (customId: string): Promise { + const id = customId.replace('custom:', '') + const storedSkeleton = await skeletonStorage.getSkeleton(id) + + if (!storedSkeleton) { + console.error('Custom skeleton not found:', id) + return + } + + this.selected_custom_skeleton_id = id + + // Clone and bake scale into armature positions (scale 1.0 since we bake it) + const clonedArmature = storedSkeleton.armature.clone() + clonedArmature.name = storedSkeleton.name + clonedArmature.position.set(0, 0, 0) + + // Bake scale into positions + if (this.skeleton_scale_percentage !== 1) { + clonedArmature.traverse((obj) => { + if (obj instanceof Object3D && obj !== clonedArmature) { + obj.position.multiplyScalar(this.skeleton_scale_percentage) + } + }) + } + clonedArmature.updateMatrixWorld(true) + + // Show preview with scale 1.0 (scale is baked into positions) + add_preview_skeleton_from_bvh(this._main_scene, clonedArmature, 1.0) + .catch((err) => { + console.error('Error loading custom skeleton preview:', err) + }) + } + public regenerate_origin_markers (): void { add_origin_markers(this._main_scene) } @@ -106,6 +196,11 @@ export class StepLoadSkeleton extends EventTarget { const skeleton_selection = this.ui.dom_skeleton_drop_type.options const skeleton_file: string = skeleton_selection[skeleton_selection.selectedIndex].value + // Check if it's a custom skeleton + if (skeleton_file.startsWith('custom:')) { + return SkeletonType.Custom + } + // set the skeleton type. This will be used for the animations listing later // so it knows what animations to load switch (skeleton_file) { @@ -151,27 +246,41 @@ export class StepLoadSkeleton extends EventTarget { // show the scale skeleton options in case they are hidden this.ui.dom_scale_skeleton_controls!.style.display = 'flex' - // load the preview skeleton - // need to get the file name for the correct skeleton - // we pass the skeleton scale in the case where we set a skeleton, change scale, then change the skeleton - add_preview_skeleton(this._main_scene, this.skeleton_file_path(), this.hand_skeleton_type(), this.skeleton_scale()).then(() => { - // enable the ability to progress to next step + // Get the selected value + const selectedValue = this.ui.dom_skeleton_drop_type?.value + + // Check if it's a custom skeleton + if (selectedValue?.startsWith('custom:')) { + const customId = selectedValue.replace('custom:', '') + this.selected_custom_skeleton_id = customId + this.load_custom_skeleton_preview(selectedValue) this.allow_proceeding_to_next_step(true) - }).catch((err) => { - console.error('error loading preview skeleton: ', err) - }) + } else { + // Reset custom skeleton selection + this.selected_custom_skeleton_id = null + + // load the preview skeleton + // need to get the file name for the correct skeleton + // we pass the skeleton scale in the case where we set a skeleton, change scale, then change the skeleton + add_preview_skeleton(this._main_scene, this.skeleton_file_path(), this.hand_skeleton_type(), this.skeleton_scale()).then(() => { + // enable the ability to progress to next step + this.allow_proceeding_to_next_step(true) + }).catch((err) => { + console.error('error loading preview skeleton: ', err) + }) + } }) } if (this.ui.dom_load_skeleton_button !== null) { - this.ui.dom_load_skeleton_button.addEventListener('click', () => { + this.ui.dom_load_skeleton_button.addEventListener('click', async () => { if (this.ui.dom_skeleton_drop_type === null) { console.warn('could not find skeleton selection drop down HTML element') return } // add back loading information here - this.load_skeleton_file(this.skeleton_file_path()) + await this.load_skeleton_file(this.skeleton_file_path()) }) }// end if statement @@ -204,14 +313,31 @@ export class StepLoadSkeleton extends EventTarget { if (this.ui.dom_scale_skeleton_percentage_display !== null) { this.ui.dom_scale_skeleton_percentage_display.textContent = display_value } + // re-add the preview skeleton with the new scale - add_preview_skeleton(this._main_scene, this.skeleton_file_path(), this.hand_skeleton_type(), this.skeleton_scale_percentage) - .catch((err) => { - console.error('error loading preview skeleton: ', err) - }) + if (this.selected_custom_skeleton_id) { + // For custom skeletons, reload from storage with new scale + const customId = `custom:${this.selected_custom_skeleton_id}` + this.load_custom_skeleton_preview(customId) + .catch((err) => { + console.error('error loading custom skeleton preview: ', err) + }) + } else { + // For standard skeletons, use the existing function + add_preview_skeleton(this._main_scene, this.skeleton_file_path(), this.hand_skeleton_type(), this.skeleton_scale_percentage) + .catch((err) => { + console.error('error loading preview skeleton: ', err) + }) + } } - public load_skeleton_file (file_path: string): void { + public async load_skeleton_file (file_path: string): Promise { + // Check if this is a custom skeleton from storage + if (this.selected_custom_skeleton_id) { + await this.load_custom_skeleton_from_storage() + return + } + // load skeleton from GLB file this.loader.load(file_path, (gltf: GLTFResult) => { // traverse scene and find first bone object @@ -254,6 +380,52 @@ export class StepLoadSkeleton extends EventTarget { }) } + /** + * Load a custom skeleton from storage + */ + private async load_custom_skeleton_from_storage (): Promise { + if (!this.selected_custom_skeleton_id) { + console.error('No custom skeleton ID selected') + return + } + + const storedSkeleton = await skeletonStorage.getSkeleton(this.selected_custom_skeleton_id) + if (!storedSkeleton) { + console.error('Custom skeleton not found in storage:', this.selected_custom_skeleton_id) + return + } + + // Clone the stored armature + this.loaded_armature = storedSkeleton.armature.clone() + this.loaded_armature.name = storedSkeleton.name + + // reset the armature to 0,0,0 in case it is off for some reason + this.loaded_armature.position.set(0, 0, 0) + this.loaded_armature.updateWorldMatrix(true, true) + + // For custom skeletons, we bake the scale into bone positions directly + // This avoids the double-scaling issue when going back and forth between steps + this.bake_scale_into_armature(this.loaded_armature, this.skeleton_scale()) + + // Dispatch event with the loaded armature + this.dispatchEvent(new CustomEvent('skeletonLoaded', { detail: this.loaded_armature })) + } + + /** + * Bake scale directly into bone positions (for custom skeletons) + */ + private bake_scale_into_armature (armature: Object3D, scale: number): void { + if (scale === 1) return + + armature.scale.set(1, 1, 1) + armature.traverse((obj) => { + if (obj instanceof Object3D && obj !== armature) { + obj.position.multiplyScalar(scale) + } + }) + armature.updateMatrixWorld(true) + } + private has_select_skeleton_ui_option (): boolean { return this.ui.dom_skeleton_drop_type?.options[0].value === 'select-skeleton' } diff --git a/src/lib/services/SkeletonStorage.ts b/src/lib/services/SkeletonStorage.ts new file mode 100644 index 0000000..9f2ce6b --- /dev/null +++ b/src/lib/services/SkeletonStorage.ts @@ -0,0 +1,238 @@ +import { type Object3D, type AnimationClip, Bone, Skeleton } from 'three' +import { type Quaternion } from 'three' +import { BVHImporter } from '../processes/import/BVHImporter' + +export interface StoredSkeletonInfo { + id: string + name: string + bvhContent: string + boneCount: number + animationCount: number + createdAt: number +} + +export interface StoredSkeleton { + id: string + name: string + armature: Object3D + animations: AnimationClip[] + skeleton: Skeleton + createdAt: number +} + +const STORAGE_KEY = 'mesh2motion_imported_skeletons' + +/** + * Persistent storage for imported skeletons using localStorage. + * Stores raw BVH content and re-parses when needed. + * This allows skeletons to persist across page navigations. + */ +export class SkeletonStorage { + private static instance: SkeletonStorage + private bvhImporter: BVHImporter = new BVHImporter() + private skeletonCache: Map = new Map() + private rest_pose_rotation_corrections: Map | null = null + + private constructor () { + // Load metadata from localStorage on initialization + this.loadFromStorage() + } + + public setRestPoseRotationCorrections (corrections: Map): void { + this.rest_pose_rotation_corrections = corrections + } + + public getRestPoseRotationCorrections (): Map | null { + return this.rest_pose_rotation_corrections + } + + public static getInstance (): SkeletonStorage { + if (SkeletonStorage.instance === undefined) { + SkeletonStorage.instance = new SkeletonStorage() + } + return SkeletonStorage.instance + } + + /** + * Store a skeleton with its animations from BVH content + */ + public async storeSkeletonFromBVH (name: string, bvhContent: string): Promise { + const id = `custom-${Date.now()}-${Math.random().toString(36).substr(2, 9)}` + + // Parse the BVH to get metadata + const result = await this.bvhImporter.parseBVHText(bvhContent) + + // Store metadata and BVH content in localStorage + const skeletonInfo: StoredSkeletonInfo = { + id, + name, + bvhContent, + boneCount: result.skeleton.bones.length, + animationCount: result.animations.length, + createdAt: Date.now() + } + + this.saveToStorage(skeletonInfo) + + // Cache the parsed skeleton + this.skeletonCache.set(id, { + id, + name, + armature: result.armature.clone(), + animations: result.animations.map(anim => anim.clone()), + skeleton: result.skeleton, + createdAt: skeletonInfo.createdAt + }) + + console.log(`Stored skeleton "${name}" with id: ${id} (${result.skeleton.bones.length} bones, ${result.animations.length} animations)`) + return id + } + + /** + * Get a stored skeleton by ID (re-parses from BVH if not cached) + */ + public async getSkeleton (id: string): Promise { + // Check cache first + if (this.skeletonCache.has(id)) { + return this.skeletonCache.get(id) + } + + // If not in cache, load from localStorage and re-parse + const info = this.getSkeletonInfo(id) + if (!info) { + return undefined + } + + // Re-parse the BVH content + try { + const result = await this.bvhImporter.parseBVHText(info.bvhContent) + const storedSkeleton: StoredSkeleton = { + id: info.id, + name: info.name, + armature: result.armature.clone(), + animations: result.animations.map(anim => anim.clone()), + skeleton: result.skeleton, + createdAt: info.createdAt + } + + // Cache it for future use + this.skeletonCache.set(id, storedSkeleton) + return storedSkeleton + } catch (error) { + console.error('Error parsing stored BVH:', error) + return undefined + } + } + + /** + * Get skeleton metadata (without parsing) + */ + public getSkeletonInfo (id: string): StoredSkeletonInfo | undefined { + const stored = this.loadAllFromStorage() + return stored.find(s => s.id === id) + } + + /** + * Get all stored skeletons metadata + */ + public getAllSkeletonsInfo (): StoredSkeletonInfo[] { + return this.loadAllFromStorage() + } + + /** + * Get all stored skeletons (full objects - async as they may need parsing) + */ + public async getAllSkeletons (): Promise { + const infos = this.loadAllFromStorage() + const skeletons: StoredSkeleton[] = [] + + for (const info of infos) { + const skeleton = await this.getSkeleton(info.id) + if (skeleton) { + skeletons.push(skeleton) + } + } + + return skeletons + } + + /** + * Remove a stored skeleton + */ + public removeSkeleton (id: string): boolean { + // Remove from cache + this.skeletonCache.delete(id) + + // Remove from localStorage + const stored = this.loadAllFromStorage() + const index = stored.findIndex(s => s.id === id) + if (index !== -1) { + stored.splice(index, 1) + localStorage.setItem(STORAGE_KEY, JSON.stringify(stored)) + return true + } + return false + } + + /** + * Check if any skeletons are stored + */ + public hasSkeletons (): boolean { + return this.loadAllFromStorage().length > 0 + } + + /** + * Get the count of stored skeletons + */ + public getSkeletonCount (): number { + return this.loadAllFromStorage().length + } + + /** + * Clear all stored skeletons + */ + public clearAll (): void { + this.skeletonCache.clear() + localStorage.removeItem(STORAGE_KEY) + } + + /** + * Load metadata from localStorage + */ + private loadFromStorage (): void { + // This just ensures the storage is initialized + // Actual data is loaded on-demand + } + + /** + * Load all skeleton info from localStorage + */ + private loadAllFromStorage (): StoredSkeletonInfo[] { + try { + const stored = localStorage.getItem(STORAGE_KEY) + if (stored) { + return JSON.parse(stored) + } + } catch (error) { + console.error('Error loading skeletons from storage:', error) + } + return [] + } + + /** + * Save skeleton info to localStorage + */ + private saveToStorage (info: StoredSkeletonInfo): void { + try { + const stored = this.loadAllFromStorage() + stored.push(info) + localStorage.setItem(STORAGE_KEY, JSON.stringify(stored)) + } catch (error) { + console.error('Error saving skeleton to storage:', error) + throw new Error('Failed to save skeleton. Storage may be full.') + } + } +} + +// Export singleton instance +export const skeletonStorage = SkeletonStorage.getInstance() diff --git a/src/retarget/index.html b/src/retarget/index.html index 28239ae..02a6b42 100644 --- a/src/retarget/index.html +++ b/src/retarget/index.html @@ -24,6 +24,7 @@ Explore Use Your Model Use Your Rigged Model + Import
@@ -210,4 +211,4 @@

- \ No newline at end of file +