diff --git a/assets/test_character.glb b/assets/test_character.glb new file mode 100644 index 0000000..d085390 Binary files /dev/null and b/assets/test_character.glb differ diff --git a/index.html b/index.html index f99f98a..b114e1a 100644 --- a/index.html +++ b/index.html @@ -9,28 +9,53 @@
- +
-

Debug Controls

-
- +
+

Debug Controls

+
-
- -
-
- - -
-
- Model Status: - No model loaded +
+
+ +
+
+ +
+
+ +
+
+ + +
+
+ +
+
+ + +
+
+ Model Status: + No model loaded +
@@ -42,8 +67,17 @@ + + + + + + - + + + + diff --git a/src/cameraManager.js b/src/cameraManager.js new file mode 100644 index 0000000..ea74011 --- /dev/null +++ b/src/cameraManager.js @@ -0,0 +1,26 @@ +// Camera Setup +class CameraManager { + constructor() { + this.camera = new THREE.PerspectiveCamera( + 75, + window.innerWidth / window.innerHeight, + 0.1, + 1000 + ); + this.camera.position.set(5, 5, 5); + this.camera.lookAt(0, 0, 0); + + this.setupResizeHandler(); + } + + setupResizeHandler() { + window.addEventListener('resize', () => { + this.camera.aspect = window.innerWidth / window.innerHeight; + this.camera.updateProjectionMatrix(); + }); + } + + getCamera() { + return this.camera; + } +} diff --git a/src/controlsManager.js b/src/controlsManager.js new file mode 100644 index 0000000..2567171 --- /dev/null +++ b/src/controlsManager.js @@ -0,0 +1,19 @@ +// Orbit Controls Setup +class ControlsManager { + constructor(camera, renderer) { + this.controls = new THREE.OrbitControls(camera, renderer.domElement); + this.controls.enabled = false; + this.controls.enableDamping = true; + this.controls.dampingFactor = 0.05; + } + + getControls() { + return this.controls; + } + + update() { + if (this.controls.enabled) { + this.controls.update(); + } + } +} diff --git a/src/debugUI.js b/src/debugUI.js new file mode 100644 index 0000000..712a5a8 --- /dev/null +++ b/src/debugUI.js @@ -0,0 +1,279 @@ +// Debug UI Controller +class DebugUI { + constructor(scene, camera, controls, gridHelper, loader) { + this.scene = scene; + this.camera = camera; + this.controls = controls; + this.gridHelper = gridHelper; + this.loader = loader; + this.loadedModel = null; + this.skeletonHelper = null; + this.animationMixer = null; + this.animations = []; + this.currentAction = null; + this.isMinimized = false; + + this.init(); + } + + init() { + this.setupMinimize(); + this.setupOrbitControls(); + this.setupGridToggle(); + this.setupSkeletonToggle(); + this.setupAnimationControls(); + this.setupAssetSelector(); + } + + setupMinimize() { + const debugUI = document.getElementById('debug-ui'); + const minimizeBtn = document.getElementById('minimize-btn'); + + minimizeBtn.addEventListener('click', () => { + this.isMinimized = !this.isMinimized; + debugUI.classList.toggle('minimized', this.isMinimized); + minimizeBtn.textContent = this.isMinimized ? '+' : '−'; + }); + } + + setupOrbitControls() { + document.getElementById('orbit-controls-toggle').addEventListener('change', (event) => { + this.controls.enabled = event.target.checked; + }); + } + + setupGridToggle() { + document.getElementById('show-grid-toggle').addEventListener('change', (event) => { + this.gridHelper.visible = event.target.checked; + }); + } + + setupSkeletonToggle() { + document.getElementById('show-skeleton-toggle').addEventListener('change', (event) => { + if (event.target.checked) { + this.showSkeleton(); + } else { + this.hideSkeleton(); + } + }); + } + + showSkeleton() { + if (!this.loadedModel) return; + + this.loadedModel.traverse((child) => { + if (child.isSkinnedMesh && child.skeleton) { + if (!this.skeletonHelper) { + this.skeletonHelper = new THREE.SkeletonHelper(this.loadedModel); + this.skeletonHelper.material.linewidth = 2; + this.scene.add(this.skeletonHelper); + } + } + }); + } + + hideSkeleton() { + if (this.skeletonHelper) { + this.scene.remove(this.skeletonHelper); + this.skeletonHelper = null; + } + } + + setupAnimationControls() { + // Animation selector + document.getElementById('animation-select').addEventListener('change', (event) => { + this.selectAnimation(event.target.value); + }); + + // Play animation toggle + document.getElementById('play-animation-toggle').addEventListener('change', (event) => { + if (event.target.checked) { + this.playAnimation(); + } else { + this.pauseAnimation(); + } + }); + } + + selectAnimation(animationName) { + if (!this.animationMixer || !animationName) { + this.pauseAnimation(); + return; + } + + // Stop current action + if (this.currentAction) { + this.currentAction.stop(); + } + + // Find and play selected animation + const clip = this.animations.find(anim => anim.name === animationName); + if (clip) { + this.currentAction = this.animationMixer.clipAction(clip); + + // Auto-play if checkbox is checked + if (document.getElementById('play-animation-toggle').checked) { + this.currentAction.play(); + } + } + } + + playAnimation() { + if (this.currentAction) { + this.currentAction.play(); + } + } + + pauseAnimation() { + if (this.currentAction) { + this.currentAction.paused = true; + } + } + + updateAnimationMixer(delta) { + if (this.animationMixer) { + this.animationMixer.update(delta); + } + } + + // Program custom animations here + createCustomAnimations() { + // Example: Create a custom rotation animation + const times = [0, 2]; + const values = [0, Math.PI*2]; + const track = new THREE.NumberKeyframeTrack('.rotation[y]', times, values); + const clip = new THREE.AnimationClip('RotateY', 2, [track]); + return [clip]; + + return []; // Return array of custom AnimationClip objects + } + + setupAssetSelector() { + // Available assets in the assets folder + const assets = [ + 'assets/test_character.glb' + // Add more assets here as needed + ]; + + // Populate asset dropdown + const assetSelect = document.getElementById('asset-select'); + assets.forEach(assetPath => { + const option = document.createElement('option'); + option.value = assetPath; + option.textContent = assetPath.split('/').pop(); // Show only filename + assetSelect.appendChild(option); + }); + + // Asset selection handler + assetSelect.addEventListener('change', (event) => { + this.loadAsset(event.target.value); + }); + } + + loadAsset(assetPath) { + if (!assetPath) { + // Remove model if "No Model" is selected + if (this.loadedModel) { + this.scene.remove(this.loadedModel); + this.loadedModel = null; + } + this.updateModelStatus('No model loaded'); + return; + } + + // Load selected asset + this.updateModelStatus('Loading...'); + + this.loader.load( + assetPath, + (gltf) => { + // Remove previous model if exists + if (this.loadedModel) { + this.scene.remove(this.loadedModel); + } + + // Remove previous skeleton helper if exists + this.hideSkeleton(); + + // Uncheck skeleton toggle + document.getElementById('show-skeleton-toggle').checked = false; + + this.loadedModel = gltf.scene; + + // Get bounding box + const box = new THREE.Box3().setFromObject(this.loadedModel); + const size = box.getSize(new THREE.Vector3()); + const center = box.getCenter(new THREE.Vector3()); + + // Calculate max dimension + const maxDim = Math.max(size.x, size.y, size.z); + + // Scale model to fit in a 4 unit cube (fits nicely with the grid) + const targetSize = 4; + const scale = targetSize / maxDim; + this.loadedModel.scale.setScalar(scale); + + // Recalculate box after scaling + box.setFromObject(this.loadedModel); + box.getCenter(center); + + // Center the model at origin on X and Z, but place bottom at y=0 + this.loadedModel.position.x = -center.x; + this.loadedModel.position.y = -box.min.y; + this.loadedModel.position.z = -center.z; + + this.scene.add(this.loadedModel); + + // Setup animations + this.animations = gltf.animations || []; + + // Add custom animations + const customAnimations = this.createCustomAnimations(); + this.animations = [...this.animations, ...customAnimations]; + + if (this.animations.length > 0) { + this.animationMixer = new THREE.AnimationMixer(this.loadedModel); + + // Populate animation dropdown + const animSelect = document.getElementById('animation-select'); + animSelect.innerHTML = ''; + + this.animations.forEach(clip => { + const option = document.createElement('option'); + option.value = clip.name; + option.textContent = clip.name; + animSelect.appendChild(option); + }); + } else { + this.animationMixer = null; + document.getElementById('animation-select').innerHTML = ''; + } + + // Reset animation controls + document.getElementById('animation-select').value = ''; + document.getElementById('play-animation-toggle').checked = false; + + // Check for skeleton + let hasSkeleton = false; + this.loadedModel.traverse((child) => { + if (child.isSkinnedMesh) { + hasSkeleton = true; + console.log('Skeleton found:', child.skeleton); + } + }); + + const statusText = hasSkeleton ? 'Model loaded & fitted (with skeleton)' : 'Model loaded & fitted'; + this.updateModelStatus(statusText); + }, + undefined, + (error) => { + console.error('Error loading model:', error); + this.updateModelStatus('Error loading model'); + } + ); + } + + updateModelStatus(status) { + document.getElementById('model-status').textContent = status; + } +} diff --git a/src/main.js b/src/main.js index 75a8bf4..5194c85 100644 --- a/src/main.js +++ b/src/main.js @@ -1,125 +1,42 @@ -// Scene setup -const scene = new THREE.Scene(); -scene.background = new THREE.Color(0xffffff); - -// Camera setup -const camera = new THREE.PerspectiveCamera( - 75, - window.innerWidth / window.innerHeight, - 0.1, - 1000 +// Initialize all managers +const sceneManager = new SceneManager(); +const cameraManager = new CameraManager(); +const rendererManager = new RendererManager(); +const controlsManager = new ControlsManager( + cameraManager.getCamera(), + rendererManager.getRenderer() ); -camera.position.set(5, 5, 5); -camera.lookAt(0, 0, 0); -// Renderer setup -const renderer = new THREE.WebGLRenderer({ antialias: true }); -renderer.setSize(window.innerWidth, window.innerHeight); -renderer.setPixelRatio(window.devicePixelRatio); -document.getElementById('canvas-container').appendChild(renderer.domElement); - -// Orbit Controls (initially disabled) -const controls = new THREE.OrbitControls(camera, renderer.domElement); -controls.enabled = false; -controls.enableDamping = true; -controls.dampingFactor = 0.05; - -// Lighting -const ambientLight = new THREE.AmbientLight(0xffffff, 0.6); -scene.add(ambientLight); - -const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8); -directionalLight.position.set(5, 10, 5); -scene.add(directionalLight); - -// Grid -const gridHelper = new THREE.GridHelper(20, 20, 0x000000, 0x888888); -scene.add(gridHelper); - -// Model container -let loadedModel = null; +// Get instances +const scene = sceneManager.getScene(); +const camera = cameraManager.getCamera(); +const renderer = rendererManager.getRenderer(); +const controls = controlsManager.getControls(); +const gridHelper = sceneManager.getGridHelper(); // GLB Loader const loader = new THREE.GLTFLoader(); -// File input handler -document.getElementById('file-input').addEventListener('change', (event) => { - const file = event.target.files[0]; - if (file) { - const reader = new FileReader(); - reader.onload = (e) => { - const arrayBuffer = e.target.result; - - // Remove previous model if exists - if (loadedModel) { - scene.remove(loadedModel); - } - - // Load the GLB - loader.parse(arrayBuffer, '', (gltf) => { - loadedModel = gltf.scene; - - // Get bounding box - const box = new THREE.Box3().setFromObject(loadedModel); - const size = box.getSize(new THREE.Vector3()); - const center = box.getCenter(new THREE.Vector3()); - - // Calculate max dimension - const maxDim = Math.max(size.x, size.y, size.z); - - // Scale model to fit in a 4 unit cube (fits nicely with the grid) - const targetSize = 4; - const scale = targetSize / maxDim; - loadedModel.scale.setScalar(scale); - - // Recalculate box after scaling - box.setFromObject(loadedModel); - box.getCenter(center); - - // Center the model at origin on X and Z, but place bottom at y=0 - loadedModel.position.x = -center.x; - loadedModel.position.y = -box.min.y; - loadedModel.position.z = -center.z; - - scene.add(loadedModel); - document.getElementById('model-status').textContent = 'Model loaded & fitted'; - }, (error) => { - console.error('Error loading model:', error); - document.getElementById('model-status').textContent = 'Error loading model'; - }); - }; - reader.readAsArrayBuffer(file); - document.getElementById('model-status').textContent = 'Loading...'; - } -}); - -// Orbit controls toggle -document.getElementById('orbit-controls-toggle').addEventListener('change', (event) => { - controls.enabled = event.target.checked; -}); - -// Grid toggle -document.getElementById('show-grid-toggle').addEventListener('change', (event) => { - gridHelper.visible = event.target.checked; -}); +// Initialize Debug UI +const debugUI = new DebugUI(scene, camera, controls, gridHelper, loader); // Animation loop +const clock = new THREE.Clock(); + function animate() { requestAnimationFrame(animate); - if (controls.enabled) { - controls.update(); - } + const delta = clock.getDelta(); - renderer.render(scene, camera); + // Update controls + controlsManager.update(); + + // Update animations + debugUI.updateAnimationMixer(delta); + + // Render scene + rendererManager.render(scene, camera); } -// Handle window resize -window.addEventListener('resize', () => { - camera.aspect = window.innerWidth / window.innerHeight; - camera.updateProjectionMatrix(); - renderer.setSize(window.innerWidth, window.innerHeight); -}); - // Start animation animate(); diff --git a/src/rendererManager.js b/src/rendererManager.js new file mode 100644 index 0000000..1d187fa --- /dev/null +++ b/src/rendererManager.js @@ -0,0 +1,25 @@ +// Renderer Setup +class RendererManager { + constructor() { + this.renderer = new THREE.WebGLRenderer({ antialias: true }); + this.renderer.setSize(window.innerWidth, window.innerHeight); + this.renderer.setPixelRatio(window.devicePixelRatio); + document.getElementById('canvas-container').appendChild(this.renderer.domElement); + + this.setupResizeHandler(); + } + + setupResizeHandler() { + window.addEventListener('resize', () => { + this.renderer.setSize(window.innerWidth, window.innerHeight); + }); + } + + getRenderer() { + return this.renderer; + } + + render(scene, camera) { + this.renderer.render(scene, camera); + } +} diff --git a/src/sceneManager.js b/src/sceneManager.js new file mode 100644 index 0000000..d03d2a9 --- /dev/null +++ b/src/sceneManager.js @@ -0,0 +1,32 @@ +// Scene Setup +class SceneManager { + constructor() { + this.scene = new THREE.Scene(); + this.scene.background = new THREE.Color(0xffffff); + + this.setupLighting(); + this.setupGrid(); + } + + setupLighting() { + const ambientLight = new THREE.AmbientLight(0xffffff, 0.6); + this.scene.add(ambientLight); + + const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8); + directionalLight.position.set(5, 10, 5); + this.scene.add(directionalLight); + } + + setupGrid() { + this.gridHelper = new THREE.GridHelper(20, 20, 0x000000, 0x888888); + this.scene.add(this.gridHelper); + } + + getScene() { + return this.scene; + } + + getGridHelper() { + return this.gridHelper; + } +} diff --git a/styles/main.css b/styles/main.css index 5b5285d..91fea54 100644 --- a/styles/main.css +++ b/styles/main.css @@ -38,10 +38,56 @@ canvas { font-size: 16px; font-weight: bold; color: black; - border-bottom: 2px solid #333; padding-bottom: 8px; } +.debug-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; + border-bottom: 2px solid #333; + padding-bottom: 0px; +} + +.debug-header h3 { + margin: 0; + border: none; + padding: 0; +} + +#minimize-btn { + background: #333; + color: white; + border: none; + border-radius: 10px; + width: 24px; + height: 24px; + cursor: pointer; + font-size: 18px; + line-height: 1; + display: flex; + align-items: center; + justify-content: center; + padding: 0; +} + +#minimize-btn:hover { + background: #555; +} + +#debug-content { + transition: max-height 0.3s ease, opacity 0.3s ease; + max-height: 500px; + opacity: 1; + overflow: hidden; +} + +#debug-ui.minimized #debug-content { + max-height: 0; + opacity: 0; +} + .debug-item { margin-bottom: 12px; } @@ -73,7 +119,19 @@ canvas { border-radius: 4px; } -.file-label { +.debug-item select { + width: 100%; + padding: 6px; + margin-top: 5px; + font-size: 13px; + border: 1px solid #ccc; + border-radius: 4px; + background: white; + cursor: pointer; +} + +.file-label, +.asset-label { display: block; margin-bottom: 5px; font-weight: bold;