import { ContextManager, useIsLoaded, Observable, useOverlay, started, useOnBeforeRender, registerLoadable } from '@zcomponent/core'; import { CSS } from '@zcomponent/html/lib/CSS'; import * as THREE from 'three'; import { ScaleToFit, AlignTransform, DefaultEnvironment, ShadowPlane, GLTF, MouseOrbit, Group, DirectionalLight, PerspectiveCamera, XAlignment, YAlignment, ZAlignment, useRenderer, useCamera, useScene } from '../index'; import { AnimationClip } from 'three'; import { GLTFTextureMemoryCalculator } from './memory'; import { Menu, MenuItem } from '../menu'; import { createIconRadio } from '../editorcontext'; const svg = ` `; const svg_copy = ` `; const VECTOR_POOL = { v1: new THREE.Vector3(), v2: new THREE.Vector3(), v3: new THREE.Vector3(), cameraPos: new THREE.Vector3(), cameraRight: new THREE.Vector3(), topNear: new THREE.Vector3(), bottomNear: new THREE.Vector3(), topFar: new THREE.Vector3(), planeNormal: new THREE.Vector3(), }; const TOP_RAY = new THREE.Raycaster(); const BOTTOM_RAY = new THREE.Raycaster(); const RAYCAST_COORDS = new THREE.Vector2(); const NEAR_DISTANCE = 0.1; const FAR_DISTANCE = 100; const SHADOW_MAP_SIZE = 1024; const DEFAULT_SLIDER_POSITION = 0.5; interface ModelPreviewConstructorProps { /** @zprop * @zvalues files *.+(glb|gltf) */ comparisonSource?: string; /** @zprop * @zvalues files *.+(glb|gltf) */ source: string; } interface ModelPreviewState { camera?: { matrix: number[]; }; viewMode?: 'Material' | 'Wireframe' | 'Normals' | 'UV'; comparisonViewMode?: 'Original' | 'Optimized' | 'Compare'; } /** * @zcomponent */ export class ModelPreview extends Group { public css = new CSS(this.contextManager, { source: new URL('./ModelPreview.css', import.meta.url).toString(), includeAtDesignTime: true, }); public scaleToFit = new ScaleToFit(this.contextManager, { updateEveryFrame: false, }); public alignTransform = new AlignTransform(this.contextManager, { updateEveryFrame: false, }); public perspectiveCamera = new PerspectiveCamera(this.contextManager, { activateAtStart: true, }); public defaults = new Group(this.contextManager, {}); public directionalLight = new DirectionalLight(this.contextManager, { shadowMapSize: [SHADOW_MAP_SIZE, SHADOW_MAP_SIZE], }); public defaultEnvironment = new DefaultEnvironment(this.contextManager, {}); public shadowPlane = new ShadowPlane(this.contextManager, {}); private overlayContainer: HTMLDivElement; private animationsList: HTMLUListElement; private morphTargetsList: HTMLUListElement; private materialsList: HTMLUListElement; public gltf: GLTF; public gltfComparison?: GLTF; public mouseOrbitBehavior: MouseOrbit; private _source?: string; private _comparisonSource?: string; /** * @zui * @ztype proportion * @zdefault 0.5 * @zgroup Comparison */ public sliderPosition = new Observable(DEFAULT_SLIDER_POSITION); private _isComparisonMode: boolean; private _leftPlane = new THREE.Plane(new THREE.Vector3(-1, 0, 0), 0); private _rightPlane = new THREE.Plane(new THREE.Vector3(1, 0, 0), 0); private _renderer: THREE.WebGLRenderer | null = null; private _camera: THREE.Camera | null = null; private _originalClippingEnabled = false; private _separatorLine: HTMLDivElement | null = null; private compressionInfoDisplay: HTMLDivElement | null = null; private readonly _enableCameraCaching: boolean = true; private _originalModelHash: string | null = null; private _frameCount = 0; private _lastSaveFrame = 0; private readonly SAVE_INTERVAL_FRAMES = 60 * 10; // ~10s constructor(contextManager: ContextManager, constructorProps: ModelPreviewConstructorProps) { super(contextManager, constructorProps); this._source = constructorProps.source; this._comparisonSource = constructorProps.comparisonSource; this._isComparisonMode = !!(constructorProps.source && constructorProps.comparisonSource); this.gltf = new GLTF(contextManager, { source: constructorProps.source, }); if (constructorProps.comparisonSource) { this.gltfComparison = new GLTF(contextManager, { source: constructorProps.comparisonSource, }); } this.mouseOrbitBehavior = new MouseOrbit(contextManager, this.perspectiveCamera); this.createHTMLOverlay(); this.setupProperties(); this.setupHierarchy(); if (this._isComparisonMode) { this._setupComparisonMode(); } registerLoadable(contextManager, this._initializeCameraPosition()); this.register(useIsLoaded(contextManager), this.onLoaded); } public setComparisonFromArrayBuffer = async (buffer: ArrayBuffer) => { const blob = new Blob([buffer], { type: 'model/gltf-binary' }); const blobUrl = URL.createObjectURL(blob); this._updateComparisonModel(blobUrl); }; private async _updateComparisonModel(blobUrl: string): Promise { if (!this.gltfComparison || !this._isComparisonMode) { URL.revokeObjectURL(blobUrl); return; } try { const matrix = this.gltfComparison.element.matrix.clone(); const newGltfComparison = new GLTF(this.contextManager, { source: blobUrl }); await newGltfComparison.loaded; const parent = this.gltfComparison.element.parent; if (parent) { parent.remove(this.gltfComparison.element); parent.add(newGltfComparison.element); newGltfComparison.element.applyMatrix4(matrix); } this.gltfComparison.dispose(); this.gltfComparison = newGltfComparison; this._updateViewMode(); this._updateComparisonViewMode(); } catch (error) { console.error('Failed to update comparison model:', error); } finally { URL.revokeObjectURL(blobUrl); } } private _setupComparisonMode(): void { const renderer = useRenderer(this.contextManager); if (renderer instanceof THREE.WebGLRenderer) { this._renderer = renderer; this._originalClippingEnabled = this._renderer.localClippingEnabled; this._renderer.localClippingEnabled = true; } this.sliderPosition.addListener(value => { this._updateClipping(); this._updateSeparatorPosition(value); }); } private _setupCameraContext(): void { this._camera = useCamera(this.contextManager).value; if (!this._renderer) { const renderer = useRenderer(this.contextManager); if (renderer instanceof THREE.WebGLRenderer) { this._renderer = renderer; } } if (this._originalModelHash && this._camera) { this.restoreCameraState(); } } private _frame = () => { if (this._camera && this.gltf.element && this.gltfComparison?.element) this._updateClipping(); if (this._camera && this.gltf.element && this._enableCameraCaching) { this._frameCount++; if (this._frameCount - this._lastSaveFrame >= this.SAVE_INTERVAL_FRAMES) { this.saveCameraState(); this._lastSaveFrame = this._frameCount; } } }; private _viewMode: 'Material' | 'Wireframe' | 'Normals' | 'UV' = 'Material'; private _comparisonViewMode: 'Original' | 'Optimized' | 'Compare' = 'Compare'; private _wireframeMaterial = new THREE.MeshBasicMaterial({ wireframe: true, color: 0x888888, }); private _normalMaterial = new THREE.MeshNormalMaterial(); private _uvMaterial = new THREE.ShaderMaterial({ uniforms: {}, vertexShader: ` varying vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); } `, fragmentShader: ` varying vec2 vUv; void main() { gl_FragColor = vec4(vUv.x, vUv.y, 0.0, 1.0); } `, }); private _originalMaterials = new WeakMap(); private _viewModeSelect: HTMLSelectElement | null = null; private _comparisonRadios: NodeListOf | null = null; public setToolbarDOM(toolbar: HTMLElement) { const viewModes = document.createElement('select'); viewModes.className = 'zee-toolbar-select'; viewModes.style.marginRight = '8px'; const modes = [ { name: 'Material', value: 'Material' as const }, { name: 'Wireframe', value: 'Wireframe' as const }, { name: 'Normals', value: 'Normals' as const }, { name: 'UV', value: 'UV' as const }, ]; for (const mode of modes) { const option = document.createElement('option'); option.innerText = mode.name; option.value = mode.value; if (mode.value === this._viewMode) { option.selected = true; } viewModes.appendChild(option); } viewModes.addEventListener('change', () => { this._viewMode = viewModes.value as typeof this._viewMode; this._updateViewMode(); this.saveViewState(); }); // Store reference to select element this._viewModeSelect = viewModes; if (this._isComparisonMode) { const comparisonToggle = this._createComparisonToggle(); toolbar.appendChild(comparisonToggle); this._comparisonRadios = toolbar.querySelectorAll('input[name="comparison"]') as NodeListOf; } viewModes.style.marginLeft = 'auto'; toolbar.appendChild(viewModes); const menuItems: MenuItem[] = [ { title: 'Reset Camera', onClick: () => { this._resetCamera(); }, }, ]; const menu = new Menu(menuItems); toolbar.appendChild(menu.dom); } private _updateViewMode(): void { if (!this.contextManager) return; const scene = useScene(this.contextManager); if (!scene) return; if (this.gltf.element) this._applyMaterialOverride(this.gltf.element, this._viewMode); if (this._isComparisonMode && this.gltfComparison?.element) this._applyMaterialOverride(this.gltfComparison.element, this._viewMode); } private _applyMaterialOverride(object: THREE.Object3D, viewMode: typeof this._viewMode): void { object.traverse(child => { if (!(child instanceof THREE.Mesh) || !child.material) return; if (!this._originalMaterials.has(child)) { this._originalMaterials.set(child, child.material); } if (viewMode === 'Material') { const original = this._originalMaterials.get(child); if (original) child.material = original; return; } const overrideMaterial = this._createOverrideMaterial(viewMode); if (!overrideMaterial) return; const originalMaterial = Array.isArray(child.material) ? child.material[0] : child.material; if (originalMaterial.clippingPlanes) { overrideMaterial.clippingPlanes = originalMaterial.clippingPlanes; overrideMaterial.clipShadows = true; overrideMaterial.side = THREE.DoubleSide; } child.material = overrideMaterial; }); } private _createOverrideMaterial(viewMode: typeof this._viewMode): THREE.Material | null { switch (viewMode) { case 'Wireframe': return this._wireframeMaterial.clone(); case 'Normals': return this._normalMaterial.clone(); case 'UV': return this._uvMaterial.clone(); default: return null; } } private _createComparisonToggle(): HTMLFormElement { const form = document.createElement('form'); form.style.marginRight = '8px'; const [originalLabel, original] = createIconRadio('Original', 'comparison'); const [optimisedLabel, optimised] = createIconRadio('Optimized', 'comparison'); const [compareLabel, compare] = createIconRadio('Compare', 'comparison'); compare.checked = this._comparisonViewMode === 'Compare'; original.checked = this._comparisonViewMode === 'Original'; optimised.checked = this._comparisonViewMode === 'Optimized'; original.addEventListener('change', () => { if (original.checked) this._setComparisonViewMode('Original'); }); optimised.addEventListener('change', () => { if (optimised.checked) this._setComparisonViewMode('Optimized'); }); compare.addEventListener('change', () => { if (compare.checked) this._setComparisonViewMode('Compare'); }); form.append(originalLabel); form.append(optimisedLabel); form.append(compareLabel); return form; } private _setComparisonViewMode(mode: 'Original' | 'Optimized' | 'Compare'): void { this._comparisonViewMode = mode; this._updateComparisonViewMode(); this.saveViewState(); } private _updateComparisonViewMode(): void { if (!this._isComparisonMode) return; switch (this._comparisonViewMode) { case 'Original': this._showOnlyOriginal(); break; case 'Optimized': this._showOnlyOptimised(); break; case 'Compare': this._showSplitComparison(); break; } } private _showOnlyOriginal(): void { if (this._renderer) this._renderer.localClippingEnabled = false; if (this.gltf.element) { this._clearClippingPlanes(this.gltf.element); this.gltf.element.visible = true; } if (this.gltfComparison?.element) { this._clearClippingPlanes(this.gltfComparison.element); this.gltfComparison.element.visible = false; } this._hideSeparatorLine(); this._updateViewMode(); } private _showOnlyOptimised(): void { if (this._renderer) this._renderer.localClippingEnabled = false; if (this.gltf.element) { this._clearClippingPlanes(this.gltf.element); this.gltf.element.visible = false; } if (this.gltfComparison?.element) { this._clearClippingPlanes(this.gltfComparison.element); this.gltfComparison.element.visible = true; } this._hideSeparatorLine(); this._updateViewMode(); } private _showSplitComparison(): void { if (this.gltf.element) this.gltf.element.visible = true; if (this.gltfComparison?.element) this.gltfComparison.element.visible = true; if (this._renderer) this._renderer.localClippingEnabled = true; this._showSeparatorLine(); this._updateClipping(); this._updateViewMode(); } private _hideSeparatorLine(): void { if (!this._separatorLine) return; this._separatorLine.style.display = 'none'; this._separatorLine.style.pointerEvents = 'none'; } private _showSeparatorLine(): void { if (!this._separatorLine) return; this._separatorLine.style.display = 'block'; this._separatorLine.style.pointerEvents = 'auto'; } private _resetCamera(): void { this.perspectiveCamera.position.value = [0.67, 0.5, 1.631]; this.mouseOrbitBehavior.initialTarget.value = [0, 0.5, 0]; if (this._originalModelHash && this._enableCameraCaching) { const stateKey = `modelPreview_state_${this._originalModelHash}`; try { localStorage.removeItem(stateKey); } catch (e) { console.warn('Failed to clear saved state:', e); } } } private createHTMLOverlay(): void { this.overlayContainer = document.createElement('div'); const overlayHTML = `

Animations

Morph targets

Materials

`; this.overlayContainer.innerHTML = overlayHTML; document.body.appendChild(this.overlayContainer); this.animationsList = document.createElement('ul'); this.morphTargetsList = document.createElement('ul'); this.materialsList = document.createElement('ul'); const animationsContainer = this.overlayContainer.querySelector('#animations-list'); const morphTargetsContainer = this.overlayContainer.querySelector('#morph-targets-list'); const materialsContainer = this.overlayContainer.querySelector('#materials-list'); animationsContainer?.appendChild(this.animationsList); morphTargetsContainer?.appendChild(this.morphTargetsList); materialsContainer?.appendChild(this.materialsList); } private _createSeparatorLine(): void { if (this._separatorLine) return; const overlay = useOverlay(this.contextManager).value; this._separatorLine = document.createElement('div'); this._separatorLine.className = 'model-preview-separator'; const handle = document.createElement('div'); handle.className = 'model-preview-handle'; handle.innerHTML = svg; this._separatorLine.appendChild(handle); this._setupDragHandling(this._separatorLine, handle, overlay); overlay.appendChild(this._separatorLine); } private _createComparisonInfoDisplays(): void { if (this.compressionInfoDisplay) return; const overlay = useOverlay(this.contextManager).value; this.compressionInfoDisplay = document.createElement('div'); this.compressionInfoDisplay.className = 'model-compression-info panel-base zee-studio'; this.compressionInfoDisplay.innerHTML = `

Optimisation

File size Loading... Loading...
Network Loading... Loading...
Texture VRAM Loading... Loading...
Poly count Loading... Loading...
`; overlay.appendChild(this.compressionInfoDisplay); } private _setupDragHandling(separator: HTMLElement, handle: HTMLElement, overlay: HTMLElement): void { let isDragging = false; let startX = 0; let startPosition = DEFAULT_SLIDER_POSITION; const startDrag = (e: MouseEvent | TouchEvent) => { isDragging = true; startX = e instanceof MouseEvent ? e.clientX : e.touches[0].clientX; startPosition = this.sliderPosition.value; separator.classList.add('dragging'); handle.classList.add('dragging'); document.body.style.userSelect = 'none'; e.preventDefault(); }; const drag = (e: MouseEvent | TouchEvent) => { if (!isDragging) return; const currentX = e instanceof MouseEvent ? e.clientX : e.touches[0].clientX; const deltaX = currentX - startX; const containerWidth = overlay.offsetWidth; const deltaPosition = deltaX / containerWidth; const newPosition = THREE.MathUtils.clamp(startPosition + deltaPosition, 0, 1); this.sliderPosition.value = newPosition; }; const endDrag = () => { if (!isDragging) return; isDragging = false; separator.classList.remove('dragging'); handle.classList.remove('dragging'); document.body.style.userSelect = ''; }; separator.addEventListener('mousedown', startDrag); handle.addEventListener('mousedown', startDrag); document.addEventListener('mousemove', drag); document.addEventListener('mouseup', endDrag); separator.addEventListener('touchstart', startDrag); handle.addEventListener('touchstart', startDrag); document.addEventListener('touchmove', drag); document.addEventListener('touchend', endDrag); } private _updateSeparatorPosition(position: number): void { if (this._separatorLine) this._separatorLine.style.left = `${position * 100}%`; } private onLoaded = () => { this._setupCameraContext(); this.gltf.animationClips.addListener(this.updateAnimationsList); this.gltf.morphTargetMeshes.addListener(this.updateMorphTargetsList); this.gltf.materials.addListener(this.updateMaterialsList); if (this._isComparisonMode) { this._createSeparatorLine(); this._createComparisonInfoDisplays(); useOnBeforeRender(this.contextManager).addListener(this._frame, -10); this._updateClipping(); this.updateComparisonStats(); this._updateComparisonViewMode(); } this._updateViewMode(); if (this._enableCameraCaching) { this._setupCameraCaching(); } }; private _setupCameraCaching(): void { const overlay = useOverlay(this.contextManager).value; overlay.addEventListener('mouseup', () => { setTimeout(() => this.saveCameraState(), 100); }); overlay.addEventListener('touchend', () => { setTimeout(() => this.saveCameraState(), 100); }); } private async _initializeCameraPosition(): Promise { if (!this._source) return; try { const modelSizes = await this.getModelSizes(this._source); if (!modelSizes.hash) return; this._originalModelHash = modelSizes.hash; const stateKey = `modelPreview_state_${this._originalModelHash}`; const savedState = localStorage.getItem(stateKey); if (savedState) { const state: ModelPreviewState = JSON.parse(savedState); this.restoreFromState(state); } } catch (e) { console.warn('Failed to initialize state:', e); } } private restoreFromState(state: ModelPreviewState): void { if (state.viewMode) { this._viewMode = state.viewMode; if (this._viewModeSelect) { this._viewModeSelect.value = this._viewMode; } } if (state.comparisonViewMode && this._isComparisonMode) { this._comparisonViewMode = state.comparisonViewMode; if (this._comparisonRadios) { this._comparisonRadios.forEach(radio => { const label = radio.parentElement?.textContent?.trim(); radio.checked = label === this._comparisonViewMode; }); } } this._updateViewMode(); if (this._isComparisonMode) { this._updateComparisonViewMode(); } if (state.camera?.matrix && Array.isArray(state.camera.matrix) && state.camera.matrix.length === 16) { this._camera = useCamera(this.contextManager).value; if (!this._renderer) { const renderer = useRenderer(this.contextManager); if (renderer instanceof THREE.WebGLRenderer) this._renderer = renderer; } if (this._camera) { this._camera.matrix.fromArray(state.camera.matrix); this._camera.matrix.decompose(this._camera.position, this._camera.quaternion, this._camera.scale); } } } private updateAnimationsList = (clips: Map) => { const animationsPanel = this.overlayContainer.querySelector('.animations-section') as HTMLElement; if (animationsPanel) animationsPanel.style.display = clips.size > 0 ? 'flex' : 'none'; this.animationsList.innerHTML = ''; for (const [name, clip] of clips.entries()) { const label = document.createElement('li'); label.innerText = name; this.animationsList.appendChild(label); const button = document.createElement('button'); button.classList.add('inline'); button.innerText = '▶'; button.addEventListener('click', () => { // Play animation on both models this.playAnimation(clip); }); label.appendChild(button); } }; private playAnimation(clip: AnimationClip): void { this.gltf.mixer?.value?.stopAllAction(); const primaryAction = this.gltf.mixer?.value?.clipAction(clip); primaryAction?.reset(); primaryAction?.play(); if (this._isComparisonMode && this.gltfComparison?.mixer?.value) { const comparisonClip = Array.from(this.gltfComparison.animationClips.value.values()).find(c => c.name === clip.name); if (comparisonClip) { this.gltfComparison.mixer.value.stopAllAction(); const comparisonAction = this.gltfComparison.mixer.value.clipAction(comparisonClip); comparisonAction?.reset(); comparisonAction?.play(); } } } private updateMorphTargetsList = (entries: Map>) => { const morphTargetsPanel = this.overlayContainer.querySelector('.morph-targets-section') as HTMLElement; if (morphTargetsPanel) morphTargetsPanel.style.display = entries.size > 0 ? 'flex' : 'none'; this.morphTargetsList.innerHTML = ''; for (const [name] of entries.entries()) { const label = document.createElement('li'); const textSpan = document.createElement('span'); textSpan.innerText = name; textSpan.style.width = '60px'; label.appendChild(textSpan); this.morphTargetsList.appendChild(label); const input = document.createElement('input'); input.classList.add('zee-input-proportion-range'); input.type = 'range'; input.min = '0'; input.max = '1'; input.step = 'any'; input.value = '0'; input.addEventListener('input', () => { const val = parseFloat(input.value) || 0; this.updateMorphTarget(name, val); }); label.appendChild(input); } }; private updateMorphTarget(name: string, value: number): void { const primaryMeshes = this.gltf.morphTargetMeshes.value.get(name); if (primaryMeshes) { for (const mesh of primaryMeshes.values()) { const indx = mesh.morphTargetDictionary?.[name]; if (indx !== undefined && mesh.morphTargetInfluences) mesh.morphTargetInfluences[indx] = value; } } if (this._isComparisonMode && this.gltfComparison) { const comparisonMeshes = this.gltfComparison.morphTargetMeshes.value.get(name); if (comparisonMeshes) { for (const mesh of comparisonMeshes.values()) { const indx = mesh.morphTargetDictionary?.[name]; if (indx !== undefined && Array.isArray(mesh.morphTargetInfluences)) { mesh.morphTargetInfluences[indx] = value; } } } } } private updateMaterialsList = (entries: Map>) => { this.materialsList.innerHTML = ''; for (const [name] of entries.entries()) { const label = document.createElement('li'); label.innerText = name; const button = document.createElement('button'); button.classList.add('inline'); button.innerHTML = svg_copy; button.title = 'Copy material name'; button.onclick = async e => { e.stopPropagation(); try { await navigator.clipboard.writeText(name); button.innerHTML = '✓'; } catch { button.innerHTML = '✗'; } setTimeout(() => (button.innerHTML = svg_copy), 1000); }; label.appendChild(button); this.materialsList.appendChild(label); } }; private async updateComparisonStats(): Promise { if (!this.gltf.element || !this.gltfComparison?.element) return; if (!this.compressionInfoDisplay) return; const originalPolyCount = this.calculatePolyCount(this.gltf.element); const compressedPolyCount = this.calculatePolyCount(this.gltfComparison.element); const textureCalculator = new GLTFTextureMemoryCalculator(); const originalTextureReport = this._calculateTextureMemoryWithOriginalMaterials(this.gltf.element, textureCalculator); const compressedTextureReport = this._calculateTextureMemoryWithOriginalMaterials(this.gltfComparison.element, textureCalculator); const [originalSizes, compressedSizes] = await Promise.all([this.getModelSizes(this._source), this.getModelSizes(this._comparisonSource)]); const originalElements = this.compressionInfoDisplay.querySelectorAll('.info-original'); const compressedElements = this.compressionInfoDisplay.querySelectorAll('.info-compressed'); if (originalElements.length >= 4 && compressedElements.length >= 4) { // File size originalElements[0].textContent = this.formatBytes(originalSizes.fileSize); originalElements[0].classList.remove('loading'); compressedElements[0].textContent = this.formatBytes(compressedSizes.fileSize); compressedElements[0].classList.remove('loading'); // Network originalElements[1].textContent = this.formatBytes(originalSizes.networkSize); originalElements[1].classList.remove('loading'); compressedElements[1].textContent = this.formatBytes(compressedSizes.networkSize); compressedElements[1].classList.remove('loading'); // Texture VRAM originalElements[2].textContent = this.formatBytes(originalTextureReport.totalMemoryBytes); originalElements[2].classList.remove('loading'); compressedElements[2].textContent = this.formatBytes(compressedTextureReport.totalMemoryBytes); compressedElements[2].classList.remove('loading'); // Poly count originalElements[3].textContent = originalPolyCount.toLocaleString(); originalElements[3].classList.remove('loading'); compressedElements[3].textContent = compressedPolyCount.toLocaleString(); compressedElements[3].classList.remove('loading'); } } private calculatePolyCount(object: THREE.Object3D): number { let totalTriangles = 0; object.traverse(child => { if (child instanceof THREE.Mesh && child.geometry) { const geometry = child.geometry; if (geometry.index) { totalTriangles += geometry.index.count / 3; } else { const positionAttribute = geometry.attributes.position; if (positionAttribute) { totalTriangles += positionAttribute.count / 3; } } } }); return Math.floor(totalTriangles); } private _calculateTextureMemoryWithOriginalMaterials(object: THREE.Object3D, calculator: GLTFTextureMemoryCalculator): ReturnType { // temp restore original materials for texture calculation const materialSwaps: Array<{ mesh: THREE.Mesh; current: THREE.Material | THREE.Material[] }> = []; object.traverse(child => { if (child instanceof THREE.Mesh && child.material && this._originalMaterials.has(child)) { const original = this._originalMaterials.get(child); if (original && original !== child.material) { materialSwaps.push({ mesh: child, current: child.material }); child.material = original; } } }); const report = calculator.calculateGLTFTextureMemory(object); materialSwaps.forEach(({ mesh, current }) => { mesh.material = current; }); return report; } private formatBytes(bytes: number): string { if (bytes === 0) return '0.00 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); const value = bytes / Math.pow(k, i); // 2 dec places const truncated = Math.floor(value * 100) / 100; return truncated.toFixed(2) + ' ' + sizes[i]; } private async getModelSizes(url?: string): Promise<{ fileSize: number; networkSize: number; hash?: string; }> { if (!url) return { fileSize: 0, networkSize: 0 }; try { const response = await fetch(url); const blob = await response.blob(); const fileSize = blob.size; const originalArrayBuffer = await blob.arrayBuffer(); const hashBuffer = await crypto.subtle.digest('SHA-256', originalArrayBuffer); const hashArray = Array.from(new Uint8Array(hashBuffer)); const hashHex = hashArray.map((b: number) => b.toString(16).padStart(2, '0')).join(''); const compressionStream = new (window as { CompressionStream: typeof CompressionStream }).CompressionStream('gzip'); const compressedStream = blob.stream().pipeThrough(compressionStream); const compressedBlob = await new Response(compressedStream).blob(); const networkSize = compressedBlob.size; return { fileSize: fileSize, networkSize: networkSize, hash: hashHex, }; } catch (error) { console.error('Failed to get model sizes:', error); return { fileSize: 0, networkSize: 0 }; } } private saveState(): void { if (!this._originalModelHash) { console.warn('Cannot save state - no model hash available'); return; } const state: ModelPreviewState = { viewMode: this._viewMode, comparisonViewMode: this._comparisonViewMode, }; if (this._camera) { this._camera.updateMatrix(); const matrix = this._camera.matrix.elements; state.camera = { matrix: matrix.map(v => parseFloat(v.toFixed(6))), }; } const stateKey = `modelPreview_state_${this._originalModelHash}`; try { localStorage.setItem(stateKey, JSON.stringify(state)); } catch (e) { console.warn('Failed to save state:', e); } } private saveCameraState(): void { this.saveState(); } private saveViewState(): void { this.saveState(); } private restoreCameraState(): void { if (!this._originalModelHash) return; const stateKey = `modelPreview_state_${this._originalModelHash}`; const savedState = localStorage.getItem(stateKey); if (savedState) { const state: ModelPreviewState = JSON.parse(savedState); this.restoreFromState(state); } } private _updateClipping(): void { if (!this._isComparisonMode || !this._camera || !this._renderer || !this.gltf.element || !this.gltfComparison?.element) return; this._camera.updateMatrixWorld(true); const ndcX = this.sliderPosition.value * 2 - 1; RAYCAST_COORDS.set(ndcX, 1); TOP_RAY.setFromCamera(RAYCAST_COORDS, this._camera); RAYCAST_COORDS.set(ndcX, -1); BOTTOM_RAY.setFromCamera(RAYCAST_COORDS, this._camera); this._camera.getWorldPosition(VECTOR_POOL.cameraPos); VECTOR_POOL.topNear.copy(VECTOR_POOL.cameraPos).addScaledVector(TOP_RAY.ray.direction, NEAR_DISTANCE); VECTOR_POOL.bottomNear.copy(VECTOR_POOL.cameraPos).addScaledVector(BOTTOM_RAY.ray.direction, NEAR_DISTANCE); VECTOR_POOL.topFar.copy(VECTOR_POOL.cameraPos).addScaledVector(TOP_RAY.ray.direction, FAR_DISTANCE); VECTOR_POOL.v1.subVectors(VECTOR_POOL.bottomNear, VECTOR_POOL.topNear); VECTOR_POOL.v2.subVectors(VECTOR_POOL.topFar, VECTOR_POOL.topNear); VECTOR_POOL.planeNormal.crossVectors(VECTOR_POOL.v1, VECTOR_POOL.v2); VECTOR_POOL.planeNormal.normalize(); // normal points to the right const cameraMatrix = this._camera.matrixWorld; VECTOR_POOL.cameraRight.setFromMatrixColumn(cameraMatrix, 0); if (VECTOR_POOL.planeNormal.dot(VECTOR_POOL.cameraRight) < 0) { VECTOR_POOL.planeNormal.negate(); } this._leftPlane.normal.copy(VECTOR_POOL.planeNormal); this._leftPlane.constant = -VECTOR_POOL.planeNormal.dot(VECTOR_POOL.topNear); this._rightPlane.normal.copy(VECTOR_POOL.planeNormal).negate(); this._rightPlane.constant = -this._rightPlane.normal.dot(VECTOR_POOL.topNear); this._applyClippingPlanes(this.gltf.element, [this._rightPlane]); this._applyClippingPlanes(this.gltfComparison.element, [this._leftPlane]); } private _applyClippingPlanes(object: THREE.Object3D, planes: THREE.Plane[]): void { object.traverse(child => { if (child instanceof THREE.Mesh && child.material) { const materials = Array.isArray(child.material) ? child.material : [child.material]; materials.forEach(material => { if (material instanceof THREE.Material) { material.clippingPlanes = planes; material.clipShadows = true; material.side = THREE.DoubleSide; material.needsUpdate = true; } }); } }); } private _clearClippingPlanes(object: THREE.Object3D): void { object.traverse(child => { if (child instanceof THREE.Mesh && child.material) { const materials = Array.isArray(child.material) ? child.material : [child.material]; materials.forEach(material => { if (material instanceof THREE.Material) { material.clippingPlanes = null; material.needsUpdate = true; } }); } }); } private setupProperties(): void { this.alignTransform.pinOriginToBoundingX.value = XAlignment.center; this.alignTransform.pinOriginToBoundingY.value = YAlignment.bottom; this.alignTransform.pinOriginToBoundingZ.value = ZAlignment.center; this.perspectiveCamera.position.value = [0.67, 0.5, 1.631]; this.mouseOrbitBehavior.enabled.value = true; this.mouseOrbitBehavior.initialTarget.value = [0, 0.5, 0]; this.directionalLight.castShadow.value = true; this.directionalLight.position.value = [0, 2, 0]; } private setupHierarchy(): void { this.appendChild(this.scaleToFit); this.scaleToFit.appendChild(this.alignTransform); this.alignTransform.appendChild(this.gltf); if (this.gltfComparison) this.alignTransform.appendChild(this.gltfComparison); this.appendChild(this.css); this.appendChild(this.defaults); this.defaults.appendChild(this.directionalLight); this.defaults.appendChild(this.defaultEnvironment); this.defaults.appendChild(this.shadowPlane); this.appendChild(this.perspectiveCamera); } dispose(): never { if (this._isComparisonMode) { this._viewMode = 'Material'; this._updateViewMode(); this._separatorLine?.remove(); this.compressionInfoDisplay?.remove(); if (this._renderer instanceof THREE.WebGLRenderer) { this._renderer.localClippingEnabled = this._originalClippingEnabled; } if (this.gltf.element) { this._clearClippingPlanes(this.gltf.element); } if (this.gltfComparison?.element) { this._clearClippingPlanes(this.gltfComparison.element); } } this.overlayContainer?.parentNode?.removeChild(this.overlayContainer); this.mouseOrbitBehavior.dispose(); super.dispose(); } }