import * as THREE from 'three'; import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; import { TransformControls } from 'three/examples/jsm/controls/TransformControls.js'; import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader.js'; import { HumanoidUnitAssembler, LODGenerator, UNIT_VOLUME_TEMPLATES, VolumeGenerator, analyzeHumanoidSilhouetteSnapshot, analyzeUnitPartQuality, getHumanoidPartDefinition, getUnitAttachmentSockets, type ProceduralMesh } from '@procgen/proceduralGen/index.js'; import { getUnitBlueprint, UNIT_COMBAT_PROFILES, type BuildingType, type UnitType } from '@shared'; import { UNIT_TO_ASSET_ID } from './render/AssetMapping'; import { UnitLODGeometryFactory, type GeometryDetail } from './render/UnitLODGeometryFactory'; import { UnitSchemaLODFactory } from './render/UnitSchemaLODFactory'; import type { PlacementOverrideSet, VisualLanguageSchema, VolumeHierarchyOverrides } from './render/ProceduralMeshLoader'; import { getUnitPalette, getUnitBounds } from './render/unit/unitConfig'; import { getProceduralSurfaceTextureSet, preloadGeneratedSurfaceTexturesFromManifest, TextureQuality, type SurfaceTexturePattern, type SurfaceTextureVariant } from './materials/ProceduralSurfaceTextures'; import { applyCuratedEnvironmentMap } from './materials/CuratedEnvironmentMap'; import { resolveUnitSurfaceRepeat } from './materials/SurfaceRepeat'; import { resolveUnitAuthoredMaterialPreset, type AuthoredModelMaterialPreset } from './materials/AuthoredModelMaterialPalette'; type ViewerModel = { id: string; kind: 'obj' | 'procedural'; source?: 'tree' | 'unit' | 'building'; label?: string; assetId?: string; assetName?: string; category?: string; role?: string; techLevel?: number | null; previewLod?: 'lod0' | 'lod1' | 'lod2' | 'lod3'; bounds?: { radius: number; height: number; } | null; tags?: string[]; family?: string; lod?: string; variant?: number | null; totalTriangles?: number | null; stemTriangles?: number | null; foliageTriangles?: number | null; fileName?: string; url?: string; bytes?: number; }; type ViewerManifest = { generatedAt?: string; source?: { visualManifest?: { procgenVersion?: string; }; }; models: ViewerModel[]; }; type ProceduralSchema = VisualLanguageSchema & { techLevel?: number; tags?: string[]; [key: string]: unknown; }; type VisualAssetManifest = { units?: ProceduralSchema[]; buildings?: ProceduralSchema[]; }; type SchemaOverrideRecord = { placementOverrides?: PlacementOverrideSet; volumeHierarchyOverrides?: VolumeHierarchyOverrides; }; type SchemaOverrideManifest = { schemaOverrides: Record; }; type UnitBounds = { radius: number; height: number; }; interface MeshDiagnosticInfo { unitType: UnitType; part: UnitPart; channel: UnitMaterialChannel; geometryIndex: number; triangles: number; center: { x: number; y: number; z: number }; size: { x: number; y: number; z: number }; sourcePart?: unknown; sourceVolumes?: unknown; sourceVolumeLevels?: unknown; sourceVolumeIndices?: unknown; materialSlot?: unknown; materialType?: unknown; inferredPartNodeId?: string; inferredPartId?: string; inferredPartGroup?: string; inferredPartPurpose?: string; inferredPartDistance?: number; inferredSilhouetteCritical?: boolean; inferredApproximate?: boolean; inferredLineage?: string; inferredLineageSource?: string; inferredLineageAnchor?: string; inferredAnchorTo?: string; inferredAnchorTargetAnchor?: string; inferredAnchorSelfAnchor?: string; inferredAnchorSocketId?: string; inferredAttachmentGap?: number; inferredAttachmentLateralError?: number; } type EditTarget = { kind: 'humanoidPart' | 'volume'; id: string; level?: 'primary' | 'secondary' | 'tertiary'; index?: number; }; type EditSelection = { object: THREE.Object3D; info: MeshDiagnosticInfo; target: EditTarget; }; type SelectionOverlay = { outline: THREE.LineSegments; label: THREE.Sprite; labelTexture: THREE.CanvasTexture; axes?: THREE.AxesHelper; ghosts?: THREE.LineSegments[]; symmetryMode: 'none' | 'mirrorX' | 'radial2' | 'radial4'; }; type SocketDebugEntry = { id: string; purpose: string; position: THREE.Vector3; }; type HumanoidLayoutPartLike = { id: string; partId: string; center: { x: number; y: number; z: number }; size: { x: number; y: number; z: number }; anchorTo?: string; anchorTargetAnchor?: string; anchorSelfAnchor?: string; anchorSocketId?: string; attachmentGap?: number; attachmentLateralError?: number; }; type PreviewGeometrySource = 'factory' | 'schema' | 'mixed'; type MotionLabState = 'idle' | 'move' | 'track' | 'fire' | 'ability'; type HumanoidMotionRole = | 'upperLeg' | 'lowerLeg' | 'foot' | 'hip' | 'upperArm' | 'forearm' | 'hand' | 'shoulder' | 'torso'; type HumanoidMotionSide = 'left' | 'right' | 'center'; type HumanoidJointLandmarkSide = { hip: THREE.Vector3; knee: THREE.Vector3; ankle: THREE.Vector3; shoulder: THREE.Vector3; elbow: THREE.Vector3; wrist: THREE.Vector3; }; type HumanoidJointLandmarks = { centerTorso: THREE.Vector3; centerPelvis: THREE.Vector3; centerChest: THREE.Vector3; centerNeck: THREE.Vector3; centerHead: THREE.Vector3; left: HumanoidJointLandmarkSide; right: HumanoidJointLandmarkSide; }; type HumanoidPoseLayer = { pivot: THREE.Vector3; deltaX: number; deltaY: number; deltaZ: number; }; type MotionRigNode = { node: THREE.Object3D; restPosition: THREE.Vector3; restRotation: THREE.Euler; restScale: THREE.Vector3; restCenter: THREE.Vector3; info: MeshDiagnosticInfo; humanoidRole?: HumanoidMotionRole; humanoidSide?: HumanoidMotionSide; humanoidWeight?: number; humanoidFallback?: boolean; humanoidComposite?: boolean; humanoidDecorative?: boolean; humanoidApproximateTorsoCoerce?: boolean; humanoidGroupId?: string; humanoidGroupSize?: number; humanoidSocketKey?: string; humanoidSocketTarget?: string; humanoidSocketMatchedKey?: string; humanoidSocketExact?: boolean; humanoidSocketSemantic?: boolean; humanoidSocketStable?: boolean; humanoidSocketRolePenalty?: number; trackLoopS?: number; trackLoopPitchOffset?: number; trackLoopNormalOffset?: number; trackLastPitch?: number; }; type HumanoidMotionGroup = { id: string; role: HumanoidMotionRole; side: HumanoidMotionSide; weight: number; fallback: boolean; composite: boolean; nodes: MotionRigNode[]; }; type MotionParticle = { sprite: THREE.Sprite; material: THREE.SpriteMaterial; velocity: THREE.Vector3; age: number; life: number; startSize: number; endSize: number; startOpacity: number; endOpacity: number; active: boolean; }; type MotionParticleEmitter = { particles: MotionParticle[]; gravity: number; drag: number; }; type MotionRig = { turretNodes: MotionRigNode[]; gunNodes: MotionRigNode[]; machineGunNodes: MotionRigNode[]; trackNodes: MotionRigNode[]; humanoidNodes: MotionRigNode[]; humanoidGroups: HumanoidMotionGroup[]; fxGroup: THREE.Group; muzzleFlash: THREE.Mesh; machineGunFlash: THREE.Mesh; abilityRing: THREE.Mesh; defensivePulse: THREE.Mesh; targetMarker: THREE.Mesh; targetLine: THREE.Line; mainGunTracer: THREE.Line; machineGunTracer: THREE.Line; defensiveWorldGroup: THREE.Group; mainGunSmokeEmitter: MotionParticleEmitter; machineGunEmitter: MotionParticleEmitter; defensiveEmitter: MotionParticleEmitter; turretPivot: THREE.Vector3; muzzleLocal: THREE.Vector3 | null; machineGunMuzzleLocal: THREE.Vector3; smokeLeftLocal: THREE.Vector3 | null; smokeRightLocal: THREE.Vector3 | null; restRootPosition: THREE.Vector3; restRootRotation: THREE.Euler; terrainBaseline: number; trackFrontZ: number; trackRearZ: number; trackTopY: number; trackBottomY: number; trackWrapCenterY: number; trackWrapRadius: number; trackFrontWrapCenterZ: number; trackRearWrapCenterZ: number; trackLoopLength: number; trackLoopCurve: THREE.CatmullRomCurve3; trackLeftX: number; trackRightX: number; trackCandidateCount: number; trackRejectedCount: number; humanoidNodeCount: number; humanoidGroupCount: number; armorPlatingNodeCount: number; armorPlatingSocketedCount: number; humanoidLandmarks: HumanoidJointLandmarks | null; trackSyntheticLinks: { left: THREE.InstancedMesh; right: THREE.InstancedMesh; linkCountPerSide: number; } | null; prevMainGunPulse: number; prevMachineGunPulse: number; defensiveBurstTimer: number; trackedTurretYaw: number; trackedGunPitch: number; lastTargetLocal: THREE.Vector3; }; type HumanoidPivotOutlier = { mesh: string; role: HumanoidMotionRole; side: HumanoidMotionSide; pivotDistance: number; pivotDistanceNorm: number; channel: string; inferredPartNodeId: string | null; inferredPartId: string | null; approximate: boolean; composite: boolean; sourcePart: string | null; }; type HumanoidNodeMappingInconsistency = { key: string; count: number; roles: string[]; sides: string[]; }; type HumanoidNodeKeyCluster = { key: string; count: number; dominantRole: string; dominantSide: string; approximateCount: number; avgPivotDistanceNorm: number; maxPivotDistanceNorm: number; }; type HumanoidMotionRigReport = { solverVersion: string; unitType: UnitType; nodeCount: number; groupCount: number; avgGroupSize: number; armorPlatingNodeCount: number; armorPlatingSocketedCount: number; armorPlatingSocketExactCount: number; armorPlatingSocketSemanticCount: number; armorPlatingSocketStableCount: number; armorPlatingSocketFallbackCount: number; uniqueNodeKeyCount: number; roleCounts: Record; sideCounts: Record; chainDepthCounts: Record; fallbackCount: number; compositeCount: number; decorativeCount: number; approximateCount: number; approximateCoercedTorsoCount: number; approximateLimbCount: number; approximateRoleCounts: Record; landmarkAvailable: boolean; avgPivotDistanceNorm: number; p95PivotDistanceNorm: number; maxPivotDistanceNorm: number; inconsistencyCount: number; outlierCount: number; outliers: HumanoidPivotOutlier[]; inconsistencies: HumanoidNodeMappingInconsistency[]; topNodeKeys: HumanoidNodeKeyCluster[]; }; type HumanoidJointRotationEntry = { id: HumanoidJointSocketId; parentId: HumanoidJointSocketId | null; absolutePositionLocal: { x: number; y: number; z: number }; absolutePositionWorld: { x: number; y: number; z: number }; absoluteRotationEulerDeg: { x: number; y: number; z: number }; relativeRotationEulerDeg: { x: number; y: number; z: number }; absoluteRotationQuaternion: { x: number; y: number; z: number; w: number }; relativeRotationQuaternion: { x: number; y: number; z: number; w: number }; }; type HumanoidJointRotationReport = { solverVersion: string; unitType: UnitType; generatedAt: string; coordinateFrames: { local: 'model_root'; world: 'scene_world'; }; jointCount: number; joints: HumanoidJointRotationEntry[]; jointAnglesDeg: Record; }; type HumanoidJointSocketId = | 'centerPelvis' | 'centerTorso' | 'centerChest' | 'leftShoulder' | 'leftElbow' | 'leftWrist' | 'leftHip' | 'leftKnee' | 'leftAnkle' | 'rightShoulder' | 'rightElbow' | 'rightWrist' | 'rightHip' | 'rightKnee' | 'rightAnkle'; type HumanoidJointSocketLayout = Record; type HumanoidJointSocketBoneRef = { from: HumanoidJointSocketId; to: HumanoidJointSocketId; line: THREE.Line; label: string; }; type HumanoidJointAngleRef = { id: string; parent: HumanoidJointSocketId; joint: HumanoidJointSocketId; child: HumanoidJointSocketId; sprite: THREE.Sprite; color: string; }; type HumanoidJointSocketOverlayRefs = { markers: Record; bones: HumanoidJointSocketBoneRef[]; angles: HumanoidJointAngleRef[]; }; type HumanoidFrameAxisRef = { from: HumanoidJointSocketId; to: HumanoidJointSocketId; right: THREE.ArrowHelper; up: THREE.ArrowHelper; forward: THREE.ArrowHelper; label: string; }; type HumanoidFrameOverlayRefs = { bones: HumanoidFrameAxisRef[]; axisLength: number; }; type MotionLabProfile = { label: string; uiSpeedDefault: number; driveSpeed: number; drivePathWave: number; drivePathCurve: number; idleBobAmplitude: number; idleBobFrequency: number; moveBobAmplitude: number; moveBobFrequency: number; terrainPitchResponse: number; terrainRollResponse: number; fireHullKickAmplitude: number; fireHullKickFrequency: number; turretYawIdleAmplitude: number; turretYawIdleFrequency: number; turretYawMoveAmplitude: number; turretYawMoveFrequency: number; turretYawFireAmplitude: number; turretYawFireFrequency: number; turretYawAbilityAmplitude: number; turretYawAbilityFrequency: number; fireCycleRate: number; firePulseCenter: number; firePulseSharpness: number; recoilDistance: number; recoilPitch: number; abilityGunPitchAmplitude: number; abilityGunPitchFrequency: number; trackPhaseMoveSpeed: number; trackPhaseIdleSpeed: number; trackTopSag: number; trackBottomBounce: number; trackWrapLift: number; trackTravelWarpWrap: number; trackTravelWarpRun: number; trackRotationWrap: number; trackRotationRun: number; trackTerrainCompression: number; gaitStrideFrequency: number; gaitLegSwing: number; gaitKneeBend: number; gaitFootPitch: number; gaitArmSwing: number; gaitElbowBend: number; gaitTorsoTwist: number; gaitPelvisShift: number; gaitPelvisDrop: number; cameraDistanceScale: number; cameraOffsetX: number; cameraOffsetY: number; cameraOffsetZ: number; }; const canvas = document.querySelector('#meshlab-canvas'); const modelSelect = document.querySelector('#model-select'); const wireframeToggle = document.querySelector('#wireframe-toggle'); const autoRotateToggle = document.querySelector('#autorotate-toggle'); const diagnosticsToggle = document.querySelector('#diagnostics-toggle'); const socketHelpersToggle = document.querySelector('#socket-helpers-toggle'); const humanoidFramesToggle = document.querySelector('#humanoid-frames-toggle'); const partProbesToggle = document.querySelector('#part-probes-toggle'); const weaponMountOverlayToggle = document.querySelector('#weapon-mount-overlay-toggle'); const partGroupHighlightToggle = document.querySelector('#part-group-highlight-toggle'); const partGroupHighlightMode = document.querySelector('#part-group-highlight-mode'); const partVisibilityMode = document.querySelector('#part-visibility-mode'); const partVisibilityAllButton = document.querySelector('#part-visibility-all'); const partVisibilityNoneButton = document.querySelector('#part-visibility-none'); const partVisibilityList = document.querySelector('#part-visibility-list'); const editModeToggle = document.querySelector('#edit-mode-toggle'); const editLockToggle = document.querySelector('#edit-lock-toggle'); const editAllowOverlapToggle = document.querySelector('#edit-allow-overlap-toggle'); const editSelectedPill = document.querySelector('#edit-selected-pill'); const editCopyTargetButton = document.querySelector('#edit-copy-target'); const editTransformMode = document.querySelector('#edit-transform-mode'); const editGizmoSpace = document.querySelector('#edit-gizmo-space'); const editLabelToggle = document.querySelector('#edit-label-toggle'); const editFrameToggle = document.querySelector('#edit-frame-toggle'); const editDragTranslateToggle = document.querySelector('#edit-drag-translate-toggle'); const editSymmetryMode = document.querySelector('#edit-symmetry-mode'); const editSnapToggle = document.querySelector('#edit-snap-toggle'); const editSnapStepInput = document.querySelector('#edit-snap-step'); const editRotateStepInput = document.querySelector('#edit-rotate-step'); const editSnapSocketButton = document.querySelector('#edit-snap-socket'); const editSnapHardpointButton = document.querySelector('#edit-snap-hardpoint'); const editSnapJointButton = document.querySelector('#edit-snap-joint'); const editSnapSurfaceButton = document.querySelector('#edit-snap-surface'); const editNudgeStepInput = document.querySelector('#edit-nudge-step'); const editNudgeXMinusButton = document.querySelector('#edit-nudge-x-minus'); const editNudgeXPlusButton = document.querySelector('#edit-nudge-x-plus'); const editNudgeYMinusButton = document.querySelector('#edit-nudge-y-minus'); const editNudgeYPlusButton = document.querySelector('#edit-nudge-y-plus'); const editNudgeZMinusButton = document.querySelector('#edit-nudge-z-minus'); const editNudgeZPlusButton = document.querySelector('#edit-nudge-z-plus'); const editUpgradeSelect = document.querySelector('#edit-upgrade-select'); const editUpgradeApplyButton = document.querySelector('#edit-upgrade-apply'); const editUpgradePreview = document.querySelector('#edit-upgrade-preview'); const editFocusSelectionButton = document.querySelector('#edit-focus-selection'); const editClearTargetButton = document.querySelector('#edit-clear-target'); const editUndoButton = document.querySelector('#edit-undo'); const editRedoButton = document.querySelector('#edit-redo'); const editSaveOverridesButton = document.querySelector('#edit-save-overrides'); const editClearOverridesButton = document.querySelector('#edit-clear-overrides'); const editStatusNode = document.querySelector('#edit-status'); const resetViewButton = document.querySelector('#reset-view'); const statusNode = document.querySelector('#meshlab-status'); const hoverInfoNode = document.querySelector('#hover-info'); const issuesNode = document.querySelector('#diag-issues'); const metadataNode = document.querySelector('#model-metadata'); const socketMetadataNode = document.querySelector('#socket-metadata'); const downloadLink = document.querySelector('#download-model'); const simEnableToggle = document.querySelector('#sim-enable-toggle'); const simLockCameraToggle = document.querySelector('#sim-lock-camera-toggle'); const simTerrainToggle = document.querySelector('#sim-terrain-toggle'); const simTrackDebugToggle = document.querySelector('#sim-track-debug-toggle'); const simStateSelect = document.querySelector('#sim-state-select'); const simSpeedRange = document.querySelector('#sim-speed-range'); const simPrevStateButton = document.querySelector('#sim-prev-state'); const simNextStateButton = document.querySelector('#sim-next-state'); const simTriggerButton = document.querySelector('#sim-trigger-state'); const simStatusNode = document.querySelector('#sim-status'); const trackDebugNode = document.querySelector('#track-debug-info'); if ( !canvas || !modelSelect || !wireframeToggle || !autoRotateToggle || !diagnosticsToggle || !socketHelpersToggle || !humanoidFramesToggle || !partProbesToggle || !weaponMountOverlayToggle || !partGroupHighlightToggle || !partGroupHighlightMode || !partVisibilityMode || !partVisibilityAllButton || !partVisibilityNoneButton || !partVisibilityList || !editModeToggle || !editLockToggle || !editAllowOverlapToggle || !editSelectedPill || !editCopyTargetButton || !editTransformMode || !editGizmoSpace || !editLabelToggle || !editFrameToggle || !editDragTranslateToggle || !editSymmetryMode || !editSnapToggle || !editSnapStepInput || !editRotateStepInput || !editSnapSocketButton || !editSnapHardpointButton || !editSnapJointButton || !editSnapSurfaceButton || !editNudgeStepInput || !editNudgeXMinusButton || !editNudgeXPlusButton || !editNudgeYMinusButton || !editNudgeYPlusButton || !editNudgeZMinusButton || !editNudgeZPlusButton || !editUpgradeSelect || !editUpgradeApplyButton || !editUpgradePreview || !editFocusSelectionButton || !editClearTargetButton || !editUndoButton || !editRedoButton || !editSaveOverridesButton || !editClearOverridesButton || !editStatusNode || !resetViewButton || !statusNode || !hoverInfoNode || !issuesNode || !metadataNode || !socketMetadataNode || !downloadLink || !simEnableToggle || !simLockCameraToggle || !simTerrainToggle || !simTrackDebugToggle || !simStateSelect || !simSpeedRange || !simPrevStateButton || !simNextStateButton || !simTriggerButton || !simStatusNode || !trackDebugNode ) { throw new Error('MeshLab viewer initialization failed: missing required DOM elements.'); } let editLabelEnabled = true; let editFrameEnabled = true; let editDragTranslateEnabled = true; let dragTranslateActive = false; let dragTranslateSuppressClick = false; const dragTranslatePlane = new THREE.Plane(); const dragTranslateHit = new THREE.Vector3(); const dragTranslateOriginWorld = new THREE.Vector3(); const dragTranslateNextWorld = new THREE.Vector3(); let editAxisConstraint: 'x' | 'y' | 'z' | null = null; const editUndoStack: Array<{ assetId: string; record: SchemaOverrideRecord | null }> = []; const editRedoStack: Array<{ assetId: string; record: SchemaOverrideRecord | null }> = []; const queryParams = new URLSearchParams(window.location.search); // Force deterministic visual checks: wireframe is off by default unless explicitly requested. wireframeToggle.checked = queryParams.get('wireframe') === '1'; partGroupHighlightToggle.checked = queryParams.get('partGroups') === '1'; if (queryParams.get('partGroupMode')) { partGroupHighlightMode.value = queryParams.get('partGroupMode') as string; } editLabelEnabled = editLabelToggle.checked; editFrameEnabled = editFrameToggle.checked; editDragTranslateEnabled = editDragTranslateToggle.checked; const captureModeEnabled = queryParams.get('capture') === '1'; const requestedTextureQuality = (queryParams.get('textureQuality') ?? '').toLowerCase(); const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.outputColorSpace = THREE.SRGBColorSpace; renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.06; const scene = new THREE.Scene(); scene.background = new THREE.Color('#07141d'); applyCuratedEnvironmentMap(scene, renderer); const camera = new THREE.PerspectiveCamera(55, 1, 0.1, 1000); camera.position.set(7, 5, 9); const controls = new OrbitControls(camera, renderer.domElement); controls.enableDamping = true; controls.dampingFactor = 0.08; controls.autoRotate = autoRotateToggle.checked; controls.autoRotateSpeed = 1.2; const transformControls = new TransformControls(camera, renderer.domElement); transformControls.visible = false; transformControls.enabled = false; scene.add(transformControls); transformControls.addEventListener('dragging-changed', (event) => { const dragging = Boolean((event as { value?: boolean }).value); if (dragging) { controlsWereEnabled = controls.enabled; controls.enabled = false; } else { controls.enabled = controlsWereEnabled; } if (dragging) { if (activeEditSelection) { editTransformBaseline = captureTransformSnapshot(activeEditSelection.object); } } else { applyEditTransformDelta(); } }); const hemiLight = new THREE.HemisphereLight('#c8d8e8', '#151a1f', 0.56); scene.add(hemiLight); const keyLight = new THREE.DirectionalLight('#fff6e5', 2.35); keyLight.position.set(9, 14, 8); scene.add(keyLight); const fillLight = new THREE.DirectionalLight('#8ab8df', 0.26); fillLight.position.set(-8, 4, -6); scene.add(fillLight); const rimLight = new THREE.DirectionalLight('#dce9f9', 0.42); rimLight.position.set(-4, 9, -12); scene.add(rimLight); const grid = new THREE.GridHelper(24, 24, '#53b8dc', '#204859'); grid.position.y = -2.5; scene.add(grid); const originMarker = new THREE.AxesHelper(2); originMarker.position.set(0, -2.5, 0); scene.add(originMarker); const loader = new OBJLoader(); const trackAnimMatrix = new THREE.Matrix4(); const trackAnimPosition = new THREE.Vector3(); const trackAnimQuaternion = new THREE.Quaternion(); const trackAnimScale = new THREE.Vector3(1, 1, 1); const trackAnimEuler = new THREE.Euler(); const trackTerrainSampleLocal = new THREE.Vector3(); const trackTerrainSampleWorld = new THREE.Vector3(); const motionAimTargetLocal = new THREE.Vector3(); const motionAimDirectionLocal = new THREE.Vector3(); const motionParticleVelocity = new THREE.Vector3(); const motionParticleOrigin = new THREE.Vector3(); const motionMainMuzzleLocal = new THREE.Vector3(); const motionMachineGunMuzzleLocal = new THREE.Vector3(); const motionPosePivotTranslate = new THREE.Matrix4(); const motionPosePivotTranslateInv = new THREE.Matrix4(); const motionPoseRestTranslate = new THREE.Matrix4(); const motionPoseDeltaRotate = new THREE.Matrix4(); const motionPoseRestRotate = new THREE.Matrix4(); const motionPoseRestScale = new THREE.Matrix4(); const motionPoseComposed = new THREE.Matrix4(); const motionPoseDeltaEuler = new THREE.Euler(0, 0, 0, 'XYZ'); const motionPoseDeltaQuat = new THREE.Quaternion(); const motionPoseRestQuat = new THREE.Quaternion(); const motionPoseComposedPos = new THREE.Vector3(); const motionPoseComposedScale = new THREE.Vector3(); const motionPoseComposedQuat = new THREE.Quaternion(); const humanoidJointWorldQuat = new THREE.Quaternion(); const humanoidJointAbsQuat = new THREE.Quaternion(); const humanoidJointRelQuat = new THREE.Quaternion(); const humanoidJointParentQuat = new THREE.Quaternion(); const humanoidJointRight = new THREE.Vector3(); const humanoidJointUp = new THREE.Vector3(); const humanoidJointForward = new THREE.Vector3(); const humanoidJointWorldPos = new THREE.Vector3(); const humanoidJointWorldUp = new THREE.Vector3(0, 1, 0); const humanoidJointWorldAltUp = new THREE.Vector3(1, 0, 0); const humanoidJointBasis = new THREE.Matrix4(); const humanoidJointEuler = new THREE.Euler(0, 0, 0, 'XYZ'); const humanoidJointNodeCenterLocal = new THREE.Vector3(); const humanoidJointNodeCenterRoot = new THREE.Vector3(); const humanoidJointVecA = new THREE.Vector3(); const humanoidJointVecB = new THREE.Vector3(); const humanoidJointTmpVec0 = new THREE.Vector3(); const humanoidJointTmpVec1 = new THREE.Vector3(); const humanoidJointTmpVec2 = new THREE.Vector3(); const createSoftParticleTexture = () => { const size = 128; const canvasEl = document.createElement('canvas'); canvasEl.width = size; canvasEl.height = size; const ctx = canvasEl.getContext('2d'); if (!ctx) { return null; } const gradient = ctx.createRadialGradient( size * 0.5, size * 0.5, size * 0.08, size * 0.5, size * 0.5, size * 0.5 ); gradient.addColorStop(0, 'rgba(255,255,255,0.95)'); gradient.addColorStop(0.4, 'rgba(235,235,235,0.62)'); gradient.addColorStop(0.72, 'rgba(190,190,190,0.24)'); gradient.addColorStop(1, 'rgba(160,160,160,0.0)'); ctx.fillStyle = gradient; ctx.fillRect(0, 0, size, size); const texture = new THREE.CanvasTexture(canvasEl); texture.needsUpdate = true; texture.wrapS = THREE.ClampToEdgeWrapping; texture.wrapT = THREE.ClampToEdgeWrapping; texture.minFilter = THREE.LinearFilter; texture.magFilter = THREE.LinearFilter; return texture; }; const SOFT_PARTICLE_TEXTURE = createSoftParticleTexture(); const clock = new THREE.Clock(); let activeObject: THREE.Object3D | null = null; let activeModel: ViewerModel | null = null; let models: ViewerModel[] = []; let lastFitDistance = 12; let loadToken = 0; let generatedDownloadUrl: string | null = null; let overrideDownloadUrl: string | null = null; const generatedTrianglesById = new Map(); let visualSchemaCache: Map | null = null; let visualSchemaOverrideCache: SchemaOverrideManifest | null = null; let visualSchemaRevision = 'initial'; const schemaLODFactory = new UnitSchemaLODFactory(); const raycaster = new THREE.Raycaster(); const pointerNdc = new THREE.Vector2(0, 0); let pointerInsideCanvas = false; let activeHighlightMeshes: THREE.Mesh[] | null = null; let activeHighlightRoot: THREE.Object3D | null = null; let activeDiagnosticGroup: THREE.Group | null = null; let activeUnitType: UnitType | null = null; let activeUnitBounds: UnitBounds | null = null; let activeProceduralSchema: VisualLanguageSchema | null = null; let activePreviewGeometrySource: PreviewGeometrySource | null = null; let activeConstructionFloor: THREE.Group | null = null; let activeMotionRig: MotionRig | null = null; let activeMotionTerrain: THREE.Group | null = null; let activeTrackDebugGroup: THREE.Group | null = null; let motionTime = 0; let motionStateTime = 0; let motionDriveDistance = 0; let motionDriveDirection = 1; let motionTrackPhase = 0; let motionAbilityConcealRemaining = 0; const motionPrevRootPosition = new THREE.Vector3(); let lastMotionState: MotionLabState = 'idle'; let controlsWereEnabled = true; const activeOverrideStore: SchemaOverrideManifest = { schemaOverrides: {} }; let activeEditSelection: EditSelection | null = null; let editTransformBaseline: { position: THREE.Vector3; rotation: THREE.Euler; scale: THREE.Vector3 } | null = null; let editModeEnabled = false; let pendingEditReselect: { target: EditTarget; center: THREE.Vector3 | null } | null = null; let activeSelectionOverlay: SelectionOverlay | null = null; const MOTION_STATE_ORDER: MotionLabState[] = ['idle', 'move', 'track', 'fire', 'ability']; const MOTION_PATH_HALF_LENGTH = 18; const DEFAULT_MOTION_PROFILE: MotionLabProfile = { label: 'Standard', uiSpeedDefault: 1.0, driveSpeed: 4.3, drivePathWave: 1.25, drivePathCurve: 0.1, idleBobAmplitude: 0.014, idleBobFrequency: 1.8, moveBobAmplitude: 0.022, moveBobFrequency: 8.2, terrainPitchResponse: 0.9, terrainRollResponse: 0.9, fireHullKickAmplitude: 0.015, fireHullKickFrequency: 9, turretYawIdleAmplitude: 0.2, turretYawIdleFrequency: 0.56, turretYawMoveAmplitude: 0.16, turretYawMoveFrequency: 0.92, turretYawFireAmplitude: 0.05, turretYawFireFrequency: 1.8, turretYawAbilityAmplitude: 0.28, turretYawAbilityFrequency: 1.3, fireCycleRate: 2.1, firePulseCenter: 0.11, firePulseSharpness: 14, recoilDistance: 0.18, recoilPitch: 0.08, abilityGunPitchAmplitude: 0.02, abilityGunPitchFrequency: 2.5, trackPhaseMoveSpeed: 9.4, trackPhaseIdleSpeed: 1.2, trackTopSag: 0.033, trackBottomBounce: 0.015, trackWrapLift: 0.02, trackTravelWarpWrap: 0.043, trackTravelWarpRun: 0.012, trackRotationWrap: 0.12, trackRotationRun: 0.03, trackTerrainCompression: 0.46, gaitStrideFrequency: 9.4, gaitLegSwing: 0.38, gaitKneeBend: 0.52, gaitFootPitch: 0.26, gaitArmSwing: 0.34, gaitElbowBend: 0.28, gaitTorsoTwist: 0.12, gaitPelvisShift: 0.035, gaitPelvisDrop: 0.028, cameraDistanceScale: 0.86, cameraOffsetX: 0.7, cameraOffsetY: 0.44, cameraOffsetZ: 0.78 }; const MOTION_PROFILE_BY_UNIT: Partial> = { Commander: { ...DEFAULT_MOTION_PROFILE, label: 'Command Biped', uiSpeedDefault: 1.0, driveSpeed: 4.1, drivePathWave: 1.08, drivePathCurve: 0.085, moveBobAmplitude: 0.014, moveBobFrequency: 6.8, gaitStrideFrequency: 6.6, gaitLegSwing: 0.24, gaitKneeBend: 0.34, gaitFootPitch: 0.14, gaitArmSwing: 0.16, gaitElbowBend: 0.16, gaitTorsoTwist: 0.085, gaitPelvisShift: 0.018, gaitPelvisDrop: 0.014, cameraDistanceScale: 0.9, cameraOffsetX: 0.68, cameraOffsetY: 0.45, cameraOffsetZ: 0.8 }, Tank: { ...DEFAULT_MOTION_PROFILE, label: 'MBT Agile', uiSpeedDefault: 1.05, driveSpeed: 4.8, drivePathWave: 1.32, moveBobAmplitude: 0.024, moveBobFrequency: 8.8, turretYawMoveAmplitude: 0.19, turretYawMoveFrequency: 1.1, recoilDistance: 0.2, recoilPitch: 0.085, trackPhaseMoveSpeed: 10.4, trackTopSag: 0.048, trackBottomBounce: 0.021, trackWrapLift: 0.028, trackTravelWarpWrap: 0.058, trackTravelWarpRun: 0.018, trackRotationWrap: 0.15, trackRotationRun: 0.042, trackTerrainCompression: 0.64, cameraDistanceScale: 0.84 }, Siege: { ...DEFAULT_MOTION_PROFILE, label: 'Siege Artillery', uiSpeedDefault: 0.85, driveSpeed: 3.2, drivePathWave: 0.92, drivePathCurve: 0.082, idleBobAmplitude: 0.01, moveBobAmplitude: 0.017, moveBobFrequency: 6.6, terrainPitchResponse: 0.82, terrainRollResponse: 0.78, turretYawIdleAmplitude: 0.12, turretYawMoveAmplitude: 0.11, turretYawFireAmplitude: 0.03, turretYawAbilityAmplitude: 0.2, fireCycleRate: 1.35, firePulseCenter: 0.15, firePulseSharpness: 10, recoilDistance: 0.29, recoilPitch: 0.12, fireHullKickAmplitude: 0.026, fireHullKickFrequency: 7.4, trackPhaseMoveSpeed: 7.2, trackTopSag: 0.038, trackBottomBounce: 0.012, trackWrapLift: 0.017, trackTerrainCompression: 0.58, cameraDistanceScale: 0.94, cameraOffsetX: 0.74, cameraOffsetY: 0.48 }, HeavyTank: { ...DEFAULT_MOTION_PROFILE, label: 'Heavy Assault', uiSpeedDefault: 0.75, driveSpeed: 2.8, drivePathWave: 0.84, drivePathCurve: 0.074, idleBobAmplitude: 0.011, moveBobAmplitude: 0.016, moveBobFrequency: 5.9, terrainPitchResponse: 0.76, terrainRollResponse: 0.72, turretYawIdleAmplitude: 0.1, turretYawMoveAmplitude: 0.09, turretYawFireAmplitude: 0.025, turretYawAbilityAmplitude: 0.16, fireCycleRate: 1.1, firePulseCenter: 0.16, firePulseSharpness: 8.6, recoilDistance: 0.34, recoilPitch: 0.135, fireHullKickAmplitude: 0.03, fireHullKickFrequency: 6.4, trackPhaseMoveSpeed: 6.5, trackTopSag: 0.042, trackBottomBounce: 0.01, trackWrapLift: 0.016, trackTerrainCompression: 0.7, cameraDistanceScale: 0.98, cameraOffsetX: 0.77, cameraOffsetY: 0.51 } }; const THIN_GEOMETRY_THRESHOLD = 0.02; const MIN_TRIANGLES_FOR_THIN_ALERT = 6; const WEAPON_LOW_Y_RATIO = 0.58; const WEAPON_REAR_Z_RATIO = 0.08; const TRACK_HIGH_Y_RATIO = 0.34; const HUMANOID_MOTION_SOLVER_VERSION = 'chain-layered-v23-move-tuck-lock'; const TRACK_LOW_Y_RATIO = 0.08; const ARMOR_ATTACHMENT_GAP_RATIO = 0.02; const ARMOR_ATTACHMENT_LATERAL_RATIO = 0.016; const SLOT_COLORS: Record = { 0: new THREE.Color('#6f7f93'), 1: new THREE.Color('#4ec7f5'), 2: new THREE.Color('#8ce7ff'), 3: new THREE.Color('#ffbe62') }; interface UnitSurfaceProfile { pattern: SurfaceTexturePattern; variant: SurfaceTextureVariant; repeat: number; roughnessBias: number; } type UnitPart = 'base' | 'details' | 'highlights' | 'weapons'; type UnitMaterialChannel = | 'hullArmor' | 'armorPlating' | 'mechanicalCore' | 'hydraulics' | 'optics' | 'trim' | 'weaponHousing' | 'heatShield' | 'rubberTrack'; interface UnitMaterialChannelProfile { sourcePart: UnitPart; variant: SurfaceTextureVariant; repeatScale: number; roughnessOffset: number; metalness: number; roughness: number; tintStrength: number; clearcoat: number; clearcoatRoughness: number; envMapIntensity: number; normalScale: number; aoIntensity: number; uvDensity: number; } const DEFAULT_UNIT_SURFACE_PROFILE: UnitSurfaceProfile = { pattern: 'industrial', variant: 'machinery', repeat: 1.05, roughnessBias: 0.44 }; const UNIT_SURFACE_PROFILES: Partial> = { Tank: { pattern: 'industrial', variant: 'vehicle_armor', repeat: 0.66, roughnessBias: 0.62 }, HeavyTank: { pattern: 'painted', variant: 'default', repeat: 0.68, roughnessBias: 0.58 }, Siege: { pattern: 'painted', variant: 'default', repeat: 0.7, roughnessBias: 0.57 }, Commander: { pattern: 'industrial', variant: 'unit_plating', repeat: 1.0, roughnessBias: 0.42 }, Drone: { pattern: 'industrial', variant: 'sensor_composite', repeat: 1.16, roughnessBias: 0.36 }, Wasp: { pattern: 'industrial', variant: 'sensor_composite', repeat: 1.16, roughnessBias: 0.36 }, ShieldGenerator: { pattern: 'industrial', variant: 'unit_plating', repeat: 1.02, roughnessBias: 0.42 }, Gatherer: { pattern: 'industrial', variant: 'machinery', repeat: 1.05, roughnessBias: 0.48 }, Engineer: { pattern: 'industrial', variant: 'machinery', repeat: 1.08, roughnessBias: 0.48 }, Medic: { pattern: 'industrial', variant: 'utility_hull', repeat: 1.04, roughnessBias: 0.46 }, Scout: { pattern: 'industrial', variant: 'sensor_composite', repeat: 1.2, roughnessBias: 0.4 }, Sniper: { pattern: 'industrial', variant: 'sensor_composite', repeat: 1.18, roughnessBias: 0.4 }, Rifleman: { pattern: 'industrial', variant: 'unit_plating', repeat: 1.1, roughnessBias: 0.44 }, Assault: { pattern: 'industrial', variant: 'unit_plating', repeat: 1.1, roughnessBias: 0.43 }, Combat: { pattern: 'industrial', variant: 'unit_plating', repeat: 1.08, roughnessBias: 0.45 }, Grenadier: { pattern: 'industrial', variant: 'defense_armor', repeat: 1.03, roughnessBias: 0.46 }, RocketInfantry: { pattern: 'industrial', variant: 'defense_armor', repeat: 1.03, roughnessBias: 0.45 }, EliteStriker: { pattern: 'industrial', variant: 'defense_armor', repeat: 0.98, roughnessBias: 0.44 } }; const UNIT_MATERIAL_CHANNEL_PROFILES: Record = { hullArmor: { sourcePart: 'base', variant: 'factory_hull', repeatScale: 0.88, roughnessOffset: -0.02, metalness: 0.14, roughness: 0.46, tintStrength: 0.05, clearcoat: 0.34, clearcoatRoughness: 0.22, envMapIntensity: 0.9, normalScale: 0.2, aoIntensity: 0.52, uvDensity: 0.22 }, armorPlating: { sourcePart: 'highlights', variant: 'unit_plating', repeatScale: 0.92, roughnessOffset: -0.06, metalness: 0.1, roughness: 0.34, tintStrength: 0.08, clearcoat: 0.38, clearcoatRoughness: 0.16, envMapIntensity: 0.94, normalScale: 0.24, aoIntensity: 0.48, uvDensity: 0.26 }, mechanicalCore: { sourcePart: 'details', variant: 'machinery', repeatScale: 1.2, roughnessOffset: 0.06, metalness: 0.92, roughness: 0.22, tintStrength: 0.04, clearcoat: 0.08, clearcoatRoughness: 0.4, envMapIntensity: 0.9, normalScale: 0.42, aoIntensity: 0.62, uvDensity: 0.36 }, hydraulics: { sourcePart: 'details', variant: 'machinery', repeatScale: 1.35, roughnessOffset: 0.08, metalness: 0.94, roughness: 0.18, tintStrength: 0.03, clearcoat: 0.06, clearcoatRoughness: 0.44, envMapIntensity: 0.92, normalScale: 0.4, aoIntensity: 0.6, uvDensity: 0.4 }, optics: { sourcePart: 'highlights', variant: 'sensor_composite', repeatScale: 1.22, roughnessOffset: -0.08, metalness: 0.04, roughness: 0.12, tintStrength: 0.08, clearcoat: 0.82, clearcoatRoughness: 0.08, envMapIntensity: 1.0, normalScale: 0.18, aoIntensity: 0.28, uvDensity: 0.33 }, trim: { sourcePart: 'highlights', variant: 'unit_markings', repeatScale: 1.3, roughnessOffset: -0.08, metalness: 0.02, roughness: 0.62, tintStrength: 0.1, clearcoat: 0.06, clearcoatRoughness: 0.48, envMapIntensity: 0.66, normalScale: 0.18, aoIntensity: 0.36, uvDensity: 0.4 }, weaponHousing: { sourcePart: 'weapons', variant: 'vehicle_gunmetal', repeatScale: 1.08, roughnessOffset: -0.06, metalness: 0.88, roughness: 0.24, tintStrength: 0.06, clearcoat: 0.12, clearcoatRoughness: 0.32, envMapIntensity: 0.94, normalScale: 0.44, aoIntensity: 0.56, uvDensity: 0.38 }, heatShield: { sourcePart: 'weapons', variant: 'vehicle_heatshield', repeatScale: 1.42, roughnessOffset: 0.18, metalness: 0.06, roughness: 0.74, tintStrength: 0.05, clearcoat: 0.02, clearcoatRoughness: 0.6, envMapIntensity: 0.5, normalScale: 0.32, aoIntensity: 0.58, uvDensity: 0.44 }, rubberTrack: { sourcePart: 'base', variant: 'vehicle_composite_track', repeatScale: 1.16, roughnessOffset: 0.18, metalness: 0.04, roughness: 0.9, tintStrength: 0.02, clearcoat: 0, clearcoatRoughness: 1, envMapIntensity: 0.45, normalScale: 0.28, aoIntensity: 0.76, uvDensity: 0.42 } }; type UnitMaterialChannelProfileOverrideMap = Partial< Record>>> >; const UNIT_MATERIAL_CHANNEL_OVERRIDES: UnitMaterialChannelProfileOverrideMap = { Tank: { hullArmor: { variant: 'vehicle_armor', repeatScale: 0.62, roughnessOffset: -0.01, tintStrength: 0.03, metalness: 0.72, roughness: 0.58, clearcoat: 0.04, clearcoatRoughness: 0.52, normalScale: 0.24, aoIntensity: 0.68, uvDensity: 0.3 }, armorPlating: { variant: 'vehicle_ceramic_armor', repeatScale: 0.86, roughnessOffset: -0.07, tintStrength: 0.06, metalness: 0.68, roughness: 0.46, clearcoat: 0.1, clearcoatRoughness: 0.4, normalScale: 0.34, aoIntensity: 0.7, uvDensity: 0.36 }, mechanicalCore: { variant: 'vehicle_gunmetal', repeatScale: 0.86, roughnessOffset: 0.04, tintStrength: 0.04, metalness: 0.88, roughness: 0.34, normalScale: 0.2, aoIntensity: 0.74, uvDensity: 0.32 }, hydraulics: { variant: 'vehicle_gunmetal', repeatScale: 1.36, roughnessOffset: 0.03, tintStrength: 0.01, metalness: 0.94, roughness: 0.22, normalScale: 0.28, aoIntensity: 0.82, uvDensity: 0.56 }, trim: { variant: 'vehicle_armor', repeatScale: 0.96, roughnessOffset: -0.02, tintStrength: 0.03, metalness: 0.36, roughness: 0.42, normalScale: 0.14, uvDensity: 0.34 }, weaponHousing: { variant: 'vehicle_armor', repeatScale: 0.9, roughnessOffset: -0.01, tintStrength: 0.08, metalness: 0.82, roughness: 0.33, normalScale: 0.24, aoIntensity: 0.66, uvDensity: 0.32 }, heatShield: { variant: 'vehicle_heatshield', repeatScale: 0.96, roughnessOffset: 0.08, tintStrength: 0.04, metalness: 0.62, roughness: 0.4, normalScale: 0.18, uvDensity: 0.32 }, optics: { variant: 'vehicle_carbon_optics', repeatScale: 0.96, roughnessOffset: -0.06, tintStrength: 0.08, metalness: 0.3, roughness: 0.22, normalScale: 0.14, uvDensity: 0.3 }, rubberTrack: { variant: 'vehicle_composite_track', repeatScale: 1.18, roughnessOffset: 0.18, metalness: 0.08, roughness: 0.9, normalScale: 0.3, aoIntensity: 0.82, uvDensity: 0.56 } }, HeavyTank: { hullArmor: { variant: 'default', repeatScale: 0.54, roughnessOffset: 0.02, tintStrength: 0.1, clearcoat: 0.24, clearcoatRoughness: 0.38, normalScale: 0.08, uvDensity: 0.2 }, armorPlating: { variant: 'vehicle_reactive_armor', repeatScale: 0.58, roughnessOffset: -0.01, tintStrength: 0.12, clearcoat: 0.22, clearcoatRoughness: 0.34, normalScale: 0.1, uvDensity: 0.22 }, mechanicalCore: { variant: 'vehicle_gunmetal', repeatScale: 0.72, roughnessOffset: 0.04, tintStrength: 0.04, metalness: 0.9, roughness: 0.36, normalScale: 0.12 }, hydraulics: { variant: 'vehicle_gunmetal', repeatScale: 0.78, roughnessOffset: 0.06, tintStrength: 0.02, metalness: 0.94, roughness: 0.3, normalScale: 0.12 }, trim: { variant: 'unit_markings', repeatScale: 0.76, roughnessOffset: -0.04, tintStrength: 0.08, normalScale: 0.08 }, weaponHousing: { variant: 'vehicle_weapons', repeatScale: 0.74, roughnessOffset: 0.04, tintStrength: 0.06, metalness: 0.88, roughness: 0.34, normalScale: 0.12 }, heatShield: { variant: 'vehicle_heatshield', repeatScale: 0.76, roughnessOffset: 0.08, tintStrength: 0.04, metalness: 0.62, roughness: 0.44, normalScale: 0.1 }, optics: { variant: 'vehicle_carbon_optics', repeatScale: 0.8, roughnessOffset: -0.08, tintStrength: 0.06, normalScale: 0.08 }, rubberTrack: { variant: 'vehicle_tracks', repeatScale: 0.8, roughnessOffset: 0.16, metalness: 0.06, roughness: 0.84, normalScale: 0.12, uvDensity: 0.26 } }, Siege: { hullArmor: { variant: 'default', repeatScale: 0.56, roughnessOffset: 0.02, tintStrength: 0.1, clearcoat: 0.22, clearcoatRoughness: 0.34, normalScale: 0.08, uvDensity: 0.2 }, armorPlating: { variant: 'vehicle_armor', repeatScale: 0.6, roughnessOffset: -0.01, tintStrength: 0.12, clearcoat: 0.22, clearcoatRoughness: 0.32, normalScale: 0.1, uvDensity: 0.22 }, mechanicalCore: { variant: 'vehicle_gunmetal', repeatScale: 0.72, roughnessOffset: 0.04, tintStrength: 0.04, metalness: 0.88, roughness: 0.36, normalScale: 0.12 }, trim: { variant: 'unit_markings', repeatScale: 0.74, roughnessOffset: -0.04, tintStrength: 0.08, normalScale: 0.08 }, weaponHousing: { variant: 'vehicle_weapons', repeatScale: 0.76, roughnessOffset: 0.02, tintStrength: 0.06, metalness: 0.86, roughness: 0.34, normalScale: 0.12, uvDensity: 0.24 }, heatShield: { variant: 'vehicle_heatshield', repeatScale: 0.78, roughnessOffset: 0.08, tintStrength: 0.04, metalness: 0.6, roughness: 0.44, normalScale: 0.1 }, optics: { variant: 'vehicle_carbon_optics', repeatScale: 0.82, roughnessOffset: -0.08, tintStrength: 0.06, normalScale: 0.08 } }, Commander: { hullArmor: { variant: 'vehicle_ceramic_armor', repeatScale: 0.92 }, weaponHousing: { variant: 'vehicle_gunmetal', repeatScale: 0.98 }, optics: { variant: 'vehicle_carbon_optics', repeatScale: 1.02 } } }; const FORCE_PRISTINE_SURFACES = true; const PRISTINE_VARIANT_BY_CHANNEL: Record = { hullArmor: 'pristine_painted_steel', armorPlating: 'pristine_painted_steel', mechanicalCore: 'pristine_painted_steel', hydraulics: 'pristine_painted_steel', optics: 'pristine_carbon', trim: 'pristine_plastic', weaponHousing: 'pristine_painted_steel', heatShield: 'pristine_carbon', rubberTrack: 'pristine_plastic' }; const resolveChannelProfile = ( unitType: UnitType, channel: UnitMaterialChannel ): UnitMaterialChannelProfile => { const base = UNIT_MATERIAL_CHANNEL_PROFILES[channel]; const override = UNIT_MATERIAL_CHANNEL_OVERRIDES[unitType]?.[channel]; const merged = override ? { ...base, ...override } : base; if (!FORCE_PRISTINE_SURFACES) { return merged; } return { ...merged, variant: PRISTINE_VARIANT_BY_CHANNEL[channel] }; }; const USE_SCHEMA_UNIT_PREVIEW = true; const FORCE_FACTORY_PREVIEW_UNITS = new Set(['Tank']); const PROCGEN_VERSION = VolumeGenerator.VERSION; const resolvePreviewTextureSize = (unitType: UnitType): number => { if (requestedTextureQuality === 'high') { return TextureQuality.HIGH; } if (requestedTextureQuality === 'medium') { return TextureQuality.MEDIUM; } if (requestedTextureQuality === 'low') { return TextureQuality.LOW; } // Default to low in MeshLab to keep material iteration responsive. void unitType; return TextureQuality.LOW; }; const ASSET_ID_TO_UNIT_TYPE = new Map( Object.entries(UNIT_TO_ASSET_ID).map(([unitType, assetId]) => [assetId, unitType as UnitType]) ); const UNIT_GEOMETRY_REVISION: Partial> = { Tank: 'tank-leopard3-blueprint-r34' }; type ConstructionFloorPattern = 'chevrons' | 'hazard' | 'runway' | 'command' | 'radial' | 'reinforced'; interface ConstructionFloorTheme { buildingType: BuildingType; buildingLabel: string; pattern: ConstructionFloorPattern; baseColor: THREE.ColorRepresentation; accentColor: THREE.ColorRepresentation; lineColor: THREE.ColorRepresentation; ringColor: THREE.ColorRepresentation; emissiveColor: THREE.ColorRepresentation; gridPrimary: THREE.ColorRepresentation; gridSecondary: THREE.ColorRepresentation; } const UNIT_CONSTRUCTION_BUILDING_BY_TYPE: Record = { Commander: 'CommandCenter', Combat: 'Barracks', Gatherer: 'Barracks', Siege: 'AdvancedFactory', Drone: 'DroneHive', EliteStriker: 'Barracks', Rifleman: 'Barracks', Grenadier: 'Barracks', Sniper: 'Barracks', Medic: 'Barracks', Engineer: 'Barracks', Tank: 'Factory', HeavyTank: 'AdvancedFactory', Assault: 'Barracks', RocketInfantry: 'Barracks', Scout: 'Barracks', ShieldGenerator: 'Factory', Wasp: 'Airfield' }; const DEFAULT_CONSTRUCTION_FLOOR_THEME: Omit = { buildingLabel: 'Factory', pattern: 'hazard', baseColor: '#111820', accentColor: '#1f313f', lineColor: '#6fd2ff', ringColor: '#33506a', emissiveColor: '#0e2735', gridPrimary: '#2f4f69', gridSecondary: '#1b2d3a' }; const CONSTRUCTION_FLOOR_THEMES: Partial>> = { Barracks: { buildingLabel: 'Barracks', pattern: 'chevrons', baseColor: '#1b1f24', accentColor: '#2b333e', lineColor: '#c8b37a', ringColor: '#665a3e', emissiveColor: '#3a3323', gridPrimary: '#4d4f4a', gridSecondary: '#262b2d' }, Factory: { buildingLabel: 'Factory', pattern: 'hazard', baseColor: '#121820', accentColor: '#283544', lineColor: '#8bdcff', ringColor: '#3f617d', emissiveColor: '#163346', gridPrimary: '#3a5770', gridSecondary: '#1f3242' }, AdvancedFactory: { buildingLabel: 'Advanced Factory', pattern: 'reinforced', baseColor: '#111922', accentColor: '#2d3d52', lineColor: '#8cf5d8', ringColor: '#3a8e83', emissiveColor: '#193f3b', gridPrimary: '#2d6f68', gridSecondary: '#1a3838' }, Airfield: { buildingLabel: 'Airfield', pattern: 'runway', baseColor: '#151a1f', accentColor: '#26313a', lineColor: '#d0e3f4', ringColor: '#6a7684', emissiveColor: '#313f4d', gridPrimary: '#56616e', gridSecondary: '#27313c' }, CommandCenter: { buildingLabel: 'Command Center', pattern: 'command', baseColor: '#17161d', accentColor: '#332b48', lineColor: '#d7b6ff', ringColor: '#6f5894', emissiveColor: '#3c2f57', gridPrimary: '#57456f', gridSecondary: '#2d2638' }, DroneHive: { buildingLabel: 'Drone Hive', pattern: 'radial', baseColor: '#17151f', accentColor: '#2e2640', lineColor: '#9d7cff', ringColor: '#5f4d92', emissiveColor: '#352e55', gridPrimary: '#4f4180', gridSecondary: '#29253a' } }; const resolveConstructionFloorTheme = (unitType: UnitType): ConstructionFloorTheme => { const buildingType = UNIT_CONSTRUCTION_BUILDING_BY_TYPE[unitType] ?? 'Factory'; const theme = CONSTRUCTION_FLOOR_THEMES[buildingType] ?? DEFAULT_CONSTRUCTION_FLOOR_THEME; return { ...theme, buildingType }; }; const createFloorStripe = ( group: THREE.Group, color: THREE.ColorRepresentation, width: number, depth: number, x: number, z: number, y: number, rotationZ = 0, opacity = 0.52 ) => { const stripe = new THREE.Mesh( new THREE.PlaneGeometry(width, depth), new THREE.MeshStandardMaterial({ color, roughness: 0.62, metalness: 0.12, transparent: true, opacity }) ); stripe.rotation.x = -Math.PI / 2; stripe.rotation.z = rotationZ; stripe.position.set(x, y, z); group.add(stripe); }; const addConstructionFloorPattern = ( group: THREE.Group, theme: ConstructionFloorTheme, radius: number, y: number ) => { const color = theme.lineColor; const stripeWidth = Math.max(0.22, radius * 0.08); const stripeDepth = Math.max(0.7, radius * 0.26); switch (theme.pattern) { case 'chevrons': { const z = radius * 0.18; createFloorStripe(group, color, stripeWidth, stripeDepth, -radius * 0.22, z, y, Math.PI * 0.15, 0.62); createFloorStripe(group, color, stripeWidth, stripeDepth, radius * 0.22, z, y, -Math.PI * 0.15, 0.62); createFloorStripe(group, color, stripeWidth * 0.7, stripeDepth * 0.7, 0, z + stripeDepth * 0.1, y, 0, 0.52); break; } case 'hazard': { for (let i = -2; i <= 2; i++) { const x = i * radius * 0.18; createFloorStripe(group, color, stripeWidth * 0.62, stripeDepth * 1.15, x, 0, y, Math.PI * 0.22, 0.48); } break; } case 'runway': { createFloorStripe(group, color, stripeWidth * 0.52, radius * 1.3, 0, 0, y, 0, 0.72); createFloorStripe(group, color, stripeWidth * 0.3, radius * 1.25, -radius * 0.19, 0, y, 0, 0.38); createFloorStripe(group, color, stripeWidth * 0.3, radius * 1.25, radius * 0.19, 0, y, 0, 0.38); break; } case 'command': { createFloorStripe(group, color, stripeWidth * 0.56, radius * 1.1, 0, 0, y, 0, 0.66); createFloorStripe(group, color, radius * 1.1, stripeWidth * 0.56, 0, 0, y, 0, 0.66); break; } case 'radial': { const spokeCount = 10; for (let i = 0; i < spokeCount; i++) { const theta = (i / spokeCount) * Math.PI * 2; const x = Math.sin(theta) * radius * 0.24; const z = Math.cos(theta) * radius * 0.24; createFloorStripe(group, color, stripeWidth * 0.4, radius * 0.65, x, z, y, -theta, 0.5); } break; } case 'reinforced': { for (let i = -1; i <= 1; i++) { createFloorStripe(group, color, stripeWidth * 0.54, radius * 1.05, i * radius * 0.22, 0, y, 0, 0.56); } for (let i = -1; i <= 1; i++) { createFloorStripe(group, color, radius * 1.05, stripeWidth * 0.36, 0, i * radius * 0.18, y, 0, 0.44); } break; } default: break; } }; const buildConstructionFloor = (unitType: UnitType, bounds: UnitBounds, floorY: number): THREE.Group => { const theme = resolveConstructionFloorTheme(unitType); const radius = Math.max(8, bounds.radius * 10.5); const group = new THREE.Group(); group.name = `construction-floor-${theme.buildingType.toLowerCase()}`; const base = new THREE.Mesh( new THREE.CircleGeometry(radius, 88), new THREE.MeshStandardMaterial({ color: theme.baseColor, emissive: theme.emissiveColor, emissiveIntensity: 0.08, roughness: 0.92, metalness: 0.06 }) ); base.rotation.x = -Math.PI / 2; base.position.y = floorY; base.receiveShadow = true; group.add(base); const innerDisc = new THREE.Mesh( new THREE.CircleGeometry(radius * 0.72, 72), new THREE.MeshStandardMaterial({ color: theme.accentColor, roughness: 0.82, metalness: 0.1 }) ); innerDisc.rotation.x = -Math.PI / 2; innerDisc.position.y = floorY + 0.004; group.add(innerDisc); const outerRing = new THREE.Mesh( new THREE.RingGeometry(radius * 0.9, radius * 0.98, 96), new THREE.MeshStandardMaterial({ color: theme.ringColor, roughness: 0.65, metalness: 0.22 }) ); outerRing.rotation.x = -Math.PI / 2; outerRing.position.y = floorY + 0.006; group.add(outerRing); const midRing = new THREE.Mesh( new THREE.RingGeometry(radius * 0.46, radius * 0.52, 72), new THREE.MeshStandardMaterial({ color: theme.ringColor, roughness: 0.72, metalness: 0.16, transparent: true, opacity: 0.76 }) ); midRing.rotation.x = -Math.PI / 2; midRing.position.y = floorY + 0.007; group.add(midRing); const gridOverlay = new THREE.GridHelper( radius * 1.9, Math.max(14, Math.floor(radius * 1.9)), theme.gridPrimary, theme.gridSecondary ); gridOverlay.position.y = floorY + 0.008; const gridMaterials = Array.isArray(gridOverlay.material) ? gridOverlay.material : [gridOverlay.material]; for (const material of gridMaterials) { material.transparent = true; material.opacity = 0.52; material.depthWrite = false; } group.add(gridOverlay); addConstructionFloorPattern(group, theme, radius, floorY + 0.01); const centerBadge = new THREE.Mesh( new THREE.RingGeometry(radius * 0.1, radius * 0.15, 42), new THREE.MeshStandardMaterial({ color: theme.lineColor, roughness: 0.52, metalness: 0.24, emissive: theme.lineColor, emissiveIntensity: 0.08, transparent: true, opacity: 0.76 }) ); centerBadge.rotation.x = -Math.PI / 2; centerBadge.position.y = floorY + 0.012; group.add(centerBadge); group.userData.constructionFloorTheme = { buildingType: theme.buildingType, buildingLabel: theme.buildingLabel }; return group; }; const resolveConstructionFloorY = (unitObject: THREE.Object3D, bounds: UnitBounds): number => { const fallbackY = -bounds.height * 0.53; const worldBounds = new THREE.Box3().setFromObject(unitObject); if (!Number.isFinite(worldBounds.min.y) || !Number.isFinite(worldBounds.max.y)) { return fallbackY; } const measuredHeight = Math.max(0.1, worldBounds.max.y - worldBounds.min.y); const referenceHeight = Math.max(bounds.height, measuredHeight); const clearance = Math.max(0.018, referenceHeight * 0.015); return worldBounds.min.y - clearance; }; const clearConstructionFloor = () => { if (!activeConstructionFloor) return; scene.remove(activeConstructionFloor); disposeObject3DTree(activeConstructionFloor); activeConstructionFloor = null; grid.visible = true; originMarker.visible = true; }; const syncConstructionFloor = () => { if (captureModeEnabled) { clearConstructionFloor(); return; } const unitType = activeUnitType; const unitBounds = activeUnitBounds; if (!activeObject || !unitType || !unitBounds) { clearConstructionFloor(); return; } const floorY = resolveConstructionFloorY(activeObject, unitBounds); const floorKey = `${unitType}:${unitBounds.radius.toFixed(4)}:${unitBounds.height.toFixed(4)}:${floorY.toFixed(4)}`; if (activeConstructionFloor?.userData?.floorKey === floorKey) { grid.visible = false; originMarker.visible = false; return; } clearConstructionFloor(); activeConstructionFloor = buildConstructionFloor(unitType, unitBounds, floorY); activeConstructionFloor.userData.floorKey = floorKey; scene.add(activeConstructionFloor); grid.visible = false; originMarker.visible = false; }; const formatBytes = (bytes?: number) => { if (typeof bytes !== 'number' || !Number.isFinite(bytes)) return 'unknown'; if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`; return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; }; const applyWireframe = (node: THREE.Object3D, enabled: boolean) => { node.traverse((child) => { const mesh = child as THREE.Mesh; if (!mesh.isMesh || !mesh.material) return; const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]; for (const material of materials) { const maybeStandard = material as THREE.Material & { wireframe?: boolean }; if (typeof maybeStandard.wireframe === 'boolean') { maybeStandard.wireframe = enabled; } } }); }; const PART_GROUP_HIGHLIGHT_COLORS: Record = { torso: new THREE.Color('#66d3ff'), head: new THREE.Color('#a0c4ff'), arms: new THREE.Color('#8fd6a9'), legs: new THREE.Color('#c3b1e1'), joints: new THREE.Color('#f4d35e'), backpack: new THREE.Color('#ffd6a5'), weapons: new THREE.Color('#ffadad'), hardpoints: new THREE.Color('#f7b267'), sensors: new THREE.Color('#9bf6ff'), accessories: new THREE.Color('#ffb463'), unknown: new THREE.Color('#b7c6df') }; const PART_PURPOSE_HIGHLIGHT_COLORS: Record = { armor_plate: new THREE.Color('#7fd3ff'), limb_segment: new THREE.Color('#8fd6a9'), joint: new THREE.Color('#f4d35e'), connector: new THREE.Color('#d7aefb'), housing: new THREE.Color('#ff9f9f'), helmet: new THREE.Color('#a0c4ff'), backpack: new THREE.Color('#ffd6a5'), weapon: new THREE.Color('#ffadad'), sensor: new THREE.Color('#9bf6ff'), unknown: new THREE.Color('#b7c6df') }; const PART_GROUP_HIGHLIGHT_MATERIALS = new Map(); const PART_PURPOSE_HIGHLIGHT_MATERIALS = new Map(); let partVisibilityBuckets: Map | null = null; let partVisibilityState = new Map(); let partVisibilityInputs = new Map(); const resolvePartGroupBucket = (info: MeshDiagnosticInfo): string => { const rawId = info.inferredPartId ?? info.inferredLineage ?? ''; const id = String(rawId).toLowerCase(); const partName = String(info.part ?? '').toLowerCase(); const channelName = String(info.channel ?? '').toLowerCase(); const sourceText = [ info.sourcePart, info.inferredLineageSource ] .filter(Boolean) .map((value) => String(value).toLowerCase()) .join(' '); const purpose = info.inferredPartPurpose ?? info.inferredPartGroup ?? null; const weaponSignal = /weapon|gun|barrel|muzzle|ammo|turret|rocket|grenade|launcher|rifle|cannon/; const backpackSignal = /backpack|pack/; const sensorSignal = /sensor|optic|antenna/; const anatomySignal = /helmet|head|jaw|visor|shoulder|clavicle|arm|forearm|hand|elbow|bicep|tricep|torso|rib|sternum|abdomen|pelvis|hip|thigh|knee|calf|shin|ankle|foot|boot/; if (partName === 'weapons' || channelName.includes('weapon')) { return 'weapons'; } if (partName === 'backpack' || channelName.includes('backpack')) { return 'backpack'; } if (partName === 'sensors' || channelName.includes('sensor') || channelName.includes('optic') || channelName.includes('antenna')) { return 'sensors'; } if (weaponSignal.test(sourceText)) { return 'weapons'; } if (weaponSignal.test(sourceText)) { return 'weapons'; } if (backpackSignal.test(sourceText) && !anatomySignal.test(id)) { return 'backpack'; } if (sensorSignal.test(sourceText)) { return 'sensors'; } if (purpose === 'weapon') { return 'weapons'; } if (purpose === 'backpack') { return 'backpack'; } if (purpose === 'sensor') { return 'sensors'; } if (purpose === 'helmet') { return 'head'; } if (purpose === 'joint') { return 'joints'; } if ( id.includes('torso') || id.includes('chest') || id.includes('rib') || id.includes('sternum') || id.includes('abdomen') || id.includes('pelvis') || id.includes('spine') || id.includes('neck') || id.includes('collar') || id.includes('waist') ) { return 'torso'; } if (id.includes('helmet') || id.includes('head') || id.includes('visor') || id.includes('jaw')) { return 'head'; } if ( id.includes('shoulder') || id.includes('upper_arm') || id.includes('forearm') || id.includes('hand') || id.includes('elbow') || id.includes('bicep') || id.includes('tricep') || id.includes('cuff') ) { return 'arms'; } if ( id.includes('hip') || id.includes('thigh') || id.includes('knee') || id.includes('calf') || id.includes('shin') || id.includes('ankle') || id.includes('foot') || id.includes('heel') || id.includes('toe') || id.includes('boot') ) { return 'legs'; } if (id.includes('backpack') || id.includes('pack')) { return 'backpack'; } if (id.includes('hardpoint') || id.includes('cradle') || id.includes('weapon_mount')) { return 'hardpoints'; } if ( id.includes('weapon') || id.includes('gun') || id.includes('barrel') || id.includes('muzzle') || id.includes('ammo') ) { return 'weapons'; } if (id.includes('sensor') || id.includes('optic') || id.includes('antenna')) { return 'sensors'; } if (id.includes('joint') || id.includes('ring') || id.includes('socket')) { return 'joints'; } return purpose ?? 'unknown'; }; const resolvePartHighlightKey = (info: MeshDiagnosticInfo, mode: string): string => { if (mode === 'group') { return resolvePartGroupBucket(info); } return info.inferredPartPurpose ?? info.inferredPartGroup ?? 'unknown'; }; const resolvePartVisibilityKey = (info: MeshDiagnosticInfo, mode: string): string => { if (mode === 'group') { return resolvePartGroupBucket(info); } return info.inferredPartPurpose ?? info.inferredPartGroup ?? 'unknown'; }; const formatPartVisibilityLabel = (key: string) => key.replace(/_/g, ' '); const buildPartVisibilityBuckets = (node: THREE.Object3D, mode: string) => { const buckets = new Map(); const meshes = collectPartGroupHighlightMeshes(node); for (const mesh of meshes) { const info = mesh.userData?.meshDiagnostic as MeshDiagnosticInfo | undefined; if (!info) continue; const key = resolvePartVisibilityKey(info, mode); const list = buckets.get(key) ?? []; list.push(mesh); buckets.set(key, list); } return buckets; }; const applyPartVisibilityFilters = () => { if (!partVisibilityBuckets) return; for (const [key, meshes] of partVisibilityBuckets.entries()) { const visible = partVisibilityState.get(key) ?? true; for (const mesh of meshes) { mesh.userData.__partVisibility = visible; mesh.visible = visible; } } }; const renderPartVisibilityList = () => { partVisibilityInputs = new Map(); partVisibilityList.innerHTML = ''; if (!partVisibilityBuckets || partVisibilityBuckets.size === 0) { partVisibilityList.textContent = 'No part types detected.'; return; } const keys = Array.from(partVisibilityBuckets.keys()).sort((a, b) => a.localeCompare(b)); for (const key of keys) { const item = document.createElement('label'); item.className = 'part-visibility-item'; const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; checkbox.checked = partVisibilityState.get(key) ?? true; checkbox.addEventListener('change', () => { partVisibilityState.set(key, checkbox.checked); applyPartVisibilityFilters(); }); const text = document.createElement('span'); text.textContent = formatPartVisibilityLabel(key); const count = document.createElement('span'); count.className = 'part-visibility-count'; count.textContent = `(${partVisibilityBuckets.get(key)?.length ?? 0})`; item.append(checkbox, text, count); partVisibilityList.append(item); partVisibilityInputs.set(key, checkbox); } }; const rebuildPartVisibility = (node: THREE.Object3D | null) => { if (!node) { partVisibilityBuckets = null; partVisibilityState = new Map(); partVisibilityInputs = new Map(); partVisibilityList.textContent = 'Load a model to view part types.'; return; } const mode = partVisibilityMode.value; const buckets = buildPartVisibilityBuckets(node, mode); const nextState = new Map(); for (const key of buckets.keys()) { nextState.set(key, partVisibilityState.get(key) ?? true); } partVisibilityBuckets = buckets; partVisibilityState = nextState; renderPartVisibilityList(); applyPartVisibilityFilters(); }; const createHighlightMaterial = (highlightColor: THREE.Color) => { const baseColor = highlightColor.clone().lerp(new THREE.Color('#ffffff'), 0.18); return new THREE.MeshStandardMaterial({ color: baseColor, emissive: highlightColor.clone(), emissiveIntensity: 0.45, metalness: 0.18, roughness: 0.62 }); }; const resolveHighlightMaterial = (mesh: THREE.Mesh, info: MeshDiagnosticInfo, mode: string) => { const highlightCache = mesh.userData.meshlabHighlightByMode as Record | undefined; const resolvedCache = highlightCache ?? {}; if (!highlightCache) { mesh.userData.meshlabHighlightByMode = resolvedCache; } if (resolvedCache[mode]) { return resolvedCache[mode]; } const groupKey = resolvePartHighlightKey(info, mode); const colorTable = mode === 'group' ? PART_GROUP_HIGHLIGHT_COLORS : PART_PURPOSE_HIGHLIGHT_COLORS; const highlightColor = colorTable[groupKey] ?? colorTable.unknown; const materialCache = mode === 'group' ? PART_GROUP_HIGHLIGHT_MATERIALS : PART_PURPOSE_HIGHLIGHT_MATERIALS; let highlightMaterial = materialCache.get(groupKey); if (!highlightMaterial) { highlightMaterial = createHighlightMaterial(highlightColor); materialCache.set(groupKey, highlightMaterial); } const highlight = Array.isArray(mesh.material) ? new Array(mesh.material.length).fill(highlightMaterial) : highlightMaterial; resolvedCache[mode] = highlight; return highlight; }; const collectPartGroupHighlightMeshes = (node: THREE.Object3D): THREE.Mesh[] => { const meshes: THREE.Mesh[] = []; node.traverse((child) => { const mesh = child as THREE.Mesh; if (!mesh.isMesh || !mesh.material) return; const info = mesh.userData?.meshDiagnostic as MeshDiagnosticInfo | undefined; if (!info) return; meshes.push(mesh); }); return meshes; }; const buildPartGroupHighlightCache = (node: THREE.Object3D) => { const meshes = collectPartGroupHighlightMeshes(node); for (const mesh of meshes) { const info = mesh.userData?.meshDiagnostic as MeshDiagnosticInfo | undefined; if (!info) continue; if (!mesh.userData.meshlabBaseMaterial) { mesh.userData.meshlabBaseMaterial = mesh.material; } // Warm caches for both modes so toggling is instant. resolveHighlightMaterial(mesh, info, 'group'); resolveHighlightMaterial(mesh, info, 'purpose'); } return meshes; }; const syncPartGroupHighlightCache = (node: THREE.Object3D) => { activeHighlightRoot = node; activeHighlightMeshes = buildPartGroupHighlightCache(node); }; const clearPartGroupHighlightCache = () => { activeHighlightRoot = null; activeHighlightMeshes = null; }; const applyPartGroupHighlight = (node: THREE.Object3D, enabled: boolean) => { const cachedMeshes = node === activeHighlightRoot ? activeHighlightMeshes : null; const applyMesh = (mesh: THREE.Mesh) => { if (!mesh.isMesh || !mesh.material) return; const info = mesh.userData?.meshDiagnostic as MeshDiagnosticInfo | undefined; if (!info) return; const baseMaterial = mesh.userData?.meshlabBaseMaterial as THREE.Material | THREE.Material[] | undefined; if (enabled) { if (!baseMaterial) { mesh.userData.meshlabBaseMaterial = mesh.material; } const mode = partGroupHighlightMode.value; mesh.material = resolveHighlightMaterial(mesh, info, mode); return; } if (baseMaterial) { mesh.material = baseMaterial; } }; if (cachedMeshes) { cachedMeshes.forEach(applyMesh); return; } node.traverse((child) => { applyMesh(child as THREE.Mesh); }); }; const renderMetadata = (model: ViewerModel | null) => { if (!model) { metadataNode.innerHTML = ''; return; } const generatedTriangles = generatedTrianglesById.get(model.id) ?? null; const unitType = model.assetId ? ASSET_ID_TO_UNIT_TYPE.get(model.assetId) : undefined; const revision = unitType ? UNIT_GEOMETRY_REVISION[unitType] : undefined; const floorTheme = unitType ? resolveConstructionFloorTheme(unitType) : null; const common: Array<[string, string]> = [ ['Type', model.kind === 'obj' ? 'OBJ asset' : 'Procedural asset'], ['Source', model.source ?? 'unknown'] ]; const entries: Array<[string, string]> = model.kind === 'obj' ? [ ...common, ['Model', model.fileName ?? 'unknown'], ['Family', model.family ?? 'unknown'], ['LOD', model.lod ?? 'unknown'], ['Variant', model.variant == null ? 'n/a' : String(model.variant)], ['Triangles', model.totalTriangles == null ? 'n/a' : model.totalTriangles.toLocaleString()], ['Stem Triangles', model.stemTriangles == null ? 'n/a' : model.stemTriangles.toLocaleString()], ['Foliage Triangles', model.foliageTriangles == null ? 'n/a' : model.foliageTriangles.toLocaleString()], ['File Size', formatBytes(model.bytes)] ] : [ ...common, ['Asset ID', model.assetId ?? 'unknown'], ['Name', model.assetName ?? model.label ?? 'unknown'], ['Category', model.category ?? 'unknown'], ['Role', model.role ?? 'unknown'], ['Tech Level', model.techLevel == null ? 'n/a' : String(model.techLevel)], ['Preview LOD', model.previewLod ?? (model.source === 'unit' ? 'lod0' : 'lod1')], ['Triangles', generatedTriangles == null ? 'calculating...' : generatedTriangles.toLocaleString()], ['Bounds', model.bounds ? `r ${model.bounds.radius}, h ${model.bounds.height}` : 'n/a'], ...(model.source === 'unit' ? ([['ProcGen Version', PROCGEN_VERSION]] as Array<[string, string]>) : []), ...(model.source === 'unit' && floorTheme ? ([['Constructed In', floorTheme.buildingLabel], ['Floor Theme', floorTheme.pattern]] as Array<[string, string]>) : []), ...(model.source === 'unit' ? ([['Mesh Source', activePreviewGeometrySource ?? 'pending']] as Array<[string, string]>) : []), ...(revision ? ([['Geometry Rev', revision]] as Array<[string, string]>) : []) ]; metadataNode.innerHTML = entries .map(([key, value]) => `
${key}
${value}
`) .join(''); }; const setStatus = (value: string) => { statusNode.textContent = value; }; const setHoverInfo = (value: string) => { hoverInfoNode.textContent = value; }; const setIssueInfo = (value: string) => { issuesNode.textContent = value; }; const setSocketMetadata = (entries: Array<[string, string]>) => { if (entries.length === 0) { socketMetadataNode.innerHTML = ''; return; } socketMetadataNode.innerHTML = entries .map(([key, value]) => `
${key}
${value}
`) .join(''); }; const resolveUnitBounds = (model: ViewerModel | null, schema?: VisualLanguageSchema | null): UnitBounds | null => { if (!model) { return null; } const fromModel = model.bounds; if ( fromModel && Number.isFinite(fromModel.radius) && Number.isFinite(fromModel.height) && fromModel.radius > 0 && fromModel.height > 0 ) { return { radius: fromModel.radius, height: fromModel.height }; } if ( schema?.bounds && Number.isFinite(schema.bounds.radius) && Number.isFinite(schema.bounds.height) && schema.bounds.radius > 0 && schema.bounds.height > 0 ) { return { radius: schema.bounds.radius, height: schema.bounds.height }; } return null; }; const setMotionStatus = (value: string) => { simStatusNode.textContent = value; }; const setTrackDebugInfo = (value: string) => { trackDebugNode.textContent = value; }; const setEditStatus = (value: string) => { editStatusNode.textContent = value; }; const isUserTyping = () => { const active = document.activeElement as HTMLElement | null; if (!active) return false; const tag = active.tagName.toLowerCase(); return tag === 'input' || tag === 'textarea' || tag === 'select' || active.isContentEditable; }; const formatEditTargetLabel = (target: EditTarget | null): string => { if (!target) return 'None'; if (target.kind === 'humanoidPart') { return `humanoid:${target.id}`; } const level = target.level ? `:${target.level}` : ''; const index = target.index != null ? `#${target.index}` : ''; return `volume:${target.id}${level}${index}`; }; const updateSelectedTargetPill = (target: EditTarget | null) => { const label = formatEditTargetLabel(target); editSelectedPill.textContent = label; editCopyTargetButton.disabled = label === 'None'; if (label === 'None') { editSelectedPill.classList.add('is-empty'); } else { editSelectedPill.classList.remove('is-empty'); } }; const updateUpgradePreview = () => { if (!activeEditSelection || activeEditSelection.target.kind !== 'volume') { editUpgradePreview.textContent = 'Select a volume'; editUpgradePreview.classList.add('is-empty'); return; } const target = activeEditSelection.target; const to = editUpgradeSelect.value; if (!to) { editUpgradePreview.textContent = `${target.id} -> ?`; editUpgradePreview.classList.add('is-empty'); return; } if (to === target.id) { editUpgradePreview.textContent = `${target.id} (no change)`; editUpgradePreview.classList.add('is-empty'); return; } const level = target.level ? `:${target.level}` : ''; const index = target.index != null ? `#${target.index}` : ''; editUpgradePreview.textContent = `${target.id}${level}${index} -> ${to}`; editUpgradePreview.classList.remove('is-empty'); }; const cloneOverrideRecord = (record: SchemaOverrideRecord | null): SchemaOverrideRecord | null => { if (!record) return null; try { return JSON.parse(JSON.stringify(record)) as SchemaOverrideRecord; } catch { return record ? { ...record } : null; } }; const pushUndoSnapshot = (assetId: string) => { editUndoStack.push({ assetId, record: cloneOverrideRecord(activeOverrideStore.schemaOverrides[assetId] ?? null) }); editRedoStack.length = 0; syncUndoRedoButtons(); }; const findUndoEntry = (assetId: string, stack: Array<{ assetId: string; record: SchemaOverrideRecord | null }>) => { for (let i = stack.length - 1; i >= 0; i -= 1) { if (stack[i].assetId === assetId) { return stack.splice(i, 1)[0] ?? null; } } return null; }; const applyOverrideSnapshot = (assetId: string, snapshot: SchemaOverrideRecord | null) => { if (!snapshot) { delete activeOverrideStore.schemaOverrides[assetId]; return; } activeOverrideStore.schemaOverrides[assetId] = cloneOverrideRecord(snapshot) ?? snapshot; }; const undoOverrideChange = () => { if (!activeModel?.assetId) return; const assetId = activeModel.assetId; const entry = findUndoEntry(assetId, editUndoStack); if (!entry) { setEditStatus('Undo: nothing to revert.'); return; } editRedoStack.push({ assetId, record: cloneOverrideRecord(activeOverrideStore.schemaOverrides[assetId] ?? null) }); applyOverrideSnapshot(assetId, entry.record); requestEditRecompose(activeEditSelection?.target ?? null, activeEditSelection ? getMeshWorldCenter(activeEditSelection.object) : null); syncEditFlagsFromOverrides(activeEditSelection?.target ?? null); updateUpgradePreview(); syncUndoRedoButtons(); syncUndoRedoButtons(); setEditStatus('Undo applied.'); }; const redoOverrideChange = () => { if (!activeModel?.assetId) return; const assetId = activeModel.assetId; const entry = findUndoEntry(assetId, editRedoStack); if (!entry) { setEditStatus('Redo: nothing to apply.'); return; } editUndoStack.push({ assetId, record: cloneOverrideRecord(activeOverrideStore.schemaOverrides[assetId] ?? null) }); applyOverrideSnapshot(assetId, entry.record); requestEditRecompose(activeEditSelection?.target ?? null, activeEditSelection ? getMeshWorldCenter(activeEditSelection.object) : null); syncEditFlagsFromOverrides(activeEditSelection?.target ?? null); updateUpgradePreview(); syncUndoRedoButtons(); setEditStatus('Redo applied.'); }; const syncUndoRedoButtons = () => { if (!activeModel?.assetId) { editUndoButton.disabled = true; editRedoButton.disabled = true; return; } const assetId = activeModel.assetId; editUndoButton.disabled = !editUndoStack.some((entry) => entry.assetId === assetId); editRedoButton.disabled = !editRedoStack.some((entry) => entry.assetId === assetId); }; const resolveActiveBounds = (): UnitBounds | null => { if (activeProceduralSchema?.bounds) { const { radius, height } = activeProceduralSchema.bounds; if (Number.isFinite(radius) && Number.isFinite(height) && radius > 0 && height > 0) { return { radius, height }; } } return activeUnitBounds; }; const normalizeHumanoidPlacementId = (value: unknown): string | null => { if (typeof value !== 'string') return null; const trimmed = value.trim(); if (!trimmed) return null; return trimmed.startsWith('~') ? trimmed.slice(1) : trimmed; }; const resolveMirrorHumanoidId = (id: string): string | null => { if (!id) return null; const upperStart = id[0]; if ((upperStart === 'L' || upperStart === 'R') && id.length > 1 && id[1] === id[1].toUpperCase()) { return `${upperStart === 'L' ? 'R' : 'L'}${id.slice(1)}`; } if (id.includes('Left')) return id.replace('Left', 'Right'); if (id.includes('Right')) return id.replace('Right', 'Left'); if (id.includes('left')) return id.replace('left', 'right'); if (id.includes('right')) return id.replace('right', 'left'); if (id.includes('_L')) return id.replace('_L', '_R'); if (id.includes('_R')) return id.replace('_R', '_L'); return null; }; const mergePlacementOverrides = ( base: PlacementOverrideSet | undefined, next: PlacementOverrideSet | undefined ): PlacementOverrideSet | undefined => { const baseEntries = base?.entries ?? []; const nextEntries = next?.entries ?? []; if (baseEntries.length === 0 && nextEntries.length === 0) return undefined; if (baseEntries.length === 0) return { version: 1, entries: [...nextEntries] }; if (nextEntries.length === 0) return { version: 1, entries: [...baseEntries] }; return { version: 1, entries: [...baseEntries, ...nextEntries] }; }; const mergeVolumeHierarchyOverrides = ( base: VolumeHierarchyOverrides | undefined, next: VolumeHierarchyOverrides | undefined ): VolumeHierarchyOverrides | undefined => { const baseReplace = base?.replace ?? []; const nextReplace = next?.replace ?? []; if (baseReplace.length === 0 && nextReplace.length === 0) return undefined; return { replace: [...baseReplace, ...nextReplace] }; }; const mergeSchemaOverrideRecords = ( ...records: Array ): SchemaOverrideRecord | null => { let placement: PlacementOverrideSet | undefined; let hierarchy: VolumeHierarchyOverrides | undefined; for (const record of records) { if (!record) continue; placement = mergePlacementOverrides(placement, record.placementOverrides); hierarchy = mergeVolumeHierarchyOverrides(hierarchy, record.volumeHierarchyOverrides); } if (!placement && !hierarchy) return null; return { placementOverrides: placement, volumeHierarchyOverrides: hierarchy }; }; const applySchemaOverrides = ( schema: VisualLanguageSchema, overrides: SchemaOverrideRecord | null ): VisualLanguageSchema => { if (!overrides) return schema; return { ...schema, placementOverrides: mergePlacementOverrides(schema.placementOverrides, overrides.placementOverrides), volumeHierarchyOverrides: mergeVolumeHierarchyOverrides(schema.volumeHierarchyOverrides, overrides.volumeHierarchyOverrides) }; }; const loadVisualSchemaOverrides = async (): Promise => { if (visualSchemaOverrideCache) { return visualSchemaOverrideCache; } const overrideUrl = `/assets_visual_manifest.override.json?rev=${encodeURIComponent(visualSchemaRevision)}`; try { const response = await fetch(overrideUrl, { cache: 'no-store' }); if (!response.ok) { visualSchemaOverrideCache = { schemaOverrides: {} }; return visualSchemaOverrideCache; } const parsed = await parseJsonWithDiagnostics( response, 'assets_visual_manifest.override.json' ); const schemaOverrides = parsed?.schemaOverrides && typeof parsed.schemaOverrides === 'object' ? parsed.schemaOverrides : {}; visualSchemaOverrideCache = { schemaOverrides }; return visualSchemaOverrideCache; } catch (error) { console.warn('[MeshLabViewer] Failed to load schema override manifest.', error); visualSchemaOverrideCache = { schemaOverrides: {} }; return visualSchemaOverrideCache; } }; const resolveSchemaWithOverrides = ( schema: VisualLanguageSchema | null, assetId: string | null ): VisualLanguageSchema | null => { if (!schema || !assetId) return schema; const fileOverrides = visualSchemaOverrideCache?.schemaOverrides?.[assetId]; const liveOverrides = activeOverrideStore.schemaOverrides?.[assetId]; const mergedOverrides = mergeSchemaOverrideRecords(fileOverrides, liveOverrides); return mergedOverrides ? applySchemaOverrides(schema, mergedOverrides) : schema; }; const mergeOverrideManifests = ( base: SchemaOverrideManifest | null, next: SchemaOverrideManifest | null ): SchemaOverrideManifest => { const merged: SchemaOverrideManifest = { schemaOverrides: {} }; const append = (manifest: SchemaOverrideManifest | null) => { if (!manifest) return; for (const [assetId, record] of Object.entries(manifest.schemaOverrides ?? {})) { if (!merged.schemaOverrides[assetId]) { merged.schemaOverrides[assetId] = { ...record }; } else { const existing = merged.schemaOverrides[assetId]; merged.schemaOverrides[assetId] = mergeSchemaOverrideRecords(existing, record) ?? existing; } } }; append(base); append(next); return merged; }; const captureTransformSnapshot = (object: THREE.Object3D) => ({ position: object.position.clone(), rotation: object.rotation.clone(), scale: object.scale.clone() }); const getMeshWorldCenter = (object: THREE.Object3D): THREE.Vector3 => { const box = new THREE.Box3().setFromObject(object); return box.getCenter(new THREE.Vector3()); }; const clearEditSelection = () => { activeEditSelection = null; editTransformBaseline = null; transformControls.detach(); transformControls.visible = false; transformControls.enabled = false; disposeSelectionOverlay(); editLockToggle.checked = false; editAllowOverlapToggle.checked = false; updateSelectedTargetPill(null); updateUpgradePreview(); syncUndoRedoButtons(); setEditStatus(editModeEnabled ? 'Edit mode enabled. Click a mesh to select.' : 'Edit mode disabled.'); }; const resolveVolumeIndices = ( schema: VisualLanguageSchema | null, level: 'primary' | 'secondary' | 'tertiary', volumeType: string ): number[] => { if (!schema?.volumeHierarchy) return []; if (level === 'primary') { return schema.volumeHierarchy.primary === volumeType ? [0] : []; } const list = level === 'secondary' ? schema.volumeHierarchy.secondary : schema.volumeHierarchy.tertiary; const indices: number[] = []; for (let i = 0; i < list.length; i += 1) { if (list[i] === volumeType) { indices.push(i); } } return indices; }; const resolveVolumeTargetHint = ( schema: VisualLanguageSchema | null, part: UnitPart, volumeType: string ): { level?: 'primary' | 'secondary' | 'tertiary'; index?: number } => { if (!schema?.volumeHierarchy) return {}; const primaryMatch = schema.volumeHierarchy.primary === volumeType; const secondaryIndices = resolveVolumeIndices(schema, 'secondary', volumeType); const tertiaryIndices = resolveVolumeIndices(schema, 'tertiary', volumeType); if (primaryMatch && part === 'base') { return { level: 'primary', index: 0 }; } if (secondaryIndices.length > 0 && tertiaryIndices.length === 0) { return { level: 'secondary', index: secondaryIndices.length === 1 ? secondaryIndices[0] : undefined }; } if (tertiaryIndices.length > 0 && secondaryIndices.length === 0) { return { level: 'tertiary', index: tertiaryIndices.length === 1 ? tertiaryIndices[0] : undefined }; } if (secondaryIndices.length > 0 && tertiaryIndices.length > 0) { const prefer = part === 'details' ? 'secondary' : 'tertiary'; const indices = prefer === 'secondary' ? secondaryIndices : tertiaryIndices; return { level: prefer, index: indices.length === 1 ? indices[0] : undefined }; } if (primaryMatch) { return { level: 'primary', index: 0 }; } if (secondaryIndices.length > 0) { return { level: 'secondary', index: secondaryIndices.length === 1 ? secondaryIndices[0] : undefined }; } if (tertiaryIndices.length > 0) { return { level: 'tertiary', index: tertiaryIndices.length === 1 ? tertiaryIndices[0] : undefined }; } return {}; }; const resolveEditTargetFromInfo = (info: MeshDiagnosticInfo): EditTarget | null => { if (isHumanoidPreviewUnitType(info.unitType)) { const nodeId = normalizeHumanoidPlacementId(info.inferredPartNodeId); const partId = normalizeHumanoidPlacementId(info.inferredPartId); const resolved = nodeId ?? partId; if (resolved) { return { kind: 'humanoidPart', id: resolved }; } } const sourceVolumes = Array.isArray(info.sourceVolumes) ? info.sourceVolumes.filter((value): value is string => typeof value === 'string') : []; if (sourceVolumes.length === 0) { return null; } const volumeType = sourceVolumes[0]; const sourceLevels = Array.isArray(info.sourceVolumeLevels) ? info.sourceVolumeLevels.map((value) => value === 'primary' || value === 'secondary' || value === 'tertiary' ? value : null ) : []; const sourceIndices = Array.isArray(info.sourceVolumeIndices) ? info.sourceVolumeIndices.map((value) => (Number.isFinite(value as number) ? Number(value) : null)) : []; let resolvedLevel: 'primary' | 'secondary' | 'tertiary' | undefined; let resolvedIndex: number | undefined; for (let i = 0; i < sourceVolumes.length; i += 1) { if (sourceVolumes[i] !== volumeType) continue; const level = sourceLevels[i]; const index = sourceIndices[i]; if (level) { resolvedLevel = level; } if (index != null) { resolvedIndex = index; } if (resolvedLevel || resolvedIndex != null) { break; } } if (!resolvedLevel || resolvedIndex == null) { const hint = resolveVolumeTargetHint(activeProceduralSchema, info.part, volumeType); resolvedLevel = resolvedLevel ?? hint.level; resolvedIndex = resolvedIndex ?? hint.index; } return { kind: 'volume', id: volumeType, level: resolvedLevel, index: resolvedIndex }; }; const findEditSelectionCandidate = (target: EditTarget, anchor: THREE.Vector3 | null): EditSelection | null => { if (!activeObject) return null; const candidates: Array<{ selection: EditSelection; distance: number }> = []; activeObject.traverse((node) => { if (!(node instanceof THREE.Mesh)) return; const info = node.userData?.meshDiagnostic as MeshDiagnosticInfo | undefined; if (!info) return; if (target.kind === 'humanoidPart') { const nodeId = normalizeHumanoidPlacementId(info.inferredPartNodeId); const partId = normalizeHumanoidPlacementId(info.inferredPartId); if (target.id !== nodeId && target.id !== partId) return; } else { const sourceVolumes = Array.isArray(info.sourceVolumes) ? info.sourceVolumes.filter((value): value is string => typeof value === 'string') : []; if (!sourceVolumes.includes(target.id)) return; if (target.level || target.index != null) { const sourceLevels = Array.isArray(info.sourceVolumeLevels) ? info.sourceVolumeLevels .map((value) => (value === 'primary' || value === 'secondary' || value === 'tertiary' ? value : null)) : []; const sourceIndices = Array.isArray(info.sourceVolumeIndices) ? info.sourceVolumeIndices.map((value) => (Number.isFinite(value as number) ? Number(value) : null)) : []; let matched = false; for (let i = 0; i < sourceVolumes.length; i += 1) { if (sourceVolumes[i] !== target.id) continue; const levelMatch = target.level ? sourceLevels[i] === target.level : true; const indexMatch = target.index != null ? sourceIndices[i] === target.index : true; if (levelMatch && indexMatch) { matched = true; break; } } if (!matched) return; } } const center = getMeshWorldCenter(node); const distance = anchor ? anchor.distanceTo(center) : 0; candidates.push({ selection: { object: node, info, target }, distance }); }); if (candidates.length === 0) return null; candidates.sort((a, b) => a.distance - b.distance); return candidates[0].selection; }; const setEditSelection = (selection: EditSelection) => { activeEditSelection = selection; editTransformBaseline = captureTransformSnapshot(selection.object); transformControls.attach(selection.object); transformControls.setMode(editTransformMode.value as 'translate' | 'rotate' | 'scale'); transformControls.visible = editModeEnabled; transformControls.enabled = editModeEnabled; syncEditFlagsFromOverrides(selection.target); disposeSelectionOverlay(); activeSelectionOverlay = createSelectionOverlay(selection); updateSelectedTargetPill(selection.target); updateUpgradePreview(); if (selection.target.kind === 'humanoidPart') { setEditStatus(`Editing humanoid part ${selection.target.id}.`); } else { const levelLabel = selection.target.level ? ` ${selection.target.level}` : ''; const indexLabel = selection.target.index != null ? `#${selection.target.index}` : ''; setEditStatus(`Editing volume ${selection.target.id}${levelLabel}${indexLabel}.`); } }; const resolveUpgradeOptions = (): string[] => { const options = new Set(); if (activeProceduralSchema?.volumeHierarchy) { options.add(activeProceduralSchema.volumeHierarchy.primary); for (const vol of activeProceduralSchema.volumeHierarchy.secondary) options.add(vol); for (const vol of activeProceduralSchema.volumeHierarchy.tertiary) options.add(vol); } for (const template of Object.values(UNIT_VOLUME_TEMPLATES)) { const hierarchy = template?.volumeHierarchy; if (!hierarchy) continue; options.add(hierarchy.primary); for (const vol of hierarchy.secondary) options.add(vol); for (const vol of hierarchy.tertiary) options.add(vol); } return Array.from(options.values()).sort((a, b) => a.localeCompare(b)); }; const refreshUpgradeOptions = () => { editUpgradeSelect.innerHTML = ''; const options = resolveUpgradeOptions(); if (options.length === 0) { const option = document.createElement('option'); option.value = ''; option.textContent = 'No volume types'; editUpgradeSelect.append(option); editUpgradeSelect.disabled = true; editUpgradeApplyButton.disabled = true; return; } for (const value of options) { const option = document.createElement('option'); option.value = value; option.textContent = value; editUpgradeSelect.append(option); } editUpgradeSelect.disabled = false; editUpgradeApplyButton.disabled = false; updateUpgradePreview(); }; const requestEditRecompose = (target: EditTarget | null, anchor: THREE.Vector3 | null) => { if (!activeModel) return; if (target) { pendingEditReselect = { target, center: anchor }; } else { pendingEditReselect = null; } schemaLODFactory.clearCache(); void loadModel(activeModel); }; const registerPlacementOverrides = (assetId: string, entries: PlacementOverrideSet['entries']) => { if (!assetId) return; const record = activeOverrideStore.schemaOverrides[assetId] ?? { placementOverrides: { version: 1, entries: [] } }; const existing = record.placementOverrides?.entries ?? []; const filtered = existing.filter((entry) => !entries.some((incoming) => matchesPlacementTarget(entry, incoming.target))); record.placementOverrides = { version: 1, entries: [...filtered, ...entries] }; activeOverrideStore.schemaOverrides[assetId] = record; }; const matchesPlacementTarget = ( entry: PlacementOverrideSet['entries'][number], target: EditTarget ) => { if (entry.target.kind !== target.kind || entry.target.id !== target.id) return false; const levelMatch = entry.target.level === target.level || entry.target.level == null || target.level == null; const indexMatch = entry.target.index === target.index || entry.target.index == null || target.index == null; return levelMatch && indexMatch; }; const entryHasTransform = (entry: PlacementOverrideSet['entries'][number]) => { const translate = entry.transform?.translate; const rotate = entry.transform?.rotate; const scale = entry.transform?.scale; return Boolean(translate || rotate || scale); }; const updatePlacementOverrideFlags = (assetId: string, target: EditTarget, flags: { lock?: boolean; allowOverlap?: boolean }) => { const record = activeOverrideStore.schemaOverrides[assetId] ?? { placementOverrides: { version: 1, entries: [] } }; const entries = [...(record.placementOverrides?.entries ?? [])]; let matched = false; const nextEntries = entries.map((entry) => { if (!matchesPlacementTarget(entry, target)) return entry; matched = true; const nextFlags = flags.lock || flags.allowOverlap ? { ...flags } : undefined; return { ...entry, flags: nextFlags }; }); if (!matched && (flags.lock || flags.allowOverlap)) { const placeholder = buildPlacementOverrideEntry( target, { x: 0, y: 0, z: 0 }, { x: 0, y: 0, z: 0 }, { x: 1, y: 1, z: 1 }, flags ); if (placeholder) { nextEntries.push(placeholder); } } const cleaned = nextEntries.filter((entry) => entryHasTransform(entry) || (entry.flags?.lock || entry.flags?.allowOverlap)); record.placementOverrides = { version: 1, entries: cleaned }; activeOverrideStore.schemaOverrides[assetId] = record; }; const resolveOverridesForActiveAsset = (): SchemaOverrideRecord | null => { if (!activeModel?.assetId) return null; const fileOverrides = visualSchemaOverrideCache?.schemaOverrides?.[activeModel.assetId] ?? null; const liveOverrides = activeOverrideStore.schemaOverrides?.[activeModel.assetId] ?? null; return mergeSchemaOverrideRecords(fileOverrides, liveOverrides); }; const syncEditFlagsFromOverrides = (target: EditTarget | null) => { if (!target) return; const overrides = resolveOverridesForActiveAsset(); const entries = overrides?.placementOverrides?.entries ?? []; let lock = false; let allowOverlap = false; for (const entry of entries) { if (!matchesPlacementTarget(entry, target)) continue; if (entry.flags?.lock) lock = true; if (entry.flags?.allowOverlap) allowOverlap = true; } editLockToggle.checked = lock; editAllowOverlapToggle.checked = allowOverlap; }; const registerVolumeHierarchyOverride = ( assetId: string, rule: NonNullable[number] ) => { if (!assetId) return; const record = activeOverrideStore.schemaOverrides[assetId] ?? {}; const existing = record.volumeHierarchyOverrides?.replace ?? []; const filtered = existing.filter((entry) => { if (entry.level !== rule.level) return true; if (entry.index !== rule.index) return true; if (entry.from !== rule.from) return true; return false; }); record.volumeHierarchyOverrides = { replace: [...filtered, rule] }; activeOverrideStore.schemaOverrides[assetId] = record; }; const normalizeAngle = (value: number): number => { let result = value; while (result > Math.PI) result -= Math.PI * 2; while (result < -Math.PI) result += Math.PI * 2; return result; }; const rotateVectorAroundY = (vector: { x: number; y: number; z: number }, angle: number) => { const cos = Math.cos(angle); const sin = Math.sin(angle); return { x: vector.x * cos + vector.z * sin, y: vector.y, z: -vector.x * sin + vector.z * cos }; }; const buildSelectionLabelTexture = (text: string): { texture: THREE.CanvasTexture; sprite: THREE.Sprite } => { const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); if (!ctx) { const texture = new THREE.CanvasTexture(document.createElement('canvas')); const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: texture, transparent: true, depthTest: false })); return { texture, sprite }; } const paddingX = 16; const paddingY = 10; const fontSize = 18; const font = `${fontSize}px "Iosevka", "Fira Mono", monospace`; ctx.font = font; const metrics = ctx.measureText(text); const width = Math.ceil(metrics.width + paddingX * 2); const height = Math.ceil(fontSize + paddingY * 2); canvas.width = width; canvas.height = height; ctx.font = font; ctx.fillStyle = 'rgba(8, 14, 18, 0.78)'; ctx.strokeStyle = 'rgba(255, 214, 143, 0.9)'; ctx.lineWidth = 2; ctx.beginPath(); if (typeof (ctx as CanvasRenderingContext2D & { roundRect?: (...args: number[]) => void }).roundRect === 'function') { (ctx as CanvasRenderingContext2D & { roundRect: (...args: number[]) => void }) .roundRect(1, 1, width - 2, height - 2, 6); } else { ctx.rect(1, 1, width - 2, height - 2); } ctx.fill(); ctx.stroke(); ctx.fillStyle = '#ffd28a'; ctx.textBaseline = 'middle'; ctx.fillText(text, paddingX, height / 2); const texture = new THREE.CanvasTexture(canvas); texture.needsUpdate = true; const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: texture, transparent: true, depthTest: false })); sprite.renderOrder = 999; return { texture, sprite }; }; const disposeMaterial = (material: THREE.Material | THREE.Material[]) => { if (Array.isArray(material)) { for (const entry of material) { entry.dispose(); } } else { material.dispose(); } }; const createSelectionAxesHelper = (selection: EditSelection): THREE.AxesHelper => { const bounds = resolveActiveBounds(); const axisLength = bounds ? Math.max(0.16, bounds.radius * 0.18) : 0.28; const axes = new THREE.AxesHelper(axisLength); axes.renderOrder = 997; axes.visible = editFrameEnabled; axes.userData.__selectionHelper = true; scene.add(axes); return axes; }; const buildSymmetryMatrices = ( mode: 'none' | 'mirrorX' | 'radial2' | 'radial4' ): THREE.Matrix4[] => { if (mode === 'mirrorX') { return [new THREE.Matrix4().makeScale(-1, 1, 1)]; } if (mode === 'radial2' || mode === 'radial4') { const count = mode === 'radial2' ? 2 : 4; const step = (Math.PI * 2) / count; const matrices: THREE.Matrix4[] = []; for (let i = 1; i < count; i += 1) { matrices.push(new THREE.Matrix4().makeRotationY(step * i)); } return matrices; } return []; }; const createSelectionGhosts = ( selection: EditSelection, mode: 'none' | 'mirrorX' | 'radial2' | 'radial4' ): THREE.LineSegments[] => { if (!(selection.object instanceof THREE.Mesh)) return []; const matrices = buildSymmetryMatrices(mode); if (matrices.length === 0) return []; const ghosts: THREE.LineSegments[] = []; selection.object.updateMatrixWorld(); const baseMatrix = selection.object.matrixWorld.clone(); const baseGeometry = new THREE.EdgesGeometry(selection.object.geometry, 12); for (let i = 0; i < matrices.length; i += 1) { const matrix = matrices[i]; const material = new THREE.LineBasicMaterial({ color: 0x8ad1ff, transparent: true, opacity: 0.38, depthTest: false }); const geometry = i === 0 ? baseGeometry : baseGeometry.clone(); const ghost = new THREE.LineSegments(geometry, material); ghost.renderOrder = 996; ghost.matrixAutoUpdate = false; ghost.userData.symmetryMatrix = matrix.clone(); ghost.userData.__selectionHelper = true; ghost.matrix.copy(matrix.clone().multiply(baseMatrix)); ghost.matrixWorldNeedsUpdate = true; scene.add(ghost); ghosts.push(ghost); } return ghosts; }; const updateSelectionGhosts = (ghosts: THREE.LineSegments[], baseMatrix: THREE.Matrix4) => { for (const ghost of ghosts) { const symmetryMatrix = ghost.userData.symmetryMatrix as THREE.Matrix4 | undefined; if (!symmetryMatrix) continue; ghost.matrix.copy(symmetryMatrix.clone().multiply(baseMatrix)); ghost.matrixWorldNeedsUpdate = true; } }; const createSelectionOverlay = (selection: EditSelection): SelectionOverlay | null => { if (!(selection.object instanceof THREE.Mesh)) { return null; } const geometry = new THREE.EdgesGeometry(selection.object.geometry, 12); const material = new THREE.LineBasicMaterial({ color: 0xffd28a, transparent: true, opacity: 0.9, depthTest: false }); const outline = new THREE.LineSegments(geometry, material); outline.renderOrder = 998; const labelText = selection.target.kind === 'humanoidPart' ? `humanoid:${selection.target.id}` : `volume:${selection.target.id}${selection.target.level ? `:${selection.target.level}` : ''}${selection.target.index != null ? `#${selection.target.index}` : ''}`; const { texture, sprite } = buildSelectionLabelTexture(labelText); sprite.renderOrder = 999; sprite.material.depthTest = false; sprite.material.depthWrite = false; sprite.visible = editLabelEnabled; const bounds = resolveActiveBounds(); const widthScale = bounds ? Math.max(0.9, bounds.radius * 1.1) : 1.2; const heightScale = bounds ? Math.max(0.26, bounds.height * 0.08) : 0.4; sprite.scale.set(widthScale, heightScale, 1); scene.add(sprite); selection.object.add(outline); const symmetryMode = editSymmetryMode.value as 'none' | 'mirrorX' | 'radial2' | 'radial4'; const axes = createSelectionAxesHelper(selection); const ghosts = createSelectionGhosts(selection, symmetryMode); return { outline, label: sprite, labelTexture: texture, axes, ghosts, symmetryMode }; }; const disposeSelectionOverlay = () => { if (!activeSelectionOverlay) return; if (activeSelectionOverlay.outline.parent) { activeSelectionOverlay.outline.parent.remove(activeSelectionOverlay.outline); } activeSelectionOverlay.outline.geometry.dispose(); disposeMaterial(activeSelectionOverlay.outline.material as THREE.Material); if (activeSelectionOverlay.label.parent) { activeSelectionOverlay.label.parent.remove(activeSelectionOverlay.label); } activeSelectionOverlay.labelTexture.dispose(); disposeMaterial(activeSelectionOverlay.label.material as THREE.Material); if (activeSelectionOverlay.axes) { if (activeSelectionOverlay.axes.parent) { activeSelectionOverlay.axes.parent.remove(activeSelectionOverlay.axes); } activeSelectionOverlay.axes.geometry.dispose(); disposeMaterial(activeSelectionOverlay.axes.material as THREE.Material); } if (activeSelectionOverlay.ghosts) { for (const ghost of activeSelectionOverlay.ghosts) { if (ghost.parent) { ghost.parent.remove(ghost); } ghost.geometry.dispose(); disposeMaterial(ghost.material as THREE.Material); } } activeSelectionOverlay = null; }; const updateSelectionOverlayPosition = () => { if (!activeSelectionOverlay || !activeEditSelection) return; const symmetryMode = editSymmetryMode.value as 'none' | 'mirrorX' | 'radial2' | 'radial4'; if (activeSelectionOverlay.symmetryMode !== symmetryMode) { const selection = activeEditSelection; disposeSelectionOverlay(); activeSelectionOverlay = createSelectionOverlay(selection); return; } const center = getMeshWorldCenter(activeEditSelection.object); const bounds = resolveActiveBounds(); const lift = bounds ? bounds.height * 0.08 : 0.4; activeSelectionOverlay.label.position.copy(center).add(new THREE.Vector3(0, lift, 0)); if (activeSelectionOverlay.axes) { activeEditSelection.object.updateMatrixWorld(); const quat = new THREE.Quaternion(); activeEditSelection.object.getWorldQuaternion(quat); activeSelectionOverlay.axes.position.copy(center); activeSelectionOverlay.axes.quaternion.copy(quat); } if (activeSelectionOverlay.ghosts) { activeEditSelection.object.updateMatrixWorld(); updateSelectionGhosts(activeSelectionOverlay.ghosts, activeEditSelection.object.matrixWorld); } }; const applyAxisConstraint = (axis: 'x' | 'y' | 'z' | null) => { editAxisConstraint = axis; transformControls.showX = axis === null || axis === 'x'; transformControls.showY = axis === null || axis === 'y'; transformControls.showZ = axis === null || axis === 'z'; setEditStatus(axis ? `Axis constraint: ${axis.toUpperCase()}` : 'Axis constraint cleared.'); }; const updatePointerNdcFromEvent = (event: PointerEvent | MouseEvent) => { const rect = canvas.getBoundingClientRect(); if (rect.width <= 0 || rect.height <= 0) { pointerInsideCanvas = false; return; } pointerInsideCanvas = true; pointerNdc.x = ((event.clientX - rect.left) / rect.width) * 2 - 1; pointerNdc.y = -(((event.clientY - rect.top) / rect.height) * 2 - 1); }; const setDragTranslateActive = (active: boolean) => { dragTranslateActive = active; if (!active) { controls.enabled = controlsWereEnabled; } }; const startDragTranslate = (event: PointerEvent) => { if (!editModeEnabled || !editDragTranslateEnabled || !activeEditSelection) return; if (event.button !== 0) return; if (transformControls.dragging) return; updatePointerNdcFromEvent(event); raycaster.setFromCamera(pointerNdc, camera); const hits = raycaster.intersectObject(activeEditSelection.object, true); if (hits.length === 0) return; const center = getMeshWorldCenter(activeEditSelection.object); const normal = new THREE.Vector3(); camera.getWorldDirection(normal); dragTranslatePlane.setFromNormalAndCoplanarPoint(normal, center); if (!raycaster.ray.intersectPlane(dragTranslatePlane, dragTranslateHit)) return; dragTranslateOriginWorld.copy(activeEditSelection.object.getWorldPosition(new THREE.Vector3())); editTransformBaseline = captureTransformSnapshot(activeEditSelection.object); dragTranslateSuppressClick = false; controlsWereEnabled = controls.enabled; controls.enabled = false; setDragTranslateActive(true); }; const updateDragTranslate = (event: PointerEvent) => { if (!dragTranslateActive || !activeEditSelection) return; updatePointerNdcFromEvent(event); raycaster.setFromCamera(pointerNdc, camera); if (!raycaster.ray.intersectPlane(dragTranslatePlane, dragTranslateNextWorld)) return; const deltaWorld = dragTranslateNextWorld.clone().sub(dragTranslateHit); const newWorld = dragTranslateOriginWorld.clone().add(deltaWorld); const parent = activeEditSelection.object.parent; const local = parent ? parent.worldToLocal(newWorld) : newWorld; activeEditSelection.object.position.copy(local); dragTranslateSuppressClick = true; }; const endDragTranslate = () => { if (!dragTranslateActive) return; setDragTranslateActive(false); applyEditTransformDelta(); }; const getSnapStep = (): number => { const value = Number.parseFloat(editSnapStepInput.value); if (!Number.isFinite(value)) return 0.1; return Math.max(0.01, Math.min(5, value)); }; const getRotateStepRadians = (): number => { const value = Number.parseFloat(editRotateStepInput.value); if (!Number.isFinite(value)) return Math.PI / 12; const clamped = Math.max(1, Math.min(90, value)); return THREE.MathUtils.degToRad(clamped); }; const applySnapSettings = () => { if (!editSnapToggle.checked) { transformControls.setTranslationSnap(null); transformControls.setRotationSnap(null); transformControls.setScaleSnap(null); return; } const step = getSnapStep(); transformControls.setTranslationSnap(step); transformControls.setScaleSnap(step); transformControls.setRotationSnap(getRotateStepRadians()); }; const suspendEditSelectionForReload = () => { activeEditSelection = null; editTransformBaseline = null; transformControls.detach(); transformControls.visible = false; transformControls.enabled = false; disposeSelectionOverlay(); }; const restoreEditSelectionIfPending = () => { if (!pendingEditReselect) { if (editModeEnabled) { setEditStatus('Edit mode enabled. Click a mesh to select.'); } return; } const { target, center } = pendingEditReselect; pendingEditReselect = null; const candidate = findEditSelectionCandidate(target, center); if (candidate) { setEditSelection(candidate); } else if (editModeEnabled) { setEditStatus('Edit mode enabled. Click a mesh to select.'); } syncUndoRedoButtons(); }; const buildPlacementOverrideEntry = ( target: EditTarget, translate: { x: number; y: number; z: number }, rotate: { x: number; y: number; z: number }, scale: { x: number; y: number; z: number }, flags?: { lock?: boolean; allowOverlap?: boolean } ): PlacementOverrideSet['entries'][number] | null => { const translateMag = Math.abs(translate.x) + Math.abs(translate.y) + Math.abs(translate.z); const rotateMag = Math.abs(rotate.x) + Math.abs(rotate.y) + Math.abs(rotate.z); const scaleMag = Math.abs(scale.x - 1) + Math.abs(scale.y - 1) + Math.abs(scale.z - 1); const hasFlags = Boolean(flags?.lock || flags?.allowOverlap); if (translateMag < 1e-5 && rotateMag < 1e-5 && scaleMag < 1e-5 && !hasFlags) { return null; } const transform: PlacementOverrideSet['entries'][number]['transform'] = {}; if (translateMag >= 1e-5) transform.translate = translate; if (rotateMag >= 1e-5) transform.rotate = rotate; if (scaleMag >= 1e-5) transform.scale = scale; return { target, transform, space: 'normalized', flags: hasFlags ? { ...flags } : undefined }; }; const applyEditOverrideEntries = (entries: PlacementOverrideSet['entries'], status: string) => { if (!activeModel?.assetId) return; pushUndoSnapshot(activeModel.assetId); registerPlacementOverrides(activeModel.assetId, entries); const anchor = activeEditSelection ? getMeshWorldCenter(activeEditSelection.object) : null; requestEditRecompose(activeEditSelection?.target ?? null, anchor); setEditStatus(status); syncUndoRedoButtons(); }; const applyEditTransformDelta = () => { if (!editModeEnabled || !activeEditSelection || !editTransformBaseline || !activeProceduralSchema) return; const bounds = resolveActiveBounds(); if (!bounds) return; if (!activeModel?.assetId) return; const object = activeEditSelection.object; const deltaPosition = object.position.clone().sub(editTransformBaseline.position); const deltaRotation = new THREE.Euler( normalizeAngle(object.rotation.x - editTransformBaseline.rotation.x), normalizeAngle(object.rotation.y - editTransformBaseline.rotation.y), normalizeAngle(object.rotation.z - editTransformBaseline.rotation.z) ); const deltaScale = new THREE.Vector3( editTransformBaseline.scale.x !== 0 ? object.scale.x / editTransformBaseline.scale.x : 1, editTransformBaseline.scale.y !== 0 ? object.scale.y / editTransformBaseline.scale.y : 1, editTransformBaseline.scale.z !== 0 ? object.scale.z / editTransformBaseline.scale.z : 1 ); const translate = { x: deltaPosition.x / bounds.radius, y: deltaPosition.y / bounds.height, z: deltaPosition.z / bounds.radius }; const rotate = { x: deltaRotation.x, y: deltaRotation.y, z: deltaRotation.z }; const scale = { x: deltaScale.x, y: deltaScale.y, z: deltaScale.z }; const flags = { lock: editLockToggle.checked ? true : undefined, allowOverlap: editAllowOverlapToggle.checked ? true : undefined }; const target: EditTarget = { ...activeEditSelection.target }; if (target.kind === 'volume') { const hint = resolveVolumeTargetHint(activeProceduralSchema, activeEditSelection.info.part, target.id); if (!target.level && hint.level) target.level = hint.level; if (target.index === undefined && hint.index !== undefined) target.index = hint.index; } const symmetry = editSymmetryMode.value as 'none' | 'mirrorX' | 'radial2' | 'radial4'; const entries: PlacementOverrideSet['entries'] = []; if ((symmetry === 'radial2' || symmetry === 'radial4') && target.kind === 'volume' && target.level) { const radialCount = symmetry === 'radial2' ? 2 : 4; const indices = resolveVolumeIndices(activeProceduralSchema, target.level, target.id); const count = Math.min(radialCount, Math.max(1, indices.length)); const baseIndex = target.index !== undefined && indices.includes(target.index) ? target.index : indices[0] ?? 0; const baseOffset = indices.indexOf(baseIndex); const step = (Math.PI * 2) / count; const chosen = indices.length > 0 ? indices.slice(0, count) : [baseIndex]; for (let i = 0; i < chosen.length; i += 1) { const idx = chosen[i]; const theta = (i - (baseOffset >= 0 ? baseOffset : 0)) * step; const rotatedTranslate = rotateVectorAroundY(translate, theta); const rotatedRotate = { ...rotate, y: rotate.y + theta }; const entry = buildPlacementOverrideEntry( { ...target, index: idx }, rotatedTranslate, rotatedRotate, scale, flags ); if (entry) entries.push(entry); } } else { const entry = buildPlacementOverrideEntry(target, translate, rotate, scale, flags); if (entry) entries.push(entry); if (symmetry === 'mirrorX' && target.kind === 'humanoidPart') { const mirrorId = resolveMirrorHumanoidId(target.id); if (mirrorId) { const mirroredTranslate = { x: -translate.x, y: translate.y, z: translate.z }; const mirroredRotate = { x: rotate.x, y: -rotate.y, z: -rotate.z }; const mirrored = buildPlacementOverrideEntry( { ...target, id: mirrorId }, mirroredTranslate, mirroredRotate, scale, flags ); if (mirrored) entries.push(mirrored); } } } if (entries.length === 0) { setEditStatus('No transform delta detected.'); return; } pushUndoSnapshot(activeModel.assetId); registerPlacementOverrides(activeModel.assetId, entries); const anchor = getMeshWorldCenter(object); requestEditRecompose(target, anchor); syncUndoRedoButtons(); }; const applyEditTranslationDelta = (delta: THREE.Vector3, label: string) => { if (!activeEditSelection || !activeModel?.assetId) return; const bounds = resolveActiveBounds(); if (!bounds) return; const target: EditTarget = { ...activeEditSelection.target }; if (target.kind === 'volume') { const hint = resolveVolumeTargetHint(activeProceduralSchema, activeEditSelection.info.part, target.id); if (!target.level && hint.level) target.level = hint.level; if (target.index === undefined && hint.index !== undefined) target.index = hint.index; } const translate = { x: delta.x / bounds.radius, y: delta.y / bounds.height, z: delta.z / bounds.radius }; const rotate = { x: 0, y: 0, z: 0 }; const scale = { x: 1, y: 1, z: 1 }; const flags = { lock: editLockToggle.checked ? true : undefined, allowOverlap: editAllowOverlapToggle.checked ? true : undefined }; const entry = buildPlacementOverrideEntry(target, translate, rotate, scale, flags); if (!entry) return; applyEditOverrideEntries([entry], label); }; const snapSelectionToNearestSocket = (predicate?: (socket: SocketDebugEntry) => boolean, label = 'Snapped to socket.') => { if (!activeEditSelection || !activeUnitType || !activeUnitBounds) { setEditStatus('Socket snapping requires a unit with bounds.'); return; } const sockets = getSocketDebugEntries(activeUnitType, activeUnitBounds).filter((socket) => predicate ? predicate(socket) : true ); if (sockets.length === 0) { setEditStatus('No sockets available for snapping.'); return; } const center = getMeshWorldCenter(activeEditSelection.object); const nearest = findNearestSocket(center, sockets); if (!nearest) { setEditStatus('No nearby socket found.'); return; } const delta = nearest.socket.position.clone().sub(center); applyEditTranslationDelta(delta, label); }; const snapSelectionToNearestHumanoidJoint = () => { if (!activeEditSelection || !activeUnitBounds || !activeMotionRig || !activeObject || !activeUnitType) { setEditStatus('Joint snapping requires a humanoid unit.'); return; } if (!isHumanoidPreviewUnitType(activeUnitType)) { setEditStatus('Joint snapping only available for humanoid previews.'); return; } const joints = getHumanoidJointSocketEntries(activeMotionRig, activeObject, activeUnitBounds); if (joints.length === 0) { setEditStatus('No humanoid joints available for snapping.'); return; } const center = getMeshWorldCenter(activeEditSelection.object); const nearest = findNearestSocket(center, joints); if (!nearest) { setEditStatus('No nearby joint found.'); return; } const delta = nearest.socket.position.clone().sub(center); applyEditTranslationDelta(delta, `Snapped to joint ${nearest.socket.id}.`); }; const projectSelectionToSurface = () => { if (!activeEditSelection || !activeObject) { setEditStatus('Select a mesh to project.'); return; } const center = getMeshWorldCenter(activeEditSelection.object); const dir = center.clone().normalize(); if (dir.lengthSq() < 1e-6) { setEditStatus('Selection is centered; cannot project.'); return; } raycaster.set(new THREE.Vector3(0, 0, 0), dir); const hits = raycaster.intersectObject(activeObject, true); if (hits.length === 0) { setEditStatus('Surface projection failed.'); return; } const targetPoint = hits[hits.length - 1].point; const delta = targetPoint.clone().sub(center); applyEditTranslationDelta(delta, 'Projected to surface.'); }; const getNudgeStep = (): number => { const raw = Number.parseFloat(editNudgeStepInput.value); if (!Number.isFinite(raw)) return 0.05; return Math.max(0.001, Math.min(5, raw)); }; const applyNudge = (axis: 'x' | 'y' | 'z', direction: 1 | -1) => { if (!activeEditSelection) { setEditStatus('Select a mesh to nudge.'); return; } const step = getNudgeStep(); const delta = new THREE.Vector3( axis === 'x' ? step * direction : 0, axis === 'y' ? step * direction : 0, axis === 'z' ? step * direction : 0 ); applyEditTranslationDelta(delta, `Nudged ${axis.toUpperCase()} ${direction > 0 ? '+' : '-'}.`); }; const focusSelection = () => { if (!activeEditSelection) { setEditStatus('Select a mesh to focus.'); return; } const bounds = new THREE.Box3().setFromObject(activeEditSelection.object); const center = bounds.getCenter(new THREE.Vector3()); const size = bounds.getSize(new THREE.Vector3()); const maxDim = Math.max(size.x, size.y, size.z); const distance = Math.max(2, maxDim * 2.2); const direction = camera.position.clone().sub(controls.target).normalize(); camera.position.copy(center.clone().add(direction.multiplyScalar(distance))); camera.near = Math.max(0.05, distance / 300); camera.far = Math.max(200, distance * 30); camera.updateProjectionMatrix(); controls.target.copy(center); controls.update(); setEditStatus('Focused on selection.'); }; const clearOverridesForTarget = () => { if (!activeModel?.assetId || !activeEditSelection) return; const record = activeOverrideStore.schemaOverrides[activeModel.assetId]; if (!record) { setEditStatus('No overrides stored for this asset.'); return; } pushUndoSnapshot(activeModel.assetId); const target = activeEditSelection.target; if (record.placementOverrides) { record.placementOverrides.entries = record.placementOverrides.entries.filter( (entry) => !matchesPlacementTarget(entry, target) ); } if (record.volumeHierarchyOverrides?.replace && target.kind === 'volume') { record.volumeHierarchyOverrides.replace = record.volumeHierarchyOverrides.replace.filter((rule) => { if (rule.level !== (target.level ?? rule.level)) return true; if (target.index != null && rule.index != null && target.index !== rule.index) return true; if (rule.from && rule.from !== target.id) return true; return false; }); } const placementCount = record.placementOverrides?.entries.length ?? 0; const replaceCount = record.volumeHierarchyOverrides?.replace?.length ?? 0; if (placementCount === 0 && replaceCount === 0) { delete activeOverrideStore.schemaOverrides[activeModel.assetId]; } else { activeOverrideStore.schemaOverrides[activeModel.assetId] = record; } requestEditRecompose(activeEditSelection.target, getMeshWorldCenter(activeEditSelection.object)); syncEditFlagsFromOverrides(activeEditSelection.target); setEditStatus('Cleared overrides for selection.'); syncUndoRedoButtons(); }; const copySelectedTargetToClipboard = async () => { if (!activeEditSelection) { setEditStatus('Select a mesh to copy.'); return; } const label = formatEditTargetLabel(activeEditSelection.target); try { await navigator.clipboard.writeText(label); setEditStatus(`Copied ${label} to clipboard.`); } catch (error) { console.warn('[MeshLabViewer] Clipboard copy failed.', error); setEditStatus('Clipboard copy failed.'); } }; const applyUpgradeSwap = () => { if (!activeEditSelection || activeEditSelection.target.kind !== 'volume') { setEditStatus('Select a volume mesh to apply upgrade swaps.'); return; } if (!activeModel?.assetId) return; const to = editUpgradeSelect.value; if (!to) { setEditStatus('Select a replacement volume type.'); return; } if (to === activeEditSelection.target.id) { setEditStatus('Upgrade skipped: replacement matches current volume.'); return; } const target = activeEditSelection.target; const hint = resolveVolumeTargetHint(activeProceduralSchema, activeEditSelection.info.part, target.id); const level = target.level ?? hint.level; if (!level) { setEditStatus('Unable to resolve volume level for upgrade.'); return; } const index = target.index ?? hint.index; pushUndoSnapshot(activeModel.assetId); registerVolumeHierarchyOverride(activeModel.assetId, { level, index, from: target.id, to }); const anchor = getMeshWorldCenter(activeEditSelection.object); requestEditRecompose(target, anchor); setEditStatus(`Upgrade applied: ${target.id} -> ${to}.`); updateUpgradePreview(); }; const clearOverridesForActiveAsset = () => { if (!activeModel?.assetId) return; if (!activeOverrideStore.schemaOverrides[activeModel.assetId]) { setEditStatus('No overrides stored for this asset.'); return; } pushUndoSnapshot(activeModel.assetId); delete activeOverrideStore.schemaOverrides[activeModel.assetId]; requestEditRecompose(activeEditSelection?.target ?? null, activeEditSelection ? getMeshWorldCenter(activeEditSelection.object) : null); if (activeEditSelection) { syncEditFlagsFromOverrides(activeEditSelection.target); } setEditStatus('Cleared overrides for active asset.'); syncUndoRedoButtons(); }; const saveOverrideManifest = () => { const merged = mergeOverrideManifests(visualSchemaOverrideCache, activeOverrideStore); const json = JSON.stringify(merged, null, 2); const blob = new Blob([json], { type: 'application/json' }); if (overrideDownloadUrl) { URL.revokeObjectURL(overrideDownloadUrl); } overrideDownloadUrl = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = overrideDownloadUrl; link.download = 'assets_visual_manifest.override.json'; document.body.appendChild(link); link.click(); link.remove(); setEditStatus('Override manifest downloaded.'); }; const getSelectedMotionState = (): MotionLabState => { const raw = simStateSelect.value as MotionLabState; return MOTION_STATE_ORDER.includes(raw) ? raw : 'idle'; }; const isTrackedUnitType = (unitType: UnitType | null) => unitType === 'Tank' || unitType === 'HeavyTank' || unitType === 'Siege'; const HUMANOID_PREVIEW_UNITS = new Set([ 'Commander', 'Combat', 'Rifleman', 'Grenadier', 'Sniper', 'Medic', 'Engineer', 'Assault', 'RocketInfantry', 'Scout', 'EliteStriker' ]); const isHumanoidPreviewUnitType = (unitType: UnitType): boolean => HUMANOID_PREVIEW_UNITS.has(unitType); const ARMOR_PLATING_CHANNEL = 'armorPlating'; const HULL_ARMOR_CHANNEL = 'hullArmor'; const isSocketedArmorChannel = (channel: unknown): boolean => { const normalized = String(channel ?? ''); return normalized === ARMOR_PLATING_CHANNEL || normalized === HULL_ARMOR_CHANNEL; }; const getHumanoidNodeHint = (info: MeshDiagnosticInfo): string => ( `${String(info.inferredPartNodeId ?? '')} ${String(info.inferredPartId ?? '')} ${String(info.inferredPartPurpose ?? '')} ` + `${String(info.inferredLineage ?? '')} ${String(info.inferredLineageAnchor ?? '')} ` + `${String(info.part ?? '')} ${String(info.channel ?? '')}` ).toLowerCase(); const getHumanoidSourceHint = (info: MeshDiagnosticInfo): string => ( `${String(info.sourcePart ?? '')} ${String(info.inferredLineageSource ?? '')}` ).toLowerCase(); const resolveHumanoidMotionSide = (info: MeshDiagnosticInfo, bounds: UnitBounds): HumanoidMotionSide => { const nodeId = String(info.inferredPartNodeId ?? ''); if (/^~?L[A-Z]/.test(nodeId) || /\bleft\b/i.test(nodeId)) return 'left'; if (/^~?R[A-Z]/.test(nodeId) || /\bright\b/i.test(nodeId)) return 'right'; const hint = getHumanoidNodeHint(info); if ( hint.includes('lupper') || hint.includes('lforearm') || hint.includes('lshoulder') || hint.includes('lhip') || hint.includes('llower') || hint.includes('lfoot') || hint.includes('lelbow') || hint.includes('lhand') || hint.includes('ltoe') ) { return 'left'; } if ( hint.includes('rupper') || hint.includes('rforearm') || hint.includes('rshoulder') || hint.includes('rhip') || hint.includes('rlower') || hint.includes('rfoot') || hint.includes('relbow') || hint.includes('rhand') || hint.includes('rtoe') ) { return 'right'; } const spatialThreshold = Math.max(0.02, bounds.radius * 0.08); if (info.center.x <= -spatialThreshold) return 'left'; if (info.center.x >= spatialThreshold) return 'right'; return 'center'; }; const resolveHumanoidMotionRole = (info: MeshDiagnosticInfo): HumanoidMotionRole | null => { if (info.part === 'weapons') return null; const hint = getHumanoidNodeHint(info); if ( hint.includes('hiphydraulic') || hint.includes('hydraulicrod') || hint.includes('hydraulicsleeve') || hint.includes('hiprod') || hint.includes('hipsleeve') ) { return 'hip'; } if ( hint.includes('kneeactuator') || hint.includes('kneepiston') || hint.includes('calfactuator') ) { return 'lowerLeg'; } if (hint.includes('upperleg') || hint.includes('thigh')) return 'upperLeg'; if (hint.includes('lowerleg') || hint.includes('calf') || hint.includes('shin') || hint.includes('knee')) return 'lowerLeg'; if (hint.includes('foot') || hint.includes('toe') || hint.includes('heel') || hint.includes('sole') || hint.includes('boot') || hint.includes('ankle')) return 'foot'; if (hint.includes('hipjoint') || hint.includes('pelvis') || hint.includes('hip')) return 'hip'; if (hint.includes('shouldercuff')) return 'shoulder'; if (hint.includes('shoulder') || hint.includes('clavicle')) return 'shoulder'; if (hint.includes('upperarm') || hint.includes('bicep') || hint.includes('tricep')) return 'upperArm'; if (hint.includes('forearm') || hint.includes('elbow') || (hint.includes('cuff') && !hint.includes('shoulder'))) return 'forearm'; if (hint.includes('hand')) return 'hand'; if ( hint.includes('torso') || hint.includes('waist') || hint.includes('abdomen') || hint.includes('spine') || hint.includes('chest') || hint.includes('rib') || hint.includes('flank') || hint.includes('bulkhead') || hint.includes('latplate') ) { return 'torso'; } return null; }; const resolveStrictCriticalHumanoidRole = (info: MeshDiagnosticInfo): HumanoidMotionRole | null => { const hint = getHumanoidNodeHint(info); if (hint.includes('shoulder')) return 'shoulder'; if (hint.includes('upperarm') || hint.includes('bicep') || hint.includes('tricep')) return 'upperArm'; if (hint.includes('forearm') || hint.includes('elbow')) return 'forearm'; if (hint.includes('hand')) return 'hand'; if (hint.includes('upperleg') || hint.includes('thigh')) return 'upperLeg'; if (hint.includes('lowerleg') || hint.includes('calf') || hint.includes('shin') || hint.includes('knee')) return 'lowerLeg'; if (hint.includes('foot') || hint.includes('toe') || hint.includes('heel') || hint.includes('sole') || hint.includes('ankle')) return 'foot'; if (hint.includes('hip') || hint.includes('pelvis')) return 'hip'; if ( hint.includes('helmet') || hint.includes('head') || hint.includes('neck') || hint.includes('torso') || hint.includes('waist') || hint.includes('abdomen') || hint.includes('spine') || hint.includes('chest') || hint.includes('rib') || hint.includes('flank') || hint.includes('bulkhead') || hint.includes('latplate') ) { return 'torso'; } return null; }; const hasHumanoidExplicitAnchor = (info: MeshDiagnosticInfo): boolean => { const nodeId = String(info.inferredPartNodeId ?? '').trim(); const partId = String(info.inferredPartId ?? '').trim(); const nodeExplicit = nodeId.length > 0 && !nodeId.startsWith('~'); const partExplicit = partId.length > 0 && !partId.startsWith('~'); return nodeExplicit || partExplicit; }; const hasHumanoidSideTag = (info: MeshDiagnosticInfo): boolean => { const nodeId = String(info.inferredPartNodeId ?? ''); if (/^~?L[A-Z]/.test(nodeId) || /^~?R[A-Z]/.test(nodeId)) { return true; } const hint = getHumanoidNodeHint(info); return ( hint.includes('left') || hint.includes('right') || hint.includes('lupper') || hint.includes('rupper') || hint.includes('lforearm') || hint.includes('rforearm') || hint.includes('lshoulder') || hint.includes('rshoulder') || hint.includes('lhip') || hint.includes('rhip') || hint.includes('llower') || hint.includes('rlower') || hint.includes('lfoot') || hint.includes('rfoot') || hint.includes('lelbow') || hint.includes('relbow') || hint.includes('lhand') || hint.includes('rhand') || hint.includes('ltoe') || hint.includes('rtoe') ); }; const hasStrongHumanoidLimbHint = (info: MeshDiagnosticInfo): boolean => { const hint = getHumanoidNodeHint(info); if ( hint.includes('hiphydraulic') || hint.includes('hydraulicrod') || hint.includes('hydraulicsleeve') || hint.includes('hiprod') || hint.includes('hipsleeve') || hint.includes('kneeactuator') || hint.includes('kneepiston') || hint.includes('calfactuator') ) { return true; } if (hint.includes('upperleg') || hint.includes('thigh')) return true; if (hint.includes('lowerleg') || hint.includes('calf') || hint.includes('shin') || hint.includes('knee')) return true; if (hint.includes('foot') || hint.includes('toe') || hint.includes('heel') || hint.includes('sole') || hint.includes('ankle')) return true; if (hint.includes('hipjoint') || hint.includes('hip')) return true; if (hint.includes('shoulder') || hint.includes('clavicle')) return true; if (hint.includes('upperarm') || hint.includes('bicep') || hint.includes('tricep')) return true; if (hint.includes('forearm') || hint.includes('elbow')) return true; if (hint.includes('hand')) return true; return false; }; const hasHumanoidCoreTorsoHint = (info: MeshDiagnosticInfo): boolean => { const hint = getHumanoidNodeHint(info); return ( hint.includes('torso') || hint.includes('waist') || hint.includes('abdomen') || hint.includes('spine') || hint.includes('chest') || hint.includes('rib') || hint.includes('pelvisguard') || hint.includes('pelvis') || hint.includes('latplate') || hint.includes('flank') || hint.includes('bulkhead') || hint.includes('collar') || hint.includes('neck') || hint.includes('helmet') ); }; const isHumanoidDecorativeAttachment = (info: MeshDiagnosticInfo): boolean => { const channel = String(info.channel ?? '').toLowerCase(); const part = String(info.part ?? '').toLowerCase(); const hint = getHumanoidNodeHint(info); if (part === 'highlights') { return true; } return ( channel.includes('optics') || channel.includes('sensor') || channel.includes('antenna') || channel.includes('light') || channel.includes('emissive') || hint.includes('optics') || hint.includes('sensor') || hint.includes('antenna') || hint.includes('light') ); }; const sanitizeHumanoidRoleForApproximate = ( role: HumanoidMotionRole, node: MotionRigNode, info: MeshDiagnosticInfo, bounds: UnitBounds ): HumanoidMotionRole => { if (!info.inferredApproximate) { return role; } const hint = getHumanoidNodeHint(info); const yNorm = node.restCenter.y / Math.max(bounds.height, 1e-4); const absXNorm = Math.abs(node.restCenter.x) / Math.max(bounds.radius, 1e-4); const hintedSide = resolveHumanoidMotionSide(info, bounds); const sideLockedLimb = isBilateralHumanoidRole(role) && hintedSide !== 'center'; if (isHumanoidDecorativeAttachment(info)) { return 'torso'; } if (hasHumanoidCoreTorsoHint(info)) { const keepHipRole = sideLockedLimb && ( role === 'hip' || role === 'upperLeg' || hint.includes('pelvisguard') || hint.includes('hip') ); if (!keepHipRole) { return 'torso'; } } if (role !== 'torso') { const explicitAnchor = hasHumanoidExplicitAnchor(info); const strongHint = hasStrongHumanoidLimbHint(info); if (!explicitAnchor && !strongHint) { return 'torso'; } if (isBilateralHumanoidRole(role) && !hasHumanoidSideTag(info) && absXNorm < 0.18) { return 'torso'; } } if ( (role === 'upperLeg' || role === 'lowerLeg' || role === 'foot' || role === 'hip') && yNorm > 0.58 && !sideLockedLimb ) { return 'torso'; } if ( (role === 'upperArm' || role === 'forearm' || role === 'hand' || role === 'shoulder') && yNorm < 0.22 && !sideLockedLimb ) { return 'torso'; } if ((role === 'shoulder' || role === 'upperArm' || role === 'forearm' || role === 'hand') && absXNorm < 0.06) { return 'torso'; } return role; }; const isBilateralHumanoidRole = (role: HumanoidMotionRole): boolean => ( role === 'upperLeg' || role === 'lowerLeg' || role === 'foot' || role === 'hip' || role === 'upperArm' || role === 'forearm' || role === 'hand' || role === 'shoulder' ); const resolveHumanoidMotionWeight = (role: HumanoidMotionRole): number => { switch (role) { case 'upperLeg': return 1.0; case 'lowerLeg': return 0.9; case 'foot': return 0.82; case 'hip': return 0.7; case 'upperArm': return 0.85; case 'forearm': return 0.76; case 'hand': return 0.55; case 'shoulder': return 0.62; case 'torso': return 0.5; default: return 0.6; } }; const resolveHumanoidFallbackRole = ( node: MotionRigNode, bounds: UnitBounds ): { role: HumanoidMotionRole; side: HumanoidMotionSide; weight: number } => { const sideThreshold = Math.max(0.02, bounds.radius * 0.08); const side: HumanoidMotionSide = node.restCenter.x < -sideThreshold ? 'left' : node.restCenter.x > sideThreshold ? 'right' : 'center'; const yNorm = node.restCenter.y / Math.max(bounds.height, 1e-4); if (side !== 'center') { if (yNorm < 0.1) return { role: 'foot', side, weight: 0.45 }; if (yNorm < 0.22) return { role: 'lowerLeg', side, weight: 0.48 }; if (yNorm < 0.32) return { role: 'upperLeg', side, weight: 0.46 }; return { role: 'shoulder', side, weight: 0.42 }; } return { role: 'torso', side: 'center', weight: 0.36 }; }; const isHumanoidOversizedApproximateMesh = ( info: MeshDiagnosticInfo, bounds: UnitBounds ): boolean => { const approximate = Boolean(info.inferredApproximate) || String(info.inferredPartNodeId ?? '').startsWith('~'); if (!approximate) { return false; } const spanY = info.size.y / Math.max(bounds.height, 1e-4); const spanX = info.size.x / Math.max(bounds.radius * 2, 1e-4); const spanZ = info.size.z / Math.max(bounds.radius * 2, 1e-4); const planarCoverage = Math.max(spanX, spanZ); return spanY >= 0.62 && planarCoverage >= 0.2; }; const normalizeHumanoidNodeKey = (value: unknown): string => ( String(value ?? '') .replace(/^~+/, '') .trim() .toLowerCase() ); const resolveHumanoidNodeKeyFromInfo = (info: MeshDiagnosticInfo): string => ( normalizeHumanoidNodeKey(info.inferredPartNodeId) || normalizeHumanoidNodeKey(info.inferredPartId) ); const normalizeHumanoidAnchorMatchKey = (key: string): string => ( key.replace(/^(node|part|lineage|key):/, '') ); const resolveHumanoidRoleHintFromNodeKey = (key: string): HumanoidMotionRole | null => { if (key.includes('upperarm') || key.includes('bicep') || key.includes('tricep')) return 'upperArm'; if (key.includes('forearm') || key.includes('elbow')) return 'forearm'; if (key.includes('hand') || key.includes('wrist')) return 'hand'; if (key.includes('shouldercuff') || key.includes('shoulder') || key.includes('clavicle')) return 'shoulder'; if ( key.includes('hiphydraulic') || key.includes('hydraulicrod') || key.includes('hydraulicsleeve') || key.includes('hiprod') || key.includes('hipsleeve') || key.includes('hipjoint') || key.includes('pelvisguard') || key.includes('pelvis') || key.includes('hip') ) { return 'hip'; } if (key.includes('upperleg') || key.includes('thigh')) return 'upperLeg'; if (key.includes('lowerleg') || key.includes('calf') || key.includes('shin') || key.includes('knee')) return 'lowerLeg'; if (key.includes('foot') || key.includes('toe') || key.includes('heel') || key.includes('sole') || key.includes('boot') || key.includes('ankle')) return 'foot'; if ( key.includes('torso') || key.includes('waist') || key.includes('abdomen') || key.includes('spine') || key.includes('chest') || key.includes('rib') || key.includes('flank') || key.includes('bulkhead') || key.includes('latplate') || key.includes('collar') || key.includes('neck') || key.includes('helmet') || key.includes('head') ) { return 'torso'; } return null; }; const shouldHardLockHumanoidRoleFromNodeKey = (key: string): boolean => ( key.includes('shoulderjoint') || key.includes('shouldercuff') || key.includes('clavicle') || key.includes('upperarm') || key.includes('bicep') || key.includes('tricep') || key.includes('forearm') || key.includes('elbow') || key.includes('hand') || key.includes('wrist') || key.includes('hiphydraulic') || key.includes('hydraulicrod') || key.includes('hydraulicsleeve') || key.includes('hiprod') || key.includes('hipsleeve') || key.includes('hipjoint') || key.includes('upperleg') || key.includes('thigh') || key.includes('lowerleg') || key.includes('calf') || key.includes('shin') || key.includes('knee') || key.includes('foot') || key.includes('toe') || key.includes('heel') || key.includes('sole') || key.includes('boot') || key.includes('ankle') || key.includes('tendon') || key.includes('abdomen') || key.includes('spine') || key.includes('neck') || key.includes('helmet') || key.includes('head') || key.includes('torso') || key.includes('chest') || key.includes('rib') || key.includes('waist') || key.includes('pelvis') ); const resolveHumanoidHardRoleLockFromInfo = ( info: MeshDiagnosticInfo ): { role: HumanoidMotionRole; side: HumanoidMotionSide | null } | null => { const key = resolveHumanoidNodeKeyFromInfo(info); if (!key) return null; const role = resolveHumanoidRoleHintFromNodeKey(key); if (!role || !shouldHardLockHumanoidRoleFromNodeKey(key)) { return null; } const sideHint = resolveHumanoidSideHintFromNodeKey(key); return { role, side: sideHint === 'center' ? null : sideHint }; }; const resolveHumanoidSideHintFromNodeKey = (key: string): HumanoidMotionSide => { if ( key.startsWith('l') || key.includes('left') || key.includes('lupper') || key.includes('lforearm') || key.includes('lshoulder') || key.includes('lhip') || key.includes('llower') || key.includes('lfoot') || key.includes('lelbow') || key.includes('lhand') || key.includes('ltoe') ) { return 'left'; } if ( key.startsWith('r') || key.includes('right') || key.includes('rupper') || key.includes('rforearm') || key.includes('rshoulder') || key.includes('rhip') || key.includes('rlower') || key.includes('rfoot') || key.includes('relbow') || key.includes('rhand') || key.includes('rtoe') ) { return 'right'; } return 'center'; }; const normalizeHumanoidNodeAssignments = (nodes: MotionRigNode[]) => { const grouped = new Map(); for (const node of nodes) { const key = normalizeHumanoidNodeKey(node.info.inferredPartNodeId) || normalizeHumanoidNodeKey(node.info.inferredPartId); if (!key) continue; const list = grouped.get(key) ?? []; list.push(node); grouped.set(key, list); } for (const [key, entries] of grouped.entries()) { if (entries.length <= 1) continue; const roleVotes = new Map(); const sideVotes = new Map(); let weightSum = 0; let weightCount = 0; let approximateEntries = 0; let decorativeEntries = 0; let explicitLimbEvidence = false; let approximateLimbEvidence = false; for (const node of entries) { if (node.humanoidRole) { let boost = node.humanoidFallback ? 1.2 : 3; if (node.info.inferredApproximate) { boost *= 0.35; approximateEntries += 1; } if (node.humanoidDecorative) { boost *= 0.2; decorativeEntries += 1; } if (node.humanoidApproximateTorsoCoerce && node.humanoidRole === 'torso') { boost *= 2.6; } if ( !node.info.inferredApproximate && node.humanoidRole !== 'torso' && hasHumanoidExplicitAnchor(node.info) ) { explicitLimbEvidence = true; } if ( node.info.inferredApproximate && !isHumanoidDecorativeAttachment(node.info) && !hasHumanoidCoreTorsoHint(node.info) && hasStrongHumanoidLimbHint(node.info) && hasHumanoidSideTag(node.info) ) { approximateLimbEvidence = true; } roleVotes.set(node.humanoidRole, (roleVotes.get(node.humanoidRole) ?? 0) + boost); } if (node.humanoidSide) { let boost = node.humanoidFallback ? 1 : 2; if (node.info.inferredApproximate) { boost *= 0.35; } if (node.humanoidDecorative) { boost *= 0.25; } sideVotes.set(node.humanoidSide, (sideVotes.get(node.humanoidSide) ?? 0) + boost); } if (typeof node.humanoidWeight === 'number' && Number.isFinite(node.humanoidWeight)) { weightSum += node.humanoidWeight; weightCount += 1; } } if (roleVotes.size <= 0) continue; let canonicalRole = Array.from(roleVotes.entries()) .sort((a, b) => b[1] - a[1])[0][0]; let canonicalSide = sideVotes.size > 0 ? Array.from(sideVotes.entries()).sort((a, b) => b[1] - a[1])[0][0] : 'center'; const keyRoleHint = resolveHumanoidRoleHintFromNodeKey(key); const keySideHint = resolveHumanoidSideHintFromNodeKey(key); if (keyRoleHint != null && shouldHardLockHumanoidRoleFromNodeKey(key)) { canonicalRole = keyRoleHint; } if (canonicalRole === 'torso' && keyRoleHint != null && keyRoleHint !== 'torso') { canonicalRole = keyRoleHint; } if (canonicalRole !== 'torso' && canonicalSide === 'center' && keySideHint !== 'center') { canonicalSide = keySideHint; } const approximateRatio = approximateEntries / Math.max(1, entries.length); const decorativeRatio = decorativeEntries / Math.max(1, entries.length); if (approximateRatio >= 0.55 && !explicitLimbEvidence && !approximateLimbEvidence) { canonicalRole = 'torso'; canonicalSide = 'center'; } if (decorativeRatio >= 0.5 && !explicitLimbEvidence) { canonicalRole = 'torso'; canonicalSide = 'center'; } if (canonicalRole === 'torso') { canonicalSide = 'center'; } const canonicalWeight = weightCount > 0 ? (weightSum / weightCount) : resolveHumanoidMotionWeight(canonicalRole); for (const node of entries) { node.humanoidRole = canonicalRole; node.humanoidSide = canonicalSide; node.humanoidWeight = canonicalWeight; } } }; const getHumanoidNodeVolume = (node: MotionRigNode): number => ( Math.max(1e-6, node.info.size.x * node.info.size.y * node.info.size.z) ); const resolveHumanoidSegmentMergeKey = (node: MotionRigNode): string | null => { const role = node.humanoidRole; if (!role) return null; const side = node.humanoidSide ?? 'center'; if (side === 'center') return null; const hint = getHumanoidNodeHint(node.info); if (role === 'shoulder') return `segment:${side}:shoulder`; if (role === 'upperArm') return `segment:${side}:upperArm`; if (role === 'forearm') return `segment:${side}:forearm`; if (role === 'hand') return `segment:${side}:hand`; if (hint.includes('upperarm') || hint.includes('bicep') || hint.includes('tricep')) return `segment:${side}:upperArm`; if (hint.includes('forearm') || hint.includes('elbow')) return `segment:${side}:forearm`; if (hint.includes('hand')) return `segment:${side}:hand`; if (hint.includes('shoulder') || hint.includes('clavicle')) return `segment:${side}:shoulder`; const hipLike = ( role === 'hip' || hint.includes('hiphydraulic') || hint.includes('hydraulicrod') || hint.includes('hydraulicsleeve') || hint.includes('hiprod') || hint.includes('hipsleeve') || hint.includes('hipjoint') || hint.includes('pelvisguard') ); if (hipLike) return `segment:${side}:hip`; const footLike = ( role === 'foot' || hint.includes('foot') || hint.includes('sole') || hint.includes('toe') || hint.includes('heel') || hint.includes('boot') || hint.includes('ankle') ); if (footLike) return `segment:${side}:foot`; const lowerLegLike = ( role === 'lowerLeg' || hint.includes('lowerleg') || hint.includes('calf') || hint.includes('shin') || hint.includes('knee') ); if (lowerLegLike) return `segment:${side}:lowerLeg`; const upperLegLike = ( role === 'upperLeg' || hint.includes('upperleg') || hint.includes('thigh') ); if (upperLegLike) return `segment:${side}:upperLeg`; return null; }; const resolveHumanoidSegmentGroup = ( id: string ): { role: HumanoidMotionRole; side: HumanoidMotionSide } | null => { const match = /^segment:(left|right):(shoulder|upperArm|forearm|hand|hip|upperLeg|lowerLeg|foot)$/.exec(id); if (!match) return null; const side = match[1] === 'left' ? 'left' : 'right'; const role = match[2] as HumanoidMotionRole; return { role, side }; }; const refineHumanoidUpperBodyRoleByLateralDepth = ( role: HumanoidMotionRole, node: MotionRigNode, bounds: UnitBounds ): HumanoidMotionRole => { if (role !== 'shoulder' && role !== 'upperArm' && role !== 'forearm' && role !== 'hand') { return role; } const hint = getHumanoidNodeHint(node.info); if ( hint.includes('shoulderjoint') || hint.includes('shouldercuff') || hint.includes('shoulderyoke') || hint.includes('clavicle') ) { return 'shoulder'; } if (hint.includes('hand')) return 'hand'; if (hint.includes('forearm') || hint.includes('elbow')) return 'forearm'; if (hint.includes('upperarm') || hint.includes('bicep') || hint.includes('tricep')) return 'upperArm'; if (hint.includes('shoulder') || hint.includes('clavicle')) { const xNorm = Math.abs(node.restCenter.x) / Math.max(bounds.radius, 1e-4); const yNorm = node.restCenter.y / Math.max(bounds.height, 1e-4); if (xNorm >= 0.92 || (xNorm >= 0.8 && yNorm < 0.34)) return 'hand'; if (xNorm >= 0.72 || (xNorm >= 0.64 && yNorm < 0.36)) return 'forearm'; if (xNorm >= 0.54 || yNorm < 0.37) return 'upperArm'; return 'shoulder'; } return role; }; const collectHumanoidAnchorKeysFromInfo = (info: MeshDiagnosticInfo): string[] => { const keys: string[] = []; const seen = new Set(); const pushKey = (prefix: 'node' | 'part' | 'lineage' | 'key', raw: unknown) => { const normalized = normalizeHumanoidNodeKey(raw); if (!normalized) return; const prefixed = `${prefix}:${normalized}`; if (!seen.has(prefixed)) { seen.add(prefixed); keys.push(prefixed); } const generic = `key:${normalized}`; if (!seen.has(generic)) { seen.add(generic); keys.push(generic); } }; pushKey('node', info.inferredPartNodeId); pushKey('part', info.inferredPartId); pushKey('lineage', info.inferredLineageAnchor); const lineage = String(info.inferredLineage ?? '').trim(); if (lineage.length > 0) { const lineageTokens = lineage.split(/[:>\s/|,]+/g).filter((token) => token.length > 0); for (const token of lineageTokens.slice(0, 3)) { pushKey('lineage', token); } } return keys; }; const resolveHumanoidNodeAnchorKeys = (node: MotionRigNode): string[] => ( collectHumanoidAnchorKeysFromInfo(node.info) ); const resolveHumanoidNodeAnchorKey = (node: MotionRigNode): string | null => ( resolveHumanoidNodeAnchorKeys(node)[0] ?? null ); const getHumanoidSocketRolePenalty = ( desired: HumanoidMotionRole, candidate: HumanoidMotionRole ): number => { if (desired === candidate) return 0; const upperBody = new Set(['shoulder', 'upperArm', 'forearm', 'hand']); const lowerBody = new Set(['hip', 'upperLeg', 'lowerLeg', 'foot']); if (desired === 'torso') { return candidate === 'torso' ? 0 : 0.72; } if (upperBody.has(desired)) { if (upperBody.has(candidate)) return 0.16; if (candidate === 'torso') return 0.82; return 1.2; } if (lowerBody.has(desired)) { if (lowerBody.has(candidate)) return 0.14; if (candidate === 'torso') return 0.84; return 1.2; } return 0.9; }; const socketHumanoidArmorPlatingNodes = ( nodes: MotionRigNode[], bounds: UnitBounds ): { total: number; socketed: number } => { const anchorsByKey = new Map(); const anchors: MotionRigNode[] = []; const scoreAnchorCandidate = (node: MotionRigNode, isArmor: boolean): number => { const volume = getHumanoidNodeVolume(node); const explicitFactor = hasHumanoidExplicitAnchor(node.info) ? 1.16 : 0.88; const approxFactor = node.info.inferredApproximate ? 0.72 : 1; const armorFactor = isArmor ? 0.82 : 1.1; const fallbackFactor = node.humanoidFallback ? 0.82 : 1.05; return volume * explicitFactor * approxFactor * armorFactor * fallbackFactor; }; for (const node of nodes) { if (!node.humanoidRole) continue; const isArmor = isSocketedArmorChannel(node.info.channel); anchors.push(node); const keys = resolveHumanoidNodeAnchorKeys(node); if (keys.length <= 0) continue; for (const key of keys) { const existing = anchorsByKey.get(key); if (!existing) { anchorsByKey.set(key, node); continue; } const existingScore = scoreAnchorCandidate(existing, isSocketedArmorChannel(existing.info.channel)); const nodeScore = scoreAnchorCandidate(node, isArmor); if (nodeScore > existingScore) { anchorsByKey.set(key, node); } } } let total = 0; let socketed = 0; for (const node of nodes) { if (!node.humanoidRole) continue; const isArmor = isSocketedArmorChannel(node.info.channel); if (!isArmor) continue; total += 1; node.humanoidSocketExact = false; node.humanoidSocketSemantic = false; node.humanoidSocketStable = false; node.humanoidSocketMatchedKey = undefined; node.humanoidSocketRolePenalty = undefined; let target: MotionRigNode | null = null; let targetKey: string | null = null; let matchedOwnKey: string | null = null; const hintedRole = resolveHumanoidMotionRole(node.info); let desiredRole = hintedRole ?? node.humanoidRole ?? 'torso'; if ( desiredRole === 'torso' && node.info.inferredApproximate && hasStrongHumanoidLimbHint(node.info) ) { desiredRole = resolveHumanoidFallbackRole(node, bounds).role; } let desiredSide = node.humanoidSide ?? 'center'; if (isBilateralHumanoidRole(desiredRole)) { const hintedSide = resolveHumanoidMotionSide(node.info, bounds); if (hintedSide !== 'center') { desiredSide = hintedSide; } else if (desiredSide === 'center') { desiredSide = resolveHumanoidFallbackRole(node, bounds).side; } } const ownKeys = resolveHumanoidNodeAnchorKeys(node); if (ownKeys.length > 0) { let bestKeyedScore = Number.POSITIVE_INFINITY; for (const ownKey of ownKeys) { const keyed = anchorsByKey.get(ownKey) ?? null; if (!keyed?.humanoidRole) continue; const keyedPenalty = getHumanoidSocketRolePenalty(desiredRole, keyed.humanoidRole); const keyedSidePenalty = ( isBilateralHumanoidRole(desiredRole) && desiredSide !== 'center' && keyed.humanoidSide !== desiredSide ) ? 0.52 : 0; const keyedApproxPenalty = keyed.info.inferredApproximate ? 0.08 : 0; const selfPenalty = keyed === node ? 0.015 : 0; const keyedScore = keyedPenalty + keyedSidePenalty + keyedApproxPenalty + selfPenalty; if (keyedScore < bestKeyedScore && (keyedPenalty <= 0.96 || desiredRole === 'torso')) { bestKeyedScore = keyedScore; target = keyed; targetKey = ownKey; matchedOwnKey = ownKey; } } } if (!target && anchors.length > 0) { const invR = 1 / Math.max(bounds.radius, 1e-4); const invH = 1 / Math.max(bounds.height, 1e-4); let bestScore = Number.POSITIVE_INFINITY; for (const candidate of anchors) { if (!candidate.humanoidRole) continue; const dx = (node.restCenter.x - candidate.restCenter.x) * invR; const dy = (node.restCenter.y - candidate.restCenter.y) * invH; const dz = (node.restCenter.z - candidate.restCenter.z) * invR; const dist = Math.sqrt(dx * dx + dy * dy + dz * dz); const rolePenalty = getHumanoidSocketRolePenalty(desiredRole, candidate.humanoidRole); const sidePenalty = ( isBilateralHumanoidRole(desiredRole) && desiredSide !== 'center' && candidate.humanoidSide !== desiredSide ) ? 0.88 : 0; const approxPenalty = candidate.info.inferredApproximate ? 0.12 : 0; const score = dist + rolePenalty + sidePenalty + approxPenalty; if (score < bestScore) { bestScore = score; target = candidate; targetKey = resolveHumanoidNodeAnchorKey(candidate); } } } if (!target) continue; const targetRole = target.humanoidRole ?? 'torso'; const rolePenalty = getHumanoidSocketRolePenalty(desiredRole, targetRole); const forceTorsoRole = desiredRole === 'torso' && hasHumanoidCoreTorsoHint(node.info); const hardDesiredRole = desiredRole !== 'torso' && ( hasStrongHumanoidLimbHint(node.info) || Boolean(hintedRole && hintedRole !== 'torso') ); const keepDesiredRole = desiredRole !== 'torso' && (rolePenalty <= 0.34 || hardDesiredRole); const resolvedRole = forceTorsoRole ? 'torso' : (keepDesiredRole ? desiredRole : targetRole); const resolvedSide = forceTorsoRole ? 'center' : (keepDesiredRole ? desiredSide : (target.humanoidSide ?? desiredSide)); const chainAwareRole = refineHumanoidUpperBodyRoleByLateralDepth(resolvedRole, node, bounds); const hardLock = resolveHumanoidHardRoleLockFromInfo(node.info); node.humanoidRole = hardLock?.role ?? chainAwareRole; node.humanoidSide = hardLock?.side ?? resolvedSide; const ownKey = ownKeys[0] ?? null; const resolvedSocketKey = resolveHumanoidSegmentMergeKey(node) ?? target.humanoidGroupId ?? targetKey ?? ownKey ?? undefined; node.humanoidWeight = Math.max( node.humanoidWeight ?? 0, (target.humanoidWeight ?? resolveHumanoidMotionWeight(targetRole)) * 0.94 ); node.humanoidSocketKey = resolvedSocketKey; node.humanoidSocketTarget = String( target.info.inferredPartNodeId ?? target.info.inferredPartId ?? target.node.name ?? 'anchor' ); const exactSocketMatch = Boolean( matchedOwnKey && targetKey && normalizeHumanoidAnchorMatchKey(matchedOwnKey) === normalizeHumanoidAnchorMatchKey(targetKey) ); const semanticSocketMatch = !exactSocketMatch && ( rolePenalty <= 0.22 && ( !isBilateralHumanoidRole(node.humanoidRole ?? 'torso') || desiredSide === 'center' || node.humanoidSide === desiredSide || target.humanoidSide === desiredSide ) ); const torsoStable = desiredRole === 'torso' && targetRole === 'torso' && (hasHumanoidCoreTorsoHint(node.info) || ownKeys.length > 0); const explicitNodeHint = hasHumanoidExplicitAnchor(node.info); const hardRoleConfidence = Boolean(hardLock) || ( desiredRole !== 'torso' && rolePenalty <= 0.34 && (hasStrongHumanoidLimbHint(node.info) || explicitNodeHint) ) || torsoStable; const hardSideConfidence = !isBilateralHumanoidRole(node.humanoidRole ?? 'torso') || (node.humanoidSide != null && node.humanoidSide !== 'center') || Boolean(hardLock?.side); const stableSocketAssignment = ( exactSocketMatch || semanticSocketMatch || torsoStable ) && hardRoleConfidence && hardSideConfidence; node.humanoidSocketMatchedKey = targetKey ?? undefined; node.humanoidSocketRolePenalty = Number(rolePenalty.toFixed(4)); node.humanoidSocketExact = exactSocketMatch; node.humanoidSocketSemantic = semanticSocketMatch; node.humanoidSocketStable = stableSocketAssignment; node.humanoidFallback = !stableSocketAssignment; node.humanoidComposite = !(exactSocketMatch || semanticSocketMatch); node.humanoidGroupId = target.humanoidGroupId ?? resolvedSocketKey; socketed += 1; } return { total, socketed }; }; const resolveHumanoidMotionGroupKey = (node: MotionRigNode, bounds: UnitBounds): string => { const segmentKey = resolveHumanoidSegmentMergeKey(node); if (segmentKey) return segmentKey; if (node.humanoidSocketKey) return node.humanoidSocketKey; const nodeKey = normalizeHumanoidNodeKey(node.info.inferredPartNodeId); if (nodeKey) return `node:${nodeKey}`; const partKey = normalizeHumanoidNodeKey(node.info.inferredPartId); if (partKey) return `part:${partKey}`; const role = node.humanoidRole ?? 'torso'; const side = node.humanoidSide ?? 'center'; const yNorm = node.restCenter.y / Math.max(bounds.height, 1e-4); const zNorm = node.restCenter.z / Math.max(bounds.radius, 1e-4); const yBucket = Math.round(yNorm * 8); const zBucket = Math.round(zNorm * 6); return `fallback:${role}:${side}:y${yBucket}:z${zBucket}`; }; const buildHumanoidMotionGroups = ( nodes: MotionRigNode[], bounds: UnitBounds ): HumanoidMotionGroup[] => { const grouped = new Map(); for (const node of nodes) { if (!node.humanoidRole) continue; const key = resolveHumanoidMotionGroupKey(node, bounds); const list = grouped.get(key) ?? []; list.push(node); grouped.set(key, list); } const groups: HumanoidMotionGroup[] = []; for (const [id, entries] of grouped.entries()) { if (entries.length <= 0) continue; const forcedSegment = resolveHumanoidSegmentGroup(id); const roleVotes = new Map(); const sideVotes = new Map(); let weightSum = 0; let weightMass = 0; let fallback = false; let composite = false; let xSum = 0; for (const node of entries) { const role = node.humanoidRole ?? 'torso'; const side = node.humanoidSide ?? 'center'; const volume = getHumanoidNodeVolume(node); const confidence = node.info.inferredApproximate ? 0.45 : 1; const vote = volume * confidence; roleVotes.set(role, (roleVotes.get(role) ?? 0) + vote); sideVotes.set(side, (sideVotes.get(side) ?? 0) + vote); const w = node.humanoidWeight ?? resolveHumanoidMotionWeight(role); weightSum += w * volume; weightMass += volume; xSum += node.restCenter.x * volume; fallback = fallback || Boolean(node.humanoidFallback); composite = composite || Boolean(node.humanoidComposite); } let role = Array.from(roleVotes.entries()).sort((a, b) => b[1] - a[1])[0]?.[0] ?? 'torso'; let side = Array.from(sideVotes.entries()).sort((a, b) => b[1] - a[1])[0]?.[0] ?? 'center'; if (forcedSegment) { role = forcedSegment.role; side = forcedSegment.side; } if (side === 'center' && isBilateralHumanoidRole(role)) { const xAvg = xSum / Math.max(weightMass, 1e-6); const threshold = Math.max(0.02, bounds.radius * 0.06); if (xAvg < -threshold) side = 'left'; else if (xAvg > threshold) side = 'right'; } let weight = weightMass > 0 ? (weightSum / weightMass) : resolveHumanoidMotionWeight(role); if (forcedSegment) { weight = Math.max(weight, resolveHumanoidMotionWeight(role) * 0.92); } for (const node of entries) { node.humanoidRole = role; node.humanoidSide = side; node.humanoidWeight = weight; node.humanoidGroupId = id; node.humanoidGroupSize = entries.length; } groups.push({ id, role, side, weight, fallback, composite, nodes: entries }); } groups.sort((a, b) => b.nodes.length - a.nodes.length); return groups; }; const averageHumanoidCenters = ( nodes: MotionRigNode[], role: HumanoidMotionRole, side?: HumanoidMotionSide ): THREE.Vector3 | null => { const roleMatches = nodes.filter((node) => node.humanoidRole === role); if (roleMatches.length <= 0) return null; const matches = side == null ? roleMatches : roleMatches.filter((node) => node.humanoidSide === side); const source = matches.length > 0 ? matches : roleMatches; let sx = 0; let sy = 0; let sz = 0; for (const node of source) { sx += node.restCenter.x; sy += node.restCenter.y; sz += node.restCenter.z; } const inv = 1 / Math.max(1, source.length); return new THREE.Vector3(sx * inv, sy * inv, sz * inv); }; const averageHumanoidCentersFiltered = ( nodes: MotionRigNode[], role: HumanoidMotionRole, side: HumanoidMotionSide, predicate: (node: MotionRigNode, hint: string) => boolean ): THREE.Vector3 | null => { const roleMatches = nodes.filter((node) => node.humanoidRole === role && node.humanoidSide === side); if (roleMatches.length <= 0) return null; const filtered = roleMatches.filter((node) => predicate(node, getHumanoidNodeHint(node.info))); const source = filtered.length > 0 ? filtered : roleMatches; let sx = 0; let sy = 0; let sz = 0; for (const node of source) { sx += node.restCenter.x; sy += node.restCenter.y; sz += node.restCenter.z; } const inv = 1 / Math.max(1, source.length); return new THREE.Vector3(sx * inv, sy * inv, sz * inv); }; const lerpVector = (a: THREE.Vector3, b: THREE.Vector3, t: number): THREE.Vector3 => ( new THREE.Vector3().copy(a).lerp(b, THREE.MathUtils.clamp(t, 0, 1)) ); const buildHumanoidJointLandmarks = ( nodes: MotionRigNode[], bounds: UnitBounds ): HumanoidJointLandmarks | null => { if (nodes.length <= 0) return null; const torso = averageHumanoidCenters(nodes, 'torso') ?? new THREE.Vector3(0, bounds.height * 0.46, 0); const buildSide = (side: 'left' | 'right'): HumanoidJointLandmarkSide => { const sideSign = side === 'left' ? -1 : 1; const shoulder = averageHumanoidCentersFiltered(nodes, 'shoulder', side, (_node, hint) => ( hint.includes('shoulderjoint') || hint.includes('shouldercuff') || hint.includes('clavicle') )) ?? averageHumanoidCenters(nodes, 'upperArm', side) ?? averageHumanoidCenters(nodes, 'shoulder', side) ?? averageHumanoidCenters(nodes, 'upperArm', side) ?? new THREE.Vector3( torso.x + sideSign * bounds.radius * 0.34, torso.y + bounds.height * 0.11, torso.z + bounds.radius * 0.04 ); const shoulderOffset = (shoulder.x - torso.x) * sideSign; const clampedShoulderOffset = THREE.MathUtils.clamp(shoulderOffset, bounds.radius * 0.14, bounds.radius * 0.24); shoulder.x = torso.x + sideSign * clampedShoulderOffset; shoulder.z = THREE.MathUtils.clamp( shoulder.z, torso.z - bounds.radius * 0.08, torso.z + bounds.radius * 0.14 ); const upperArm = averageHumanoidCentersFiltered(nodes, 'upperArm', side, (_node, hint) => ( hint.includes('upperarm') || hint.includes('bicep') || hint.includes('tricep') )) ?? averageHumanoidCenters(nodes, 'upperArm', side) ?? shoulder.clone(); let forearm = averageHumanoidCentersFiltered(nodes, 'forearm', side, (_node, hint) => ( hint.includes('forearm') || hint.includes('elbow') )) ?? averageHumanoidCenters(nodes, 'forearm', side) ?? new THREE.Vector3(upperArm.x + sideSign * bounds.radius * 0.08, upperArm.y - bounds.height * 0.14, upperArm.z + bounds.radius * 0.05); let hand = averageHumanoidCentersFiltered(nodes, 'hand', side, (_node, hint) => ( hint.includes('hand') || hint.includes('wrist') )) ?? averageHumanoidCenters(nodes, 'hand', side) ?? new THREE.Vector3(forearm.x + sideSign * bounds.radius * 0.05, forearm.y - bounds.height * 0.08, forearm.z + bounds.radius * 0.07); const armLenUpper = THREE.MathUtils.clamp(bounds.height * 0.142, bounds.radius * 0.17, bounds.radius * 0.3); const armLenLower = THREE.MathUtils.clamp(bounds.height * 0.128, bounds.radius * 0.15, bounds.radius * 0.28); // Keep elbows/wrists closer to a natural relaxed stance (downward with slight forward bias). const preferredUpperDir = new THREE.Vector3(sideSign * 0.09, -0.99, 0.07).normalize(); const preferredLowerDir = new THREE.Vector3(sideSign * 0.06, -0.992, 0.1).normalize(); const upperRawDir = forearm.clone().sub(shoulder); if (upperRawDir.lengthSq() < 1e-6) { upperRawDir.copy(preferredUpperDir); } else { upperRawDir.normalize(); } upperRawDir.lerp(preferredUpperDir, 0.84).normalize(); const elbow = shoulder.clone().addScaledVector(upperRawDir, armLenUpper); const lowerRawDir = hand.clone().sub(forearm); if (lowerRawDir.lengthSq() < 1e-6) { lowerRawDir.copy(preferredLowerDir); } else { lowerRawDir.normalize(); } lowerRawDir.lerp(preferredLowerDir, 0.84).normalize(); const wrist = elbow.clone().addScaledVector(lowerRawDir, armLenLower); const minElbowOffset = bounds.radius * 0.11; const maxElbowOffset = bounds.radius * 0.22; const elbowOffset = THREE.MathUtils.clamp((elbow.x - torso.x) * sideSign, minElbowOffset, maxElbowOffset); elbow.x = torso.x + sideSign * elbowOffset; elbow.y = Math.min(elbow.y, shoulder.y - bounds.height * 0.07); elbow.z = THREE.MathUtils.clamp(elbow.z, torso.z - bounds.radius * 0.06, torso.z + bounds.radius * 0.14); const minWristOffset = bounds.radius * 0.12; const maxWristOffset = bounds.radius * 0.24; const wristOffset = THREE.MathUtils.clamp((wrist.x - torso.x) * sideSign, minWristOffset, maxWristOffset); wrist.x = torso.x + sideSign * wristOffset; wrist.y = Math.min(wrist.y, elbow.y - bounds.height * 0.06); wrist.z = THREE.MathUtils.clamp(wrist.z, torso.z - bounds.radius * 0.04, torso.z + bounds.radius * 0.16); forearm = elbow.clone(); hand = wrist.clone(); const hip = averageHumanoidCenters(nodes, 'hip', side) ?? averageHumanoidCenters(nodes, 'upperLeg', side) ?? new THREE.Vector3( torso.x + sideSign * bounds.radius * 0.19, torso.y - bounds.height * 0.21, torso.z + bounds.radius * 0.02 ); const upperLeg = averageHumanoidCenters(nodes, 'upperLeg', side) ?? hip.clone(); const lowerLeg = averageHumanoidCenters(nodes, 'lowerLeg', side) ?? new THREE.Vector3(upperLeg.x + sideSign * bounds.radius * 0.02, upperLeg.y - bounds.height * 0.16, upperLeg.z + bounds.radius * 0.02); const foot = averageHumanoidCenters(nodes, 'foot', side) ?? new THREE.Vector3(lowerLeg.x, lowerLeg.y - bounds.height * 0.14, lowerLeg.z + bounds.radius * 0.08); const knee = lerpVector(upperLeg, lowerLeg, 0.56); const ankle = lerpVector(lowerLeg, foot, 0.62); return { hip, knee, ankle, shoulder, elbow, wrist }; }; const left = buildSide('left'); const right = buildSide('right'); const centerPelvis = lerpVector(left.hip, right.hip, 0.5); const centerChest = lerpVector(torso, lerpVector(left.shoulder, right.shoulder, 0.5), 0.72); const centerNeck = lerpVector(centerChest, lerpVector(left.shoulder, right.shoulder, 0.5), 0.65); const centerHead = centerNeck.clone().add(new THREE.Vector3(0, bounds.height * 0.08, bounds.radius * 0.015)); return { centerTorso: torso, centerPelvis, centerChest, centerNeck, centerHead, left, right }; }; const resolveHumanoidTorsoPivot = ( node: MotionRigNode, landmarks: HumanoidJointLandmarks ): THREE.Vector3 => { const hint = getHumanoidNodeHint(node.info); const sourceHint = getHumanoidSourceHint(node.info); const shoulderSourced = ( sourceHint.includes('shoulder_pad') || sourceHint.includes('shoulderpad') || sourceHint.includes('clavicle') ); const pelvisLike = ( hint.includes('pelvis') || hint.includes('abdomen') || hint.includes('waist') || hint.includes('hip') ); if (pelvisLike) { if (shoulderSourced || node.restCenter.y > landmarks.centerChest.y - 0.08) { return landmarks.centerChest; } return landmarks.centerPelvis; } if (shoulderSourced) { return landmarks.centerChest; } if ( hint.includes('collar') || hint.includes('neck') || hint.includes('clavicle') ) { return landmarks.centerNeck; } if ( hint.includes('helmet') || hint.includes('visor') || hint.includes('crown') || hint.includes('head') ) { return landmarks.centerHead; } if ( hint.includes('chest') || hint.includes('sternum') || hint.includes('cuirass') || hint.includes('rib') ) { return landmarks.centerChest; } return landmarks.centerTorso; }; const resolveHumanoidPivotForNode = ( rig: MotionRig, node: MotionRigNode ): THREE.Vector3 => { const landmarks = rig.humanoidLandmarks; if (!landmarks || !node.humanoidRole) { return node.restCenter; } const sideSet = node.humanoidSide === 'right' ? landmarks.right : landmarks.left; switch (node.humanoidRole) { case 'torso': return resolveHumanoidTorsoPivot(node, landmarks); case 'shoulder': case 'upperArm': return sideSet.shoulder; case 'forearm': return sideSet.elbow; case 'hand': return sideSet.wrist; case 'hip': case 'upperLeg': return sideSet.hip; case 'lowerLeg': return sideSet.knee; case 'foot': return sideSet.ankle; default: return node.restCenter; } }; const hasHumanoidPoseDelta = (dx: number, dy: number, dz: number): boolean => ( Math.abs(dx) > 1e-6 || Math.abs(dy) > 1e-6 || Math.abs(dz) > 1e-6 ); type HumanoidRagdollLayerKind = | 'torso' | 'shoulder' | 'elbow' | 'wrist' | 'hip' | 'knee' | 'ankle'; type HumanoidRagdollLayerLimits = { minX: number; maxX: number; minY: number; maxY: number; minZ: number; maxZ: number; }; const HUMANOID_RAGDOLL_LIMITS: Record = { torso: { minX: -0.2, maxX: 0.2, minY: -0.24, maxY: 0.24, minZ: -0.16, maxZ: 0.16 }, shoulder: { minX: -0.44, maxX: 0.44, minY: -0.34, maxY: 0.34, minZ: -0.24, maxZ: 0.24 }, elbow: { minX: -0.04, maxX: 0.92, minY: -0.2, maxY: 0.2, minZ: -0.16, maxZ: 0.16 }, wrist: { minX: -0.3, maxX: 0.3, minY: -0.2, maxY: 0.2, minZ: -0.12, maxZ: 0.12 }, hip: { minX: -0.55, maxX: 0.55, minY: -0.2, maxY: 0.2, minZ: -0.16, maxZ: 0.16 }, knee: { minX: -0.04, maxX: 0.98, minY: -0.05, maxY: 0.05, minZ: -0.06, maxZ: 0.06 }, ankle: { minX: -0.34, maxX: 0.34, minY: -0.1, maxY: 0.1, minZ: -0.08, maxZ: 0.08 } }; const clampHumanoidRagdollLayer = ( kind: HumanoidRagdollLayerKind, dx: number, dy: number, dz: number ): { dx: number; dy: number; dz: number } => { const limits = HUMANOID_RAGDOLL_LIMITS[kind]; return { dx: THREE.MathUtils.clamp(dx, limits.minX, limits.maxX), dy: THREE.MathUtils.clamp(dy, limits.minY, limits.maxY), dz: THREE.MathUtils.clamp(dz, limits.minZ, limits.maxZ) }; }; const applyHumanoidNodeLayeredPose = ( node: MotionRigNode, layers: HumanoidPoseLayer[] ) => { motionPoseComposed.identity(); for (const layer of layers) { if (!hasHumanoidPoseDelta(layer.deltaX, layer.deltaY, layer.deltaZ)) { continue; } motionPoseDeltaEuler.set(layer.deltaX, layer.deltaY, layer.deltaZ, 'XYZ'); motionPoseDeltaQuat.setFromEuler(motionPoseDeltaEuler); motionPosePivotTranslate.makeTranslation(layer.pivot.x, layer.pivot.y, layer.pivot.z); motionPosePivotTranslateInv.makeTranslation(-layer.pivot.x, -layer.pivot.y, -layer.pivot.z); motionPoseDeltaRotate.makeRotationFromQuaternion(motionPoseDeltaQuat); motionPoseComposed .multiply(motionPosePivotTranslate) .multiply(motionPoseDeltaRotate) .multiply(motionPosePivotTranslateInv); } motionPoseRestQuat.setFromEuler(node.restRotation); motionPoseRestTranslate.makeTranslation(node.restPosition.x, node.restPosition.y, node.restPosition.z); motionPoseRestRotate.makeRotationFromQuaternion(motionPoseRestQuat); motionPoseRestScale.makeScale(node.restScale.x, node.restScale.y, node.restScale.z); motionPoseComposed .multiply(motionPoseRestTranslate) .multiply(motionPoseRestRotate) .multiply(motionPoseRestScale); motionPoseComposed.decompose(motionPoseComposedPos, motionPoseComposedQuat, motionPoseComposedScale); node.node.position.copy(motionPoseComposedPos); node.node.quaternion.copy(motionPoseComposedQuat); node.node.scale.copy(motionPoseComposedScale); }; const getHumanoidRoleChainDepth = (role: HumanoidMotionRole): number => { switch (role) { case 'torso': return 1; case 'shoulder': case 'upperArm': case 'hip': case 'upperLeg': return 2; case 'forearm': case 'lowerLeg': return 3; case 'hand': case 'foot': return 4; default: return 1; } }; const collectHumanoidMotionRigReport = ( rig: MotionRig, unitType: UnitType, bounds: UnitBounds ): HumanoidMotionRigReport | null => { if (rig.humanoidNodes.length <= 0) { return null; } const roleCounts = new Map(); const sideCounts = new Map(); const chainDepthCounts = new Map(); const approximateRoleCounts = new Map(); const keyBuckets = new Map(); const outliers: HumanoidPivotOutlier[] = []; const normDenominator = Math.max(bounds.height, bounds.radius * 2, 1e-4); const pivotNormByNode = new Map(); const pivotNormSamples: number[] = []; let approximateCount = 0; let approximateCoercedTorsoCount = 0; let approximateLimbCount = 0; let decorativeCount = 0; for (const node of rig.humanoidNodes) { const role = node.humanoidRole ?? 'unknown'; const side = node.humanoidSide ?? 'center'; roleCounts.set(role, (roleCounts.get(role) ?? 0) + 1); sideCounts.set(side, (sideCounts.get(side) ?? 0) + 1); const depth = node.humanoidRole ? getHumanoidRoleChainDepth(node.humanoidRole) : 0; chainDepthCounts.set(String(depth), (chainDepthCounts.get(String(depth)) ?? 0) + 1); if (node.info.inferredApproximate) { approximateCount += 1; approximateRoleCounts.set(role, (approximateRoleCounts.get(role) ?? 0) + 1); if (role !== 'torso') { approximateLimbCount += 1; } if (node.humanoidApproximateTorsoCoerce) { approximateCoercedTorsoCount += 1; } } if (node.humanoidDecorative) { decorativeCount += 1; } const key = normalizeHumanoidNodeKey(node.info.inferredPartNodeId) || normalizeHumanoidNodeKey(node.info.inferredPartId); if (key) { const entries = keyBuckets.get(key) ?? []; entries.push(node); keyBuckets.set(key, entries); } if (node.humanoidRole && !node.humanoidDecorative) { const pivot = resolveHumanoidPivotForNode(rig, node); const distance = pivot.distanceTo(node.restCenter); const norm = distance / normDenominator; pivotNormByNode.set(node, norm); pivotNormSamples.push(norm); if (norm > 0.28) { outliers.push({ mesh: node.node.name || 'mesh', role: node.humanoidRole, side, pivotDistance: Number(distance.toFixed(4)), pivotDistanceNorm: Number(norm.toFixed(4)), channel: String(node.info.channel ?? ''), inferredPartNodeId: node.info.inferredPartNodeId ?? null, inferredPartId: node.info.inferredPartId ?? null, approximate: Boolean(node.info.inferredApproximate), composite: Boolean(node.humanoidComposite), sourcePart: typeof node.info.sourcePart === 'string' ? node.info.sourcePart : null }); } } } const inconsistencies: HumanoidNodeMappingInconsistency[] = []; for (const [key, nodes] of keyBuckets.entries()) { if (nodes.length <= 1) continue; const roles = Array.from(new Set(nodes.map((node) => node.humanoidRole ?? 'unknown'))); const sides = Array.from(new Set(nodes.map((node) => node.humanoidSide ?? 'center'))); if (roles.length > 1 || sides.length > 1) { inconsistencies.push({ key, count: nodes.length, roles, sides }); } } const sortedPivotNorms = pivotNormSamples.slice().sort((a, b) => a - b); const avgPivotDistanceNorm = sortedPivotNorms.length > 0 ? (sortedPivotNorms.reduce((sum, value) => sum + value, 0) / sortedPivotNorms.length) : 0; const p95Index = sortedPivotNorms.length > 0 ? Math.min(sortedPivotNorms.length - 1, Math.floor(sortedPivotNorms.length * 0.95)) : 0; const p95PivotDistanceNorm = sortedPivotNorms.length > 0 ? sortedPivotNorms[p95Index] : 0; const maxPivotDistanceNorm = sortedPivotNorms.length > 0 ? sortedPivotNorms[sortedPivotNorms.length - 1] : 0; const topNodeKeys: HumanoidNodeKeyCluster[] = Array.from(keyBuckets.entries()) .map(([key, nodes]) => { const roleVotes = new Map(); const sideVotes = new Map(); let approx = 0; let pivotNormSum = 0; let pivotNormMax = 0; let pivotNormCount = 0; for (const node of nodes) { const role = node.humanoidRole ?? 'unknown'; const side = node.humanoidSide ?? 'center'; roleVotes.set(role, (roleVotes.get(role) ?? 0) + 1); sideVotes.set(side, (sideVotes.get(side) ?? 0) + 1); if (node.info.inferredApproximate) { approx += 1; } const pivotNorm = pivotNormByNode.get(node); if (typeof pivotNorm === 'number' && Number.isFinite(pivotNorm)) { pivotNormSum += pivotNorm; pivotNormMax = Math.max(pivotNormMax, pivotNorm); pivotNormCount += 1; } } const dominantRole = roleVotes.size > 0 ? Array.from(roleVotes.entries()).sort((a, b) => b[1] - a[1])[0][0] : 'unknown'; const dominantSide = sideVotes.size > 0 ? Array.from(sideVotes.entries()).sort((a, b) => b[1] - a[1])[0][0] : 'center'; return { key, count: nodes.length, dominantRole, dominantSide, approximateCount: approx, avgPivotDistanceNorm: pivotNormCount > 0 ? Number((pivotNormSum / pivotNormCount).toFixed(4)) : 0, maxPivotDistanceNorm: Number(pivotNormMax.toFixed(4)) }; }) .sort((a, b) => (b.count - a.count) || (b.maxPivotDistanceNorm - a.maxPivotDistanceNorm)) .slice(0, 12); outliers.sort((a, b) => b.pivotDistanceNorm - a.pivotDistanceNorm); inconsistencies.sort((a, b) => b.count - a.count); const armorSocketNodes = rig.humanoidNodes.filter((node) => isSocketedArmorChannel(node.info.channel)); const armorPlatingSocketExactCount = armorSocketNodes.reduce( (sum, node) => sum + (node.humanoidSocketExact ? 1 : 0), 0 ); const armorPlatingSocketSemanticCount = armorSocketNodes.reduce( (sum, node) => sum + (node.humanoidSocketSemantic ? 1 : 0), 0 ); const armorPlatingSocketStableCount = armorSocketNodes.reduce( (sum, node) => sum + (node.humanoidSocketStable ? 1 : 0), 0 ); const armorPlatingSocketFallbackCount = armorSocketNodes.reduce( (sum, node) => sum + (node.humanoidFallback ? 1 : 0), 0 ); return { solverVersion: HUMANOID_MOTION_SOLVER_VERSION, unitType, nodeCount: rig.humanoidNodes.length, groupCount: rig.humanoidGroups.length, avgGroupSize: Number((rig.humanoidNodes.length / Math.max(1, rig.humanoidGroups.length)).toFixed(3)), armorPlatingNodeCount: rig.armorPlatingNodeCount, armorPlatingSocketedCount: rig.armorPlatingSocketedCount, armorPlatingSocketExactCount, armorPlatingSocketSemanticCount, armorPlatingSocketStableCount, armorPlatingSocketFallbackCount, uniqueNodeKeyCount: keyBuckets.size, roleCounts: Object.fromEntries(roleCounts), sideCounts: Object.fromEntries(sideCounts), chainDepthCounts: Object.fromEntries(chainDepthCounts), fallbackCount: rig.humanoidNodes.reduce((sum, node) => sum + (node.humanoidFallback ? 1 : 0), 0), compositeCount: rig.humanoidNodes.reduce((sum, node) => sum + (node.humanoidComposite ? 1 : 0), 0), decorativeCount, approximateCount, approximateCoercedTorsoCount, approximateLimbCount, approximateRoleCounts: Object.fromEntries(approximateRoleCounts), landmarkAvailable: Boolean(rig.humanoidLandmarks), avgPivotDistanceNorm: Number(avgPivotDistanceNorm.toFixed(4)), p95PivotDistanceNorm: Number(p95PivotDistanceNorm.toFixed(4)), maxPivotDistanceNorm: Number(maxPivotDistanceNorm.toFixed(4)), inconsistencyCount: inconsistencies.length, outlierCount: outliers.length, outliers: outliers.slice(0, 12), inconsistencies: inconsistencies.slice(0, 12), topNodeKeys }; }; const logHumanoidMotionRigReport = ( report: HumanoidMotionRigReport, source: 'auto' | 'manual' = 'manual' ) => { const title = `[MeshLabViewer] Humanoid motion report (${source}) ${report.unitType}`; console.groupCollapsed(title); console.table({ solverVersion: report.solverVersion, unit: report.unitType, nodeCount: report.nodeCount, groupCount: report.groupCount, avgGroupSize: report.avgGroupSize, armorPlatingNodeCount: report.armorPlatingNodeCount, armorPlatingSocketedCount: report.armorPlatingSocketedCount, armorPlatingSocketExactCount: report.armorPlatingSocketExactCount, armorPlatingSocketSemanticCount: report.armorPlatingSocketSemanticCount, armorPlatingSocketStableCount: report.armorPlatingSocketStableCount, armorPlatingSocketFallbackCount: report.armorPlatingSocketFallbackCount, uniqueNodeKeys: report.uniqueNodeKeyCount, landmarkAvailable: report.landmarkAvailable, fallbackCount: report.fallbackCount, compositeCount: report.compositeCount, decorativeCount: report.decorativeCount, approximateCount: report.approximateCount, approximateCoercedTorsoCount: report.approximateCoercedTorsoCount, approximateLimbCount: report.approximateLimbCount, avgPivotDistanceNorm: report.avgPivotDistanceNorm, p95PivotDistanceNorm: report.p95PivotDistanceNorm, maxPivotDistanceNorm: report.maxPivotDistanceNorm, inconsistencyCount: report.inconsistencyCount, outlierCount: report.outlierCount }); console.log('Role counts', report.roleCounts); console.log('Side counts', report.sideCounts); console.log('Chain depth counts', report.chainDepthCounts); console.log('Approximate role counts', report.approximateRoleCounts); if (report.topNodeKeys.length > 0) { console.table(report.topNodeKeys); } if (report.inconsistencies.length > 0) { console.table(report.inconsistencies); } if (report.outliers.length > 0) { console.table(report.outliers); } console.log(report); console.groupEnd(); }; const resolveHumanoidLayoutParts = ( unitType: UnitType, schema: VisualLanguageSchema | null ): HumanoidLayoutPartLike[] => { if (!schema || !schema.bounds || !isHumanoidPreviewUnitType(unitType)) { return []; } const snapshot = HumanoidUnitAssembler.inspectLayout(schema.volumeHierarchy?.primary ?? 'hull', { bounds: schema.bounds, compositionContext: { assetCategory: String(schema.category ?? ''), assetId: String(schema.assetId ?? ''), volumeTypeIndex: 0 } }); return snapshot?.parts ?? []; }; const findNearestHumanoidLayoutPart = ( center: { x: number; y: number; z: number }, meshSize: { x: number; y: number; z: number }, bounds: UnitBounds | null, layoutParts: HumanoidLayoutPartLike[] ): HumanoidLayoutPartLike | null => { if (!bounds || layoutParts.length === 0) return null; let winner: HumanoidLayoutPartLike | null = null; let winnerScore = Number.POSITIVE_INFINITY; const invR = 1 / Math.max(bounds.radius, 1e-4); const invH = 1 / Math.max(bounds.height, 1e-4); const meshVolume = Math.max(1e-6, meshSize.x * meshSize.y * meshSize.z); for (const part of layoutParts) { const dx = (center.x - part.center.x) * invR; const dy = (center.y - part.center.y) * invH; const dz = (center.z - part.center.z) * invR; const dist = Math.sqrt(dx * dx + dy * dy + dz * dz); const partVolume = Math.max(1e-6, part.size.x * part.size.y * part.size.z); const volumeRatio = Math.max(meshVolume / partVolume, partVolume / meshVolume); const axisRatio = Math.max( Math.max(meshSize.x, part.size.x) / Math.max(1e-6, Math.min(meshSize.x, part.size.x)), Math.max(meshSize.y, part.size.y) / Math.max(1e-6, Math.min(meshSize.y, part.size.y)), Math.max(meshSize.z, part.size.z) / Math.max(1e-6, Math.min(meshSize.z, part.size.z)) ); const sizePenalty = Math.max(0, Math.log2(volumeRatio) * 0.2 + Math.log2(axisRatio) * 0.12); const score = dist + sizePenalty; if (score < winnerScore) { winnerScore = score; winner = part; } } return winnerScore <= 0.48 ? winner : null; }; const findNearestHumanoidLayoutPartApproximate = ( center: { x: number; y: number; z: number }, bounds: UnitBounds | null, layoutParts: HumanoidLayoutPartLike[] ): HumanoidLayoutPartLike | null => { if (!bounds || layoutParts.length === 0) return null; let winner: HumanoidLayoutPartLike | null = null; let winnerDist = Number.POSITIVE_INFINITY; const invR = 1 / Math.max(bounds.radius, 1e-4); const invH = 1 / Math.max(bounds.height, 1e-4); for (const part of layoutParts) { const dx = (center.x - part.center.x) * invR; const dy = (center.y - part.center.y) * invH; const dz = (center.z - part.center.z) * invR; const dist = Math.sqrt(dx * dx + dy * dy + dz * dz); if (dist < winnerDist) { winnerDist = dist; winner = part; } } return winnerDist <= 0.78 ? winner : null; }; const getMotionProfile = (unitType: UnitType | null): MotionLabProfile => { if (!unitType) return DEFAULT_MOTION_PROFILE; return MOTION_PROFILE_BY_UNIT[unitType] ?? DEFAULT_MOTION_PROFILE; }; const getAttackCooldownSeconds = (unitType: UnitType | null): number => { if (!unitType) return 1.2; const blueprint = getUnitBlueprint(unitType); if (blueprint?.combat?.attackCooldown && blueprint.combat.attackCooldown > 0) { return Math.max(0.2, blueprint.combat.attackCooldown); } const profile = UNIT_COMBAT_PROFILES[unitType]; if (!profile) return 1.2; return Math.max(0.2, profile.attackCooldown); }; const getAbilityConcealDurationSeconds = (unitType: UnitType | null): number => { if (!unitType) return 6; switch (unitType) { case 'HeavyTank': return 8; case 'Siege': return 7; case 'Tank': return 6.5; default: return 6; } }; const sampleMotionTerrainHeight = (x: number, z: number): number => ( Math.sin(z * 0.18) * 0.16 + Math.sin(z * 0.06 + x * 0.34) * 0.1 + Math.cos(x * 0.15) * 0.08 ); const createMotionLine = ( color: THREE.ColorRepresentation, opacity: number, lineWidth = 2 ) => { const geometry = new THREE.BufferGeometry(); const positions = new Float32Array(6); geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); const material = new THREE.LineBasicMaterial({ color, transparent: true, opacity, linewidth: lineWidth }); const line = new THREE.Line(geometry, material); line.visible = false; line.renderOrder = 900; return line; }; const setMotionLineEndpoints = ( line: THREE.Line, start: THREE.Vector3, end: THREE.Vector3 ) => { const attr = line.geometry.getAttribute('position'); if (!(attr instanceof THREE.BufferAttribute)) return; attr.setXYZ(0, start.x, start.y, start.z); attr.setXYZ(1, end.x, end.y, end.z); attr.needsUpdate = true; line.geometry.computeBoundingSphere(); }; const createMotionParticleEmitter = ( parent: THREE.Group, count: number, color: THREE.ColorRepresentation, gravity: number, drag: number, baseSize: number ): MotionParticleEmitter => { const particles: MotionParticle[] = []; for (let i = 0; i < count; i++) { const material = new THREE.SpriteMaterial({ color, map: SOFT_PARTICLE_TEXTURE ?? undefined, transparent: true, opacity: 0, depthWrite: false, depthTest: true, blending: THREE.NormalBlending }); const sprite = new THREE.Sprite(material); sprite.visible = false; sprite.scale.setScalar(baseSize); parent.add(sprite); particles.push({ sprite, material, velocity: new THREE.Vector3(), age: 0, life: 0, startSize: baseSize, endSize: baseSize * 1.6, startOpacity: 0.75, endOpacity: 0, active: false }); } return { particles, gravity, drag }; }; const resetMotionParticleEmitter = (emitter: MotionParticleEmitter) => { for (const particle of emitter.particles) { particle.active = false; particle.age = 0; particle.life = 0; particle.material.opacity = 0; particle.sprite.visible = false; } }; const spawnMotionParticle = ( emitter: MotionParticleEmitter, origin: THREE.Vector3, velocity: THREE.Vector3, life: number, startSize: number, endSize: number, startOpacity: number, endOpacity: number ) => { const particle = emitter.particles.find((entry) => !entry.active) ?? emitter.particles[0]; if (!particle) return; particle.active = true; particle.age = 0; particle.life = Math.max(0.04, life); particle.startSize = Math.max(0.001, startSize); particle.endSize = Math.max(0.001, endSize); particle.startOpacity = THREE.MathUtils.clamp(startOpacity, 0, 1); particle.endOpacity = THREE.MathUtils.clamp(endOpacity, 0, 1); particle.velocity.copy(velocity); particle.sprite.position.copy(origin); particle.sprite.scale.setScalar(particle.startSize); particle.material.opacity = particle.startOpacity; particle.sprite.visible = true; }; const updateMotionParticleEmitter = ( emitter: MotionParticleEmitter, deltaTime: number ) => { for (const particle of emitter.particles) { if (!particle.active) continue; particle.age += deltaTime; if (particle.age >= particle.life) { particle.active = false; particle.material.opacity = 0; particle.sprite.visible = false; continue; } const t = THREE.MathUtils.clamp(particle.age / Math.max(0.001, particle.life), 0, 1); particle.velocity.y += emitter.gravity * deltaTime; const dragScale = Math.max(0, 1 - emitter.drag * deltaTime); particle.velocity.multiplyScalar(dragScale); particle.sprite.position.addScaledVector(particle.velocity, deltaTime); const scale = THREE.MathUtils.lerp(particle.startSize, particle.endSize, t); particle.sprite.scale.setScalar(scale); particle.material.opacity = THREE.MathUtils.lerp(particle.startOpacity, particle.endOpacity, t); } }; const clearMotionTerrain = () => { if (!activeMotionTerrain) return; scene.remove(activeMotionTerrain); disposeObject3DTree(activeMotionTerrain); activeMotionTerrain = null; }; const buildMotionTerrain = (bounds: UnitBounds): THREE.Group => { const group = new THREE.Group(); group.name = 'motion-lab-terrain'; const terrainWidth = Math.max(18, bounds.radius * 14); const terrainDepth = 110; const geometry = new THREE.PlaneGeometry(terrainWidth, terrainDepth, 56, 180); geometry.rotateX(-Math.PI / 2); const positions = geometry.getAttribute('position'); const vertex = new THREE.Vector3(); for (let i = 0; i < positions.count; i++) { vertex.fromBufferAttribute(positions, i); vertex.y = sampleMotionTerrainHeight(vertex.x, vertex.z); positions.setXYZ(i, vertex.x, vertex.y, vertex.z); } positions.needsUpdate = true; geometry.computeVertexNormals(); const terrainMaterial = new THREE.MeshStandardMaterial({ color: '#254233', roughness: 0.92, metalness: 0.05 }); const terrain = new THREE.Mesh(geometry, terrainMaterial); terrain.receiveShadow = true; terrain.position.y = -bounds.height * 0.52; group.add(terrain); const pathPoints: THREE.Vector3[] = []; for (let z = -55; z <= 55; z += 1) { const x = Math.sin(z * 0.1) * 1.25; pathPoints.push(new THREE.Vector3(x, sampleMotionTerrainHeight(x, z) + 0.04, z)); } const pathGeometry = new THREE.BufferGeometry().setFromPoints(pathPoints); const pathLine = new THREE.Line( pathGeometry, new THREE.LineBasicMaterial({ color: '#6ed8ff', transparent: true, opacity: 0.66 }) ); pathLine.position.y = terrain.position.y; group.add(pathLine); return group; }; const applyHumanoidNeutralStance = (rig: MotionRig) => { const landmarks = rig.humanoidLandmarks; if (!landmarks || rig.humanoidGroups.length <= 0) return; for (const group of rig.humanoidGroups) { const side = group.side; const sideSign = side === 'left' ? -1 : side === 'right' ? 1 : 0; const sideLandmarks = side === 'left' ? landmarks.left : side === 'right' ? landmarks.right : null; const torsoPivot = landmarks.centerTorso; const shoulderPivot = sideLandmarks?.shoulder ?? torsoPivot; const elbowPivot = sideLandmarks?.elbow ?? shoulderPivot; const wristPivot = sideLandmarks?.wrist ?? elbowPivot; const hipPivot = sideLandmarks?.hip ?? landmarks.centerPelvis; const kneePivot = sideLandmarks?.knee ?? hipPivot; const anklePivot = sideLandmarks?.ankle ?? kneePivot; const layers: HumanoidPoseLayer[] = []; const pushLayer = ( kind: HumanoidRagdollLayerKind, pivot: THREE.Vector3, dx: number, dy: number, dz: number ) => { const clamped = clampHumanoidRagdollLayer(kind, dx, dy, dz); if (!hasHumanoidPoseDelta(clamped.dx, clamped.dy, clamped.dz)) return; layers.push({ pivot, deltaX: clamped.dx, deltaY: clamped.dy, deltaZ: clamped.dz }); }; switch (group.role) { case 'torso': pushLayer('torso', torsoPivot, -0.01, 0, 0); break; case 'shoulder': case 'upperArm': pushLayer('shoulder', shoulderPivot, -0.2, -sideSign * 0.44, -sideSign * 0.28); break; case 'forearm': pushLayer('shoulder', shoulderPivot, -0.18, -sideSign * 0.38, -sideSign * 0.24); pushLayer('elbow', elbowPivot, 0.46, -sideSign * 0.2, -sideSign * 0.14); break; case 'hand': pushLayer('shoulder', shoulderPivot, -0.16, -sideSign * 0.34, -sideSign * 0.22); pushLayer('elbow', elbowPivot, 0.5, -sideSign * 0.2, -sideSign * 0.14); pushLayer('wrist', wristPivot, 0.18, -sideSign * 0.06, -sideSign * 0.06); break; case 'hip': case 'upperLeg': pushLayer('hip', hipPivot, 0.02, 0, 0); break; case 'lowerLeg': pushLayer('hip', hipPivot, 0.02, 0, 0); pushLayer('knee', kneePivot, 0.04, 0, 0); break; case 'foot': pushLayer('hip', hipPivot, 0.02, 0, 0); pushLayer('knee', kneePivot, 0.04, 0, 0); pushLayer('ankle', anklePivot, -0.06, 0, 0); break; default: break; } if (layers.length <= 0) continue; for (const node of group.nodes) { applyHumanoidNodeLayeredPose(node, layers); } } }; const resetMotionRigPose = () => { if (!activeMotionRig || !activeObject) return; const uniqueNodes = new Set(); const append = (node: MotionRigNode) => { if (uniqueNodes.has(node.node)) return; uniqueNodes.add(node.node); node.node.position.copy(node.restPosition); node.node.rotation.copy(node.restRotation); node.node.scale.copy(node.restScale); const desiredVisible = node.node.userData?.__partVisibility; node.node.visible = typeof desiredVisible === 'boolean' ? desiredVisible : true; node.trackLastPitch = node.restRotation.x; }; activeMotionRig.turretNodes.forEach(append); activeMotionRig.trackNodes.forEach(append); activeMotionRig.humanoidNodes.forEach(append); applyHumanoidNeutralStance(activeMotionRig); activeObject.position.copy(activeMotionRig.restRootPosition); activeObject.rotation.copy(activeMotionRig.restRootRotation); if (activeMotionRig.trackSyntheticLinks) { activeMotionRig.trackSyntheticLinks.left.visible = false; activeMotionRig.trackSyntheticLinks.right.visible = false; } activeMotionRig.muzzleFlash.visible = false; activeMotionRig.machineGunFlash.visible = false; activeMotionRig.abilityRing.visible = false; activeMotionRig.defensivePulse.visible = false; activeMotionRig.targetMarker.visible = false; activeMotionRig.targetLine.visible = false; activeMotionRig.mainGunTracer.visible = false; activeMotionRig.machineGunTracer.visible = false; resetMotionParticleEmitter(activeMotionRig.mainGunSmokeEmitter); resetMotionParticleEmitter(activeMotionRig.machineGunEmitter); resetMotionParticleEmitter(activeMotionRig.defensiveEmitter); activeMotionRig.prevMainGunPulse = 0; activeMotionRig.prevMachineGunPulse = 0; activeMotionRig.defensiveBurstTimer = 0; activeMotionRig.trackedTurretYaw = 0; activeMotionRig.trackedGunPitch = 0; activeMotionRig.lastTargetLocal.set(0, 0.6, 1.5); }; const clearMotionRig = () => { clearTrackDebugOverlay(); if (!activeMotionRig) return; if (activeObject) { activeObject.remove(activeMotionRig.fxGroup); } scene.remove(activeMotionRig.defensiveWorldGroup); disposeObject3DTree(activeMotionRig.defensiveWorldGroup); disposeObject3DTree(activeMotionRig.fxGroup); activeMotionRig = null; }; const restoreControlMode = () => { controls.enabled = controlsWereEnabled; controls.autoRotate = autoRotateToggle.checked; }; const syncMotionTerrain = () => { const shouldRenderTerrain = Boolean( simEnableToggle.checked && simTerrainToggle.checked && activeUnitType && activeUnitBounds && activeObject ); if (!shouldRenderTerrain) { clearMotionTerrain(); return; } if (!activeMotionTerrain && activeUnitBounds) { activeMotionTerrain = buildMotionTerrain(activeUnitBounds); scene.add(activeMotionTerrain); } }; const clearTrackDebugOverlay = () => { if (!activeTrackDebugGroup) return; if (activeObject) { activeObject.remove(activeTrackDebugGroup); } disposeObject3DTree(activeTrackDebugGroup); activeTrackDebugGroup = null; }; const createTrackDebugOverlay = (rig: MotionRig): THREE.Group => { const group = new THREE.Group(); group.name = 'motion-track-debug'; group.renderOrder = 1000; const setOverlayMaterialDepth = (material: THREE.Material | THREE.Material[]) => { if (Array.isArray(material)) { for (const item of material) { item.depthTest = false; item.depthWrite = false; } return; } material.depthTest = false; material.depthWrite = false; }; const loopPoints = rig.trackLoopCurve.getSpacedPoints(96); const makeLoopPoints = (x: number) => loopPoints.map((point) => new THREE.Vector3(x, point.y, point.z)); const leftLine = new THREE.LineLoop( new THREE.BufferGeometry().setFromPoints(makeLoopPoints(rig.trackLeftX)), new THREE.LineBasicMaterial({ color: '#6ee7ff', transparent: true, opacity: 0.95, depthTest: false, depthWrite: false }) ); const rightLine = new THREE.LineLoop( new THREE.BufferGeometry().setFromPoints(makeLoopPoints(rig.trackRightX)), new THREE.LineBasicMaterial({ color: '#ffb56e', transparent: true, opacity: 0.95, depthTest: false, depthWrite: false }) ); leftLine.renderOrder = 1000; rightLine.renderOrder = 1000; group.add(leftLine); group.add(rightLine); const probeRadius = Math.max(0.03, rig.trackWrapRadius * 0.1); const probeGeometry = new THREE.SphereGeometry(probeRadius, 8, 8); const leftProbe = new THREE.Mesh( probeGeometry, new THREE.MeshBasicMaterial({ color: '#6ee7ff', transparent: true, opacity: 0.95, depthTest: false, depthWrite: false }) ); const rightProbe = new THREE.Mesh( probeGeometry, new THREE.MeshBasicMaterial({ color: '#ffb56e', transparent: true, opacity: 0.95, depthTest: false, depthWrite: false }) ); leftProbe.renderOrder = 1001; rightProbe.renderOrder = 1001; group.add(leftProbe); group.add(rightProbe); const tangentLen = Math.max(0.24, rig.trackWrapRadius * 0.92); const normalLen = Math.max(0.18, rig.trackWrapRadius * 0.68); const leftTangent = new THREE.ArrowHelper(new THREE.Vector3(0, 0, 1), new THREE.Vector3(), tangentLen, 0x6ee7ff, 0.11, 0.07); const rightTangent = new THREE.ArrowHelper(new THREE.Vector3(0, 0, 1), new THREE.Vector3(), tangentLen, 0xffb56e, 0.11, 0.07); const leftNormal = new THREE.ArrowHelper(new THREE.Vector3(0, 1, 0), new THREE.Vector3(), normalLen, 0xff67c6, 0.08, 0.05); const rightNormal = new THREE.ArrowHelper(new THREE.Vector3(0, 1, 0), new THREE.Vector3(), normalLen, 0xff67c6, 0.08, 0.05); for (const arrow of [leftTangent, rightTangent, leftNormal, rightNormal]) { setOverlayMaterialDepth(arrow.line.material); setOverlayMaterialDepth(arrow.cone.material); arrow.renderOrder = 1002; } group.add(leftTangent); group.add(rightTangent); group.add(leftNormal); group.add(rightNormal); group.userData.trackDebugRefs = { leftLine, rightLine, leftProbe, rightProbe, leftTangent, rightTangent, leftNormal, rightNormal } as TrackDebugOverlayRefs; return group; }; const syncTrackDebugOverlay = () => { const shouldRender = Boolean( simEnableToggle.checked && simTrackDebugToggle.checked && activeObject && activeMotionRig && isTrackedUnitType(activeUnitType) ); if (!shouldRender) { clearTrackDebugOverlay(); if (!simTrackDebugToggle.checked) { setTrackDebugInfo('Track debug disabled.'); } else if (!activeMotionRig || !isTrackedUnitType(activeUnitType)) { setTrackDebugInfo('Track debug: select a tracked unit and enable Motion Lab.'); } return; } if (!activeTrackDebugGroup && activeMotionRig && activeObject) { activeTrackDebugGroup = createTrackDebugOverlay(activeMotionRig); activeObject.add(activeTrackDebugGroup); } }; const updateTrackDebugOverlayFrame = ( rig: MotionRig, leftSample: TrackLoopSample, rightSample: TrackLoopSample ) => { if (!activeTrackDebugGroup) return; const refs = activeTrackDebugGroup.userData.trackDebugRefs as TrackDebugOverlayRefs | undefined; if (!refs) return; const apply = ( sample: TrackLoopSample, x: number, probe: THREE.Mesh, tangentArrow: THREE.ArrowHelper, normalArrow: THREE.ArrowHelper ) => { probe.position.set(x, sample.y, sample.z); tangentArrow.position.copy(probe.position); tangentArrow.setDirection(new THREE.Vector3(0, sample.tangentY, sample.tangentZ).normalize()); normalArrow.position.copy(probe.position); normalArrow.setDirection(new THREE.Vector3(0, sample.normalY, sample.normalZ).normalize()); }; apply(leftSample, rig.trackLeftX, refs.leftProbe, refs.leftTangent, refs.leftNormal); apply(rightSample, rig.trackRightX, refs.rightProbe, refs.rightTangent, refs.rightNormal); }; type TrackLoopSegment = 'top' | 'front' | 'bottom' | 'rear'; type TrackLoopSample = { y: number; z: number; pitch: number; segment: TrackLoopSegment; tangentY: number; tangentZ: number; normalY: number; normalZ: number; }; type TrackDebugOverlayRefs = { leftLine: THREE.LineLoop; rightLine: THREE.LineLoop; leftProbe: THREE.Mesh; rightProbe: THREE.Mesh; leftTangent: THREE.ArrowHelper; rightTangent: THREE.ArrowHelper; leftNormal: THREE.ArrowHelper; rightNormal: THREE.ArrowHelper; }; const normalizeSignedAngle = (angle: number) => ( THREE.MathUtils.euclideanModulo(angle + Math.PI, Math.PI * 2) - Math.PI ); const estimateTrackLinkDimensions = ( bounds: UnitBounds, nodes: MotionRigNode[], fallbackLength: number ) => { if (nodes.length === 0) { return { width: THREE.MathUtils.clamp(bounds.radius * 0.42, 0.22, 0.88), height: THREE.MathUtils.clamp(bounds.height * 0.08, 0.05, 0.2), length: THREE.MathUtils.clamp(fallbackLength * 0.9, 0.14, 0.44) }; } const sortedX = nodes.map((item) => item.info.size.x).sort((a, b) => a - b); const sortedY = nodes.map((item) => item.info.size.y).sort((a, b) => a - b); const median = (values: number[]) => values[Math.floor(values.length * 0.5)] ?? values[values.length - 1] ?? 0; const width = THREE.MathUtils.clamp(median(sortedX), 0.22, 0.92); const height = THREE.MathUtils.clamp(median(sortedY), 0.05, 0.2); const length = THREE.MathUtils.clamp(fallbackLength * 0.9, 0.14, 0.46); return { width, height, length }; }; const buildSyntheticTrackLinks = ( bounds: UnitBounds, rig: Pick, trackNodes: MotionRigNode[] ) => { if (trackNodes.length < 6) { return null; } const linkCountPerSide = THREE.MathUtils.clamp( Math.round(rig.trackLoopLength / Math.max(0.16, bounds.radius * 0.14)), 28, 120 ); const linkStride = rig.trackLoopLength / linkCountPerSide; const dims = estimateTrackLinkDimensions(bounds, trackNodes, linkStride); const linkGeometry = new THREE.BoxGeometry(dims.width, dims.height, dims.length); const firstTrackMesh = trackNodes[0]?.node as THREE.Mesh | undefined; const sourceMaterial = firstTrackMesh?.material; const baseMaterial = Array.isArray(sourceMaterial) ? sourceMaterial[0] : sourceMaterial; const linkMaterial = (baseMaterial?.clone?.() as THREE.Material | undefined) ?? new THREE.MeshStandardMaterial({ color: '#1d2328', roughness: 0.82, metalness: 0.18 }); const left = new THREE.InstancedMesh(linkGeometry, linkMaterial, linkCountPerSide); const right = new THREE.InstancedMesh(linkGeometry.clone(), linkMaterial.clone(), linkCountPerSide); left.name = 'track-links-left'; right.name = 'track-links-right'; left.castShadow = true; left.receiveShadow = true; right.castShadow = true; right.receiveShadow = true; left.visible = false; right.visible = false; left.instanceMatrix.setUsage(THREE.DynamicDrawUsage); right.instanceMatrix.setUsage(THREE.DynamicDrawUsage); left.userData.__diagnosticHelper = true; right.userData.__diagnosticHelper = true; return { left, right, linkCountPerSide }; }; const buildTrackLoopCurve = (rig: Pick< MotionRig, 'trackTopY' | 'trackBottomY' | 'trackWrapCenterY' | 'trackWrapRadius' | 'trackFrontWrapCenterZ' | 'trackRearWrapCenterZ' >): THREE.CatmullRomCurve3 => { const radius = Math.max(rig.trackWrapRadius, 0.08); const frontZ = rig.trackFrontWrapCenterZ; const rearZ = rig.trackRearWrapCenterZ; const topY = rig.trackTopY; const bottomY = rig.trackBottomY; const centerY = rig.trackWrapCenterY; const straightLen = Math.max(0.3, frontZ - rearZ); const topQuarterA = rearZ + straightLen * 0.32; const topQuarterB = rearZ + straightLen * 0.68; const bottomQuarterA = rearZ + straightLen * 0.28; const bottomQuarterB = rearZ + straightLen * 0.72; const points = [ new THREE.Vector3(0, topY, rearZ), new THREE.Vector3(0, topY + radius * 0.03, topQuarterA), new THREE.Vector3(0, topY + radius * 0.03, topQuarterB), new THREE.Vector3(0, topY, frontZ), new THREE.Vector3(0, centerY + radius * 0.58, frontZ + radius * 0.9), new THREE.Vector3(0, centerY, frontZ + radius * 1.02), new THREE.Vector3(0, centerY - radius * 0.58, frontZ + radius * 0.9), new THREE.Vector3(0, bottomY, frontZ), new THREE.Vector3(0, bottomY - radius * 0.03, bottomQuarterB), new THREE.Vector3(0, bottomY - radius * 0.03, bottomQuarterA), new THREE.Vector3(0, bottomY, rearZ), new THREE.Vector3(0, centerY - radius * 0.58, rearZ - radius * 0.9), new THREE.Vector3(0, centerY, rearZ - radius * 1.02), new THREE.Vector3(0, centerY + radius * 0.58, rearZ - radius * 0.9) ]; const curve = new THREE.CatmullRomCurve3(points, true, 'catmullrom', 0.24); curve.arcLengthDivisions = 720; return curve; }; const sampleTrackLoop = (rig: MotionRig, rawDistance: number): TrackLoopSample => { const loopLen = Math.max(1e-4, rig.trackLoopLength); const distance = THREE.MathUtils.euclideanModulo(rawDistance, loopLen); const t = rig.trackLoopCurve.getUtoTmapping(0, distance); const point = rig.trackLoopCurve.getPoint(t); const tangent = rig.trackLoopCurve.getTangent(t).normalize(); const tangentMag = Math.max(1e-5, Math.hypot(tangent.y, tangent.z)); const tangentY = tangent.y / tangentMag; const tangentZ = tangent.z / tangentMag; const normalY = tangentZ; const normalZ = -tangentY; const pitch = Math.atan2(tangentY, Math.abs(tangentZ)); const isRun = Math.abs(tangentZ) >= Math.abs(tangentY) * 1.2; const midpointZ = (rig.trackFrontWrapCenterZ + rig.trackRearWrapCenterZ) * 0.5; const segment: TrackLoopSegment = isRun ? point.y >= rig.trackWrapCenterY ? 'top' : 'bottom' : point.z >= midpointZ ? 'front' : 'rear'; return { y: point.y, z: point.z, pitch, segment, tangentY, tangentZ, normalY, normalZ }; }; const projectTrackNodeToLoop = ( rig: MotionRig, node: MotionRigNode ): { loopS: number; pitchOffset: number; normalOffset: number } => { const loopLen = Math.max(1e-4, rig.trackLoopLength); const coarseSteps = 192; let bestS = 0; let bestSample = sampleTrackLoop(rig, 0); let bestDistSq = Number.POSITIVE_INFINITY; const evaluate = (s: number) => { const sample = sampleTrackLoop(rig, s); const dy = sample.y - node.restCenter.y; const dz = sample.z - node.restCenter.z; const distSq = dy * dy + dz * dz; if (distSq < bestDistSq) { bestDistSq = distSq; bestS = THREE.MathUtils.euclideanModulo(s, loopLen); bestSample = sample; } }; for (let i = 0; i < coarseSteps; i++) { evaluate((loopLen * i) / coarseSteps); } let step = loopLen / coarseSteps; for (let i = 0; i < 7; i++) { evaluate(bestS - step); evaluate(bestS + step); step *= 0.5; } return { loopS: bestS, pitchOffset: normalizeSignedAngle(node.restRotation.x - bestSample.pitch), normalOffset: (node.restCenter.y - bestSample.y) * bestSample.normalY + (node.restCenter.z - bestSample.z) * bestSample.normalZ }; }; const createMotionRig = ( root: THREE.Object3D, unitType: UnitType, bounds: UnitBounds ): MotionRig => { const sockets = getUnitAttachmentSockets(unitType); const turretRingSocket = sockets.find((socket) => socket.id === 'Turret_Ring'); const muzzleSocket = sockets.find((socket) => socket.id === 'FX_Muzzle'); const smokeLeftSocket = sockets.find((socket) => socket.id === 'FX_Smoke_L'); const smokeRightSocket = sockets.find((socket) => socket.id === 'FX_Smoke_R'); const turretPivot = turretRingSocket ? toSocketPosition(bounds, turretRingSocket) : new THREE.Vector3(0, bounds.height * 0.52, -bounds.radius * 0.16); const muzzleLocal = muzzleSocket ? toSocketPosition(bounds, muzzleSocket) : null; const smokeLeftLocal = smokeLeftSocket ? toSocketPosition(bounds, smokeLeftSocket) : null; const smokeRightLocal = smokeRightSocket ? toSocketPosition(bounds, smokeRightSocket) : null; const turretNodes: MotionRigNode[] = []; const gunNodes: MotionRigNode[] = []; const machineGunCandidates: MotionRigNode[] = []; const trackCandidates: MotionRigNode[] = []; const humanoidNodes: MotionRigNode[] = []; const humanoidUnit = isHumanoidPreviewUnitType(unitType); root.traverse((child) => { const mesh = child as THREE.Mesh; if (!mesh.isMesh || mesh.userData?.__diagnosticHelper) { return; } const info = mesh.userData?.meshDiagnostic as MeshDiagnosticInfo | undefined; if (!info) { return; } const node: MotionRigNode = { node: mesh, restPosition: mesh.position.clone(), restRotation: mesh.rotation.clone(), restScale: mesh.scale.clone(), restCenter: new THREE.Vector3( info.center.x + mesh.position.x, info.center.y + mesh.position.y, info.center.z + mesh.position.z ), info }; if (humanoidUnit && info.part !== 'weapons') { const oversizedApproximate = isHumanoidOversizedApproximateMesh(info, bounds); const decorativeAttachment = isHumanoidDecorativeAttachment(info); const strictCritical = Boolean(info.inferredSilhouetteCritical); const hardLock = resolveHumanoidHardRoleLockFromInfo(info); const resolvedRole = oversizedApproximate ? null : resolveHumanoidMotionRole(info); const sanitizedRole = resolvedRole ? sanitizeHumanoidRoleForApproximate(resolvedRole, node, info, bounds) : null; const strictRole = strictCritical ? (resolveStrictCriticalHumanoidRole(info) ?? 'torso') : null; const hardLockedRoleSeed = strictCritical ? null : (hardLock?.role ?? null); const preferredRoleSeed = strictRole ?? sanitizedRole ?? hardLockedRoleSeed; const unresolvedApproximate = !strictCritical && Boolean(info.inferredApproximate) && resolvedRole == null; const forceApproximateTorso = !strictCritical && ( unresolvedApproximate || (Boolean(info.inferredApproximate) && hasHumanoidCoreTorsoHint(info)) || (Boolean(info.inferredApproximate) && sanitizedRole != null && isBilateralHumanoidRole(sanitizedRole) && !hasHumanoidSideTag(info)) ); const fallback = preferredRoleSeed == null && !forceApproximateTorso && !strictCritical ? resolveHumanoidFallbackRole(node, bounds) : null; const coercedApproximateToTorso = !strictCritical && Boolean(info.inferredApproximate) && ( forceApproximateTorso || ( resolvedRole != null && resolvedRole !== 'torso' && sanitizedRole === 'torso' ) ); const finalRole = strictRole ?? ( oversizedApproximate || decorativeAttachment || forceApproximateTorso ? 'torso' : (preferredRoleSeed ?? fallback?.role ?? 'torso') ); if (finalRole) { const chainAwareRole = refineHumanoidUpperBodyRoleByLateralDepth(finalRole, node, bounds); const lockedRole = hardLock?.role ?? chainAwareRole; const inferredSide = resolveHumanoidMotionSide(info, bounds); const restSide = node.restCenter.x < -Math.max(0.015, bounds.radius * 0.06) ? 'left' : node.restCenter.x > Math.max(0.015, bounds.radius * 0.06) ? 'right' : 'center'; const fallbackSide = fallback?.side ?? 'center'; node.humanoidRole = lockedRole; if (strictCritical) { node.humanoidSide = hardLock?.side ?? (inferredSide !== 'center' ? inferredSide : ( node.restCenter.x < -Math.max(0.015, bounds.radius * 0.06) ? 'left' : node.restCenter.x > Math.max(0.015, bounds.radius * 0.06) ? 'right' : 'center' )); } else if (oversizedApproximate || decorativeAttachment || coercedApproximateToTorso || forceApproximateTorso) { node.humanoidSide = hardLock?.side ?? 'center'; } else if (inferredSide !== 'center') { node.humanoidSide = hardLock?.side ?? inferredSide; } else if (isBilateralHumanoidRole(lockedRole)) { node.humanoidSide = hardLock?.side ?? (fallbackSide !== 'center' ? fallbackSide : restSide); } else { node.humanoidSide = 'center'; } if ( info.inferredApproximate && Math.abs(node.restCenter.x) <= bounds.radius * 0.14 && isBilateralHumanoidRole(finalRole) ) { node.humanoidSide = 'center'; } const baseWeight = strictCritical ? Math.max(resolveHumanoidMotionWeight(lockedRole), 0.72) : (fallback?.weight ?? resolveHumanoidMotionWeight(lockedRole)); const approximateDamp = decorativeAttachment ? 0.48 : (coercedApproximateToTorso || forceApproximateTorso) ? 0.56 : 0.62; let dampedWeight = info.inferredApproximate ? baseWeight * approximateDamp : baseWeight; if (Boolean(info.inferredApproximate) && lockedRole === 'torso') { dampedWeight = Math.max(dampedWeight, 0.28); } node.humanoidWeight = oversizedApproximate ? Math.min(dampedWeight, 0.42) : dampedWeight; const uncertainFallback = Boolean(fallback) || oversizedApproximate || coercedApproximateToTorso || forceApproximateTorso; const uncertainComposite = oversizedApproximate || coercedApproximateToTorso || forceApproximateTorso; node.humanoidFallback = strictCritical ? false : (decorativeAttachment ? false : uncertainFallback); node.humanoidComposite = strictCritical ? false : (decorativeAttachment ? false : uncertainComposite); node.humanoidDecorative = decorativeAttachment; node.humanoidApproximateTorsoCoerce = coercedApproximateToTorso || forceApproximateTorso; humanoidNodes.push(node); } } if (info.part === 'weapons') { turretNodes.push(node); if (info.center.z >= turretPivot.z + bounds.radius * 0.8) { gunNodes.push(node); } const size = info.size; const likelyMachineGun = info.center.z >= turretPivot.z + bounds.radius * 0.92 && info.center.z <= turretPivot.z + bounds.radius * 2.35 && Math.abs(info.center.x) >= bounds.radius * 0.12 && size.z >= bounds.radius * 0.18 && size.z <= bounds.radius * 1.3 && size.x <= bounds.radius * 0.46 && size.y <= bounds.height * 0.2; if (likelyMachineGun) { machineGunCandidates.push(node); } } if (info.channel === 'rubberTrack') { trackCandidates.push(node); } }); const scoreTrackCandidate = (item: MotionRigNode) => { const size = item.info.size; const volume = size.x * size.y * size.z; const volumeScale = Math.max(bounds.radius * bounds.height * bounds.radius, 1e-4); const volumeNorm = volume / volumeScale; const longThinRail = size.z > bounds.radius * 2.2 && size.y < bounds.height * 0.14 && size.x < bounds.radius * 0.44; const highMountedSkirtStrip = item.restCenter.y > bounds.height * 0.58 && size.y < bounds.height * 0.18 && size.z > bounds.radius * 0.9; const heavyBlock = size.x > bounds.radius * 0.66 || size.y > bounds.height * 0.34; const centerline = Math.abs(item.restCenter.x) < bounds.radius * 0.26; let score = 1.35; score += size.y < bounds.height * 0.24 ? 0.78 : -0.52; score += size.x < bounds.radius * 0.48 ? 0.56 : -0.34; score += size.z < bounds.radius * 1.45 ? 0.72 : size.z < bounds.radius * 2.1 ? 0.24 : -0.74; score += volumeNorm < 0.15 ? 0.46 : volumeNorm < 0.3 ? 0.1 : -0.68; if (centerline) score -= 0.6; if (longThinRail) score -= 2.0; if (highMountedSkirtStrip) score -= 1.7; if (heavyBlock) score -= 1.2; return score; }; const selectTrackLinksForSide = (nodes: MotionRigNode[]) => { if (nodes.length <= 8) return nodes; const scored = nodes .map((node) => ({ node, score: scoreTrackCandidate(node) })) .sort((a, b) => b.score - a.score); const minKeep = Math.max(6, Math.floor(nodes.length * 0.34)); const targetKeep = Math.max(minKeep, Math.min(nodes.length, Math.round(nodes.length * 0.72))); const selected = scored.filter((entry) => entry.score >= 0.15).slice(0, targetKeep); if (selected.length >= minKeep) { return selected.map((entry) => entry.node); } return scored.slice(0, minKeep).map((entry) => entry.node); }; const leftTrackCandidates = trackCandidates.filter((item) => item.restCenter.x < -bounds.radius * 0.12); const rightTrackCandidates = trackCandidates.filter((item) => item.restCenter.x > bounds.radius * 0.12); const centerTrackCandidates = trackCandidates.filter((item) => Math.abs(item.restCenter.x) <= bounds.radius * 0.12); const leftTrackNodesSelected = selectTrackLinksForSide(leftTrackCandidates); const rightTrackNodesSelected = selectTrackLinksForSide(rightTrackCandidates); const centerTrackNodesSelected = centerTrackCandidates.filter((item) => { const size = item.info.size; return size.z < bounds.radius * 0.95 && size.y < bounds.height * 0.22; }); const selectedTrackSet = new Set([ ...leftTrackNodesSelected, ...rightTrackNodesSelected, ...centerTrackNodesSelected ]); let trackNodes = Array.from(selectedTrackSet); if (trackNodes.length < 8 && trackCandidates.length > 0) { trackNodes = trackCandidates .map((node) => ({ node, score: scoreTrackCandidate(node) })) .sort((a, b) => b.score - a.score) .slice(0, Math.max(8, Math.round(trackCandidates.length * 0.58))) .map((entry) => entry.node); } const machineGunNodes = (machineGunCandidates.length > 0 ? machineGunCandidates : gunNodes) .map((node) => { const size = node.info.size; const compactness = (size.x + size.y) / Math.max(0.01, size.z); const sideBias = Math.abs(node.restCenter.x) / Math.max(bounds.radius, 1e-4); const forwardBias = (node.restCenter.z - turretPivot.z) / Math.max(bounds.radius, 1e-4); return { node, score: compactness * 0.45 + sideBias * 1.3 + forwardBias * 0.85 }; }) .sort((a, b) => b.score - a.score) .slice(0, 4) .map((entry) => entry.node); const machineGunMuzzleLocal = (() => { if (machineGunNodes.length > 0) { const frontMost = machineGunNodes.reduce((best, node) => { const bestFront = best.restCenter.z + best.info.size.z * 0.5; const nodeFront = node.restCenter.z + node.info.size.z * 0.5; return nodeFront > bestFront ? node : best; }, machineGunNodes[0]); return new THREE.Vector3( frontMost.restCenter.x, frontMost.restCenter.y, frontMost.restCenter.z + frontMost.info.size.z * 0.5 ); } return new THREE.Vector3(bounds.radius * 0.34, bounds.height * 0.72, (muzzleLocal?.z ?? bounds.radius * 2) - bounds.radius * 0.28); })(); const fxGroup = new THREE.Group(); fxGroup.name = 'motion-lab-fx'; const muzzleFlash = new THREE.Mesh( new THREE.SphereGeometry(Math.max(0.08, bounds.radius * 0.08), 10, 10), new THREE.MeshBasicMaterial({ color: '#ffd39a', transparent: true, opacity: 0 }) ); muzzleFlash.visible = false; muzzleFlash.position.copy(muzzleLocal ?? new THREE.Vector3(0, bounds.height * 0.64, bounds.radius * 2.1)); fxGroup.add(muzzleFlash); const machineGunFlash = new THREE.Mesh( new THREE.SphereGeometry(Math.max(0.04, bounds.radius * 0.045), 8, 8), new THREE.MeshBasicMaterial({ color: '#ffe9a0', transparent: true, opacity: 0 }) ); machineGunFlash.visible = false; machineGunFlash.position.copy(machineGunMuzzleLocal); fxGroup.add(machineGunFlash); const abilityRing = new THREE.Mesh( new THREE.TorusGeometry(Math.max(0.9, bounds.radius * 1.2), Math.max(0.04, bounds.radius * 0.07), 12, 36), new THREE.MeshBasicMaterial({ color: '#54d9ff', transparent: true, opacity: 0 }) ); abilityRing.rotation.x = Math.PI / 2; abilityRing.position.set(0, -bounds.height * 0.48, 0); abilityRing.visible = false; fxGroup.add(abilityRing); const defensivePulse = new THREE.Mesh( new THREE.SphereGeometry(Math.max(1.1, bounds.radius * 1.35), 18, 12), new THREE.MeshBasicMaterial({ color: '#7be3ff', transparent: true, opacity: 0.0 }) ); defensivePulse.visible = false; defensivePulse.material.side = THREE.BackSide; defensivePulse.position.set(0, bounds.height * 0.08, 0); fxGroup.add(defensivePulse); const targetMarker = new THREE.Mesh( new THREE.SphereGeometry(Math.max(0.08, bounds.radius * 0.1), 12, 10), new THREE.MeshBasicMaterial({ color: '#ff6f5e', transparent: true, opacity: 0.92 }) ); targetMarker.visible = false; fxGroup.add(targetMarker); const targetLine = createMotionLine('#ffc9c0', 0.55); fxGroup.add(targetLine); const mainGunTracer = createMotionLine('#ffdca6', 0.82); fxGroup.add(mainGunTracer); const machineGunTracer = createMotionLine('#ffe57a', 0.72); fxGroup.add(machineGunTracer); const defensiveWorldGroup = new THREE.Group(); defensiveWorldGroup.name = 'motion-lab-defensive-world'; scene.add(defensiveWorldGroup); const mainGunSmokeEmitter = createMotionParticleEmitter( fxGroup, 20, '#bfc8d1', 0.14, 0.88, Math.max(0.05, bounds.radius * 0.06) ); const machineGunEmitter = createMotionParticleEmitter( fxGroup, 28, '#ffd38f', -0.06, 1.2, Math.max(0.025, bounds.radius * 0.03) ); const defensiveEmitter = createMotionParticleEmitter( defensiveWorldGroup, 2400, '#cfd7dd', 0.012, 0.08, Math.max(0.42, bounds.radius * 0.56) ); root.add(fxGroup); const trackFrontZ = trackNodes.length > 0 ? trackNodes.reduce((max, item) => Math.max(max, item.restCenter.z), -Infinity) : bounds.radius * 2.7; const trackRearZ = trackNodes.length > 0 ? trackNodes.reduce((min, item) => Math.min(min, item.restCenter.z), Infinity) : -bounds.radius * 2.7; const trackTopY = trackNodes.length > 0 ? trackNodes.reduce((max, item) => Math.max(max, item.restCenter.y), -Infinity) : bounds.height * 0.36; const trackBottomY = trackNodes.length > 0 ? trackNodes.reduce((min, item) => Math.min(min, item.restCenter.y), Infinity) : bounds.height * 0.04; const trackWrapCenterY = (trackTopY + trackBottomY) * 0.5; const trackWrapRadius = Math.max(0.08, (trackTopY - trackBottomY) * 0.5); const trackFrontWrapCenterZ = trackFrontZ - trackWrapRadius; const trackRearWrapCenterZ = trackRearZ + trackWrapRadius; const leftTrackNodes = trackNodes.filter((item) => item.restCenter.x < 0); const rightTrackNodes = trackNodes.filter((item) => item.restCenter.x > 0); const trackLeftX = leftTrackNodes.length > 0 ? leftTrackNodes.reduce((sum, item) => sum + item.restCenter.x, 0) / leftTrackNodes.length : -bounds.radius * 1.08; const trackRightX = rightTrackNodes.length > 0 ? rightTrackNodes.reduce((sum, item) => sum + item.restCenter.x, 0) / rightTrackNodes.length : Math.abs(trackLeftX); const trackStraightLength = Math.max(0.3, trackFrontWrapCenterZ - trackRearWrapCenterZ); const trackLoopCurve = buildTrackLoopCurve({ trackTopY, trackBottomY, trackWrapCenterY, trackWrapRadius, trackFrontWrapCenterZ, trackRearWrapCenterZ }); const trackLoopLength = Math.max((trackStraightLength + Math.PI * trackWrapRadius) * 2, trackLoopCurve.getLength()); if (humanoidNodes.length > 0) { normalizeHumanoidNodeAssignments(humanoidNodes); } const armorSocketStats = humanoidNodes.length > 0 ? socketHumanoidArmorPlatingNodes(humanoidNodes, bounds) : { total: 0, socketed: 0 }; if (humanoidNodes.length > 0) { normalizeHumanoidNodeAssignments(humanoidNodes); } const humanoidGroups = humanoidNodes.length > 0 ? buildHumanoidMotionGroups(humanoidNodes, bounds) : []; const humanoidLandmarks = humanoidNodes.length > 0 ? buildHumanoidJointLandmarks(humanoidNodes, bounds) : null; const rig: MotionRig = { turretNodes, gunNodes, machineGunNodes, trackNodes, humanoidNodes, humanoidGroups, fxGroup, muzzleFlash, machineGunFlash, abilityRing, defensivePulse, targetMarker, targetLine, mainGunTracer, machineGunTracer, defensiveWorldGroup, mainGunSmokeEmitter, machineGunEmitter, defensiveEmitter, turretPivot, muzzleLocal, machineGunMuzzleLocal, smokeLeftLocal, smokeRightLocal, restRootPosition: root.position.clone(), restRootRotation: root.rotation.clone(), terrainBaseline: sampleMotionTerrainHeight(0, 0), trackFrontZ, trackRearZ, trackTopY, trackBottomY, trackWrapCenterY, trackWrapRadius, trackFrontWrapCenterZ, trackRearWrapCenterZ, trackLoopLength, trackLoopCurve, trackLeftX, trackRightX, trackCandidateCount: trackCandidates.length, trackRejectedCount: Math.max(0, trackCandidates.length - trackNodes.length), humanoidNodeCount: humanoidNodes.length, humanoidGroupCount: humanoidGroups.length, armorPlatingNodeCount: armorSocketStats.total, armorPlatingSocketedCount: armorSocketStats.socketed, humanoidLandmarks, trackSyntheticLinks: null, prevMainGunPulse: 0, prevMachineGunPulse: 0, defensiveBurstTimer: 0, trackedTurretYaw: 0, trackedGunPitch: 0, lastTargetLocal: new THREE.Vector3(0, bounds.height * 0.62, bounds.radius * 2.5) }; for (const item of rig.trackNodes) { const projection = projectTrackNodeToLoop(rig, item); item.trackLoopS = projection.loopS; item.trackLoopPitchOffset = projection.pitchOffset; item.trackLoopNormalOffset = projection.normalOffset; item.trackLastPitch = item.restRotation.x; } const syntheticLinks = buildSyntheticTrackLinks(bounds, rig, rig.trackNodes); if (syntheticLinks) { rig.trackSyntheticLinks = syntheticLinks; rig.fxGroup.add(syntheticLinks.left); rig.fxGroup.add(syntheticLinks.right); } return rig; }; const updateMotionUiAvailability = () => { const unitReady = Boolean(activeUnitType && activeUnitBounds && activeObject); simEnableToggle.disabled = !unitReady; simLockCameraToggle.disabled = !unitReady; simTerrainToggle.disabled = !unitReady; simTrackDebugToggle.disabled = !unitReady; simStateSelect.disabled = !unitReady; simSpeedRange.disabled = !unitReady; simPrevStateButton.disabled = !unitReady; simNextStateButton.disabled = !unitReady; simTriggerButton.disabled = !unitReady; if (!unitReady) { simEnableToggle.checked = false; simTrackDebugToggle.checked = false; setMotionStatus('Select a unit preview to run Motion Lab.'); setTrackDebugInfo('Track debug: select a tracked unit.'); } else if (!simEnableToggle.checked) { setMotionStatus('Motion lab disabled. Enable to preview states.'); if (!simTrackDebugToggle.checked) { setTrackDebugInfo('Track debug disabled.'); } } }; const initializeMotionLabForActiveModel = () => { clearMotionTerrain(); clearMotionRig(); motionTime = 0; motionStateTime = 0; motionDriveDistance = 0; motionDriveDirection = 1; motionTrackPhase = 0; motionAbilityConcealRemaining = 0; lastMotionState = getSelectedMotionState(); restoreControlMode(); if (!activeObject || !activeUnitType || !activeUnitBounds) { updateMotionUiAvailability(); return; } activeMotionRig = createMotionRig(activeObject, activeUnitType, activeUnitBounds); motionPrevRootPosition.copy(activeObject.position); const profile = getMotionProfile(activeUnitType); simSpeedRange.value = profile.uiSpeedDefault.toFixed(1); updateMotionUiAvailability(); syncMotionTerrain(); syncTrackDebugOverlay(); const motionReport = requestHumanoidMotionRigReport('auto'); if (motionReport) { setTrackDebugInfo( `Humanoid rig ${motionReport.solverVersion}: nodes ${motionReport.nodeCount}, ` + `groups ${motionReport.groupCount} (avg ${motionReport.avgGroupSize.toFixed(2)}), ` + `armor socketed ${motionReport.armorPlatingSocketedCount}/${motionReport.armorPlatingNodeCount}, ` + `stable ${motionReport.armorPlatingSocketStableCount}, exact ${motionReport.armorPlatingSocketExactCount}, ` + `outliers ${motionReport.outlierCount}, p95 ${motionReport.p95PivotDistanceNorm.toFixed(3)}, ` + `inconsistencies ${motionReport.inconsistencyCount}.` ); } }; const requestHumanoidMotionRigReport = ( source: 'auto' | 'manual' = 'manual' ): HumanoidMotionRigReport | null => { if (!activeMotionRig || !activeUnitType || !activeUnitBounds || !isHumanoidPreviewUnitType(activeUnitType)) { if (source === 'manual') { console.warn('[MeshLabViewer] Humanoid motion report unavailable: no active humanoid rig.'); } return null; } const report = collectHumanoidMotionRigReport(activeMotionRig, activeUnitType, activeUnitBounds); if (!report) { if (source === 'manual') { console.warn('[MeshLabViewer] Humanoid motion report unavailable: no humanoid nodes detected.'); } return null; } logHumanoidMotionRigReport(report, source); return report; }; const requestHumanoidJointRotationReport = ( source: 'auto' | 'manual' = 'manual' ): HumanoidJointRotationReport | null => { if (!activeMotionRig || !activeUnitType || !activeUnitBounds || !activeObject || !isHumanoidPreviewUnitType(activeUnitType)) { if (source === 'manual') { console.warn('[MeshLabViewer] Joint rotation report unavailable: no active humanoid rig.'); } return null; } const report = collectHumanoidJointRotationReport( activeMotionRig, activeUnitType, activeUnitBounds, activeObject ); if (!report) { if (source === 'manual') { console.warn('[MeshLabViewer] Joint rotation report unavailable: no humanoid joint layout detected.'); } return null; } logHumanoidJointRotationReport(report, source); return report; }; const cycleMotionState = (step: number) => { const current = getSelectedMotionState(); const currentIndex = MOTION_STATE_ORDER.indexOf(current); const nextIndex = (currentIndex + step + MOTION_STATE_ORDER.length) % MOTION_STATE_ORDER.length; simStateSelect.value = MOTION_STATE_ORDER[nextIndex]; motionStateTime = 0; }; const updateMotionLab = (deltaTime: number) => { if (!activeObject || !activeMotionRig || !activeUnitBounds || !activeUnitType) { if (!simEnableToggle.checked) { restoreControlMode(); } return; } if (!simEnableToggle.checked) { resetMotionRigPose(); motionPrevRootPosition.copy(activeObject.position); clearMotionTerrain(); clearTrackDebugOverlay(); if (simTrackDebugToggle.checked) { setTrackDebugInfo('Track debug waits for Motion Lab enable.'); } restoreControlMode(); return; } syncMotionTerrain(); syncTrackDebugOverlay(); const state = getSelectedMotionState(); if (state !== lastMotionState) { motionStateTime = 0; lastMotionState = state; } motionTime += deltaTime; motionStateTime += deltaTime; const speedScale = Number.isFinite(Number(simSpeedRange.value)) ? Math.max(0.2, Number(simSpeedRange.value)) : 1; const isMoving = state === 'move'; const isTrackState = state === 'track'; const isFireState = state === 'fire'; const isAbilitySelected = state === 'ability'; if (isAbilitySelected && motionAbilityConcealRemaining > 0) { motionAbilityConcealRemaining = Math.max(0, motionAbilityConcealRemaining - deltaTime); } const isAbilityState = isAbilitySelected && motionAbilityConcealRemaining > 0; const isTrackingState = isTrackState || isFireState; const tracked = isTrackedUnitType(activeUnitType); const motionProfile = getMotionProfile(activeUnitType); const attackCooldown = getAttackCooldownSeconds(activeUnitType); if (isMoving) { motionDriveDistance += deltaTime * (motionProfile.driveSpeed * speedScale) * motionDriveDirection; if (Math.abs(motionDriveDistance) > MOTION_PATH_HALF_LENGTH) { motionDriveDirection *= -1; motionDriveDistance = THREE.MathUtils.clamp(motionDriveDistance, -MOTION_PATH_HALF_LENGTH, MOTION_PATH_HALF_LENGTH); } } else { motionDriveDistance = THREE.MathUtils.lerp(motionDriveDistance, 0, 1 - Math.exp(-deltaTime * 3.6)); } const targetRootX = isMoving ? Math.sin(motionDriveDistance * motionProfile.drivePathCurve) * motionProfile.drivePathWave : 0; const targetRootZ = motionDriveDistance; let terrainDelta = 0; let pitch = 0; let roll = 0; if (simTerrainToggle.checked) { const wheelBase = activeUnitBounds.radius * 1.9; const trackHalf = activeUnitBounds.radius * 1.05; const centerH = sampleMotionTerrainHeight(targetRootX, targetRootZ); const frontH = sampleMotionTerrainHeight(targetRootX, targetRootZ + wheelBase); const rearH = sampleMotionTerrainHeight(targetRootX, targetRootZ - wheelBase); const leftH = sampleMotionTerrainHeight(targetRootX - trackHalf, targetRootZ); const rightH = sampleMotionTerrainHeight(targetRootX + trackHalf, targetRootZ); terrainDelta = centerH - activeMotionRig.terrainBaseline; pitch = Math.atan2(frontH - rearH, wheelBase * 2) * motionProfile.terrainPitchResponse; roll = Math.atan2(rightH - leftH, trackHalf * 2) * motionProfile.terrainRollResponse; } const bob = Math.sin(motionTime * (isMoving ? motionProfile.moveBobFrequency : motionProfile.idleBobFrequency)) * (isMoving ? motionProfile.moveBobAmplitude : motionProfile.idleBobAmplitude); const targetY = activeMotionRig.restRootPosition.y + terrainDelta + bob; const targetX = activeMotionRig.restRootPosition.x + targetRootX; const targetZ = activeMotionRig.restRootPosition.z + targetRootZ; activeObject.position.x = THREE.MathUtils.lerp(activeObject.position.x, targetX, 1 - Math.exp(-deltaTime * 6)); activeObject.position.y = THREE.MathUtils.lerp(activeObject.position.y, targetY, 1 - Math.exp(-deltaTime * 6)); activeObject.position.z = THREE.MathUtils.lerp(activeObject.position.z, targetZ, 1 - Math.exp(-deltaTime * 6)); const targetRotX = activeMotionRig.restRootRotation.x + pitch + (isFireState ? Math.sin(motionTime * motionProfile.fireHullKickFrequency) * motionProfile.fireHullKickAmplitude : 0); const targetRotZ = activeMotionRig.restRootRotation.z - roll; activeObject.rotation.x = THREE.MathUtils.lerp(activeObject.rotation.x, targetRotX, 1 - Math.exp(-deltaTime * 8)); activeObject.rotation.z = THREE.MathUtils.lerp(activeObject.rotation.z, targetRotZ, 1 - Math.exp(-deltaTime * 8)); activeObject.rotation.y = activeMotionRig.restRootRotation.y; const yAxis = new THREE.Vector3(0, 1, 0); const pivot = activeMotionRig.turretPivot; const showTargetTrackingVisual = isTrackingState; if (showTargetTrackingVisual) { const orbitRadius = activeUnitBounds.radius * (isTrackState ? 2.05 : 2.35); const frontBias = activeUnitBounds.radius * (isTrackState ? 2.6 : 2.85); motionAimTargetLocal.set( Math.sin(motionTime * 0.74) * orbitRadius, activeUnitBounds.height * (0.58 + Math.sin(motionTime * 1.03) * 0.15), frontBias + Math.cos(motionTime * 0.88) * activeUnitBounds.radius * 0.8 ); const follow = 1 - Math.exp(-deltaTime * 6.2); activeMotionRig.lastTargetLocal.lerp(motionAimTargetLocal, follow); activeMotionRig.targetMarker.visible = true; activeMotionRig.targetMarker.position.copy(activeMotionRig.lastTargetLocal); activeMotionRig.targetLine.visible = true; setMotionLineEndpoints(activeMotionRig.targetLine, pivot, activeMotionRig.lastTargetLocal); } else { activeMotionRig.targetMarker.visible = false; activeMotionRig.targetLine.visible = false; } let desiredTurretYaw = state === 'idle' ? Math.sin(motionTime * motionProfile.turretYawIdleFrequency) * motionProfile.turretYawIdleAmplitude : state === 'move' ? Math.sin(motionTime * motionProfile.turretYawMoveFrequency) * motionProfile.turretYawMoveAmplitude : state === 'ability' ? Math.sin(motionTime * motionProfile.turretYawAbilityFrequency) * motionProfile.turretYawAbilityAmplitude : Math.sin(motionTime * motionProfile.turretYawFireFrequency) * motionProfile.turretYawFireAmplitude; let desiredTrackGunPitch = 0; if (showTargetTrackingVisual) { motionAimDirectionLocal.copy(activeMotionRig.lastTargetLocal).sub(pivot); const horizontal = Math.max(0.15, Math.hypot(motionAimDirectionLocal.x, motionAimDirectionLocal.z)); desiredTurretYaw = Math.atan2(motionAimDirectionLocal.x, motionAimDirectionLocal.z); desiredTrackGunPitch = THREE.MathUtils.clamp( -Math.atan2(motionAimDirectionLocal.y, horizontal), -0.34, 0.18 ); } const yawFollow = 1 - Math.exp(-deltaTime * 9.5); activeMotionRig.trackedTurretYaw += normalizeSignedAngle(desiredTurretYaw - activeMotionRig.trackedTurretYaw) * yawFollow; const pitchFollow = 1 - Math.exp(-deltaTime * 8.4); activeMotionRig.trackedGunPitch = THREE.MathUtils.lerp(activeMotionRig.trackedGunPitch, desiredTrackGunPitch, pitchFollow); const turretYaw = activeMotionRig.trackedTurretYaw; const mainMuzzleBase = activeMotionRig.muzzleLocal ?? activeMotionRig.muzzleFlash.position; motionMainMuzzleLocal.copy(mainMuzzleBase).sub(pivot).applyAxisAngle(yAxis, turretYaw).add(pivot); motionMachineGunMuzzleLocal .copy(activeMotionRig.machineGunMuzzleLocal) .sub(pivot) .applyAxisAngle(yAxis, turretYaw) .add(pivot); activeMotionRig.muzzleFlash.position.copy(motionMainMuzzleLocal); activeMotionRig.machineGunFlash.position.copy(motionMachineGunMuzzleLocal); for (const item of activeMotionRig.turretNodes) { const offset = item.restPosition.clone().sub(pivot).applyAxisAngle(yAxis, turretYaw); item.node.position.copy(pivot).add(offset); item.node.rotation.copy(item.restRotation); item.node.rotation.y += turretYaw; } let recoil = 0; let gunPitch = 0; let muzzleFlashStrength = 0; let mainGunPulse = 0; if (isFireState || isTrackState) { const cycleCooldown = isTrackState ? attackCooldown * 1.3 : attackCooldown; const cycle = (motionStateTime % cycleCooldown) / cycleCooldown; const pulse = Math.exp(-Math.pow((cycle - motionProfile.firePulseCenter) * motionProfile.firePulseSharpness, 2)); mainGunPulse = pulse; recoil = pulse * motionProfile.recoilDistance * (isTrackState ? 0.66 : 1); gunPitch = -pulse * motionProfile.recoilPitch * (isTrackState ? 0.7 : 1); muzzleFlashStrength = pulse * (isTrackState ? 0.72 : 1); } else if (isAbilityState) { gunPitch = Math.sin(motionTime * motionProfile.abilityGunPitchFrequency) * motionProfile.abilityGunPitchAmplitude; } let machineGunPulse = 0; if (isTrackingState) { const mgCycle = (motionStateTime * (isTrackState ? 8.8 : 11.6)) % 1; machineGunPulse = Math.exp(-Math.pow((mgCycle - 0.23) * 18, 2)); } for (const item of activeMotionRig.gunNodes) { item.node.position.z -= recoil; item.node.rotation.x += gunPitch + activeMotionRig.trackedGunPitch; } for (const item of activeMotionRig.machineGunNodes) { item.node.position.z -= machineGunPulse * Math.max(0.02, activeUnitBounds.radius * 0.036); item.node.rotation.x += activeMotionRig.trackedGunPitch * 0.82 - machineGunPulse * 0.032; } if (activeMotionRig.humanoidNodeCount > 0) { const gaitBlend = isMoving ? 1 : (isTrackState ? 0.28 : 0); const gaitSpeed = THREE.MathUtils.clamp(speedScale, 0.35, 1.45); const gaitPhase = motionTime * motionProfile.gaitStrideFrequency * gaitSpeed; const torsoTwist = Math.sin(gaitPhase) * motionProfile.gaitTorsoTwist * gaitBlend; const torsoPitch = Math.sin(gaitPhase * 2) * motionProfile.gaitPelvisDrop * 0.9 * gaitBlend; const torsoRoll = Math.sin(gaitPhase * 2) * motionProfile.gaitPelvisShift * 1.35 * gaitBlend; const createHumanoidRoleWeightTable = (): Record => ({ torso: 0, shoulder: 0, upperArm: 0, forearm: 0, hand: 0, hip: 0, upperLeg: 0, lowerLeg: 0, foot: 0 }); const chainRoleWeights: Record> = { left: createHumanoidRoleWeightTable(), right: createHumanoidRoleWeightTable(), center: createHumanoidRoleWeightTable() }; for (const group of activeMotionRig.humanoidGroups) { const sideTable = chainRoleWeights[group.side]; sideTable[group.role] = Math.max(sideTable[group.role], group.weight); if (group.role === 'torso') { chainRoleWeights.center.torso = Math.max(chainRoleWeights.center.torso, group.weight); } } if (chainRoleWeights.center.torso <= 0) { chainRoleWeights.center.torso = resolveHumanoidMotionWeight('torso'); } const resolveChainRoleWeight = (role: HumanoidMotionRole, side: HumanoidMotionSide): number => { const direct = chainRoleWeights[side][role]; if (direct > 0) return direct; if (side !== 'center') { const center = chainRoleWeights.center[role]; if (center > 0) return center; } return resolveHumanoidMotionWeight(role); }; for (const group of activeMotionRig.humanoidGroups) { const role = group.role; const side = group.side; const groupRefNode = group.nodes[0]; if (!groupRefNode) continue; const sideSign = side === 'left' ? -1 : side === 'right' ? 1 : 0; const localPhase = sideSign === 0 ? gaitPhase : gaitPhase + (sideSign > 0 ? Math.PI : 0); const legSwing = Math.sin(localPhase); const kneeDrive = Math.max(0, Math.sin(localPhase + Math.PI * 0.55)); const heelStrike = Math.max(0, Math.sin(localPhase - Math.PI * 0.22)); const toeOff = Math.max(0, Math.sin(localPhase + Math.PI * 0.46)); const footPlant = Math.max(0, Math.cos(localPhase + Math.PI * 0.04)); const armPhase = localPhase + Math.PI; const armSwing = Math.sin(armPhase); const armSwingSecondary = Math.sin(armPhase + Math.PI * 0.5); const elbowFlex = Math.max(0, Math.sin(armPhase + Math.PI * 0.35)); const armPostureBlend = 0.42 + gaitBlend * 0.58; const localWeight = group.weight; const bilateralDamp = sideSign === 0 ? 0.45 : 1; const fallbackCoupling = group.composite ? 0.5 : (group.fallback ? 0.25 : 0); const fallbackGain = 1 + fallbackCoupling * 0.12; const torsoRoleWeight = resolveChainRoleWeight('torso', 'center'); const shoulderRoleWeight = sideSign === 0 ? torsoRoleWeight : Math.max( resolveChainRoleWeight('shoulder', side), resolveChainRoleWeight('upperArm', side), 0.16 ); const elbowRoleWeight = sideSign === 0 ? torsoRoleWeight : Math.max(resolveChainRoleWeight('forearm', side), shoulderRoleWeight * 0.78, 0.14); const wristRoleWeight = sideSign === 0 ? torsoRoleWeight : Math.max(resolveChainRoleWeight('hand', side), elbowRoleWeight * 0.72, 0.1); const hipRoleWeight = sideSign === 0 ? torsoRoleWeight : Math.max( resolveChainRoleWeight('hip', side), resolveChainRoleWeight('upperLeg', side), 0.18 ); const kneeRoleWeight = sideSign === 0 ? torsoRoleWeight : Math.max(resolveChainRoleWeight('lowerLeg', side), hipRoleWeight * 0.82, 0.14); const ankleRoleWeight = sideSign === 0 ? torsoRoleWeight : Math.max(resolveChainRoleWeight('foot', side), kneeRoleWeight * 0.72, 0.12); const torsoChainDx = torsoPitch * 0.34 * torsoRoleWeight * fallbackGain; const torsoChainDy = torsoTwist * 0.44 * torsoRoleWeight * fallbackGain; const torsoChainDz = torsoRoll * 0.28 * torsoRoleWeight * fallbackGain; const hipChainDx = legSwing * motionProfile.gaitLegSwing * gaitBlend * hipRoleWeight * bilateralDamp; const hipChainDy = 0; const hipChainDz = sideSign * motionProfile.gaitPelvisShift * 0.54 * gaitBlend * hipRoleWeight; const kneeChainDx = ( -legSwing * motionProfile.gaitLegSwing * 0.28 + kneeDrive * motionProfile.gaitKneeBend ) * gaitBlend * kneeRoleWeight * bilateralDamp; const kneeChainDy = 0; const kneeChainDz = 0; const ankleChainDx = ( -legSwing * motionProfile.gaitFootPitch * 0.34 + heelStrike * motionProfile.gaitFootPitch * 0.24 - toeOff * motionProfile.gaitFootPitch * 0.48 - kneeDrive * motionProfile.gaitKneeBend * 0.16 ) * gaitBlend * ankleRoleWeight * bilateralDamp * (1 - footPlant * 0.58); const ankleChainDy = -footPlant * motionProfile.gaitPelvisDrop * 0.08 * gaitBlend * ankleRoleWeight * bilateralDamp; const ankleChainDz = sideSign * legSwing * motionProfile.gaitPelvisShift * 0.18 * gaitBlend * ankleRoleWeight * bilateralDamp; const shoulderChainDx = armSwing * motionProfile.gaitArmSwing * 0.74 * gaitBlend * shoulderRoleWeight * bilateralDamp; const shoulderChainDy = 0; const shoulderChainDz = ( sideSign * -0.018 + sideSign * armSwingSecondary * 0.01 ) * gaitBlend * shoulderRoleWeight * bilateralDamp; const elbowChainDx = ( armSwing * motionProfile.gaitArmSwing * 0.24 + elbowFlex * motionProfile.gaitElbowBend * 0.68 ) * gaitBlend * elbowRoleWeight * bilateralDamp; const elbowChainDy = 0; const elbowChainDz = ( sideSign * -0.02 + sideSign * armSwingSecondary * 0.008 ) * gaitBlend * elbowRoleWeight * bilateralDamp; const wristChainDx = ( armSwing * motionProfile.gaitArmSwing * 0.1 + elbowFlex * motionProfile.gaitElbowBend * 0.18 ) * gaitBlend * wristRoleWeight * bilateralDamp; const wristChainDy = 0; const wristChainDz = ( sideSign * -0.008 + sideSign * armSwingSecondary * 0.005 ) * gaitBlend * wristRoleWeight * bilateralDamp; const shoulderBaseDx = -0.045 * armPostureBlend * localWeight * bilateralDamp; const shoulderBaseDz = -sideSign * 0.028 * armPostureBlend * localWeight * bilateralDamp; const elbowBaseDx = 0.09 * armPostureBlend * localWeight * bilateralDamp; const elbowBaseDz = -sideSign * 0.014 * armPostureBlend * localWeight * bilateralDamp; const wristBaseDx = 0.048 * armPostureBlend * localWeight * bilateralDamp; const shoulderAdductionDz = -sideSign * (0.1 + gaitBlend * 0.03) * shoulderRoleWeight * bilateralDamp; const elbowAdductionDz = -sideSign * (0.06 + gaitBlend * 0.02) * elbowRoleWeight * bilateralDamp; const wristAdductionDz = -sideSign * (0.03 + gaitBlend * 0.01) * wristRoleWeight * bilateralDamp; // Keep arms tucked toward the torso during locomotion; sign matches neutral stance. const shoulderAdductionDy = -sideSign * (0.14 + gaitBlend * 0.08) * shoulderRoleWeight * bilateralDamp; const elbowAdductionDy = -sideSign * (0.09 + gaitBlend * 0.05) * elbowRoleWeight * bilateralDamp; const wristAdductionDy = -sideSign * (0.04 + gaitBlend * 0.03) * wristRoleWeight * bilateralDamp; let torsoDx = 0; let torsoDy = 0; let torsoDz = 0; let shoulderDx = 0; let shoulderDy = 0; let shoulderDz = 0; let elbowDx = 0; let elbowDy = 0; let elbowDz = 0; let wristDx = 0; let wristDy = 0; let wristDz = 0; let hipDx = 0; let hipDy = 0; let hipDz = 0; let kneeDx = 0; let kneeDy = 0; let kneeDz = 0; let ankleDx = 0; let ankleDy = 0; let ankleDz = 0; switch (role) { case 'torso': { torsoDx += torsoChainDx * 1.1; torsoDy += torsoChainDy * 1.1; torsoDz += torsoChainDz * 1.1; break; } case 'hip': { torsoDx += torsoChainDx; torsoDy += torsoChainDy; torsoDz += torsoChainDz; hipDx += hipChainDx * 0.92; hipDy += hipChainDy; hipDz += hipChainDz * 1.2; break; } case 'upperLeg': { torsoDx += torsoChainDx; torsoDy += torsoChainDy; torsoDz += torsoChainDz; hipDx += hipChainDx; hipDy += hipChainDy; hipDz += hipChainDz; break; } case 'lowerLeg': { torsoDx += torsoChainDx; torsoDy += torsoChainDy; torsoDz += torsoChainDz; hipDx += hipChainDx; hipDy += hipChainDy; hipDz += hipChainDz; kneeDx += kneeChainDx; kneeDy += kneeChainDy; kneeDz += kneeChainDz; break; } case 'foot': { torsoDx += torsoChainDx; torsoDy += torsoChainDy; torsoDz += torsoChainDz; hipDx += hipChainDx; hipDy += hipChainDy; hipDz += hipChainDz; kneeDx += kneeChainDx; kneeDy += kneeChainDy; kneeDz += kneeChainDz; ankleDx += ankleChainDx; ankleDy += ankleChainDy; ankleDz += ankleChainDz; break; } case 'shoulder': { torsoDx += torsoChainDx; torsoDy += torsoChainDy; torsoDz += torsoChainDz; shoulderDx += shoulderBaseDx + shoulderChainDx * 0.9; shoulderDy += shoulderChainDy + shoulderAdductionDy; shoulderDz += shoulderBaseDz + shoulderChainDz * 0.98 + shoulderAdductionDz; break; } case 'upperArm': { torsoDx += torsoChainDx; torsoDy += torsoChainDy; torsoDz += torsoChainDz; shoulderDx += shoulderBaseDx + shoulderChainDx; shoulderDy += shoulderChainDy + shoulderAdductionDy; shoulderDz += shoulderBaseDz + shoulderChainDz * 0.86 + shoulderAdductionDz; break; } case 'forearm': { torsoDx += torsoChainDx; torsoDy += torsoChainDy; torsoDz += torsoChainDz; shoulderDx += shoulderBaseDx + shoulderChainDx; shoulderDy += shoulderChainDy + shoulderAdductionDy; shoulderDz += shoulderBaseDz + shoulderChainDz * 0.45 + shoulderAdductionDz; elbowDx += elbowBaseDx + elbowChainDx; elbowDy += elbowChainDy + elbowAdductionDy; elbowDz += elbowBaseDz + elbowChainDz + elbowAdductionDz; break; } case 'hand': { torsoDx += torsoChainDx; torsoDy += torsoChainDy; torsoDz += torsoChainDz; shoulderDx += shoulderBaseDx + shoulderChainDx; shoulderDy += shoulderChainDy + shoulderAdductionDy; shoulderDz += shoulderBaseDz + shoulderChainDz * 0.38 + shoulderAdductionDz; elbowDx += elbowBaseDx + elbowChainDx; elbowDy += elbowChainDy + elbowAdductionDy; elbowDz += elbowBaseDz + elbowChainDz * 0.8 + elbowAdductionDz; wristDx += wristBaseDx + wristChainDx; wristDy += wristChainDy + wristAdductionDy; wristDz += wristChainDz + wristAdductionDz; break; } default: break; } const landmarks = activeMotionRig.humanoidLandmarks; const torsoPivot = landmarks?.centerTorso ?? resolveHumanoidPivotForNode(activeMotionRig, groupRefNode); const sideLandmarks = side === 'right' ? landmarks?.right : side === 'left' ? landmarks?.left : null; const shoulderPivot = sideLandmarks?.shoulder ?? torsoPivot; const elbowPivot = sideLandmarks?.elbow ?? shoulderPivot; const wristPivot = sideLandmarks?.wrist ?? elbowPivot; const hipPivot = sideLandmarks?.hip ?? torsoPivot; const kneePivot = sideLandmarks?.knee ?? hipPivot; const anklePivot = sideLandmarks?.ankle ?? kneePivot; const layers: HumanoidPoseLayer[] = []; const pushLayer = ( kind: HumanoidRagdollLayerKind, pivot: THREE.Vector3, dx: number, dy: number, dz: number ) => { const clamped = clampHumanoidRagdollLayer(kind, dx, dy, dz); if (!hasHumanoidPoseDelta(clamped.dx, clamped.dy, clamped.dz)) return; layers.push({ pivot, deltaX: clamped.dx, deltaY: clamped.dy, deltaZ: clamped.dz }); }; switch (role) { case 'torso': pushLayer('torso', torsoPivot, torsoDx, torsoDy, torsoDz); break; case 'shoulder': case 'upperArm': pushLayer('torso', torsoPivot, torsoDx, torsoDy, torsoDz); pushLayer('shoulder', shoulderPivot, shoulderDx, shoulderDy, shoulderDz); break; case 'forearm': pushLayer('torso', torsoPivot, torsoDx, torsoDy, torsoDz); pushLayer('shoulder', shoulderPivot, shoulderDx, shoulderDy, shoulderDz); pushLayer('elbow', elbowPivot, elbowDx, elbowDy, elbowDz); break; case 'hand': pushLayer('torso', torsoPivot, torsoDx, torsoDy, torsoDz); pushLayer('shoulder', shoulderPivot, shoulderDx, shoulderDy, shoulderDz); pushLayer('elbow', elbowPivot, elbowDx, elbowDy, elbowDz); pushLayer('wrist', wristPivot, wristDx, wristDy, wristDz); break; case 'hip': case 'upperLeg': pushLayer('torso', torsoPivot, torsoDx, torsoDy, torsoDz); pushLayer('hip', hipPivot, hipDx, hipDy, hipDz); break; case 'lowerLeg': pushLayer('torso', torsoPivot, torsoDx, torsoDy, torsoDz); pushLayer('hip', hipPivot, hipDx, hipDy, hipDz); pushLayer('knee', kneePivot, kneeDx, kneeDy, kneeDz); break; case 'foot': pushLayer('torso', torsoPivot, torsoDx, torsoDy, torsoDz); pushLayer('hip', hipPivot, hipDx, hipDy, hipDz); pushLayer('knee', kneePivot, kneeDx, kneeDy, kneeDz); pushLayer('ankle', anklePivot, ankleDx, ankleDy, ankleDz); break; default: { const pivot = resolveHumanoidPivotForNode(activeMotionRig, groupRefNode); pushLayer('torso', pivot, torsoDx, torsoDy, torsoDz); break; } } for (const node of group.nodes) { applyHumanoidNodeLayeredPose(node, layers); } } } const rootDeltaX = activeObject.position.x - motionPrevRootPosition.x; const rootDeltaZ = activeObject.position.z - motionPrevRootPosition.z; motionPrevRootPosition.copy(activeObject.position); const rootTravelPlanar = Math.hypot(rootDeltaX, rootDeltaZ); const trackTravel = tracked ? rootTravelPlanar * motionDriveDirection : 0; motionTrackPhase += trackTravel; let bottomCompressionSum = 0; let bottomCompressionCount = 0; let bottomCompressionMax = 0; let leftDebugSample: TrackLoopSample | null = null; let rightDebugSample: TrackLoopSample | null = null; if (tracked) { const rootObject = activeObject; if (!rootObject) { return; } const rig = activeMotionRig; const bounds = activeUnitBounds; const terrainCenterHeight = simTerrainToggle.checked ? sampleMotionTerrainHeight(targetRootX, targetRootZ) : rig.terrainBaseline; const sideSampleX = bounds.radius * 1.06; const frontSampleZ = targetRootZ + rig.trackFrontWrapCenterZ; const rearSampleZ = targetRootZ + rig.trackRearWrapCenterZ; const terrainSideProfile = simTerrainToggle.checked ? { leftFront: (sampleMotionTerrainHeight(targetRootX - sideSampleX, frontSampleZ) - terrainCenterHeight) * motionProfile.trackTerrainCompression, leftRear: (sampleMotionTerrainHeight(targetRootX - sideSampleX, rearSampleZ) - terrainCenterHeight) * motionProfile.trackTerrainCompression, rightFront: (sampleMotionTerrainHeight(targetRootX + sideSampleX, frontSampleZ) - terrainCenterHeight) * motionProfile.trackTerrainCompression, rightRear: (sampleMotionTerrainHeight(targetRootX + sideSampleX, rearSampleZ) - terrainCenterHeight) * motionProfile.trackTerrainCompression } : { leftFront: 0, leftRear: 0, rightFront: 0, rightRear: 0 }; const sideProfileZSpan = Math.max(0.1, rig.trackFrontWrapCenterZ - rig.trackRearWrapCenterZ); rootObject.updateMatrixWorld(true); const computeTrackTerrainResponse = ( sideXLocal: number, loopSample: TrackLoopSample ): { contactCompression: number; terrainPitch: number } => { if (!simTerrainToggle.checked) { return { contactCompression: 0, terrainPitch: 0 }; } const segmentWeight = loopSample.segment === 'bottom' ? 1 : ((loopSample.segment === 'front' || loopSample.segment === 'rear') ? 0.42 : 0); if (segmentWeight <= 0) { return { contactCompression: 0, terrainPitch: 0 }; } trackTerrainSampleLocal.set(sideXLocal, loopSample.y, loopSample.z); trackTerrainSampleWorld.copy(trackTerrainSampleLocal).applyMatrix4(rootObject.matrixWorld); const terrainAtLink = sampleMotionTerrainHeight(trackTerrainSampleWorld.x, trackTerrainSampleWorld.z); const localTerrainDelta = terrainAtLink - terrainCenterHeight; const isLeft = sideXLocal < 0; const zAlpha = THREE.MathUtils.clamp((loopSample.z - rig.trackRearWrapCenterZ) / sideProfileZSpan, 0, 1); const sideDelta = isLeft ? THREE.MathUtils.lerp(terrainSideProfile.leftRear, terrainSideProfile.leftFront, zAlpha) : THREE.MathUtils.lerp(terrainSideProfile.rightRear, terrainSideProfile.rightFront, zAlpha); const rawCompression = (localTerrainDelta * motionProfile.trackTerrainCompression + sideDelta * 0.9) * segmentWeight; const clampMin = -bounds.height * (loopSample.segment === 'bottom' ? 0.06 : 0.04); const clampMax = bounds.height * (loopSample.segment === 'bottom' ? 0.14 : 0.08); const contactCompression = THREE.MathUtils.clamp(rawCompression, clampMin, clampMax); const sampleStride = Math.max(0.14, bounds.radius * 0.16); const aheadZ = trackTerrainSampleWorld.z + sampleStride * loopSample.tangentZ; const behindZ = trackTerrainSampleWorld.z - sampleStride * loopSample.tangentZ; const aheadH = sampleMotionTerrainHeight(trackTerrainSampleWorld.x, aheadZ); const behindH = sampleMotionTerrainHeight(trackTerrainSampleWorld.x, behindZ); const terrainPitch = Math.atan2(aheadH - behindH, sampleStride * 2) * 0.34 * segmentWeight; return { contactCompression, terrainPitch }; }; const updateTrackDebugLoopLine = (line: THREE.LineLoop, sideX: number) => { const positionAttr = line.geometry.getAttribute('position'); if (!(positionAttr instanceof THREE.BufferAttribute)) { return; } const sampleCount = Math.max(2, positionAttr.count); const loopStride = rig.trackLoopLength / sampleCount; for (let i = 0; i < sampleCount; i++) { const sample = sampleTrackLoop(rig, motionTrackPhase + i * loopStride); const response = computeTrackTerrainResponse(sideX, sample); positionAttr.setXYZ(i, sideX, sample.y + response.contactCompression, sample.z); } positionAttr.needsUpdate = true; line.geometry.computeBoundingSphere(); }; if (activeTrackDebugGroup) { const refs = activeTrackDebugGroup.userData.trackDebugRefs as TrackDebugOverlayRefs | undefined; if (refs) { updateTrackDebugLoopLine(refs.leftLine, rig.trackLeftX); updateTrackDebugLoopLine(refs.rightLine, rig.trackRightX); } } const leftBaseDebugSample = sampleTrackLoop(rig, motionTrackPhase); const rightBaseDebugSample = sampleTrackLoop(rig, motionTrackPhase); const leftDebugResponse = computeTrackTerrainResponse(rig.trackLeftX, leftBaseDebugSample); const rightDebugResponse = computeTrackTerrainResponse(rig.trackRightX, rightBaseDebugSample); leftDebugSample = { ...leftBaseDebugSample, y: leftBaseDebugSample.y + leftDebugResponse.contactCompression, pitch: leftBaseDebugSample.pitch + leftDebugResponse.terrainPitch }; rightDebugSample = { ...rightBaseDebugSample, y: rightBaseDebugSample.y + rightDebugResponse.contactCompression, pitch: rightBaseDebugSample.pitch + rightDebugResponse.terrainPitch }; updateTrackDebugOverlayFrame(rig, leftDebugSample, rightDebugSample); if (rig.trackSyntheticLinks) { for (const item of rig.trackNodes) { item.node.visible = false; } const synthetic = rig.trackSyntheticLinks; synthetic.left.visible = true; synthetic.right.visible = true; const loopStride = rig.trackLoopLength / Math.max(1, synthetic.linkCountPerSide); const applyTrackSide = (instanced: THREE.InstancedMesh, sideX: number) => { for (let i = 0; i < synthetic.linkCountPerSide; i++) { const loopSample = sampleTrackLoop(rig, motionTrackPhase + i * loopStride); const isBottom = loopSample.segment === 'bottom'; const terrainResponse = computeTrackTerrainResponse(sideX, loopSample); const contactCompression = terrainResponse.contactCompression; if (isBottom) { bottomCompressionSum += contactCompression; bottomCompressionCount += 1; bottomCompressionMax = Math.max(bottomCompressionMax, Math.abs(contactCompression)); } trackAnimPosition.set(sideX, loopSample.y + contactCompression, loopSample.z); trackAnimEuler.set(loopSample.pitch + terrainResponse.terrainPitch, 0, 0, 'XYZ'); trackAnimQuaternion.setFromEuler(trackAnimEuler); trackAnimMatrix.compose(trackAnimPosition, trackAnimQuaternion, trackAnimScale); instanced.setMatrixAt(i, trackAnimMatrix); } instanced.instanceMatrix.needsUpdate = true; }; applyTrackSide(synthetic.left, rig.trackLeftX); applyTrackSide(synthetic.right, rig.trackRightX); } else { for (const item of rig.trackNodes) { item.node.visible = true; item.node.position.x = item.restPosition.x; item.node.rotation.copy(item.restRotation); const loopSample = sampleTrackLoop( rig, (item.trackLoopS ?? 0) + motionTrackPhase ); const isBottom = loopSample.segment === 'bottom'; const terrainResponse = computeTrackTerrainResponse(item.restPosition.x, loopSample); const contactCompression = terrainResponse.contactCompression; const normalOffset = item.trackLoopNormalOffset ?? 0; if (isBottom) { bottomCompressionSum += contactCompression; bottomCompressionCount += 1; bottomCompressionMax = Math.max(bottomCompressionMax, Math.abs(contactCompression)); } const targetCenterY = loopSample.y + loopSample.normalY * normalOffset + contactCompression; const targetCenterZ = loopSample.z + loopSample.normalZ * normalOffset; item.node.position.y = item.restPosition.y + (targetCenterY - item.restCenter.y); item.node.position.z = item.restPosition.z + (targetCenterZ - item.restCenter.z); const basePitch = (item.trackLoopPitchOffset ?? 0) + loopSample.pitch + terrainResponse.terrainPitch; const prevPitch = item.trackLastPitch ?? item.restRotation.x; const candidateA = normalizeSignedAngle(basePitch); const candidateB = normalizeSignedAngle(basePitch + Math.PI); const deltaA = Math.abs(normalizeSignedAngle(candidateA - prevPitch)); const deltaB = Math.abs(normalizeSignedAngle(candidateB - prevPitch)); const targetPitch = deltaA <= deltaB ? candidateA : candidateB; const nextPitch = prevPitch + normalizeSignedAngle(targetPitch - prevPitch); item.node.rotation.x = nextPitch; item.trackLastPitch = nextPitch; } } } if (simTrackDebugToggle.checked) { if (!tracked || !activeMotionRig || !leftDebugSample || !rightDebugSample) { setTrackDebugInfo('Track debug: current unit is not tracked.'); } else { const avgCompression = bottomCompressionCount > 0 ? bottomCompressionSum / bottomCompressionCount : 0; const slipDelta = trackTravel - rootTravelPlanar * motionDriveDirection; const leftPitchDeg = THREE.MathUtils.radToDeg(leftDebugSample.pitch); const rightPitchDeg = THREE.MathUtils.radToDeg(rightDebugSample.pitch); const animatedCount = activeMotionRig.trackSyntheticLinks ? activeMotionRig.trackSyntheticLinks.linkCountPerSide * 2 : activeMotionRig.trackNodes.length; const animatedMode = activeMotionRig.trackSyntheticLinks ? 'synthetic' : 'mesh'; setTrackDebugInfo( `Track debug | phase ${motionTrackPhase.toFixed(3)} / ${activeMotionRig.trackLoopLength.toFixed(3)} ` + `| root d ${rootTravelPlanar.toFixed(4)} | tread d ${trackTravel.toFixed(4)} | slip ${slipDelta.toFixed(4)}\n` + `Animated ${animatedMode} links ${animatedCount}/${activeMotionRig.trackCandidateCount} ` + `| excluded ${activeMotionRig.trackRejectedCount} ` + `| bottom compression avg ${avgCompression.toFixed(4)} max ${bottomCompressionMax.toFixed(4)} ` + `| left pitch ${leftPitchDeg.toFixed(1)}deg | right pitch ${rightPitchDeg.toFixed(1)}deg` ); } } const muzzleFlashMaterial = activeMotionRig.muzzleFlash.material as THREE.MeshBasicMaterial; const machineGunFlashMaterial = activeMotionRig.machineGunFlash.material as THREE.MeshBasicMaterial; const ringMaterial = activeMotionRig.abilityRing.material as THREE.MeshBasicMaterial; const defensivePulseMaterial = activeMotionRig.defensivePulse.material as THREE.MeshBasicMaterial; const mainShotTriggered = mainGunPulse > 0.84 && activeMotionRig.prevMainGunPulse <= 0.84; activeMotionRig.prevMainGunPulse = mainGunPulse; if (muzzleFlashStrength > 0.04) { activeMotionRig.muzzleFlash.visible = true; muzzleFlashMaterial.opacity = THREE.MathUtils.clamp(0.18 + muzzleFlashStrength * 0.85, 0, 1); activeMotionRig.muzzleFlash.scale.setScalar(1 + muzzleFlashStrength * 1.9); activeMotionRig.mainGunTracer.visible = muzzleFlashStrength > 0.34; if (activeMotionRig.mainGunTracer.visible) { setMotionLineEndpoints(activeMotionRig.mainGunTracer, motionMainMuzzleLocal, activeMotionRig.lastTargetLocal); } } else { activeMotionRig.muzzleFlash.visible = false; activeMotionRig.mainGunTracer.visible = false; } if (mainShotTriggered) { motionAimDirectionLocal.copy(activeMotionRig.lastTargetLocal).sub(motionMainMuzzleLocal).normalize(); for (let i = 0; i < 7; i++) { motionParticleOrigin.copy(motionMainMuzzleLocal).add(new THREE.Vector3( (Math.random() - 0.5) * activeUnitBounds.radius * 0.08, Math.random() * activeUnitBounds.height * 0.03, (Math.random() - 0.5) * activeUnitBounds.radius * 0.08 )); motionParticleVelocity.copy(motionAimDirectionLocal).multiplyScalar(0.34 + Math.random() * 0.56); motionParticleVelocity.add(new THREE.Vector3( (Math.random() - 0.5) * 0.28, 0.12 + Math.random() * 0.34, (Math.random() - 0.5) * 0.28 )); spawnMotionParticle( activeMotionRig.mainGunSmokeEmitter, motionParticleOrigin, motionParticleVelocity, 0.46 + Math.random() * 0.48, Math.max(0.035, activeUnitBounds.radius * 0.04), Math.max(0.08, activeUnitBounds.radius * 0.12), 0.56, 0 ); } } const machineGunTriggered = machineGunPulse > 0.75 && activeMotionRig.prevMachineGunPulse <= 0.75; activeMotionRig.prevMachineGunPulse = machineGunPulse; if (machineGunPulse > 0.08) { activeMotionRig.machineGunFlash.visible = true; machineGunFlashMaterial.opacity = THREE.MathUtils.clamp(0.14 + machineGunPulse * 0.76, 0, 1); activeMotionRig.machineGunFlash.scale.setScalar(0.72 + machineGunPulse * 1.1); activeMotionRig.machineGunTracer.visible = machineGunPulse > 0.36; if (activeMotionRig.machineGunTracer.visible) { motionParticleOrigin.copy(activeMotionRig.lastTargetLocal).add(new THREE.Vector3( (Math.random() - 0.5) * activeUnitBounds.radius * 0.22, (Math.random() - 0.5) * activeUnitBounds.height * 0.14, (Math.random() - 0.5) * activeUnitBounds.radius * 0.2 )); setMotionLineEndpoints(activeMotionRig.machineGunTracer, motionMachineGunMuzzleLocal, motionParticleOrigin); } } else { activeMotionRig.machineGunFlash.visible = false; activeMotionRig.machineGunTracer.visible = false; } if (machineGunTriggered) { motionAimDirectionLocal.copy(activeMotionRig.lastTargetLocal).sub(motionMachineGunMuzzleLocal).normalize(); for (let i = 0; i < 3; i++) { motionParticleOrigin.copy(motionMachineGunMuzzleLocal).add(new THREE.Vector3( (Math.random() - 0.5) * activeUnitBounds.radius * 0.03, (Math.random() - 0.5) * activeUnitBounds.height * 0.03, (Math.random() - 0.5) * activeUnitBounds.radius * 0.03 )); motionParticleVelocity.copy(motionAimDirectionLocal).multiplyScalar(1.1 + Math.random() * 1.2); motionParticleVelocity.add(new THREE.Vector3( (Math.random() - 0.5) * 0.8, (Math.random() - 0.5) * 0.5, (Math.random() - 0.5) * 0.8 )); spawnMotionParticle( activeMotionRig.machineGunEmitter, motionParticleOrigin, motionParticleVelocity, 0.14 + Math.random() * 0.1, Math.max(0.012, activeUnitBounds.radius * 0.014), Math.max(0.024, activeUnitBounds.radius * 0.024), 0.9, 0 ); } } if (isAbilityState) { const pulse = (Math.sin(motionTime * 2.7) + 1) * 0.5; activeMotionRig.abilityRing.visible = true; ringMaterial.opacity = 0.14 + pulse * 0.34; activeMotionRig.abilityRing.scale.setScalar(1.2 + pulse * 0.78); activeMotionRig.abilityRing.rotation.z += deltaTime * 0.35; activeMotionRig.defensivePulse.visible = true; defensivePulseMaterial.opacity = 0.08 + pulse * 0.16; activeMotionRig.defensivePulse.scale.setScalar(1.18 + pulse * 0.58); activeMotionRig.defensiveBurstTimer += deltaTime; if (activeMotionRig.defensiveBurstTimer >= 0.1) { activeMotionRig.defensiveBurstTimer = 0; const emitters = [activeMotionRig.smokeLeftLocal, activeMotionRig.smokeRightLocal].filter( (entry): entry is THREE.Vector3 => entry !== null ); const perimeterSources: THREE.Vector3[] = []; const shellRadius = activeUnitBounds.radius * 1.52; const shellY = activeUnitBounds.height * 0.34; for (let i = 0; i < 14; i++) { const angle = (i / 14) * Math.PI * 2; perimeterSources.push(new THREE.Vector3( Math.cos(angle) * shellRadius, shellY + Math.sin(angle * 2.1) * activeUnitBounds.height * 0.04, Math.sin(angle) * shellRadius * 1.2 )); } const sources = emitters.length > 0 ? [...emitters, activeMotionRig.turretPivot, ...perimeterSources] : [activeMotionRig.turretPivot, ...perimeterSources]; for (const source of sources) { for (let i = 0; i < 6; i++) { motionParticleOrigin.copy(source).add(new THREE.Vector3( (Math.random() - 0.5) * activeUnitBounds.radius * 0.62, Math.random() * activeUnitBounds.height * 0.24, (Math.random() - 0.5) * activeUnitBounds.radius * 0.62 )); const localSpawn = motionParticleOrigin.clone(); activeObject.localToWorld(motionParticleOrigin); const radial = new THREE.Vector3(localSpawn.x, 0, localSpawn.z); if (radial.lengthSq() > 1e-4) { radial.normalize(); } else { radial.set(0, 0, 1); } motionParticleVelocity.set( radial.x * (0.04 + Math.random() * 0.1) + (Math.random() - 0.5) * 0.05, 0.02 + Math.random() * 0.08, radial.z * (0.04 + Math.random() * 0.1) + (Math.random() - 0.5) * 0.05 ); spawnMotionParticle( activeMotionRig.defensiveEmitter, motionParticleOrigin, motionParticleVelocity, 3.4 + Math.random() * 2.8, Math.max(0.72, activeUnitBounds.radius * 0.96), Math.max(2.4, activeUnitBounds.radius * 3.4), 0.64, 0 ); } } } } else { activeMotionRig.abilityRing.visible = false; activeMotionRig.defensivePulse.visible = false; activeMotionRig.defensiveBurstTimer = 0; } updateMotionParticleEmitter(activeMotionRig.mainGunSmokeEmitter, deltaTime); updateMotionParticleEmitter(activeMotionRig.machineGunEmitter, deltaTime); updateMotionParticleEmitter(activeMotionRig.defensiveEmitter, deltaTime); // Move state always keeps focus locked on the unit so travel inspection is stable. const lockCamera = simLockCameraToggle.checked || isMoving || isTrackState; if (lockCamera) { controlsWereEnabled = controls.enabled; controls.enabled = false; controls.autoRotate = false; const distance = Math.max(6, lastFitDistance * motionProfile.cameraDistanceScale); const target = activeObject.position.clone().add(new THREE.Vector3(0, activeUnitBounds.height * 0.1, 0)); const desiredPosition = target.clone().add(new THREE.Vector3( distance * motionProfile.cameraOffsetX, distance * motionProfile.cameraOffsetY, distance * motionProfile.cameraOffsetZ )); camera.position.lerp(desiredPosition, 1 - Math.exp(-deltaTime * 6)); controls.target.copy(target); camera.lookAt(target); } else if (!controls.enabled) { restoreControlMode(); } const stateLabel = state.toUpperCase(); const movementLabel = isMoving ? `| speed x${speedScale.toFixed(1)}` : ''; const trackLabel = tracked ? '| tracked rig active' : '| non-tracked rig'; const gaitFallbackCount = activeMotionRig.humanoidNodes.reduce( (sum, node) => sum + (node.humanoidFallback ? 1 : 0), 0 ); const gaitCompositeCount = activeMotionRig.humanoidNodes.reduce( (sum, node) => sum + (node.humanoidComposite ? 1 : 0), 0 ); const gaitLabel = activeMotionRig.humanoidNodeCount > 0 ? `| gait limbs ${activeMotionRig.humanoidNodeCount}/${activeMotionRig.humanoidGroupCount} groups (fallback ${gaitFallbackCount}, composite ${gaitCompositeCount})` : ''; const cooldownLabel = (isFireState || isTrackState) ? `| cooldown ${attackCooldown.toFixed(2)}s` : ''; const trackingLabel = isTrackingState ? '| target tracking ON' : ''; const abilityLabel = isAbilityState ? `| untargetable ${motionAbilityConcealRemaining.toFixed(1)}s` : isAbilitySelected ? '| ability expired (press Trigger)' : ''; const fxLabel = `| FX main ${mainGunPulse.toFixed(2)} mg ${machineGunPulse.toFixed(2)}`; setMotionStatus(`Motion lab: ${stateLabel} ${movementLabel} ${cooldownLabel} ${trackingLabel} ${abilityLabel} ${fxLabel} | profile ${motionProfile.label} ${trackLabel} ${gaitLabel}`); }; const disposeObject3DTree = (root: THREE.Object3D | null) => { if (!root) return; root.traverse((child) => { const mesh = child as THREE.Mesh; if (mesh.isMesh) { mesh.geometry?.dispose(); if (Array.isArray(mesh.material)) { for (const material of mesh.material) { material.dispose(); } } else { mesh.material?.dispose(); } } else { const line = child as THREE.Line; if (line.isLine) { line.geometry?.dispose(); if (Array.isArray(line.material)) { for (const material of line.material) { material.dispose(); } } else { line.material?.dispose(); } } else { const sprite = child as THREE.Sprite; if (sprite.isSprite) { if (Array.isArray(sprite.material)) { for (const material of sprite.material) { material.dispose(); } } else { sprite.material?.dispose(); } } } } }); }; const clearDiagnosticsOverlay = () => { if (activeDiagnosticGroup && activeObject) { activeObject.remove(activeDiagnosticGroup); } disposeObject3DTree(activeDiagnosticGroup); activeDiagnosticGroup = null; }; const toSocketPosition = (bounds: UnitBounds, socket: { position: { x: number; y: number; z: number } }) => new THREE.Vector3( socket.position.x * bounds.radius, socket.position.y * bounds.height, socket.position.z * bounds.radius ); const HUMANOID_JOINT_SOCKET_BONES: Array<{ from: HumanoidJointSocketId; to: HumanoidJointSocketId; label: string }> = [ { from: 'centerPelvis', to: 'centerTorso', label: 'Spine Base' }, { from: 'centerTorso', to: 'centerChest', label: 'Spine Upper' }, { from: 'centerChest', to: 'leftShoulder', label: 'L Clavicle' }, { from: 'leftShoulder', to: 'leftElbow', label: 'L Upper Arm' }, { from: 'leftElbow', to: 'leftWrist', label: 'L Forearm' }, { from: 'centerPelvis', to: 'leftHip', label: 'L Pelvis Link' }, { from: 'leftHip', to: 'leftKnee', label: 'L Thigh' }, { from: 'leftKnee', to: 'leftAnkle', label: 'L Shin' }, { from: 'centerChest', to: 'rightShoulder', label: 'R Clavicle' }, { from: 'rightShoulder', to: 'rightElbow', label: 'R Upper Arm' }, { from: 'rightElbow', to: 'rightWrist', label: 'R Forearm' }, { from: 'centerPelvis', to: 'rightHip', label: 'R Pelvis Link' }, { from: 'rightHip', to: 'rightKnee', label: 'R Thigh' }, { from: 'rightKnee', to: 'rightAnkle', label: 'R Shin' } ]; const HUMANOID_JOINT_ANGLE_DEFS: Array<{ id: string; parent: HumanoidJointSocketId; joint: HumanoidJointSocketId; child: HumanoidJointSocketId; color: string; }> = [ { id: 'L Shoulder', parent: 'centerChest', joint: 'leftShoulder', child: 'leftElbow', color: '#79f0ff' }, { id: 'R Shoulder', parent: 'centerChest', joint: 'rightShoulder', child: 'rightElbow', color: '#ffbd73' }, { id: 'L Elbow', parent: 'leftShoulder', joint: 'leftElbow', child: 'leftWrist', color: '#62ffe0' }, { id: 'R Elbow', parent: 'rightShoulder', joint: 'rightElbow', child: 'rightWrist', color: '#ffd07a' }, { id: 'L Hip', parent: 'centerPelvis', joint: 'leftHip', child: 'leftKnee', color: '#a5ff8a' }, { id: 'R Hip', parent: 'centerPelvis', joint: 'rightHip', child: 'rightKnee', color: '#ffe58a' }, { id: 'L Knee', parent: 'leftHip', joint: 'leftKnee', child: 'leftAnkle', color: '#8dff9c' }, { id: 'R Knee', parent: 'rightHip', joint: 'rightKnee', child: 'rightAnkle', color: '#ffd98f' } ]; const HUMANOID_JOINT_SOCKET_ORDER: HumanoidJointSocketId[] = [ 'centerPelvis', 'centerTorso', 'centerChest', 'leftShoulder', 'leftElbow', 'leftWrist', 'rightShoulder', 'rightElbow', 'rightWrist', 'leftHip', 'leftKnee', 'leftAnkle', 'rightHip', 'rightKnee', 'rightAnkle' ]; const HUMANOID_JOINT_PARENT_BY_ID: Record = { centerPelvis: null, centerTorso: 'centerPelvis', centerChest: 'centerTorso', leftShoulder: 'centerChest', leftElbow: 'leftShoulder', leftWrist: 'leftElbow', leftHip: 'centerPelvis', leftKnee: 'leftHip', leftAnkle: 'leftKnee', rightShoulder: 'centerChest', rightElbow: 'rightShoulder', rightWrist: 'rightElbow', rightHip: 'centerPelvis', rightKnee: 'rightHip', rightAnkle: 'rightKnee' }; const HUMANOID_JOINT_PRIMARY_CHILD_BY_ID: Partial> = { centerPelvis: 'centerTorso', centerTorso: 'centerChest', leftShoulder: 'leftElbow', leftElbow: 'leftWrist', leftHip: 'leftKnee', leftKnee: 'leftAnkle', rightShoulder: 'rightElbow', rightElbow: 'rightWrist', rightHip: 'rightKnee', rightKnee: 'rightAnkle' }; const HUMANOID_JOINT_MIRROR_BY_ID: Partial> = { leftShoulder: 'rightShoulder', leftElbow: 'rightElbow', leftWrist: 'rightWrist', leftHip: 'rightHip', leftKnee: 'rightKnee', leftAnkle: 'rightAnkle', rightShoulder: 'leftShoulder', rightElbow: 'leftElbow', rightWrist: 'leftWrist', rightHip: 'leftHip', rightKnee: 'leftKnee', rightAnkle: 'leftAnkle' }; const createDiagnosticTextSprite = (text: string, color = '#9cecff') => { const canvas = document.createElement('canvas'); canvas.width = 320; canvas.height = 120; const ctx = canvas.getContext('2d'); if (!ctx) { const fallback = new THREE.Sprite( new THREE.SpriteMaterial({ color: '#ffffff', transparent: true, opacity: 0.9, depthTest: false, depthWrite: false }) ); fallback.scale.set(0.5, 0.16, 1); fallback.userData.__diagnosticHelper = true; return fallback; } const texture = new THREE.CanvasTexture(canvas); texture.minFilter = THREE.LinearFilter; texture.magFilter = THREE.LinearFilter; texture.generateMipmaps = false; const material = new THREE.SpriteMaterial({ map: texture, transparent: true, depthTest: false, depthWrite: false }); const sprite = new THREE.Sprite(material); sprite.scale.set(0.74, 0.22, 1); sprite.renderOrder = 1003; sprite.userData.__diagnosticHelper = true; sprite.userData.__diagnosticTextState = { canvas, ctx, texture, lastText: '', color }; setDiagnosticTextSprite(sprite, text, color); return sprite; }; const setDiagnosticTextSprite = (sprite: THREE.Sprite, text: string, color = '#9cecff') => { const state = sprite.userData?.__diagnosticTextState as { canvas: HTMLCanvasElement; ctx: CanvasRenderingContext2D; texture: THREE.CanvasTexture; lastText: string; color: string; } | undefined; if (!state) return; if (state.lastText === text && state.color === color) { return; } state.lastText = text; state.color = color; const { canvas, ctx, texture } = state; ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.fillStyle = 'rgba(8, 20, 34, 0.78)'; ctx.strokeStyle = 'rgba(148, 223, 255, 0.82)'; ctx.lineWidth = 3; ctx.fillRect(8, 8, canvas.width - 16, canvas.height - 16); ctx.strokeRect(8, 8, canvas.width - 16, canvas.height - 16); ctx.fillStyle = color; ctx.font = '600 30px Consolas, monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(text, canvas.width * 0.5, canvas.height * 0.5); texture.needsUpdate = true; }; const resolveHumanoidNodeCurrentCenterInRoot = ( node: MotionRigNode, root: THREE.Object3D ): THREE.Vector3 => { humanoidJointNodeCenterLocal.set(node.info.center.x, node.info.center.y, node.info.center.z); node.node.localToWorld(humanoidJointNodeCenterLocal); humanoidJointNodeCenterRoot.copy(humanoidJointNodeCenterLocal); root.worldToLocal(humanoidJointNodeCenterRoot); return humanoidJointNodeCenterRoot.clone(); }; const averageHumanoidRoleCenterCurrent = ( rig: MotionRig, root: THREE.Object3D, role: HumanoidMotionRole, side?: HumanoidMotionSide ): THREE.Vector3 | null => { const candidates = rig.humanoidNodes.filter((node) => ( node.humanoidRole === role && !node.humanoidDecorative && (side == null || node.humanoidSide === side) )); if (candidates.length <= 0) return null; let sx = 0; let sy = 0; let sz = 0; let sw = 0; for (const node of candidates) { const w = Math.max(1e-4, getHumanoidNodeVolume(node)); const center = resolveHumanoidNodeCurrentCenterInRoot(node, root); sx += center.x * w; sy += center.y * w; sz += center.z * w; sw += w; } if (sw <= 1e-6) return null; return new THREE.Vector3(sx / sw, sy / sw, sz / sw); }; const resolveHumanoidJointSocketLayout = ( rig: MotionRig, root: THREE.Object3D, bounds: UnitBounds ): HumanoidJointSocketLayout | null => { if (rig.humanoidNodes.length <= 0) return null; const landmarks = rig.humanoidLandmarks; const centerTorso = averageHumanoidRoleCenterCurrent(rig, root, 'torso') ?? landmarks?.centerTorso.clone() ?? new THREE.Vector3(0, bounds.height * 0.46, 0); const leftShoulder = averageHumanoidRoleCenterCurrent(rig, root, 'shoulder', 'left') ?? averageHumanoidRoleCenterCurrent(rig, root, 'upperArm', 'left') ?? landmarks?.left.shoulder.clone() ?? new THREE.Vector3(-bounds.radius * 0.26, bounds.height * 0.58, bounds.radius * 0.04); const rightShoulder = averageHumanoidRoleCenterCurrent(rig, root, 'shoulder', 'right') ?? averageHumanoidRoleCenterCurrent(rig, root, 'upperArm', 'right') ?? landmarks?.right.shoulder.clone() ?? new THREE.Vector3(bounds.radius * 0.26, bounds.height * 0.58, bounds.radius * 0.04); const centerChest = landmarks?.centerChest.clone() ?? leftShoulder.clone().add(rightShoulder).multiplyScalar(0.5).lerp(centerTorso, 0.28); const leftElbow = averageHumanoidRoleCenterCurrent(rig, root, 'forearm', 'left') ?? averageHumanoidRoleCenterCurrent(rig, root, 'upperArm', 'left') ?? landmarks?.left.elbow.clone() ?? leftShoulder.clone().add(new THREE.Vector3(-bounds.radius * 0.08, -bounds.height * 0.14, bounds.radius * 0.05)); const rightElbow = averageHumanoidRoleCenterCurrent(rig, root, 'forearm', 'right') ?? averageHumanoidRoleCenterCurrent(rig, root, 'upperArm', 'right') ?? landmarks?.right.elbow.clone() ?? rightShoulder.clone().add(new THREE.Vector3(bounds.radius * 0.08, -bounds.height * 0.14, bounds.radius * 0.05)); const leftWrist = averageHumanoidRoleCenterCurrent(rig, root, 'hand', 'left') ?? averageHumanoidRoleCenterCurrent(rig, root, 'forearm', 'left') ?? landmarks?.left.wrist.clone() ?? leftElbow.clone().add(new THREE.Vector3(-bounds.radius * 0.07, -bounds.height * 0.09, bounds.radius * 0.05)); const rightWrist = averageHumanoidRoleCenterCurrent(rig, root, 'hand', 'right') ?? averageHumanoidRoleCenterCurrent(rig, root, 'forearm', 'right') ?? landmarks?.right.wrist.clone() ?? rightElbow.clone().add(new THREE.Vector3(bounds.radius * 0.07, -bounds.height * 0.09, bounds.radius * 0.05)); const leftHip = averageHumanoidRoleCenterCurrent(rig, root, 'hip', 'left') ?? averageHumanoidRoleCenterCurrent(rig, root, 'upperLeg', 'left') ?? landmarks?.left.hip.clone() ?? new THREE.Vector3(-bounds.radius * 0.18, bounds.height * 0.27, bounds.radius * 0.02); const rightHip = averageHumanoidRoleCenterCurrent(rig, root, 'hip', 'right') ?? averageHumanoidRoleCenterCurrent(rig, root, 'upperLeg', 'right') ?? landmarks?.right.hip.clone() ?? new THREE.Vector3(bounds.radius * 0.18, bounds.height * 0.27, bounds.radius * 0.02); const centerPelvis = landmarks?.centerPelvis.clone() ?? leftHip.clone().add(rightHip).multiplyScalar(0.5); const leftKnee = averageHumanoidRoleCenterCurrent(rig, root, 'lowerLeg', 'left') ?? landmarks?.left.knee.clone() ?? leftHip.clone().add(new THREE.Vector3(0, -bounds.height * 0.14, bounds.radius * 0.02)); const rightKnee = averageHumanoidRoleCenterCurrent(rig, root, 'lowerLeg', 'right') ?? landmarks?.right.knee.clone() ?? rightHip.clone().add(new THREE.Vector3(0, -bounds.height * 0.14, bounds.radius * 0.02)); const leftAnkle = averageHumanoidRoleCenterCurrent(rig, root, 'foot', 'left') ?? landmarks?.left.ankle.clone() ?? leftKnee.clone().add(new THREE.Vector3(0, -bounds.height * 0.12, bounds.radius * 0.05)); const rightAnkle = averageHumanoidRoleCenterCurrent(rig, root, 'foot', 'right') ?? landmarks?.right.ankle.clone() ?? rightKnee.clone().add(new THREE.Vector3(0, -bounds.height * 0.12, bounds.radius * 0.05)); return { centerPelvis, centerTorso, centerChest, leftShoulder, leftElbow, leftWrist, leftHip, leftKnee, leftAnkle, rightShoulder, rightElbow, rightWrist, rightHip, rightKnee, rightAnkle }; }; const computeJointAngleDeg = ( parent: THREE.Vector3, joint: THREE.Vector3, child: THREE.Vector3 ): number => { humanoidJointVecA.copy(parent).sub(joint); humanoidJointVecB.copy(child).sub(joint); const lenA = humanoidJointVecA.length(); const lenB = humanoidJointVecB.length(); if (lenA <= 1e-5 || lenB <= 1e-5) return 0; humanoidJointVecA.multiplyScalar(1 / lenA); humanoidJointVecB.multiplyScalar(1 / lenB); return THREE.MathUtils.radToDeg(humanoidJointVecA.angleTo(humanoidJointVecB)); }; const getAnyPerpendicular = (forward: THREE.Vector3): THREE.Vector3 => { const absX = Math.abs(forward.x); const absY = Math.abs(forward.y); const absZ = Math.abs(forward.z); if (absY < 0.7) { return new THREE.Vector3(0, 1, 0); } if (absX < absZ) { return new THREE.Vector3(1, 0, 0); } return new THREE.Vector3(0, 0, 1); }; const computeStableBoneFrame = ( start: THREE.Vector3, end: THREE.Vector3, rollHint: THREE.Vector3 ): { origin: THREE.Vector3; forward: THREE.Vector3; right: THREE.Vector3; up: THREE.Vector3 } => { const origin = start.clone().add(end).multiplyScalar(0.5); const forward = end.clone().sub(start); const len = forward.length(); if (len <= 1e-5) { return { origin, forward: new THREE.Vector3(0, 0, 1), right: new THREE.Vector3(1, 0, 0), up: new THREE.Vector3(0, 1, 0) }; } forward.multiplyScalar(1 / len); const up = rollHint.clone().sub(forward.clone().multiplyScalar(forward.dot(rollHint))); if (up.lengthSq() < 1e-4) { up.copy(getAnyPerpendicular(forward)); } up.normalize(); const right = new THREE.Vector3().crossVectors(up, forward).normalize(); const finalUp = new THREE.Vector3().crossVectors(forward, right).normalize(); return { origin, forward, right, up: finalUp }; }; const resolveHumanoidBoneRollHint = ( bone: { from: HumanoidJointSocketId; to: HumanoidJointSocketId }, layout: HumanoidJointSocketLayout, midpoint: THREE.Vector3 ): THREE.Vector3 => { const from = bone.from.toLowerCase(); const to = bone.to.toLowerCase(); const isArm = from.includes('shoulder') || from.includes('elbow') || from.includes('wrist') || to.includes('shoulder') || to.includes('elbow') || to.includes('wrist'); const isLeg = from.includes('hip') || from.includes('knee') || from.includes('ankle') || to.includes('hip') || to.includes('knee') || to.includes('ankle'); if (isArm) { return layout.centerChest.clone().sub(midpoint); } if (isLeg) { return layout.centerPelvis.clone().sub(midpoint); } return new THREE.Vector3(0, 0, 1); }; const round4 = (value: number): number => Number(value.toFixed(4)); const toRoundedVec3 = (value: THREE.Vector3): { x: number; y: number; z: number } => ({ x: round4(value.x), y: round4(value.y), z: round4(value.z) }); const toRoundedQuat = (value: THREE.Quaternion): { x: number; y: number; z: number; w: number } => ({ x: round4(value.x), y: round4(value.y), z: round4(value.z), w: round4(value.w) }); const toEulerDeg = (value: THREE.Quaternion): { x: number; y: number; z: number } => { humanoidJointEuler.setFromQuaternion(value, 'XYZ'); return { x: round4(THREE.MathUtils.radToDeg(humanoidJointEuler.x)), y: round4(THREE.MathUtils.radToDeg(humanoidJointEuler.y)), z: round4(THREE.MathUtils.radToDeg(humanoidJointEuler.z)) }; }; const projectJointVectorOntoPlane = ( source: THREE.Vector3, planeNormal: THREE.Vector3, out: THREE.Vector3 ): boolean => { out.copy(source).addScaledVector(planeNormal, -source.dot(planeNormal)); const lengthSq = out.lengthSq(); if (lengthSq <= 1e-10) return false; out.multiplyScalar(1 / Math.sqrt(lengthSq)); return true; }; const resolveJointSyntheticEndEffector = ( socketId: HumanoidJointSocketId, layout: HumanoidJointSocketLayout ): THREE.Vector3 | null => { const parentId = HUMANOID_JOINT_PARENT_BY_ID[socketId]; if (!parentId) return null; const grandParentId = HUMANOID_JOINT_PARENT_BY_ID[parentId]; if (!grandParentId) return null; const self = layout[socketId]; const parent = layout[parentId]; const grandParent = layout[grandParentId]; humanoidJointTmpVec0.copy(self).sub(parent); const segmentLength = humanoidJointTmpVec0.length(); if (segmentLength <= 1e-5) return null; humanoidJointTmpVec0.multiplyScalar(1 / segmentLength); humanoidJointTmpVec1.copy(parent).sub(grandParent); const parentSegmentLength = humanoidJointTmpVec1.length(); if (parentSegmentLength > 1e-5) { humanoidJointTmpVec1.multiplyScalar(1 / parentSegmentLength); } else { humanoidJointTmpVec1.set(0, 1, 0); } humanoidJointTmpVec2.crossVectors(humanoidJointTmpVec1, humanoidJointTmpVec0); if (humanoidJointTmpVec2.lengthSq() <= 1e-10) { humanoidJointTmpVec2.crossVectors(humanoidJointWorldUp, humanoidJointTmpVec0); if (humanoidJointTmpVec2.lengthSq() <= 1e-10) { humanoidJointTmpVec2.crossVectors(humanoidJointWorldAltUp, humanoidJointTmpVec0); } } if (humanoidJointTmpVec2.lengthSq() <= 1e-10) return null; humanoidJointTmpVec2.normalize(); const sideScale = socketId.startsWith('left') ? -0.22 : 0.22; return self.clone() .addScaledVector(humanoidJointTmpVec0, segmentLength * 0.72) .addScaledVector(humanoidJointTmpVec2, segmentLength * sideScale); }; const resolveJointForwardVector = ( socketId: HumanoidJointSocketId, layout: HumanoidJointSocketLayout ): THREE.Vector3 => { const self = layout[socketId]; const parentId = HUMANOID_JOINT_PARENT_BY_ID[socketId]; const childId = HUMANOID_JOINT_PRIMARY_CHILD_BY_ID[socketId] ?? null; if (socketId === 'centerChest' && parentId) { humanoidJointForward.copy(self).sub(layout[parentId]); } else if (childId) { humanoidJointForward.copy(layout[childId]).sub(self); } else { const syntheticChild = resolveJointSyntheticEndEffector(socketId, layout); if (syntheticChild) { humanoidJointForward.copy(syntheticChild).sub(self); } else if (parentId) { humanoidJointForward.copy(self).sub(layout[parentId]); } else { humanoidJointForward.set(0, 1, 0); } } if (humanoidJointForward.lengthSq() <= 1e-10) { humanoidJointForward.set(0, 1, 0); } else { humanoidJointForward.normalize(); } return humanoidJointForward; }; const resolveJointAbsoluteQuaternion = ( socketId: HumanoidJointSocketId, layout: HumanoidJointSocketLayout, absQuatByJoint: ReadonlyMap ): THREE.Quaternion => { const forward = resolveJointForwardVector(socketId, layout); const self = layout[socketId]; let hasRight = false; const mirrorId = HUMANOID_JOINT_MIRROR_BY_ID[socketId] ?? null; if (mirrorId) { const mirror = layout[mirrorId]; if (socketId.startsWith('left')) { humanoidJointTmpVec0.copy(mirror).sub(self); } else if (socketId.startsWith('right')) { humanoidJointTmpVec0.copy(self).sub(mirror); } hasRight = projectJointVectorOntoPlane(humanoidJointTmpVec0, forward, humanoidJointRight); } if (hasRight) { humanoidJointUp.crossVectors(forward, humanoidJointRight); if (humanoidJointUp.lengthSq() <= 1e-10) { hasRight = false; } else { humanoidJointUp.normalize(); humanoidJointRight.crossVectors(humanoidJointUp, forward).normalize(); } } if (!hasRight) { const parentId = HUMANOID_JOINT_PARENT_BY_ID[socketId]; let hasUp = false; if (parentId) { const parentQuat = absQuatByJoint.get(parentId); if (parentQuat) { humanoidJointTmpVec0.set(0, 1, 0).applyQuaternion(parentQuat); hasUp = projectJointVectorOntoPlane(humanoidJointTmpVec0, forward, humanoidJointUp); if (!hasUp) { humanoidJointTmpVec0.set(1, 0, 0).applyQuaternion(parentQuat); hasUp = projectJointVectorOntoPlane(humanoidJointTmpVec0, forward, humanoidJointUp); } } } if (!hasUp) { hasUp = projectJointVectorOntoPlane(humanoidJointWorldUp, forward, humanoidJointUp); if (!hasUp) { hasUp = projectJointVectorOntoPlane(humanoidJointWorldAltUp, forward, humanoidJointUp); } } if (!hasUp) { humanoidJointUp.set(0, 1, 0); } humanoidJointRight.crossVectors(humanoidJointUp, forward); if (humanoidJointRight.lengthSq() <= 1e-10) { humanoidJointRight.set(1, 0, 0); } else { humanoidJointRight.normalize(); } humanoidJointUp.crossVectors(forward, humanoidJointRight).normalize(); } else { humanoidJointRight.normalize(); } humanoidJointBasis.makeBasis(humanoidJointRight, humanoidJointUp, forward); humanoidJointAbsQuat.setFromRotationMatrix(humanoidJointBasis); return humanoidJointAbsQuat.clone(); }; const collectHumanoidJointRotationReport = ( rig: MotionRig, unitType: UnitType, bounds: UnitBounds, root: THREE.Object3D ): HumanoidJointRotationReport | null => { const layout = resolveHumanoidJointSocketLayout(rig, root, bounds); if (!layout) return null; root.getWorldQuaternion(humanoidJointWorldQuat); const absQuatByJoint = new Map(); const joints: HumanoidJointRotationEntry[] = []; for (const socketId of HUMANOID_JOINT_SOCKET_ORDER) { const absolutePositionLocal = layout[socketId].clone(); const parentId = HUMANOID_JOINT_PARENT_BY_ID[socketId]; const absQuat = resolveJointAbsoluteQuaternion(socketId, layout, absQuatByJoint); absQuatByJoint.set(socketId, absQuat.clone()); if (parentId) { const parentQuat = absQuatByJoint.get(parentId); if (parentQuat) { humanoidJointParentQuat.copy(parentQuat).invert(); humanoidJointRelQuat.copy(humanoidJointParentQuat).multiply(absQuat); } else { humanoidJointRelQuat.copy(absQuat); } } else { humanoidJointRelQuat.copy(absQuat); } const absolutePositionWorld = root.localToWorld(humanoidJointWorldPos.copy(absolutePositionLocal)).clone(); const absoluteWorldQuat = humanoidJointWorldQuat.clone().multiply(absQuat); joints.push({ id: socketId, parentId, absolutePositionLocal: toRoundedVec3(absolutePositionLocal), absolutePositionWorld: toRoundedVec3(absolutePositionWorld), absoluteRotationEulerDeg: toEulerDeg(absoluteWorldQuat), relativeRotationEulerDeg: toEulerDeg(humanoidJointRelQuat), absoluteRotationQuaternion: toRoundedQuat(absoluteWorldQuat), relativeRotationQuaternion: toRoundedQuat(humanoidJointRelQuat) }); } const jointAnglesDeg: Record = {}; for (const angleDef of HUMANOID_JOINT_ANGLE_DEFS) { jointAnglesDeg[angleDef.id] = round4(computeJointAngleDeg( layout[angleDef.parent], layout[angleDef.joint], layout[angleDef.child] )); } return { solverVersion: HUMANOID_MOTION_SOLVER_VERSION, unitType, generatedAt: new Date().toISOString(), coordinateFrames: { local: 'model_root', world: 'scene_world' }, jointCount: joints.length, joints, jointAnglesDeg }; }; const logHumanoidJointRotationReport = ( report: HumanoidJointRotationReport, source: 'auto' | 'manual' = 'manual' ) => { const title = `[MeshLabViewer] Humanoid joint rotation report (${source}) ${report.unitType}`; console.groupCollapsed(title); console.table(report.joints.map((joint) => ({ id: joint.id, parent: joint.parentId ?? 'none', localX: joint.absolutePositionLocal.x, localY: joint.absolutePositionLocal.y, localZ: joint.absolutePositionLocal.z, worldX: joint.absolutePositionWorld.x, worldY: joint.absolutePositionWorld.y, worldZ: joint.absolutePositionWorld.z, absRx: joint.absoluteRotationEulerDeg.x, absRy: joint.absoluteRotationEulerDeg.y, absRz: joint.absoluteRotationEulerDeg.z, relRx: joint.relativeRotationEulerDeg.x, relRy: joint.relativeRotationEulerDeg.y, relRz: joint.relativeRotationEulerDeg.z }))); console.log('Joint angles (deg)', report.jointAnglesDeg); console.log(report); console.groupEnd(); }; const updateHumanoidJointSocketHelpersGroup = ( group: THREE.Group, rig: MotionRig, root: THREE.Object3D, bounds: UnitBounds ) => { const refs = group.userData?.humanoidJointSocketRefs as HumanoidJointSocketOverlayRefs | undefined; if (!refs) return; const layout = resolveHumanoidJointSocketLayout(rig, root, bounds); if (!layout) return; for (const [socketId, marker] of Object.entries(refs.markers) as Array<[HumanoidJointSocketId, THREE.Mesh]>) { const pos = layout[socketId]; marker.position.copy(pos); marker.userData.__diagnosticLabel = `Joint socket ${socketId}\n` + `x ${pos.x.toFixed(2)} y ${pos.y.toFixed(2)} z ${pos.z.toFixed(2)}`; } for (const bone of refs.bones) { const start = layout[bone.from]; const end = layout[bone.to]; setMotionLineEndpoints(bone.line, start, end); const length = start.distanceTo(end); bone.line.userData.__diagnosticLabel = `${bone.label}\nLength ${length.toFixed(3)}`; bone.line.visible = true; } const labelLift = Math.max(0.03, bounds.height * 0.018); for (const angleRef of refs.angles) { const angle = computeJointAngleDeg(layout[angleRef.parent], layout[angleRef.joint], layout[angleRef.child]); const text = `${angleRef.id}: ${angle.toFixed(1)} deg`; setDiagnosticTextSprite(angleRef.sprite, text, angleRef.color); angleRef.sprite.position.copy(layout[angleRef.joint]).add(new THREE.Vector3(0, labelLift, 0)); angleRef.sprite.userData.__diagnosticLabel = text; } }; const updateHumanoidFrameHelpersGroup = ( group: THREE.Group, rig: MotionRig, root: THREE.Object3D, bounds: UnitBounds ) => { const refs = group.userData?.humanoidFrameRefs as HumanoidFrameOverlayRefs | undefined; if (!refs) return; const layout = resolveHumanoidJointSocketLayout(rig, root, bounds); if (!layout) return; const axisLength = refs.axisLength; for (const bone of refs.bones) { const start = layout[bone.from]; const end = layout[bone.to]; const midpoint = start.clone().add(end).multiplyScalar(0.5); const rollHint = resolveHumanoidBoneRollHint(bone, layout, midpoint); const frame = computeStableBoneFrame(start, end, rollHint); bone.forward.position.copy(frame.origin); bone.forward.setDirection(frame.forward); bone.forward.setLength(axisLength, axisLength * 0.28, axisLength * 0.18); bone.up.position.copy(frame.origin); bone.up.setDirection(frame.up); bone.up.setLength(axisLength, axisLength * 0.28, axisLength * 0.18); bone.right.position.copy(frame.origin); bone.right.setDirection(frame.right); bone.right.setLength(axisLength, axisLength * 0.28, axisLength * 0.18); } }; const createHumanoidFrameHelpersGroup = ( rig: MotionRig, bounds: UnitBounds ) => { const group = new THREE.Group(); group.name = 'diagnostic-humanoid-frames'; if (!activeObject) return group; const layout = resolveHumanoidJointSocketLayout(rig, activeObject, bounds); if (!layout) return group; const axisLength = Math.max(0.06, bounds.radius * 0.18); const bones: HumanoidFrameAxisRef[] = []; const setDepthTest = (material: THREE.Material | THREE.Material[], enabled: boolean): void => { if (Array.isArray(material)) { material.forEach((entry) => { entry.depthTest = enabled; }); return; } material.depthTest = enabled; }; const buildArrow = (color: number) => { const arrow = new THREE.ArrowHelper(new THREE.Vector3(0, 1, 0), new THREE.Vector3(), axisLength, color, axisLength * 0.28, axisLength * 0.18); arrow.renderOrder = 1001; arrow.userData.__diagnosticHelper = true; setDepthTest(arrow.line.material, false); setDepthTest(arrow.cone.material, false); return arrow; }; for (const boneDef of HUMANOID_JOINT_SOCKET_BONES) { const forward = buildArrow(0x5bbcff); const up = buildArrow(0x7bff7a); const right = buildArrow(0xff6b6b); bones.push({ from: boneDef.from, to: boneDef.to, right, up, forward, label: boneDef.label }); group.add(forward); group.add(up); group.add(right); } group.userData.humanoidFrameRefs = { bones, axisLength } as HumanoidFrameOverlayRefs; updateHumanoidFrameHelpersGroup(group, rig, activeObject, bounds); return group; }; const createHumanoidJointSocketHelpersGroup = ( rig: MotionRig, bounds: UnitBounds ) => { const group = new THREE.Group(); group.name = 'diagnostic-humanoid-joints'; if (!activeObject) return group; const layout = resolveHumanoidJointSocketLayout(rig, activeObject, bounds); if (!layout) return group; const markerRadius = Math.max(0.025, bounds.radius * 0.018); const markerGeo = new THREE.SphereGeometry(markerRadius, 10, 10); const markerColorFor = (id: HumanoidJointSocketId): string => { if (id.startsWith('left')) return '#73e9ff'; if (id.startsWith('right')) return '#ffbf73'; return '#9dff89'; }; const markers = {} as Record; for (const socketId of Object.keys(layout) as HumanoidJointSocketId[]) { const marker = new THREE.Mesh( markerGeo, new THREE.MeshBasicMaterial({ color: markerColorFor(socketId), depthTest: false, depthWrite: false }) ); marker.renderOrder = 1001; marker.userData.__diagnosticHelper = true; marker.position.copy(layout[socketId]); markers[socketId] = marker; group.add(marker); } const bones: HumanoidJointSocketBoneRef[] = []; for (const boneDef of HUMANOID_JOINT_SOCKET_BONES) { const line = createMotionLine('#9fd7ff', 0.86); line.renderOrder = 1001; line.userData.__diagnosticHelper = true; setMotionLineEndpoints(line, layout[boneDef.from], layout[boneDef.to]); line.visible = true; bones.push({ from: boneDef.from, to: boneDef.to, line, label: boneDef.label }); group.add(line); } const angles: HumanoidJointAngleRef[] = []; for (const angleDef of HUMANOID_JOINT_ANGLE_DEFS) { const sprite = createDiagnosticTextSprite(`${angleDef.id}: 0.0 deg`, angleDef.color); sprite.userData.__diagnosticHelper = true; angles.push({ id: angleDef.id, parent: angleDef.parent, joint: angleDef.joint, child: angleDef.child, sprite, color: angleDef.color }); group.add(sprite); } const refs: HumanoidJointSocketOverlayRefs = { markers, bones, angles }; group.userData.humanoidJointSocketRefs = refs; updateHumanoidJointSocketHelpersGroup(group, rig, activeObject, bounds); return group; }; const createSocketHelpersGroup = (unitType: UnitType, bounds: UnitBounds) => { const sockets = getUnitAttachmentSockets(unitType); const group = new THREE.Group(); group.name = 'diagnostic-socket-helpers'; const markerRadius = Math.max(0.04, bounds.radius * 0.03); for (let i = 0; i < sockets.length; i++) { const socket = sockets[i]; const socketPosition = toSocketPosition(bounds, socket); const hue = (i / Math.max(1, sockets.length)) * 0.86; const color = new THREE.Color().setHSL(hue, 0.74, 0.56); const marker = new THREE.Mesh( new THREE.SphereGeometry(markerRadius, 10, 10), new THREE.MeshBasicMaterial({ color }) ); marker.position.copy(socketPosition); marker.userData.__diagnosticHelper = true; marker.userData.__diagnosticLabel = `Socket ${socket.id}\n` + `Purpose: ${socket.purpose}\n` + `Expected local position:\n` + `x ${socketPosition.x.toFixed(2)} y ${socketPosition.y.toFixed(2)} z ${socketPosition.z.toFixed(2)}`; group.add(marker); const axisLength = markerRadius * 3.2; const axisMaterial = new THREE.LineBasicMaterial({ color: color.clone().offsetHSL(0, -0.1, 0.12) }); const axisGeometry = new THREE.BufferGeometry().setFromPoints([ new THREE.Vector3(-axisLength, 0, 0), new THREE.Vector3(axisLength, 0, 0), new THREE.Vector3(0, -axisLength, 0), new THREE.Vector3(0, axisLength, 0), new THREE.Vector3(0, 0, -axisLength), new THREE.Vector3(0, 0, axisLength) ]); const axisLines = new THREE.LineSegments(axisGeometry, axisMaterial); axisLines.position.copy(socketPosition); axisLines.userData.__diagnosticHelper = true; group.add(axisLines); const tetherGeometry = new THREE.BufferGeometry().setFromPoints([ new THREE.Vector3(0, 0, 0), socketPosition ]); const tether = new THREE.Line( tetherGeometry, new THREE.LineBasicMaterial({ color: color.clone().lerp(new THREE.Color('#ffffff'), 0.2), opacity: 0.56, transparent: true }) ); tether.userData.__diagnosticHelper = true; group.add(tether); } return group; }; const createPartProbeGroup = (bounds: UnitBounds) => { const group = new THREE.Group(); group.name = 'diagnostic-part-probes'; const owner = activeObject; if (!owner) { return group; } const probeRadius = Math.max(0.03, bounds.radius * 0.02); const upOffset = Math.max(0.08, bounds.height * 0.03); const probeRefs: Array<{ mesh: THREE.Mesh; centerLocal: THREE.Vector3; probe: THREE.Mesh; stem: THREE.Line; upOffset: number; }> = []; owner.traverse((node) => { const mesh = node as THREE.Mesh; if (!mesh.isMesh || mesh.userData?.__diagnosticHelper) { return; } const info = mesh.userData?.meshDiagnostic as MeshDiagnosticInfo | undefined; if (!info) { return; } if (!mesh.geometry.boundingBox) { mesh.geometry.computeBoundingBox(); } const localCenter = mesh.geometry.boundingBox ? mesh.geometry.boundingBox.getCenter(new THREE.Vector3()) : new THREE.Vector3(info.center.x, info.center.y, info.center.z); const worldCenter = localCenter.clone().applyMatrix4(mesh.matrixWorld); const center = owner.worldToLocal(worldCenter); const probe = new THREE.Mesh( new THREE.OctahedronGeometry(probeRadius, 0), new THREE.MeshBasicMaterial({ color: '#ffd75c' }) ); probe.position.copy(center); probe.userData.__diagnosticHelper = true; probe.userData.__diagnosticLabel = `Probe ${info.part}/${info.channel} #${info.geometryIndex}\n` + `Center: x ${info.center.x.toFixed(2)} y ${info.center.y.toFixed(2)} z ${info.center.z.toFixed(2)}\n` + `Size: x ${info.size.x.toFixed(2)} y ${info.size.y.toFixed(2)} z ${info.size.z.toFixed(2)}`; group.add(probe); const stem = new THREE.Line( new THREE.BufferGeometry().setFromPoints([ center, new THREE.Vector3(center.x, center.y + upOffset, center.z) ]), new THREE.LineBasicMaterial({ color: '#ffd75c', opacity: 0.6, transparent: true }) ); stem.userData.__diagnosticHelper = true; group.add(stem); probeRefs.push({ mesh, centerLocal: localCenter, probe, stem, upOffset }); }); group.userData.partProbeRefs = probeRefs; return group; }; const collectUnitMeshDiagnostics = () => { if (!activeObject) return [] as MeshDiagnosticInfo[]; const infos: MeshDiagnosticInfo[] = []; activeObject.traverse((node) => { const mesh = node as THREE.Mesh; if (!mesh.isMesh || mesh.userData?.__diagnosticHelper) return; const info = mesh.userData?.meshDiagnostic as MeshDiagnosticInfo | undefined; if (info) { infos.push(info); } }); return infos; }; const getMinAxisSample = (info: MeshDiagnosticInfo): { axis: 'x' | 'y' | 'z'; value: number } => { const dimensions: Array<{ axis: 'x' | 'y' | 'z'; value: number }> = [ { axis: 'x', value: info.size.x }, { axis: 'y', value: info.size.y }, { axis: 'z', value: info.size.z } ]; dimensions.sort((a, b) => a.value - b.value); return dimensions[0]; }; const getSocketDebugEntries = (unitType: UnitType, bounds: UnitBounds): SocketDebugEntry[] => { return getUnitAttachmentSockets(unitType).map((socket) => ({ id: socket.id, purpose: socket.purpose, position: toSocketPosition(bounds, socket) })); }; const getHumanoidJointSocketEntries = ( rig: MotionRig, root: THREE.Object3D, bounds: UnitBounds ): SocketDebugEntry[] => { const layout = resolveHumanoidJointSocketLayout(rig, root, bounds); if (!layout) return []; return (Object.keys(layout) as HumanoidJointSocketId[]).map((id) => ({ id, purpose: 'Humanoid joint', position: layout[id] })); }; const findNearestSocket = (point: THREE.Vector3, sockets: SocketDebugEntry[]) => { if (sockets.length === 0) { return null; } let best: { socket: SocketDebugEntry; distance: number } | null = null; for (const socket of sockets) { const distance = point.distanceTo(socket.position); if (!best || distance < best.distance) { best = { socket, distance }; } } return best; }; const evaluateHoverHints = (info: MeshDiagnosticInfo, bounds: UnitBounds | null) => { if (!bounds) return [] as string[]; const hints: string[] = []; if (info.part === 'weapons') { const turretLowThreshold = bounds.height * WEAPON_LOW_Y_RATIO; if (info.center.y < turretLowThreshold) { const delta = turretLowThreshold - info.center.y; hints.push( `Weapon housing/turret sits low: y ${info.center.y.toFixed(2)} < ${turretLowThreshold.toFixed(2)} ` + `(delta ${delta.toFixed(2)}).` ); } const rearwardThreshold = bounds.radius * WEAPON_REAR_Z_RATIO; if (info.center.z < rearwardThreshold) { const delta = rearwardThreshold - info.center.z; hints.push( `Weapon cluster is rearward: z ${info.center.z.toFixed(2)} < ${rearwardThreshold.toFixed(2)} ` + `(delta ${delta.toFixed(2)}).` ); } if (Math.abs(info.center.x) > bounds.radius * 0.9) { hints.push('Weapon lateral offset looks high. Re-center turret housing.'); } } if (info.part === 'base' && Math.abs(info.center.x) > bounds.radius * 1.45 && info.size.z > bounds.radius * 1.4) { const highTrackThreshold = bounds.height * TRACK_HIGH_Y_RATIO; if (info.center.y > highTrackThreshold) { hints.push( `Track assembly appears high: y ${info.center.y.toFixed(2)} > ${highTrackThreshold.toFixed(2)}. ` + 'Lower track/wheel Y offsets.' ); } const lowTrackThreshold = bounds.height * TRACK_LOW_Y_RATIO; if (info.center.y < lowTrackThreshold) { hints.push( `Track assembly appears low: y ${info.center.y.toFixed(2)} < ${lowTrackThreshold.toFixed(2)}. ` + 'Raise running gear Y offsets.' ); } } const thinSample = getMinAxisSample(info); if (thinSample.value < THIN_GEOMETRY_THRESHOLD) { hints.push( `Very thin geometry detected: min ${thinSample.value.toFixed(3)} on ${thinSample.axis}-axis ` + `(threshold ${THIN_GEOMETRY_THRESHOLD.toFixed(3)}).` ); } return hints; }; const auditArmorAttachments = ( unitType: UnitType, bounds: UnitBounds, infos: MeshDiagnosticInfo[] ) => { const baseGapThreshold = Math.max(0.012, bounds.radius * ARMOR_ATTACHMENT_GAP_RATIO); const baseLateralThreshold = Math.max(0.01, bounds.radius * ARMOR_ATTACHMENT_LATERAL_RATIO); const armorInfos = infos.filter((info) => { if (info.part !== 'base' && info.part !== 'details') return false; if (info.inferredPartPurpose && info.inferredPartPurpose !== 'armor_plate') return false; if (!info.inferredPartPurpose && info.channel !== 'armorPlating') return false; return true; }); const entries = armorInfos.map((info) => { const partScale = Math.max(info.size.x, info.size.y, info.size.z); const anchorTarget = info.inferredAnchorTargetAnchor ?? 'center'; const centerAnchored = anchorTarget === 'center'; const gapThreshold = Math.max(baseGapThreshold, partScale * (centerAnchored ? 0.18 : 0.08)); const lateralThreshold = Math.max(baseLateralThreshold, partScale * (centerAnchored ? 0.18 : 0.12)); const gap = Number.isFinite(info.inferredAttachmentGap ?? Number.NaN) ? Number(info.inferredAttachmentGap) : null; const lateral = Number.isFinite(info.inferredAttachmentLateralError ?? Number.NaN) ? Number(info.inferredAttachmentLateralError) : null; const anchored = Boolean(info.inferredAnchorTo || info.inferredAnchorSocketId); let status: 'ok' | 'off' | 'unanchored' | 'unknown' | 'root' = 'unknown'; if (!anchored) { status = info.inferredPartId === 'core_torso_command' ? 'root' : 'unanchored'; } else if (gap === null || lateral === null) { status = 'unknown'; } else if (Math.abs(gap) <= gapThreshold && lateral <= lateralThreshold) { status = 'ok'; } else { status = 'off'; } return { info, gap, lateral, anchored, status, gapThreshold, lateralThreshold }; }); const anchoredCount = entries.filter((entry) => entry.anchored).length; const okCount = entries.filter((entry) => entry.status === 'ok').length; const offCount = entries.filter((entry) => entry.status === 'off').length; const unanchoredCount = entries.filter((entry) => entry.status === 'unanchored').length; const unknownCount = entries.filter((entry) => entry.status === 'unknown').length; const rootCount = entries.filter((entry) => entry.status === 'root').length; const worstGap = entries .map((entry) => entry.gap) .filter((value): value is number => Number.isFinite(value)) .reduce((max, value) => Math.max(max, Math.abs(value)), 0); const worstLateral = entries .map((entry) => entry.lateral) .filter((value): value is number => Number.isFinite(value)) .reduce((max, value) => Math.max(max, value), 0); if (entries.length > 0) { console.groupCollapsed(`[MeshLabViewer] Armor attachment audit (${unitType})`); console.table(entries.map((entry) => ({ mesh: `${entry.info.part}:${entry.info.channel}:${entry.info.geometryIndex}`, partId: entry.info.inferredPartId ?? '', nodeId: entry.info.inferredPartNodeId ?? '', anchorTo: entry.info.inferredAnchorTo ?? entry.info.inferredAnchorSocketId ?? '', gap: entry.gap ?? 'n/a', lateral: entry.lateral ?? 'n/a', status: entry.status, gapThreshold: entry.gapThreshold.toFixed(3), lateralThreshold: entry.lateralThreshold.toFixed(3) }))); console.groupEnd(); } return { summaryLines: [ `Armor attachment audit: plates ${entries.length} | anchored ${anchoredCount} | ok ${okCount} | off ${offCount} | unanchored ${unanchoredCount} | root ${rootCount} | unknown ${unknownCount}`, `Thresholds: |gap| <= ${baseGapThreshold.toFixed(3)} (scaled), lateral <= ${baseLateralThreshold.toFixed(3)} (scaled) | worst gap ${worstGap.toFixed(3)}, worst lateral ${worstLateral.toFixed(3)}` ], offenders: entries .filter((entry) => entry.status === 'off' || entry.status === 'unanchored' || entry.status === 'unknown') .sort((a, b) => { const ag = Math.abs(a.gap ?? 0); const bg = Math.abs(b.gap ?? 0); if (bg !== ag) return bg - ag; return (b.lateral ?? 0) - (a.lateral ?? 0); }) }; }; const evaluateModelIssues = (unitType: UnitType | null, bounds: UnitBounds | null) => { if (!unitType || !bounds) { return ['Select a unit with bounds to run issue scan.']; } const infos = collectUnitMeshDiagnostics(); if (infos.length === 0) { return ['No per-part diagnostics found for this model.']; } const ignoredThinFaceCount = infos .map((info) => ({ info, thin: getMinAxisSample(info) })) .filter(({ info, thin }) => thin.value < THIN_GEOMETRY_THRESHOLD && info.triangles < MIN_TRIANGLES_FOR_THIN_ALERT) .length; const socketEntries = getSocketDebugEntries(unitType, bounds).map((socket) => ({ id: socket.id, position: { x: socket.position.x, y: socket.position.y, z: socket.position.z }, purpose: socket.purpose })); const activeSchema = activeProceduralSchema; const silhouette = activeSchema ? (() => { const snapshot = HumanoidUnitAssembler.inspectLayout(activeSchema.volumeHierarchy?.primary ?? 'hull', { bounds: activeSchema.bounds ?? bounds, compositionContext: { assetCategory: String(activeSchema.category ?? ''), assetId: String(activeSchema.assetId ?? ''), volumeTypeIndex: 0 } }); if (!snapshot) { return null; } return analyzeHumanoidSilhouetteSnapshot(snapshot, activeSchema.bounds ?? bounds, snapshot.family); })() : null; const analysis = analyzeUnitPartQuality({ unitType, bounds, meshes: infos, sockets: socketEntries, geometrySource: activePreviewGeometrySource ?? 'unknown', ignoredThinFragments: ignoredThinFaceCount, purposePrimitiveViolations: silhouette?.purposeViolations ?? [], silhouetteMetrics: silhouette ? { shoulderHipRatio: silhouette.metrics.shoulderHipRatio, shoulderWidthRatio: silhouette.metrics.shoulderWidthRatio, torsoHeightRatio: silhouette.metrics.torsoHeightRatio, headHeightRatio: silhouette.metrics.headHeightRatio, hipLevelRatio: silhouette.metrics.hipLevelRatio, expected: { shoulderHipRatio: silhouette.profile.shoulderHipRatio, torsoHeightRatio: silhouette.profile.torsoHeightRatio, headHeightRatio: silhouette.profile.headHeightRatio, hipLevelRatioMax: 0.03 } } : undefined }); const armorAudit = auditArmorAttachments(unitType, bounds, infos); if (analysis.issues.length === 0 && (!silhouette || silhouette.issues.length === 0)) { const armorLines = armorAudit.summaryLines; const armorOffenders = armorAudit.offenders .slice(0, 12) .map((entry, index) => `${index + 1}. ${entry.info.part}/${entry.info.channel}#${entry.info.geometryIndex} ` + `${entry.info.inferredPartId ?? entry.info.inferredPartNodeId ?? 'unknown'} ` + `gap ${entry.gap?.toFixed(3) ?? 'n/a'} lateral ${entry.lateral?.toFixed(3) ?? 'n/a'} ` + `(${entry.status})` ); return armorOffenders.length > 0 ? [ ...analysis.summaryLines, '', ...armorLines, 'Armor attachment offenders (top 12, full list in console):', ...armorOffenders ] : [...analysis.summaryLines, '', ...armorLines, 'No obvious placement anomalies detected by heuristics.']; } const issueLines = analysis.issues .slice(0, 8) .map((issue, index) => `${index + 1}. ${issue.message}`); const silhouetteLines = silhouette ? silhouette.issues.slice(0, 6).map((issue, index) => `${index + 1}. ${issue.message}`) : []; const armorLines = armorAudit.summaryLines; const armorOffenders = armorAudit.offenders .slice(0, 12) .map((entry, index) => `${index + 1}. ${entry.info.part}/${entry.info.channel}#${entry.info.geometryIndex} ` + `${entry.info.inferredPartId ?? entry.info.inferredPartNodeId ?? 'unknown'} ` + `gap ${entry.gap?.toFixed(3) ?? 'n/a'} lateral ${entry.lateral?.toFixed(3) ?? 'n/a'} ` + `(${entry.status})` ); const armorSection = armorOffenders.length > 0 ? ['', ...armorLines, 'Armor attachment offenders (top 12, full list in console):', ...armorOffenders] : ['', ...armorLines]; return silhouetteLines.length > 0 ? [...analysis.summaryLines, '', ...issueLines, ...armorSection, '', 'Silhouette profile:', ...silhouetteLines] : [...analysis.summaryLines, '', ...issueLines, ...armorSection]; }; const updateIssuePanel = () => { const issues = evaluateModelIssues(activeUnitType, activeUnitBounds); setIssueInfo(issues.join('\n')); }; const updateSocketMetadata = () => { if (!diagnosticsToggle.checked || !activeUnitType || !activeUnitBounds) { setSocketMetadata([]); return; } const sockets = getUnitAttachmentSockets(activeUnitType); if (sockets.length === 0) { setSocketMetadata([['Sockets', 'No socket definitions for this unit type']]); return; } const entries: Array<[string, string]> = []; entries.push(['Sockets', String(sockets.length)]); if (activeMotionRig && isHumanoidPreviewUnitType(activeUnitType)) { entries.push(['Humanoid Joint Sockets', `${HUMANOID_JOINT_SOCKET_BONES.length} bones / ${HUMANOID_JOINT_ANGLE_DEFS.length} angle probes`]); } for (let i = 0; i < sockets.length; i++) { const socket = sockets[i]; const p = toSocketPosition(activeUnitBounds, socket); entries.push([socket.id, `x ${p.x.toFixed(2)} y ${p.y.toFixed(2)} z ${p.z.toFixed(2)} | ${socket.purpose}`]); } setSocketMetadata(entries); }; const rebuildDiagnosticsOverlay = () => { clearDiagnosticsOverlay(); if (!diagnosticsToggle.checked || !activeObject || !activeUnitType || !activeUnitBounds) { return; } const group = new THREE.Group(); group.name = 'diagnostics-overlay'; if (socketHelpersToggle.checked) { group.add(createSocketHelpersGroup(activeUnitType, activeUnitBounds)); if (activeMotionRig && isHumanoidPreviewUnitType(activeUnitType)) { const jointHelpers = createHumanoidJointSocketHelpersGroup(activeMotionRig, activeUnitBounds); if (jointHelpers.children.length > 0) { group.add(jointHelpers); } } } if ( humanoidFramesToggle.checked && activeMotionRig && isHumanoidPreviewUnitType(activeUnitType) && activeUnitBounds ) { const frameHelpers = createHumanoidFrameHelpersGroup(activeMotionRig, activeUnitBounds); if (frameHelpers.children.length > 0) { group.add(frameHelpers); } } if (partProbesToggle.checked) { group.add(createPartProbeGroup(activeUnitBounds)); } if ( weaponMountOverlayToggle.checked && isHumanoidPreviewUnitType(activeUnitType) && activeProceduralSchema ) { const layoutParts = resolveHumanoidLayoutParts(activeUnitType, activeProceduralSchema); const mountOverlay = createWeaponMountOverlayGroup(layoutParts, activeUnitBounds); if (mountOverlay.children.length > 0) { group.add(mountOverlay); } } if (group.children.length > 0) { activeObject.add(group); activeDiagnosticGroup = group; } }; const updatePartProbeGroup = (group: THREE.Group) => { if (!activeObject) return; activeObject.updateMatrixWorld(true); const refs = group.userData?.partProbeRefs as Array<{ mesh: THREE.Mesh; centerLocal: THREE.Vector3; probe: THREE.Mesh; stem: THREE.Line; upOffset: number; }> | undefined; if (!refs || refs.length === 0) return; for (const ref of refs) { if (!ref.mesh.isMesh) continue; ref.mesh.updateMatrixWorld(); const worldCenter = ref.centerLocal.clone().applyMatrix4(ref.mesh.matrixWorld); const localCenter = activeObject.worldToLocal(worldCenter); ref.probe.position.copy(localCenter); const geometry = ref.stem.geometry as THREE.BufferGeometry; const position = geometry.getAttribute('position') as THREE.BufferAttribute; if (position && position.count >= 2) { position.setXYZ(0, localCenter.x, localCenter.y, localCenter.z); position.setXYZ(1, localCenter.x, localCenter.y + ref.upOffset, localCenter.z); position.needsUpdate = true; } } }; const createWeaponMountOverlayGroup = ( layoutParts: HumanoidLayoutPartLike[], bounds: UnitBounds | null ) => { const group = new THREE.Group(); group.name = 'diagnostic-weapon-mounts'; if (!bounds || layoutParts.length === 0) return group; const axisLen = Math.max(bounds.radius * 0.22, 0.12); const mountParts = layoutParts.filter((part) => ( part.partId === 'detail_weapon_hardpoint' || part.partId === 'detail_weapon_cradle' || /weaponhardpoint/i.test(part.id) || /weaponcradle/i.test(part.id) )); const createAxisLine = (start: THREE.Vector3, end: THREE.Vector3, color: number) => { const geometry = new THREE.BufferGeometry().setFromPoints([start, end]); const material = new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0.85 }); const line = new THREE.Line(geometry, material); line.renderOrder = 900; return line; }; for (const part of mountParts) { const center = new THREE.Vector3(part.center.x, part.center.y, part.center.z); const half = new THREE.Vector3(part.size.x * 0.5, part.size.y * 0.5, part.size.z * 0.5); const min = center.clone().sub(half); const max = center.clone().add(half); const box = new THREE.Box3(min, max); const color = part.partId.includes('cradle') || /weaponcradle/i.test(part.id) ? 0x8ab4ff : 0xffb347; const helper = new THREE.Box3Helper(box, color); helper.renderOrder = 890; group.add(helper); group.add(createAxisLine(center, new THREE.Vector3(center.x + axisLen, center.y, center.z), 0xff5fd3)); group.add(createAxisLine(center, new THREE.Vector3(center.x, center.y + axisLen, center.z), 0x4dff88)); group.add(createAxisLine(center, new THREE.Vector3(center.x, center.y, center.z + axisLen), 0x4dd7ff)); } return group; }; const refreshDiagnosticsUi = () => { if (!diagnosticsToggle.checked) { setHoverInfo('Diagnostics are disabled.'); setIssueInfo('Enable Diagnostics to scan mesh placement issues.'); setSocketMetadata([]); clearDiagnosticsOverlay(); return; } updateIssuePanel(); updateSocketMetadata(); rebuildDiagnosticsOverlay(); }; const updateDiagnosticsOverlayDynamic = () => { if ( !activeDiagnosticGroup || !diagnosticsToggle.checked || !activeObject || !activeUnitType ) { return; } if ( socketHelpersToggle.checked && activeMotionRig && activeUnitBounds && isHumanoidPreviewUnitType(activeUnitType) ) { const jointGroup = activeDiagnosticGroup.children.find((child) => child.name === 'diagnostic-humanoid-joints'); if (jointGroup instanceof THREE.Group) { updateHumanoidJointSocketHelpersGroup(jointGroup, activeMotionRig, activeObject, activeUnitBounds); } } if ( humanoidFramesToggle.checked && activeMotionRig && activeUnitBounds && isHumanoidPreviewUnitType(activeUnitType) ) { const frameGroup = activeDiagnosticGroup.children.find((child) => child.name === 'diagnostic-humanoid-frames'); if (frameGroup instanceof THREE.Group) { updateHumanoidFrameHelpersGroup(frameGroup, activeMotionRig, activeObject, activeUnitBounds); } } if (partProbesToggle.checked) { const probeGroup = activeDiagnosticGroup.children.find((child) => child.name === 'diagnostic-part-probes'); if (probeGroup instanceof THREE.Group) { updatePartProbeGroup(probeGroup); } } }; const getGeometryTriangleCount = (geometry: THREE.BufferGeometry) => { if (geometry.index) { return Math.floor(geometry.index.count / 3); } const positions = geometry.getAttribute('position'); return positions ? Math.floor(positions.count / 3) : 0; }; const toGeometryDetail = (previewLod: ViewerModel['previewLod']): GeometryDetail => { if (previewLod === 'lod0') return 'high'; if (previewLod === 'lod2') return 'low'; if (previewLod === 'lod3') return 'minimal'; return 'medium'; }; const clamp01 = (value: number): number => Math.min(1, Math.max(0, value)); const applyMechanicalUvProjection = (geometry: THREE.BufferGeometry, texelDensity = 0.26): THREE.BufferGeometry => { const inheritedUserData = { ...(geometry.userData ?? {}) }; let projected = geometry; if (projected.index) { projected = projected.toNonIndexed(); } projected.userData = { ...inheritedUserData, ...(projected.userData ?? {}) }; if (!projected.getAttribute('normal')) { projected.computeVertexNormals(); } const position = projected.getAttribute('position') as THREE.BufferAttribute | undefined; const normal = projected.getAttribute('normal') as THREE.BufferAttribute | undefined; if (!position || !normal || position.count !== normal.count) { return projected; } const uv = new Float32Array(position.count * 2); for (let i = 0; i < position.count; i++) { const x = position.getX(i); const y = position.getY(i); const z = position.getZ(i); const nx = normal.getX(i); const ny = normal.getY(i); const nz = normal.getZ(i); const ax = Math.abs(nx); const ay = Math.abs(ny); const az = Math.abs(nz); let u: number; let v: number; if (ay >= ax && ay >= az) { u = x; v = z * (ny >= 0 ? 1 : -1); } else if (ax >= az) { u = z * (nx >= 0 ? -1 : 1); v = y; } else { u = x * (nz >= 0 ? 1 : -1); v = y; } const offset = i * 2; uv[offset] = u * texelDensity; uv[offset + 1] = v * texelDensity; } projected.setAttribute('uv', new THREE.Float32BufferAttribute(uv, 2)); projected.setAttribute('uv2', new THREE.Float32BufferAttribute(new Float32Array(uv), 2)); projected.userData = { ...inheritedUserData, ...(projected.userData ?? {}) }; return projected; }; const getGeometryMetrics = (geometry: THREE.BufferGeometry) => { if (!geometry.boundingBox) { geometry.computeBoundingBox(); } const bounds = geometry.boundingBox; if (!bounds) { return { size: new THREE.Vector3(1, 1, 1), center: new THREE.Vector3(), longest: 1, shortest: 1, slenderness: 1, flatness: 1, verticality: 1, volume: 1 }; } const size = bounds.getSize(new THREE.Vector3()); const sx = Math.max(size.x, 1e-4); const sy = Math.max(size.y, 1e-4); const sz = Math.max(size.z, 1e-4); const center = bounds.getCenter(new THREE.Vector3()); const longest = Math.max(sx, sy, sz); const shortest = Math.min(sx, sy, sz); const mid = sx + sy + sz - longest - shortest; return { size, center, longest, shortest, slenderness: shortest / longest, flatness: sy / Math.max(sx, sz), verticality: sy / Math.max(mid, 1e-4), volume: sx * sy * sz }; }; const GEOMETRY_SLOT_BASE = 0; const GEOMETRY_SLOT_TEAM_PRIMARY = 1; const GEOMETRY_SLOT_TEAM_SECONDARY = 2; const GEOMETRY_SLOT_ACCENT = 3; const normalizeMaterialType = (value: unknown): string | null => { if (typeof value !== 'string') return null; const normalized = value.trim().toLowerCase(); return normalized.length > 0 ? normalized : null; }; const resolveMaterialTypeChannel = ( part: UnitPart, slot: number | null, materialType: string | null ): UnitMaterialChannel | null => { if (!materialType) return null; const isTeamPrimary = slot === GEOMETRY_SLOT_TEAM_PRIMARY; const isTeamSecondary = slot === GEOMETRY_SLOT_TEAM_SECONDARY; if (materialType.includes('glass_sensor') || materialType.includes('emissive_tech')) { return 'optics'; } if (materialType.includes('rubber_seal')) { return 'trim'; } if (materialType.includes('metal_tubing')) { return 'hydraulics'; } if (materialType.includes('brushed_metal')) { return 'mechanicalCore'; } if (materialType.includes('structural_metal') || materialType.includes('painted_metal')) { return part === 'base' ? 'hullArmor' : 'mechanicalCore'; } if (materialType.includes('heavy_armor')) { return 'armorPlating'; } if (materialType.includes('painted_armor')) { return part === 'base' ? 'hullArmor' : 'armorPlating'; } if (materialType.includes('composite_panel') || materialType.includes('industrial_composite')) { return part === 'base' ? 'armorPlating' : 'trim'; } if (materialType.includes('plastic') || materialType.includes('carbon_fiber') || materialType.includes('chrome')) { return 'trim'; } if (materialType.includes('team_coating')) { return isTeamSecondary ? 'trim' : (isTeamPrimary ? 'armorPlating' : 'hullArmor'); } return null; }; const extractGeometryTokens = (geometry: THREE.BufferGeometry): Set => { const tokens = new Set(); const pushToken = (value: unknown) => { if (typeof value !== 'string') return; const normalized = value.trim().toLowerCase(); if (normalized.length > 0) { tokens.add(normalized); } }; // Ignore full sourceVolumes list: it causes every split component to inherit // unrelated tags and collapses channel classification. pushToken(geometry.userData?.materialType); pushToken(geometry.userData?.sourcePart); return tokens; }; const classifyMaterialChannel = ( unitType: UnitType, part: UnitPart, geometry: THREE.BufferGeometry, geometryIndex: number ): UnitMaterialChannel => { const metrics = getGeometryMetrics(geometry); const tokens = extractGeometryTokens(geometry); const slot = typeof geometry.userData?.materialSlot === 'number' ? geometry.userData.materialSlot : null; const materialType = normalizeMaterialType(geometry.userData?.materialType); const materialTypeChannel = resolveMaterialTypeChannel(part, slot, materialType); const hasToken = (...patterns: string[]) => patterns.some((pattern) => { for (const token of tokens) { if (token.includes(pattern)) { return true; } } return false; }); const isTrack = hasToken('track', 'tread', 'rubber'); const isOptics = hasToken('scope', 'sensor', 'optic', 'antenna', 'light', 'glass'); const isArmor = hasToken('armor', 'plate', 'hull', 'turret', 'composite'); const isEngine = hasToken('engine', 'exhaust', 'machin', 'hydraulic'); const isWeapon = hasToken('weapon', 'barrel', 'mantlet', 'gun'); const isHeat = hasToken('heat', 'vent', 'muzzle', 'exhaust'); const isTrackedUnit = unitType === 'Tank' || unitType === 'HeavyTank' || unitType === 'Siege'; const sideMounted = Math.abs(metrics.center.x) > 1.35; const runningGearSide = Math.abs(metrics.center.x) > 2.2; const hydraulicLike = metrics.slenderness < 0.16 && metrics.shortest < 0.16 && metrics.longest > 1.1 && metrics.volume < 0.22; if (part === 'base') { if (isTrack) { return 'rubberTrack'; } if ( isTrackedUnit && runningGearSide && metrics.size.y <= 0.44 && metrics.size.x <= 0.58 ) { return 'rubberTrack'; } if ( isTrackedUnit && runningGearSide && metrics.size.z > 2.6 ) { if (metrics.size.y <= 0.24 && metrics.size.x <= 0.9) { return 'rubberTrack'; } if (metrics.size.x >= 0.85 && metrics.size.y >= 0.65) { return 'rubberTrack'; } if (metrics.size.y <= 0.55 && metrics.size.x <= 0.72) { return 'armorPlating'; } return 'hullArmor'; } if ( isTrackedUnit && !sideMounted && metrics.center.y <= 0.5 && metrics.size.y >= 0.45 && metrics.size.z >= 6.0 && metrics.size.x >= 4.2 ) { // Broad low hull tubs should stay on the hull channel, not armor applique. return 'hullArmor'; } if ( isTrackedUnit && !sideMounted && metrics.size.y <= 0.24 && metrics.size.z > 2.2 ) { return 'hullArmor'; } if (slot === GEOMETRY_SLOT_BASE && metrics.flatness < 0.22 && metrics.size.y < 0.38) { return 'rubberTrack'; } if (materialTypeChannel) { return materialTypeChannel; } if (isEngine) { return hydraulicLike ? 'hydraulics' : 'mechanicalCore'; } if (isOptics || slot === GEOMETRY_SLOT_ACCENT) { return 'optics'; } if (isArmor || slot === GEOMETRY_SLOT_TEAM_SECONDARY) { if (isTrackedUnit) { const prefersPlate = sideMounted || metrics.center.y > 0.92 || metrics.size.y <= 0.28; return prefersPlate ? 'armorPlating' : 'hullArmor'; } return geometryIndex % 2 === 0 ? 'armorPlating' : 'hullArmor'; } if (metrics.slenderness < 0.2) { if (isTrackedUnit) { if (hydraulicLike && sideMounted) { return 'hydraulics'; } return geometryIndex % 2 === 0 ? 'hullArmor' : 'armorPlating'; } return geometryIndex % 2 === 0 ? 'hydraulics' : 'mechanicalCore'; } if (unitType === 'Tank' || unitType === 'HeavyTank' || unitType === 'Siege') { if (sideMounted && metrics.size.y >= 0.2) { return 'armorPlating'; } return 'hullArmor'; } return geometryIndex % 3 === 0 ? 'armorPlating' : 'hullArmor'; } if (part === 'details') { if (isTrackedUnit) { if (isTrack) { return 'rubberTrack'; } if (isOptics || slot === GEOMETRY_SLOT_ACCENT || metrics.verticality > 1.45) { return 'optics'; } if ( metrics.slenderness < 0.16 && metrics.shortest < 0.14 && metrics.longest > 1.8 && metrics.volume < 0.16 ) { return 'hydraulics'; } if ((sideMounted && metrics.size.y < 0.16) || metrics.volume < 0.06 || metrics.shortest < 0.1) { return 'trim'; } if (materialTypeChannel) { return materialTypeChannel; } return geometryIndex % 3 === 0 ? 'mechanicalCore' : (geometryIndex % 3 === 1 ? 'armorPlating' : 'trim'); } if (isTrack) { return 'rubberTrack'; } if (isOptics || slot === GEOMETRY_SLOT_ACCENT) { return 'optics'; } if (materialTypeChannel) { return materialTypeChannel; } if (isEngine) { return geometryIndex % 2 === 0 ? 'mechanicalCore' : 'hydraulics'; } if (isArmor && slot === GEOMETRY_SLOT_TEAM_SECONDARY) { return geometryIndex % 2 === 0 ? 'armorPlating' : 'trim'; } if (metrics.volume < 0.05 || metrics.shortest < 0.12) { return 'trim'; } if (metrics.verticality > 1.2) { return 'optics'; } if (metrics.slenderness < 0.2 && metrics.shortest < 0.18 && metrics.volume < 0.24) { return 'hydraulics'; } return geometryIndex % 2 === 0 ? 'mechanicalCore' : 'armorPlating'; } if (part === 'highlights') { if (isTrackedUnit) { if (isTrack) { return 'rubberTrack'; } if (isOptics || slot === GEOMETRY_SLOT_ACCENT) { return 'optics'; } if (metrics.volume < 0.05 || metrics.shortest < 0.12) { return 'trim'; } if (materialTypeChannel) { return materialTypeChannel; } return geometryIndex % 2 === 0 ? 'armorPlating' : 'trim'; } if (isTrack) { return 'rubberTrack'; } if (isOptics || slot === GEOMETRY_SLOT_ACCENT) { return 'optics'; } if (materialTypeChannel) { return materialTypeChannel; } if (isArmor) { return geometryIndex % 2 === 0 ? 'armorPlating' : 'trim'; } if (metrics.verticality > 1.3 || metrics.size.y > metrics.size.x * 1.25) { return 'optics'; } if (metrics.volume < 0.045 || metrics.shortest < 0.14) { return 'trim'; } return 'armorPlating'; } if (isTrackedUnit) { if (isOptics || slot === GEOMETRY_SLOT_ACCENT) { return 'optics'; } if (isHeat || (metrics.slenderness < 0.2 && metrics.longest > 1.6)) { return 'heatShield'; } if (isWeapon || slot === GEOMETRY_SLOT_TEAM_PRIMARY) { return geometryIndex % 2 === 0 ? 'weaponHousing' : 'heatShield'; } if (metrics.volume < 0.03) { return 'trim'; } return geometryIndex % 3 === 0 ? 'weaponHousing' : (geometryIndex % 3 === 1 ? 'armorPlating' : 'heatShield'); } if (isOptics || slot === GEOMETRY_SLOT_ACCENT) { return 'optics'; } if (isHeat || slot === GEOMETRY_SLOT_TEAM_SECONDARY) { return 'heatShield'; } if (isTrack) { return 'rubberTrack'; } if (isWeapon || slot === GEOMETRY_SLOT_TEAM_PRIMARY) { return geometryIndex % 3 === 0 ? 'weaponHousing' : 'heatShield'; } if (metrics.slenderness < 0.16 && metrics.longest > 0.9) { return 'heatShield'; } if (metrics.verticality > 1.2 || metrics.size.y > metrics.size.x * 1.2) { return 'optics'; } return geometryIndex % 2 === 0 ? 'weaponHousing' : 'heatShield'; }; interface MaterialResponseTuning { roughnessFloor: number; roughnessWearBoost: number; metalnessScale: number; metalnessBias: number; clearcoatScale: number; envScale: number; useMetalnessMap: boolean; } interface ResolvedMeshlabMaterialProfile { surfacePattern: SurfaceTexturePattern; surfaceVariant: SurfaceTextureVariant; channelProfile: UnitMaterialChannelProfile; response: MaterialResponseTuning; useColorMap: boolean; useNormalMap: boolean; useAoMap: boolean; applyWear: boolean; applyDecals: boolean; authoredPreset: AuthoredModelMaterialPreset | null; } const getMaterialResponseTuning = (channel: UnitMaterialChannel): MaterialResponseTuning => { switch (channel) { case 'hullArmor': case 'armorPlating': case 'trim': return { roughnessFloor: 0.28, roughnessWearBoost: 0.04, metalnessScale: 0.6, metalnessBias: 0.0, clearcoatScale: 0.9, envScale: 0.7, useMetalnessMap: false }; case 'weaponHousing': return { roughnessFloor: 0.24, roughnessWearBoost: 0.04, metalnessScale: 0.85, metalnessBias: 0.08, clearcoatScale: 0.5, envScale: 0.78, useMetalnessMap: false }; case 'heatShield': return { roughnessFloor: 0.62, roughnessWearBoost: 0.04, metalnessScale: 0.2, metalnessBias: 0.02, clearcoatScale: 0.3, envScale: 0.5, useMetalnessMap: false }; case 'mechanicalCore': case 'hydraulics': return { roughnessFloor: 0.24, roughnessWearBoost: 0.04, metalnessScale: 0.75, metalnessBias: 0.1, clearcoatScale: 0.5, envScale: 0.7, useMetalnessMap: true }; case 'optics': return { roughnessFloor: 0.12, roughnessWearBoost: 0.02, metalnessScale: 0.25, metalnessBias: 0.02, clearcoatScale: 1.1, envScale: 0.85, useMetalnessMap: false }; case 'rubberTrack': return { roughnessFloor: 0.82, roughnessWearBoost: 0.12, metalnessScale: 0.12, metalnessBias: 0, clearcoatScale: 0, envScale: 0.34, useMetalnessMap: false }; default: return { roughnessFloor: 0.5, roughnessWearBoost: 0.08, metalnessScale: 0.35, metalnessBias: 0.02, clearcoatScale: 0.3, envScale: 0.5, useMetalnessMap: false }; } }; const resolveMeshlabMaterialProfile = ( unitType: UnitType, channel: UnitMaterialChannel, materialType: string | null ): ResolvedMeshlabMaterialProfile => { const profile = UNIT_SURFACE_PROFILES[unitType] ?? DEFAULT_UNIT_SURFACE_PROFILE; let channelProfile = resolveChannelProfile(unitType, channel); let response = getMaterialResponseTuning(channel); let surfacePattern = profile.pattern; let surfaceVariant = channelProfile.variant; let useColorMap = true; let useNormalMap = true; let useAoMap = true; let applyWear = true; let applyDecals = true; const normalized = normalizeMaterialType(materialType); const authoredPreset = resolveUnitAuthoredMaterialPreset(normalized, { unitType, channel }); if (!normalized) { return { surfacePattern, surfaceVariant, channelProfile, response, useColorMap, useNormalMap, useAoMap, applyWear, applyDecals, authoredPreset: null }; } if (authoredPreset) { return { surfacePattern, surfaceVariant, channelProfile: { ...channelProfile, normalScale: authoredPreset.useNormalMap === false ? 0 : channelProfile.normalScale, aoIntensity: authoredPreset.useAoMap === false ? 0 : channelProfile.aoIntensity, tintStrength: 0 }, response: { ...response, roughnessFloor: authoredPreset.roughness, roughnessWearBoost: 0, metalnessScale: 1, metalnessBias: 0, clearcoatScale: 1, envScale: 1, useMetalnessMap: authoredPreset.useMetalnessMap ?? false }, useColorMap: authoredPreset.useTextures ?? false, useNormalMap: authoredPreset.useNormalMap ?? false, useAoMap: authoredPreset.useAoMap ?? false, applyWear: false, applyDecals: false, authoredPreset }; } if (normalized.includes('heavy_armor')) { surfacePattern = 'industrial'; surfaceVariant = 'defense_armor'; useColorMap = false; applyWear = false; applyDecals = false; channelProfile = { ...channelProfile, metalness: 0.82, roughness: Math.max(channelProfile.roughness, 0.42), clearcoat: Math.min(channelProfile.clearcoat, 0.18), clearcoatRoughness: Math.max(channelProfile.clearcoatRoughness, 0.28), normalScale: 0.08, tintStrength: Math.min(channelProfile.tintStrength, 0.03) }; response = { ...response, useMetalnessMap: true, metalnessScale: 0.92, metalnessBias: 0.06 }; } else if (normalized.includes('painted_armor')) { surfacePattern = 'painted'; surfaceVariant = 'unit_plating'; useColorMap = false; useNormalMap = false; applyWear = false; applyDecals = false; channelProfile = { ...channelProfile, metalness: 0.14, roughness: 0.34, clearcoat: Math.max(channelProfile.clearcoat, 0.5), clearcoatRoughness: Math.min(channelProfile.clearcoatRoughness, 0.18), normalScale: 0.02, tintStrength: Math.min(channelProfile.tintStrength, 0.02) }; response = { ...response, useMetalnessMap: false, metalnessScale: 0.5, metalnessBias: 0.0, clearcoatScale: 1.0 }; } else if (normalized.includes('painted_metal')) { surfacePattern = 'painted'; surfaceVariant = 'pristine_painted_steel'; useColorMap = false; useNormalMap = false; applyWear = false; applyDecals = false; channelProfile = { ...channelProfile, metalness: 0.42, roughness: 0.28, clearcoat: Math.max(channelProfile.clearcoat, 0.34), clearcoatRoughness: Math.min(channelProfile.clearcoatRoughness, 0.14), normalScale: 0.02, tintStrength: Math.min(channelProfile.tintStrength, 0.02) }; response = { ...response, useMetalnessMap: false, metalnessScale: 0.8, metalnessBias: 0.04 }; } else if (normalized.includes('structural_metal')) { surfacePattern = 'industrial'; surfaceVariant = 'factory_hull'; useColorMap = false; applyWear = false; applyDecals = false; channelProfile = { ...channelProfile, metalness: 0.76, roughness: 0.38, clearcoat: Math.min(channelProfile.clearcoat, 0.12), normalScale: 0.06 }; response = { ...response, useMetalnessMap: true, metalnessScale: 0.9, metalnessBias: 0.06 }; } else if (normalized.includes('brushed_metal')) { surfacePattern = 'industrial'; surfaceVariant = 'vehicle_gunmetal'; useColorMap = false; applyWear = false; applyDecals = false; channelProfile = { ...channelProfile, metalness: 0.9, roughness: 0.22, clearcoat: 0.04, normalScale: 0.08 }; response = { ...response, useMetalnessMap: true, metalnessScale: 1.0, metalnessBias: 0.05 }; } else if (normalized.includes('metal_tubing')) { surfacePattern = 'industrial'; surfaceVariant = 'vehicle_gunmetal'; useColorMap = false; applyWear = false; applyDecals = false; channelProfile = { ...channelProfile, metalness: 0.96, roughness: 0.16, clearcoat: 0.02, normalScale: 0.05 }; response = { ...response, useMetalnessMap: true, metalnessScale: 1.02, metalnessBias: 0.05 }; } else if (normalized.includes('plastic')) { surfacePattern = 'painted'; surfaceVariant = 'pristine_plastic'; useColorMap = false; useNormalMap = false; useAoMap = false; applyWear = false; applyDecals = false; channelProfile = { ...channelProfile, metalness: 0.02, roughness: 0.56, clearcoat: 0.38, clearcoatRoughness: 0.16, normalScale: 0.0, tintStrength: Math.min(channelProfile.tintStrength, 0.02) }; response = { ...response, useMetalnessMap: false, metalnessScale: 0.0, metalnessBias: 0.0, clearcoatScale: 1.0 }; } else if (normalized.includes('carbon_fiber')) { surfacePattern = 'industrial'; surfaceVariant = 'pristine_carbon'; useColorMap = false; applyWear = false; applyDecals = false; channelProfile = { ...channelProfile, metalness: 0.08, roughness: 0.24, clearcoat: 0.44, clearcoatRoughness: 0.1, normalScale: 0.06 }; response = { ...response, useMetalnessMap: false, metalnessScale: 0.12, metalnessBias: 0.0, clearcoatScale: 1.0 }; } else if (normalized.includes('chrome')) { surfacePattern = 'industrial'; surfaceVariant = 'vehicle_gunmetal'; useColorMap = false; useNormalMap = false; useAoMap = false; applyWear = false; applyDecals = false; channelProfile = { ...channelProfile, metalness: 1.0, roughness: 0.08, clearcoat: 0.12, clearcoatRoughness: 0.05, envMapIntensity: Math.max(channelProfile.envMapIntensity, 1.1), normalScale: 0.0 }; response = { ...response, useMetalnessMap: false, metalnessScale: 1.0, metalnessBias: 0.0, envScale: 1.15 }; } else if (normalized.includes('glass_sensor')) { surfacePattern = 'accent'; surfaceVariant = 'signal_sensor'; useColorMap = false; useNormalMap = false; useAoMap = false; applyWear = false; applyDecals = false; channelProfile = { ...channelProfile, metalness: 0.02, roughness: 0.08, clearcoat: 0.9, clearcoatRoughness: 0.06, envMapIntensity: Math.max(channelProfile.envMapIntensity, 1.05), normalScale: 0.04 }; response = { ...response, useMetalnessMap: false, metalnessScale: 0.0, metalnessBias: 0.0 }; } return { surfacePattern, surfaceVariant, channelProfile, response, useColorMap, useNormalMap, useAoMap, applyWear, applyDecals, authoredPreset: null }; }; const getUnitChannelMaterial = ( unitType: UnitType, channel: UnitMaterialChannel, materialType: string | null ) => { const palette = getUnitPalette(unitType); const profile = UNIT_SURFACE_PROFILES[unitType] ?? DEFAULT_UNIT_SURFACE_PROFILE; const resolvedMaterial = resolveMeshlabMaterialProfile(unitType, channel, materialType); const channelProfile = resolvedMaterial.channelProfile; const response = resolvedMaterial.response; const authoredPreset = resolvedMaterial.authoredPreset; const unitBounds = getUnitBounds(unitType); const baseRepeat = resolveUnitSurfaceRepeat(unitBounds, profile.repeat); const needsGeneratedTextures = resolvedMaterial.useColorMap || resolvedMaterial.useNormalMap || resolvedMaterial.useAoMap || response.useMetalnessMap || resolvedMaterial.applyWear || resolvedMaterial.applyDecals; const textures = needsGeneratedTextures ? getProceduralSurfaceTextureSet({ cacheKey: `meshlab-unit-${unitType}-${channel}-${materialType ?? 'default'}`, pattern: resolvedMaterial.surfacePattern, variant: resolvedMaterial.surfaceVariant, repeat: baseRepeat * channelProfile.repeatScale, size: resolvePreviewTextureSize(unitType), roughnessBias: clamp01(profile.roughnessBias) }) : null; const roleTintColor = channelProfile.sourcePart === 'base' ? palette.hull : channelProfile.sourcePart === 'details' ? palette.detail : channelProfile.sourcePart === 'highlights' ? palette.highlight : palette.weapon; const coatMix = THREE.MathUtils.clamp(0.58 + channelProfile.tintStrength * 0.9, 0.5, 0.92); const tintedColor = authoredPreset ? new THREE.Color(authoredPreset.baseColor[0], authoredPreset.baseColor[1], authoredPreset.baseColor[2]) : new THREE.Color(0xcfd7df).lerp(new THREE.Color(roleTintColor), coatMix); const roughness = authoredPreset ? THREE.MathUtils.clamp(authoredPreset.roughness, 0.04, 1) : THREE.MathUtils.clamp( Math.max(channelProfile.roughness + channelProfile.roughnessOffset, response.roughnessFloor), 0.04, 1 ); const metalness = authoredPreset ? THREE.MathUtils.clamp(authoredPreset.metalness, 0, 1) : THREE.MathUtils.clamp( channelProfile.metalness * response.metalnessScale + response.metalnessBias, 0, 1 ); const clearcoat = authoredPreset ? THREE.MathUtils.clamp(authoredPreset.clearcoat ?? 0, 0, 1) : THREE.MathUtils.clamp( (channelProfile.clearcoat + (channel === 'optics' ? 0.08 : 0)) * response.clearcoatScale, 0, 1 ); const envMapIntensity = authoredPreset?.envMapIntensity ?? (channelProfile.envMapIntensity * response.envScale * (0.72 + (1 - roughness) * 0.18)); const emissiveColor = authoredPreset?.emissiveColor ? new THREE.Color( authoredPreset.emissiveColor[0], authoredPreset.emissiveColor[1], authoredPreset.emissiveColor[2] ) : new THREE.Color(0x000000); const emissiveIntensity = authoredPreset?.emissiveIntensity ?? 0; const material = new THREE.MeshPhysicalMaterial({ color: tintedColor, metalness, roughness, clearcoat, clearcoatRoughness: THREE.MathUtils.clamp(authoredPreset?.clearcoatRoughness ?? channelProfile.clearcoatRoughness, 0.03, 1), envMapIntensity, side: THREE.DoubleSide, emissive: emissiveIntensity > 0 ? emissiveColor : new THREE.Color(0x000000), emissiveIntensity, toneMapped: authoredPreset?.toneMapped ?? true }); if (Number.isFinite(authoredPreset?.transmission)) { material.transparent = true; material.opacity = THREE.MathUtils.clamp(authoredPreset?.opacity ?? 0.6, 0, 1); material.transmission = THREE.MathUtils.clamp(authoredPreset?.transmission ?? 0.8, 0, 1); material.ior = authoredPreset?.ior ?? 1.45; material.thickness = authoredPreset?.thickness ?? 0.3; } material.map = textures && resolvedMaterial.useColorMap ? textures.albedo : null; material.normalMap = textures && resolvedMaterial.useNormalMap ? textures.normal : null; material.roughnessMap = textures ? textures.roughness : null; material.metalnessMap = textures && response.useMetalnessMap ? textures.metallic : null; material.aoMap = textures && resolvedMaterial.useAoMap ? textures.ao : null; material.userData.wearMaskMap = textures?.wearMask ?? null; material.userData.decalMaskMap = textures?.decalMask ?? null; material.normalScale = new THREE.Vector2(channelProfile.normalScale, channelProfile.normalScale); material.aoMapIntensity = channelProfile.aoIntensity; const wearMaskMap = resolvedMaterial.applyWear ? textures?.wearMask ?? null : null; const decalMaskMap = resolvedMaterial.applyDecals ? textures?.decalMask ?? null : null; if (resolvedMaterial.applyWear || resolvedMaterial.applyDecals) { material.onBeforeCompile = (shader) => { shader.uniforms.wearMaskMap = { value: wearMaskMap }; shader.uniforms.decalMaskMap = { value: decalMaskMap }; shader.uniforms.meshlabRoughnessFloor = { value: response.roughnessFloor }; shader.uniforms.meshlabRoughnessWearBoost = { value: resolvedMaterial.applyWear ? response.roughnessWearBoost : 0.0 }; shader.uniforms.meshlabWearStrength = { value: resolvedMaterial.applyWear ? 1.0 : 0.0 }; shader.uniforms.meshlabDecalStrength = { value: resolvedMaterial.applyDecals ? 1.0 : 0.0 }; shader.fragmentShader = shader.fragmentShader.replace( '#include ', ` #include uniform sampler2D wearMaskMap; uniform sampler2D decalMaskMap; uniform float meshlabRoughnessFloor; uniform float meshlabRoughnessWearBoost; uniform float meshlabWearStrength; uniform float meshlabDecalStrength; ` ); shader.fragmentShader = shader.fragmentShader.replace( '#include ', ` #include float wearMask = texture2D(wearMaskMap, vMapUv).r * meshlabWearStrength; float decalMask = texture2D(decalMaskMap, vMapUv).r * meshlabDecalStrength; diffuseColor.rgb = mix(diffuseColor.rgb, diffuseColor.rgb * vec3(0.72, 0.71, 0.68), wearMask * 0.08); diffuseColor.rgb = mix(diffuseColor.rgb, vec3(0.86, 0.89, 0.93), decalMask * 0.03); ` ); shader.fragmentShader = shader.fragmentShader.replace( '#include ', ` #include float wearMaskMetal = texture2D(wearMaskMap, vMapUv).r; metalnessFactor = clamp(metalnessFactor - wearMaskMetal * 0.03, 0.0, 1.0); ` ); shader.fragmentShader = shader.fragmentShader.replace( '#include ', ` #include float wearMaskRough = texture2D(wearMaskMap, vMapUv).r; roughnessFactor = clamp( max(roughnessFactor + wearMaskRough * meshlabRoughnessWearBoost, meshlabRoughnessFloor), 0.02, 1.0 ); ` ); }; } material.needsUpdate = true; return material; }; const createUnitLODPreview = ( unitType: UnitType, schema: VisualLanguageSchema | null, previewLod: ViewerModel['previewLod'] ) => { const detail = toGeometryDetail(previewLod); const group = new THREE.Group(); let triangles = 0; let schemaGeometryCount = 0; let factoryGeometryCount = 0; const layoutParts = resolveHumanoidLayoutParts(unitType, schema); const bounds = schema?.bounds ? { radius: schema.bounds.radius, height: schema.bounds.height } : null; const schemaRequiredForUnit = USE_SCHEMA_UNIT_PREVIEW && isHumanoidPreviewUnitType(unitType) && !FORCE_FACTORY_PREVIEW_UNITS.has(unitType); if (schemaRequiredForUnit && !schema) { throw new Error(`Schema preview required for ${unitType}, but schema is missing.`); } const partOrder: UnitPart[] = ['base', 'details', 'highlights', 'weapons']; const materialCache = new Map(); for (const part of partOrder) { const allowSchemaPreview = USE_SCHEMA_UNIT_PREVIEW && Boolean(schema) && !FORCE_FACTORY_PREVIEW_UNITS.has(unitType); const preserveHumanoidComponents = isHumanoidPreviewUnitType(unitType) && detail === 'high'; const schemaGeometries = allowSchemaPreview ? schemaLODFactory.getLODGeometry(unitType, schema as VisualLanguageSchema, part, detail, { preserveComponents: preserveHumanoidComponents }) : []; if (schemaRequiredForUnit && schemaGeometries.length === 0) { throw new Error(`Schema preview for ${unitType} produced no geometry for "${part}".`); } const usingSchemaGeometry = schemaGeometries.length > 0; const sourceGeometries = usingSchemaGeometry || schemaRequiredForUnit ? schemaGeometries : UnitLODGeometryFactory.getLODGeometry(unitType, part, detail); if (usingSchemaGeometry) { schemaGeometryCount += sourceGeometries.length; } else { factoryGeometryCount += sourceGeometries.length; } for (let i = 0; i < sourceGeometries.length; i++) { const geometry = sourceGeometries[i]; const channel = classifyMaterialChannel(unitType, part, geometry, i); const materialType = normalizeMaterialType(geometry.userData?.materialType); const channelProfile = resolveChannelProfile(unitType, channel); const materialKey = `${channel}:${materialType ?? 'default'}`; let material = materialCache.get(materialKey); if (!material) { material = getUnitChannelMaterial(unitType, channel, materialType); materialCache.set(materialKey, material); } const projected = applyMechanicalUvProjection( geometry, channelProfile.uvDensity ); const metrics = getGeometryMetrics(projected); const sourceVolumes = Array.isArray(projected.userData?.sourceVolumes) ? projected.userData.sourceVolumes.filter((value: unknown): value is string => typeof value === 'string') : []; const sourceVolumeLevels = Array.isArray(projected.userData?.sourceVolumeLevels) ? projected.userData.sourceVolumeLevels.map((value: unknown) => value === 'primary' || value === 'secondary' || value === 'tertiary' ? value : null ) : []; const sourceVolumeIndices = Array.isArray(projected.userData?.sourceVolumeIndices) ? projected.userData.sourceVolumeIndices.map((value: unknown) => Number.isFinite(value as number) ? Number(value) : null ) : []; const mesh = new THREE.Mesh(projected, material); mesh.name = `${part}:${channel}:${i}`; mesh.userData.meshDiagnostic = { unitType, part, channel, geometryIndex: i, triangles: getGeometryTriangleCount(projected), center: { x: metrics.center.x, y: metrics.center.y, z: metrics.center.z }, size: { x: metrics.size.x, y: metrics.size.y, z: metrics.size.z }, sourcePart: projected.userData?.sourcePart, sourceVolumes, sourceVolumeLevels, sourceVolumeIndices, materialSlot: projected.userData?.materialSlot, materialType: projected.userData?.materialType, ...((): Partial => { const nearest = findNearestHumanoidLayoutPart(metrics.center, metrics.size, bounds, layoutParts); const approximate = !nearest ? findNearestHumanoidLayoutPartApproximate(metrics.center, bounds, layoutParts) : null; const resolved = nearest ?? approximate; if (!resolved) return {}; const isApproximate = !nearest && Boolean(approximate); const partDef = getHumanoidPartDefinition(resolved.partId); const sourceText = [ projected.userData?.sourcePart, sourceVolumes.join(' ') ] .filter(Boolean) .map((value) => String(value).toLowerCase()) .join(' '); const channelText = String(channel ?? '').toLowerCase(); const resolvedText = `${resolved.id} ${resolved.partId}`.toLowerCase(); const anatomySignal = /helmet|head|jaw|visor|shoulder|clavicle|arm|forearm|hand|elbow|bicep|tricep|torso|rib|sternum|abdomen|pelvis|hip|thigh|knee|calf|shin|ankle|foot|boot/; const weaponLike = part === 'weapons' || channelText.includes('weapon') || sourceText.includes('weapon') || sourceText.includes('barrel') || sourceText.includes('muzzle') || sourceText.includes('cradle') || sourceText.includes('hardpoint') || (materialType?.includes('weapon') ?? false); const backpackLike = !weaponLike && ( channelText.includes('backpack') || sourceText.includes('backpack') || sourceText.includes('pack') ) && !anatomySignal.test(resolvedText); return { inferredPartNodeId: isApproximate ? `~${resolved.id}` : resolved.id, inferredPartId: isApproximate ? `~${resolved.partId}` : resolved.partId, inferredPartGroup: weaponLike || backpackLike ? 'detail' : partDef?.group, inferredPartPurpose: weaponLike ? 'weapon' : (backpackLike ? 'backpack' : partDef?.purpose), inferredPartDistance: Math.sqrt( Math.pow((metrics.center.x - resolved.center.x) / Math.max(bounds?.radius ?? 1, 1e-4), 2) + Math.pow((metrics.center.y - resolved.center.y) / Math.max(bounds?.height ?? 1, 1e-4), 2) + Math.pow((metrics.center.z - resolved.center.z) / Math.max(bounds?.radius ?? 1, 1e-4), 2) ), inferredSilhouetteCritical: partDef?.silhouetteCritical, inferredApproximate: isApproximate, inferredLineage: `${resolved.id}:${resolved.partId}`, inferredLineageSource: sourceVolumes.join('>'), inferredLineageAnchor: resolved.id, inferredAnchorTo: resolved.anchorTo, inferredAnchorTargetAnchor: resolved.anchorTargetAnchor, inferredAnchorSelfAnchor: resolved.anchorSelfAnchor, inferredAnchorSocketId: resolved.anchorSocketId, inferredAttachmentGap: resolved.attachmentGap, inferredAttachmentLateralError: resolved.attachmentLateralError }; })() } as MeshDiagnosticInfo; mesh.castShadow = true; mesh.receiveShadow = true; triangles += getGeometryTriangleCount(projected); group.add(mesh); } } const geometrySource: PreviewGeometrySource = schemaGeometryCount > 0 && factoryGeometryCount > 0 ? 'mixed' : (schemaGeometryCount > 0 ? 'schema' : 'factory'); return { object: group, triangles, geometrySource }; }; const fitCameraToObject = (obj: THREE.Object3D) => { const bounds = new THREE.Box3().setFromObject(obj); const center = bounds.getCenter(new THREE.Vector3()); const size = bounds.getSize(new THREE.Vector3()); const maxDim = Math.max(size.x, size.y, size.z); obj.position.sub(center); const distance = Math.max(6, maxDim * 2.2); lastFitDistance = distance; camera.position.set(distance, distance * 0.72, distance); camera.near = Math.max(0.05, distance / 300); camera.far = Math.max(200, distance * 30); camera.updateProjectionMatrix(); controls.target.set(0, 0, 0); controls.update(); }; const resetView = () => { const distance = lastFitDistance; camera.position.set(distance, distance * 0.72, distance); controls.target.set(0, 0, 0); controls.update(); }; const cleanupGeneratedDownload = () => { if (!generatedDownloadUrl) return; URL.revokeObjectURL(generatedDownloadUrl); generatedDownloadUrl = null; }; const setDownloadFromProcedural = (mesh: ProceduralMesh, fileName: string) => { cleanupGeneratedDownload(); const lines = [`# Procedural Mesh Export`, `o ${fileName.replace(/\.obj$/i, '')}`]; for (const vertex of mesh.vertices) { lines.push(`v ${vertex.position.x} ${vertex.position.y} ${vertex.position.z}`); } for (const vertex of mesh.vertices) { lines.push(`vt ${vertex.uv.u} ${vertex.uv.v}`); } for (const vertex of mesh.vertices) { lines.push(`vn ${vertex.normal.x} ${vertex.normal.y} ${vertex.normal.z}`); } for (let i = 0; i < mesh.indices.length; i += 3) { const a = mesh.indices[i] + 1; const b = mesh.indices[i + 1] + 1; const c = mesh.indices[i + 2] + 1; lines.push(`f ${a}/${a}/${a} ${b}/${b}/${b} ${c}/${c}/${c}`); } const blob = new Blob([`${lines.join('\n')}\n`], { type: 'text/plain' }); generatedDownloadUrl = URL.createObjectURL(blob); downloadLink.href = generatedDownloadUrl; downloadLink.download = fileName; }; const proceduralMeshToObject = (mesh: ProceduralMesh) => { const geometry = new THREE.BufferGeometry(); const vertexCount = mesh.vertices.length; const positions = new Float32Array(vertexCount * 3); const normals = new Float32Array(vertexCount * 3); const colors = new Float32Array(vertexCount * 3); for (let i = 0; i < vertexCount; i++) { const vertex = mesh.vertices[i]; const color = SLOT_COLORS[vertex.materialSlot] ?? SLOT_COLORS[0]; const base = i * 3; positions[base] = vertex.position.x; positions[base + 1] = vertex.position.y; positions[base + 2] = vertex.position.z; normals[base] = vertex.normal.x; normals[base + 1] = vertex.normal.y; normals[base + 2] = vertex.normal.z; colors[base] = color.r; colors[base + 1] = color.g; colors[base + 2] = color.b; } geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); geometry.setAttribute('normal', new THREE.BufferAttribute(normals, 3)); geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3)); geometry.setIndex(mesh.indices); geometry.computeBoundingSphere(); const material = new THREE.MeshStandardMaterial({ color: '#ffffff', metalness: 0.24, roughness: 0.72, vertexColors: true }); const proceduralObject = new THREE.Mesh(geometry, material); proceduralObject.castShadow = true; proceduralObject.receiveShadow = true; return proceduralObject; }; const loadVisualSchemaCatalog = async () => { if (visualSchemaCache) { return visualSchemaCache; } const manifestUrl = `/assets_visual_manifest.json?rev=${encodeURIComponent(visualSchemaRevision)}`; const response = await fetch(manifestUrl, { cache: 'no-store' }); if (!response.ok) { throw new Error(`Failed to load assets_visual_manifest.json (${response.status})`); } const raw = await parseJsonWithDiagnostics( response, 'assets_visual_manifest.json' ); const catalog = new Map(); for (const unit of raw.units ?? []) { if (unit && typeof unit.assetId === 'string') { catalog.set(unit.assetId, unit); } } for (const building of raw.buildings ?? []) { if (building && typeof building.assetId === 'string') { catalog.set(building.assetId, building); } } await loadVisualSchemaOverrides(); visualSchemaCache = catalog; return visualSchemaCache; }; const parseJsonWithDiagnostics = async (response: Response, label: string): Promise => { const contentType = response.headers.get('content-type') ?? 'unknown'; const body = await response.text(); try { return JSON.parse(body) as T; } catch (error) { const compactSnippet = body.slice(0, 180).replace(/\s+/g, ' ').trim(); throw new Error( `${label} returned invalid JSON (status ${response.status}, content-type ${contentType}). ` + `Body starts with: "${compactSnippet}" | parse error: ${(error as Error).message}` ); } }; const resolveVisualSchemaRevision = (manifest: ViewerManifest): string => { const procgenVersion = manifest.source?.visualManifest?.procgenVersion; if (typeof procgenVersion === 'string' && procgenVersion.trim().length > 0) { return procgenVersion.trim(); } if (typeof manifest.generatedAt === 'string' && manifest.generatedAt.trim().length > 0) { return manifest.generatedAt.trim(); } return 'fallback'; }; const pickPreviewMesh = (schema: VisualLanguageSchema, previewLod: ViewerModel['previewLod']) => { const lodSet = LODGenerator.generateLODs(schema as unknown as Parameters[0]); if (previewLod === 'lod0') return lodSet.lod0; if (previewLod === 'lod2') return lodSet.lod2; if (previewLod === 'lod3') return lodSet.lod3; return lodSet.lod1; }; const findMotionRigMembership = ( object: THREE.Object3D ): { group: 'humanoid' | 'turret' | 'gun' | 'machineGun' | 'track'; node: MotionRigNode } | null => { if (!activeMotionRig) return null; const groups: Array<{ group: 'humanoid' | 'turret' | 'gun' | 'machineGun' | 'track'; nodes: MotionRigNode[] }> = [ { group: 'humanoid', nodes: activeMotionRig.humanoidNodes }, { group: 'gun', nodes: activeMotionRig.gunNodes }, { group: 'machineGun', nodes: activeMotionRig.machineGunNodes }, { group: 'turret', nodes: activeMotionRig.turretNodes }, { group: 'track', nodes: activeMotionRig.trackNodes } ]; for (const entry of groups) { const node = entry.nodes.find((candidate) => candidate.node === object); if (node) { return { group: entry.group, node }; } } return null; }; const updateHoverDiagnostics = () => { if (!diagnosticsToggle.checked) { return; } if (!activeObject || !pointerInsideCanvas) { setHoverInfo('Move cursor over mesh to inspect part details.'); return; } raycaster.setFromCamera(pointerNdc, camera); const intersections = raycaster.intersectObject(activeObject, true); if (intersections.length === 0) { setHoverInfo('No mesh under cursor.'); return; } const hit = intersections[0]; const object = hit.object as THREE.Object3D & { userData?: Record }; const helperLabel = typeof object.userData?.__diagnosticLabel === 'string' ? object.userData.__diagnosticLabel : null; if (helperLabel) { setHoverInfo(`${helperLabel}\nRay distance: ${hit.distance.toFixed(2)}`); return; } const mesh = hit.object as THREE.Mesh; const info = mesh.userData?.meshDiagnostic as MeshDiagnosticInfo | undefined; const objectName = mesh.name || mesh.parent?.name || 'mesh'; if (!info) { setHoverInfo( `Mesh: ${objectName}\n` + `Ray distance: ${hit.distance.toFixed(2)}\n` + 'No per-part diagnostics attached to this mesh.' ); return; } const lines = [ `Part: ${info.part} / ${info.channel}`, `Mesh: ${objectName} (#${info.geometryIndex})`, `Triangles: ${info.triangles.toLocaleString()}`, `Center: x ${info.center.x.toFixed(2)} y ${info.center.y.toFixed(2)} z ${info.center.z.toFixed(2)}`, `Size: x ${info.size.x.toFixed(2)} y ${info.size.y.toFixed(2)} z ${info.size.z.toFixed(2)}`, `Ray distance: ${hit.distance.toFixed(2)}` ]; if (info.inferredPartId || info.inferredPartNodeId) { lines.push( `Humanoid node: ${info.inferredPartNodeId ?? 'unknown'} | def: ${info.inferredPartId ?? 'unknown'}`, `Group/purpose: ${info.inferredPartGroup ?? 'unknown'} / ${info.inferredPartPurpose ?? 'unknown'}` + (typeof info.inferredPartDistance === 'number' ? ` | fit ${info.inferredPartDistance.toFixed(2)}` : '') + (typeof info.inferredSilhouetteCritical === 'boolean' ? ` | silhouette ${info.inferredSilhouetteCritical ? 'critical' : 'non-critical'}` : '') + (info.inferredApproximate ? ' | approx' : '') ); if (info.inferredLineage) { lines.push(`Lineage: ${info.inferredLineage}`); } if (info.inferredLineageSource) { lines.push(`Lineage source: ${info.inferredLineageSource}`); } if (info.inferredAnchorTo || info.inferredAnchorSocketId) { lines.push( `Mount: ${info.inferredAnchorTo ?? 'unknown'} ` + `${info.inferredAnchorTargetAnchor ?? 'center'} <- ${info.inferredAnchorSelfAnchor ?? 'center'}` + (info.inferredAnchorSocketId ? ` | socket ${info.inferredAnchorSocketId}` : '') ); } if (typeof info.inferredAttachmentGap === 'number' || typeof info.inferredAttachmentLateralError === 'number') { lines.push( `Attachment error: gap ${(info.inferredAttachmentGap ?? 0).toFixed(3)} | lateral ${(info.inferredAttachmentLateralError ?? 0).toFixed(3)}` ); } } const motionMembership = findMotionRigMembership(mesh); if (motionMembership) { const node = motionMembership.node; const roleInfo = node.humanoidRole ? ` role ${node.humanoidRole}/${node.humanoidSide ?? 'center'} w${(node.humanoidWeight ?? 0).toFixed(2)}` : ''; const fallbackInfo = node.humanoidFallback ? ' fallback' : ''; const compositeInfo = node.humanoidComposite ? ' composite' : ''; const mergeInfo = node.humanoidGroupId ? ` | merge ${node.humanoidGroupId} x${node.humanoidGroupSize ?? 1}` : ''; const socketInfo = node.humanoidSocketTarget ? ` | socket ${node.humanoidSocketTarget}` : ''; lines.push(`Motion rig: ${motionMembership.group}${roleInfo}${fallbackInfo}${compositeInfo}${mergeInfo}${socketInfo}`); if (activeMotionRig && node.humanoidRole) { const pivot = resolveHumanoidPivotForNode(activeMotionRig, node); const pivotDistance = pivot.distanceTo(node.restCenter); const pivotDistanceNorm = activeUnitBounds ? pivotDistance / Math.max(activeUnitBounds.height, activeUnitBounds.radius * 2, 1e-4) : 0; lines.push( `Motion pivot: x ${pivot.x.toFixed(2)} y ${pivot.y.toFixed(2)} z ${pivot.z.toFixed(2)}`, `Pivot distance: ${pivotDistance.toFixed(3)} (norm ${pivotDistanceNorm.toFixed(3)})` ); } } else if (isHumanoidPreviewUnitType(info.unitType)) { lines.push('Motion rig: static (not classified).'); } const sourceVolumes = Array.isArray(info.sourceVolumes) ? info.sourceVolumes.filter((value): value is string => typeof value === 'string') : []; if (sourceVolumes.length > 0) { lines.push(`Source volumes: ${sourceVolumes.join(', ')}`); } if (activeUnitBounds) { const yRatio = info.center.y / Math.max(activeUnitBounds.height, 1e-4); const zRatio = info.center.z / Math.max(activeUnitBounds.radius, 1e-4); const thinSample = getMinAxisSample(info); lines.push( `Normalized center: y ${yRatio.toFixed(2)}h z ${zRatio.toFixed(2)}r`, `Min axis: ${thinSample.axis}=${thinSample.value.toFixed(3)} (thin threshold ${THIN_GEOMETRY_THRESHOLD.toFixed(3)})` ); if (activeUnitType) { const sockets = getSocketDebugEntries(activeUnitType, activeUnitBounds); const nearestSocket = findNearestSocket( new THREE.Vector3(info.center.x, info.center.y, info.center.z), sockets ); if (nearestSocket) { lines.push( `Nearest socket: ${nearestSocket.socket.id} (dist ${nearestSocket.distance.toFixed(2)})` ); } } } const hints = evaluateHoverHints(info, activeUnitBounds); if (hints.length > 0) { lines.push('', 'Hints:'); for (let i = 0; i < Math.min(3, hints.length); i++) { lines.push(`- ${hints[i]}`); } } setHoverInfo(lines.join('\n')); }; const pickMeshDiagnosticAtPointer = ( clientX: number, clientY: number ): { hit: THREE.Intersection; info: MeshDiagnosticInfo } | null => { if (!activeObject) { return null; } const rect = canvas.getBoundingClientRect(); if (rect.width <= 0 || rect.height <= 0) { return null; } const ndcX = ((clientX - rect.left) / rect.width) * 2 - 1; const ndcY = -(((clientY - rect.top) / rect.height) * 2 - 1); raycaster.setFromCamera(new THREE.Vector2(ndcX, ndcY), camera); const hit = raycaster.intersectObject(activeObject, true)[0]; if (!hit) { return null; } const mesh = hit.object as THREE.Mesh; const info = mesh.userData?.meshDiagnostic as MeshDiagnosticInfo | undefined; if (!info) { return null; } return { hit, info }; }; const logMeshDiagnosticToConsole = ( hit: THREE.Intersection, info: MeshDiagnosticInfo ) => { const motionMembership = findMotionRigMembership(hit.object); const motionPivot = activeMotionRig && motionMembership?.node.humanoidRole ? resolveHumanoidPivotForNode(activeMotionRig, motionMembership.node) : null; const sourceVolumes = Array.isArray(info.sourceVolumes) ? info.sourceVolumes.filter((value): value is string => typeof value === 'string') : []; const summary = { unit: info.unitType, mesh: (hit.object as THREE.Object3D).name, part: info.part, channel: info.channel, geometryIndex: info.geometryIndex, triangles: info.triangles, center: info.center, size: info.size, inferredPartNodeId: info.inferredPartNodeId ?? null, inferredPartId: info.inferredPartId ?? null, inferredPartGroup: info.inferredPartGroup ?? null, inferredPartPurpose: info.inferredPartPurpose ?? null, inferredSilhouetteCritical: info.inferredSilhouetteCritical ?? null, inferredApproximate: info.inferredApproximate ?? null, inferredLineage: info.inferredLineage ?? null, inferredLineageSource: info.inferredLineageSource ?? null, inferredLineageAnchor: info.inferredLineageAnchor ?? null, inferredAnchorTo: info.inferredAnchorTo ?? null, inferredAnchorTargetAnchor: info.inferredAnchorTargetAnchor ?? null, inferredAnchorSelfAnchor: info.inferredAnchorSelfAnchor ?? null, inferredAnchorSocketId: info.inferredAnchorSocketId ?? null, inferredAttachmentGap: typeof info.inferredAttachmentGap === 'number' ? info.inferredAttachmentGap : null, inferredAttachmentLateralError: typeof info.inferredAttachmentLateralError === 'number' ? info.inferredAttachmentLateralError : null, motionRigGroup: motionMembership?.group ?? null, motionRigRole: motionMembership?.node.humanoidRole ?? null, motionRigSide: motionMembership?.node.humanoidSide ?? null, motionRigWeight: motionMembership?.node.humanoidWeight ?? null, motionRigFallback: motionMembership?.node.humanoidFallback ?? null, motionRigComposite: motionMembership?.node.humanoidComposite ?? null, motionRigMergeId: motionMembership?.node.humanoidGroupId ?? null, motionRigMergeSize: motionMembership?.node.humanoidGroupSize ?? null, motionRigSocketKey: motionMembership?.node.humanoidSocketKey ?? null, motionRigSocketTarget: motionMembership?.node.humanoidSocketTarget ?? null, motionPivot: motionPivot ? { x: motionPivot.x, y: motionPivot.y, z: motionPivot.z } : null, sourcePart: info.sourcePart ?? null, sourceVolumes, materialSlot: info.materialSlot ?? null, materialType: info.materialType ?? null, rayDistance: Number(hit.distance.toFixed(4)) }; const title = `[MeshLabViewer] Part pick ${info.unitType}:${info.part}/${info.channel}#${info.geometryIndex}`; console.groupCollapsed(title); console.table(summary); console.log(summary); console.groupEnd(); }; const loadModel = async (model: ViewerModel) => { const token = ++loadToken; activeModel = model; activeUnitType = null; activeUnitBounds = null; activeProceduralSchema = null; activePreviewGeometrySource = null; suspendEditSelectionForReload(); syncUndoRedoButtons(); clearPartGroupHighlightCache(); clearMotionTerrain(); clearMotionRig(); syncConstructionFloor(); updateMotionUiAvailability(); setStatus(`Loading ${model.label ?? model.fileName ?? model.assetName ?? model.id}...`); renderMetadata(model); cleanupGeneratedDownload(); setHoverInfo('Move cursor over mesh to inspect part details.'); setIssueInfo('Issue scan will appear here.'); setSocketMetadata([]); clearDiagnosticsOverlay(); rebuildPartVisibility(null); try { if (model.kind === 'obj') { if (!model.url || !model.fileName) { throw new Error('OBJ model is missing url/fileName'); } downloadLink.href = model.url; downloadLink.download = model.fileName; loader.load( model.url, (obj) => { if (token !== loadToken) return; if (activeObject) { clearDiagnosticsOverlay(); scene.remove(activeObject); } activeObject = obj; syncPartGroupHighlightCache(activeObject); applyWireframe(activeObject, wireframeToggle.checked); applyPartGroupHighlight(activeObject, partGroupHighlightToggle.checked); rebuildPartVisibility(activeObject); scene.add(activeObject); fitCameraToObject(activeObject); syncConstructionFloor(); initializeMotionLabForActiveModel(); refreshDiagnosticsUi(); restoreEditSelectionIfPending(); setStatus(`Loaded ${model.fileName}`); }, (event) => { if (token !== loadToken || !event.total) return; const progress = Math.round((event.loaded / event.total) * 100); setStatus(`Loading ${model.fileName} (${progress}%)`); }, (error) => { if (token !== loadToken) return; console.error('[MeshLabViewer] OBJ load failed', error); setStatus(`Failed to load ${model.fileName}`); } ); return; } if (!model.assetId) { throw new Error('Procedural model is missing assetId'); } const unitType = ASSET_ID_TO_UNIT_TYPE.get(model.assetId); if (model.source === 'unit' && unitType) { // Prevent stale geometry in long-running MeshLab sessions after generator updates. schemaLODFactory.clearCache(); const catalog = await loadVisualSchemaCatalog(); const schemaEntry = catalog.get(model.assetId); const schema = resolveSchemaWithOverrides( schemaEntry ?? null, model.assetId ); const { object, triangles, geometrySource } = createUnitLODPreview( unitType, schema ?? null, model.previewLod ); activeProceduralSchema = schema ?? null; activePreviewGeometrySource = geometrySource; generatedTrianglesById.set(model.id, triangles); renderMetadata(model); if (activeObject) { clearDiagnosticsOverlay(); scene.remove(activeObject); } activeObject = object; activeObject.name = model.assetName ?? model.assetId; syncPartGroupHighlightCache(activeObject); applyWireframe(activeObject, wireframeToggle.checked); applyPartGroupHighlight(activeObject, partGroupHighlightToggle.checked); rebuildPartVisibility(activeObject); scene.add(activeObject); fitCameraToObject(activeObject); activeUnitType = unitType; activeUnitBounds = resolveUnitBounds(model, schema ?? null); syncConstructionFloor(); initializeMotionLabForActiveModel(); refreshDiagnosticsUi(); refreshUpgradeOptions(); restoreEditSelectionIfPending(); // Keep download action available for unit entries via schema fallback export flow. if (schemaEntry) { const previewMesh = pickPreviewMesh(schema ?? schemaEntry, model.previewLod); const exportName = `${model.assetId.toLowerCase()}.obj`; setDownloadFromProcedural(previewMesh, exportName); } else { downloadLink.removeAttribute('href'); } setStatus(`Loaded unit LOD preview ${model.assetName ?? model.assetId} (${model.previewLod ?? 'lod1'})`); return; } const catalog = await loadVisualSchemaCatalog(); const schema = resolveSchemaWithOverrides( catalog.get(model.assetId) ?? null, model.assetId ); if (!schema) { throw new Error(`Schema ${model.assetId} was not found in assets_visual_manifest.json`); } activeProceduralSchema = schema; if (token !== loadToken) return; const previewMesh = pickPreviewMesh(schema, model.previewLod); generatedTrianglesById.set(model.id, Math.floor(previewMesh.indices.length / 3)); renderMetadata(model); const object = proceduralMeshToObject(previewMesh); object.name = schema.assetName || model.assetId; if (activeObject) { clearDiagnosticsOverlay(); scene.remove(activeObject); } activeObject = object; syncPartGroupHighlightCache(activeObject); applyWireframe(activeObject, wireframeToggle.checked); applyPartGroupHighlight(activeObject, partGroupHighlightToggle.checked); rebuildPartVisibility(activeObject); scene.add(activeObject); fitCameraToObject(activeObject); syncConstructionFloor(); initializeMotionLabForActiveModel(); refreshDiagnosticsUi(); refreshUpgradeOptions(); restoreEditSelectionIfPending(); const exportName = `${model.assetId.toLowerCase()}.obj`; setDownloadFromProcedural(previewMesh, exportName); setStatus(`Loaded procedural ${schema.assetName} (${model.previewLod ?? (model.source === 'unit' ? 'lod0' : 'lod1')})`); } catch (error) { if (token !== loadToken) return; console.error('[MeshLabViewer] Model load failed', error); syncConstructionFloor(); initializeMotionLabForActiveModel(); setStatus(`Failed to load ${model.assetName ?? model.fileName ?? model.id}`); } }; const populateModelPicker = (manifestModels: ViewerModel[]) => { modelSelect.innerHTML = ''; models = manifestModels; for (const model of models) { const option = document.createElement('option'); const label = model.kind === 'procedural' ? `${(model.source ?? 'asset').toUpperCase()} - ${model.assetName ?? model.assetId ?? model.id}` : [model.family, model.lod, model.variant == null ? null : `v${model.variant}`] .filter(Boolean) .join(' / '); option.value = model.id; option.textContent = model.kind === 'procedural' ? label : (label ? `${label} - ${model.fileName ?? model.id}` : model.fileName ?? model.id); modelSelect.append(option); } if (models.length > 0) { modelSelect.value = models[0].id; void loadModel(models[0]); return; } setStatus('No MeshLab assets found. Run mesh sync scripts.'); }; const normalizeModel = (rawModel: unknown, index: number): ViewerModel | null => { if (!rawModel || typeof rawModel !== 'object') { return null; } const record = rawModel as Record; const id = typeof record.id === 'string' ? record.id : `model-${index + 1}`; const kindValue = record.kind === 'procedural' ? 'procedural' : 'obj'; if (kindValue === 'procedural') { const configuredPreviewLod = record.previewLod === 'lod0' || record.previewLod === 'lod1' || record.previewLod === 'lod2' || record.previewLod === 'lod3' ? record.previewLod : undefined; const inferredSource = record.source === 'building' ? 'building' : 'unit'; return { id, kind: 'procedural', source: inferredSource, label: typeof record.label === 'string' ? record.label : undefined, assetId: typeof record.assetId === 'string' ? record.assetId : undefined, assetName: typeof record.assetName === 'string' ? record.assetName : undefined, category: typeof record.category === 'string' ? record.category : undefined, role: typeof record.role === 'string' ? record.role : undefined, techLevel: typeof record.techLevel === 'number' ? record.techLevel : null, previewLod: configuredPreviewLod ?? (inferredSource === 'unit' ? 'lod0' : 'lod1'), bounds: (record.bounds && typeof record.bounds === 'object') ? { radius: Number((record.bounds as { radius?: unknown }).radius ?? 0), height: Number((record.bounds as { height?: unknown }).height ?? 0) } : null, tags: Array.isArray(record.tags) ? record.tags.filter((item): item is string => typeof item === 'string') : [] }; } return { id, kind: 'obj', source: record.source === 'tree' ? 'tree' : 'tree', family: typeof record.family === 'string' ? record.family : undefined, lod: typeof record.lod === 'string' ? record.lod : undefined, variant: typeof record.variant === 'number' ? record.variant : null, totalTriangles: typeof record.totalTriangles === 'number' ? record.totalTriangles : null, stemTriangles: typeof record.stemTriangles === 'number' ? record.stemTriangles : null, foliageTriangles: typeof record.foliageTriangles === 'number' ? record.foliageTriangles : null, fileName: typeof record.fileName === 'string' ? record.fileName : undefined, url: typeof record.url === 'string' ? record.url : undefined, bytes: typeof record.bytes === 'number' ? record.bytes : undefined }; }; const normalizeManifest = (raw: unknown): ViewerManifest => { if (!raw || typeof raw !== 'object') { return { models: [] }; } const asRecord = raw as Record; if (Array.isArray(asRecord.models)) { const normalizedModels = asRecord.models .map((model, index) => normalizeModel(model, index)) .filter((model): model is ViewerModel => model !== null); const sourceRecord = (asRecord.source && typeof asRecord.source === 'object') ? asRecord.source as Record : null; const visualManifestRecord = sourceRecord?.visualManifest && typeof sourceRecord.visualManifest === 'object' ? sourceRecord.visualManifest as Record : null; return { generatedAt: typeof asRecord.generatedAt === 'string' ? asRecord.generatedAt : undefined, source: visualManifestRecord ? { visualManifest: { procgenVersion: typeof visualManifestRecord.procgenVersion === 'string' ? visualManifestRecord.procgenVersion : undefined } } : undefined, models: normalizedModels }; } if (Array.isArray(asRecord.artifacts)) { const artifacts = asRecord.artifacts as Array>; const modelsFromArtifacts = artifacts .map((artifact, index) => { const fileRaw = typeof artifact.file === 'string' ? artifact.file : ''; const fileName = fileRaw.split('/').pop() ?? ''; if (!fileName) return null; return { id: `${artifact.family ?? 'mesh'}-${artifact.lod ?? 'lod'}-${artifact.variant ?? index + 1}`, kind: 'obj', source: 'tree', family: typeof artifact.family === 'string' ? artifact.family : 'unknown', lod: typeof artifact.lod === 'string' ? artifact.lod : 'unknown', variant: typeof artifact.variant === 'number' ? artifact.variant : null, totalTriangles: typeof artifact.totalTriangles === 'number' ? artifact.totalTriangles : null, stemTriangles: typeof artifact.stemTriangles === 'number' ? artifact.stemTriangles : null, foliageTriangles: typeof artifact.foliageTriangles === 'number' ? artifact.foliageTriangles : null, fileName, url: `/assets/meshlab/${fileName}` } as ViewerModel; }) .filter((value): value is ViewerModel => value !== null); return { generatedAt: typeof asRecord.generatedAt === 'string' ? asRecord.generatedAt : undefined, models: modelsFromArtifacts }; } return { models: [] }; }; const loadManifest = async () => { try { try { await preloadGeneratedSurfaceTexturesFromManifest(); } catch (preloadError) { console.warn('[MeshLabViewer] Texture preload failed; using procedural fallback.', preloadError); } const manifestUrls = [ '/assets/meshlab/manifest.override.json', '/assets/meshlab/manifest.json' ]; let response: Response | null = null; let resolvedUrl = ''; for (const url of manifestUrls) { const candidate = await fetch(url, { cache: 'no-cache' }); if (candidate.ok) { response = candidate; resolvedUrl = url; break; } } if (!response) { throw new Error('HTTP 404'); } const raw = await parseJsonWithDiagnostics(response, resolvedUrl); const manifest = normalizeManifest(raw); const nextSchemaRevision = resolveVisualSchemaRevision(manifest); if (nextSchemaRevision !== visualSchemaRevision) { visualSchemaRevision = nextSchemaRevision; visualSchemaCache = null; visualSchemaOverrideCache = null; } populateModelPicker(manifest.models); if (manifest.generatedAt) { const timestamp = new Date(manifest.generatedAt).toLocaleString(); setStatus(`Loaded manifest (${timestamp})`); } } catch (error) { console.error('[MeshLabViewer] Manifest load failed', error); setStatus('Failed to load MeshLab manifest.'); } }; modelSelect.addEventListener('change', () => { const selected = models.find((entry) => entry.id === modelSelect.value); if (selected) { void loadModel(selected); } }); wireframeToggle.addEventListener('change', () => { if (activeObject) { applyWireframe(activeObject, wireframeToggle.checked); } }); partGroupHighlightToggle.addEventListener('change', () => { if (activeObject) { applyPartGroupHighlight(activeObject, partGroupHighlightToggle.checked); applyWireframe(activeObject, wireframeToggle.checked); } }); partGroupHighlightMode.addEventListener('change', () => { if (activeObject && partGroupHighlightToggle.checked) { applyPartGroupHighlight(activeObject, true); applyWireframe(activeObject, wireframeToggle.checked); } }); partVisibilityMode.addEventListener('change', () => { rebuildPartVisibility(activeObject); }); partVisibilityAllButton.addEventListener('click', () => { if (!partVisibilityBuckets) return; for (const key of partVisibilityBuckets.keys()) { partVisibilityState.set(key, true); const input = partVisibilityInputs.get(key); if (input) input.checked = true; } applyPartVisibilityFilters(); }); partVisibilityNoneButton.addEventListener('click', () => { if (!partVisibilityBuckets) return; for (const key of partVisibilityBuckets.keys()) { partVisibilityState.set(key, false); const input = partVisibilityInputs.get(key); if (input) input.checked = false; } applyPartVisibilityFilters(); }); editModeToggle.addEventListener('change', () => { editModeEnabled = editModeToggle.checked; if (!editModeEnabled) { clearEditSelection(); return; } transformControls.enabled = true; transformControls.visible = Boolean(activeEditSelection); transformControls.setMode(editTransformMode.value as 'translate' | 'rotate' | 'scale'); transformControls.setSpace(editGizmoSpace.value === 'local' ? 'local' : 'world'); applyAxisConstraint(editAxisConstraint); applySnapSettings(); setEditStatus(activeEditSelection ? 'Edit mode enabled.' : 'Edit mode enabled. Click a mesh to select.'); }); editTransformMode.addEventListener('change', () => { transformControls.setMode(editTransformMode.value as 'translate' | 'rotate' | 'scale'); }); editGizmoSpace.addEventListener('change', () => { transformControls.setSpace(editGizmoSpace.value === 'local' ? 'local' : 'world'); }); editLabelToggle.addEventListener('change', () => { editLabelEnabled = editLabelToggle.checked; if (activeSelectionOverlay) { activeSelectionOverlay.label.visible = editLabelEnabled; } }); editFrameToggle.addEventListener('change', () => { editFrameEnabled = editFrameToggle.checked; if (activeSelectionOverlay?.axes) { activeSelectionOverlay.axes.visible = editFrameEnabled; } }); editDragTranslateToggle.addEventListener('change', () => { editDragTranslateEnabled = editDragTranslateToggle.checked; setEditStatus(editDragTranslateEnabled ? 'Drag-to-move enabled.' : 'Drag-to-move disabled.'); }); editSymmetryMode.addEventListener('change', () => { if (editModeEnabled) { setEditStatus(`Symmetry: ${editSymmetryMode.value}`); } if (activeEditSelection) { disposeSelectionOverlay(); activeSelectionOverlay = createSelectionOverlay(activeEditSelection); } }); editSnapToggle.addEventListener('change', () => { applySnapSettings(); }); editSnapStepInput.addEventListener('change', () => { if (editSnapToggle.checked) { applySnapSettings(); } }); editRotateStepInput.addEventListener('change', () => { if (editSnapToggle.checked) { applySnapSettings(); } }); const applyFlagToggleUpdate = () => { if (!activeEditSelection || !activeModel?.assetId) return; pushUndoSnapshot(activeModel.assetId); const flags = { lock: editLockToggle.checked ? true : undefined, allowOverlap: editAllowOverlapToggle.checked ? true : undefined }; updatePlacementOverrideFlags(activeModel.assetId, activeEditSelection.target, flags); requestEditRecompose(activeEditSelection.target, getMeshWorldCenter(activeEditSelection.object)); setEditStatus('Updated placement flags.'); syncUndoRedoButtons(); }; editLockToggle.addEventListener('change', () => { applyFlagToggleUpdate(); }); editAllowOverlapToggle.addEventListener('change', () => { applyFlagToggleUpdate(); }); editSnapSocketButton.addEventListener('click', () => { snapSelectionToNearestSocket(undefined, 'Snapped to nearest socket.'); }); editSnapHardpointButton.addEventListener('click', () => { snapSelectionToNearestSocket( (socket) => /weapon|hardpoint|turret|gun|muzzle/i.test(`${socket.id} ${socket.purpose}`), 'Snapped to nearest hardpoint.' ); }); editSnapJointButton.addEventListener('click', () => { snapSelectionToNearestHumanoidJoint(); }); editSnapSurfaceButton.addEventListener('click', () => { projectSelectionToSurface(); }); editNudgeXMinusButton.addEventListener('click', () => applyNudge('x', -1)); editNudgeXPlusButton.addEventListener('click', () => applyNudge('x', 1)); editNudgeYMinusButton.addEventListener('click', () => applyNudge('y', -1)); editNudgeYPlusButton.addEventListener('click', () => applyNudge('y', 1)); editNudgeZMinusButton.addEventListener('click', () => applyNudge('z', -1)); editNudgeZPlusButton.addEventListener('click', () => applyNudge('z', 1)); editUpgradeApplyButton.addEventListener('click', () => { applyUpgradeSwap(); }); editUpgradeSelect.addEventListener('change', () => { updateUpgradePreview(); }); editFocusSelectionButton.addEventListener('click', () => { focusSelection(); }); editClearTargetButton.addEventListener('click', () => { clearOverridesForTarget(); }); editUndoButton.addEventListener('click', () => { undoOverrideChange(); }); editRedoButton.addEventListener('click', () => { redoOverrideChange(); }); editSaveOverridesButton.addEventListener('click', () => { saveOverrideManifest(); }); editClearOverridesButton.addEventListener('click', () => { clearOverridesForActiveAsset(); }); editCopyTargetButton.addEventListener('click', () => { void copySelectedTargetToClipboard(); }); autoRotateToggle.addEventListener('change', () => { controls.autoRotate = autoRotateToggle.checked; }); diagnosticsToggle.addEventListener('change', () => { refreshDiagnosticsUi(); }); socketHelpersToggle.addEventListener('change', () => { rebuildDiagnosticsOverlay(); updateSocketMetadata(); }); humanoidFramesToggle.addEventListener('change', () => { rebuildDiagnosticsOverlay(); }); partProbesToggle.addEventListener('change', () => { rebuildDiagnosticsOverlay(); updateIssuePanel(); }); weaponMountOverlayToggle.addEventListener('change', () => { rebuildDiagnosticsOverlay(); }); resetViewButton.addEventListener('click', () => { resetView(); }); const triggerMotionState = () => { motionStateTime = 0; lastMotionState = getSelectedMotionState(); if (lastMotionState === 'ability') { motionAbilityConcealRemaining = getAbilityConcealDurationSeconds(activeUnitType); } else { motionAbilityConcealRemaining = 0; } }; simEnableToggle.addEventListener('change', () => { if (!simEnableToggle.checked) { resetMotionRigPose(); clearMotionTerrain(); clearTrackDebugOverlay(); restoreControlMode(); setMotionStatus('Motion lab disabled.'); if (simTrackDebugToggle.checked) { setTrackDebugInfo('Track debug waits for Motion Lab enable.'); } return; } triggerMotionState(); syncMotionTerrain(); syncTrackDebugOverlay(); const profile = getMotionProfile(activeUnitType); setMotionStatus(`Motion lab enabled: ${getSelectedMotionState().toUpperCase()} | profile ${profile.label}.`); }); simLockCameraToggle.addEventListener('change', () => { if (!simEnableToggle.checked || simLockCameraToggle.checked) return; restoreControlMode(); }); simTerrainToggle.addEventListener('change', () => { if (!simEnableToggle.checked) { clearMotionTerrain(); return; } syncMotionTerrain(); }); simTrackDebugToggle.addEventListener('change', () => { syncTrackDebugOverlay(); if (simTrackDebugToggle.checked) { setTrackDebugInfo('Track debug enabled. Enter MOVE or TRACK state to inspect tread motion.'); } else { setTrackDebugInfo('Track debug disabled.'); } }); simStateSelect.addEventListener('change', () => { triggerMotionState(); }); simSpeedRange.addEventListener('input', () => { if (!simEnableToggle.checked) return; setMotionStatus(`Motion speed x${Number(simSpeedRange.value).toFixed(1)}`); }); simPrevStateButton.addEventListener('click', () => { cycleMotionState(-1); triggerMotionState(); }); simNextStateButton.addEventListener('click', () => { cycleMotionState(1); triggerMotionState(); }); simTriggerButton.addEventListener('click', () => { triggerMotionState(); }); canvas.addEventListener('pointermove', (event) => { updatePointerNdcFromEvent(event); if (dragTranslateActive) { updateDragTranslate(event); } }); canvas.addEventListener('pointerleave', () => { pointerInsideCanvas = false; if (diagnosticsToggle.checked) { setHoverInfo('Move cursor over mesh to inspect part details.'); } }); canvas.addEventListener('pointerdown', (event) => { if (!editModeEnabled) return; if (isUserTyping()) return; if (event.button !== 0) return; if (editDragTranslateEnabled) { startDragTranslate(event); } }); window.addEventListener('pointerup', () => { if (dragTranslateActive) { endDragTranslate(); } }); canvas.addEventListener('click', (event) => { if (dragTranslateSuppressClick) { dragTranslateSuppressClick = false; return; } const picked = pickMeshDiagnosticAtPointer(event.clientX, event.clientY); if (!picked) { if (editModeEnabled) { clearEditSelection(); } return; } if (editModeEnabled) { const target = resolveEditTargetFromInfo(picked.info); if (!target) { setEditStatus('Selection is not editable.'); return; } setEditSelection({ object: picked.hit.object, info: picked.info, target }); if (event.altKey) { logMeshDiagnosticToConsole(picked.hit, picked.info); } return; } logMeshDiagnosticToConsole(picked.hit, picked.info); }); window.addEventListener('keydown', (event) => { if (editModeEnabled && !isUserTyping() && !event.metaKey && !event.ctrlKey && !event.altKey) { if ((event.key === 'x' || event.key === 'X') && !event.shiftKey) { event.preventDefault(); applyAxisConstraint(editAxisConstraint === 'x' ? null : 'x'); return; } if ((event.key === 'y' || event.key === 'Y') && !event.shiftKey) { event.preventDefault(); applyAxisConstraint(editAxisConstraint === 'y' ? null : 'y'); return; } if ((event.key === 'z' || event.key === 'Z') && !event.shiftKey) { event.preventDefault(); applyAxisConstraint(editAxisConstraint === 'z' ? null : 'z'); return; } } if (editModeEnabled && !isUserTyping() && (event.ctrlKey || event.metaKey)) { const key = event.key.toLowerCase(); if (key === 'z' && !event.shiftKey) { event.preventDefault(); undoOverrideChange(); return; } if (key === 'y' || (key === 'z' && event.shiftKey)) { event.preventDefault(); redoOverrideChange(); return; } } if (editModeEnabled && activeEditSelection && event.shiftKey && !isUserTyping()) { if (event.key === 'L' || event.key === 'l') { event.preventDefault(); editLockToggle.checked = !editLockToggle.checked; applyFlagToggleUpdate(); return; } if (event.key === 'O' || event.key === 'o') { event.preventDefault(); editAllowOverlapToggle.checked = !editAllowOverlapToggle.checked; applyFlagToggleUpdate(); return; } if (event.key === 'F' || event.key === 'f') { event.preventDefault(); focusSelection(); return; } if (event.key === 'X' || event.key === 'x') { event.preventDefault(); clearOverridesForTarget(); return; } if (event.key === 'C' || event.key === 'c') { event.preventDefault(); void copySelectedTargetToClipboard(); return; } } if ((event.key === 'M' || event.key === 'm') && event.shiftKey) { const report = requestHumanoidMotionRigReport('manual'); if (report) { setStatus( `Humanoid motion report ${report.unitType}: nodes ${report.nodeCount}, ` + `socket stable ${report.armorPlatingSocketStableCount}/${report.armorPlatingNodeCount}, ` + `outliers ${report.outlierCount}, inconsistencies ${report.inconsistencyCount}` ); } return; } if ((event.key === 'J' || event.key === 'j') && event.shiftKey) { const report = requestHumanoidJointRotationReport('manual'); if (report) { setStatus( `Joint rotation report ${report.unitType}: joints ${report.jointCount}, ` + `frame local=${report.coordinateFrames.local} world=${report.coordinateFrames.world}` ); } } }); const meshlabDebugWindow = window as Window & { __meshlabMotionReport?: () => HumanoidMotionRigReport | null; __meshlabJointRotationReport?: () => HumanoidJointRotationReport | null; __meshlabMotionSolverVersion?: string; }; meshlabDebugWindow.__meshlabMotionReport = () => requestHumanoidMotionRigReport('manual'); meshlabDebugWindow.__meshlabJointRotationReport = () => requestHumanoidJointRotationReport('manual'); meshlabDebugWindow.__meshlabMotionSolverVersion = HUMANOID_MOTION_SOLVER_VERSION; const resize = () => { const parent = canvas.parentElement; if (!parent) return; const width = Math.max(320, parent.clientWidth); const height = Math.max(320, parent.clientHeight); renderer.setSize(width, height, false); camera.aspect = width / height; camera.updateProjectionMatrix(); }; const resizeObserver = new ResizeObserver(() => resize()); if (canvas.parentElement) { resizeObserver.observe(canvas.parentElement); } const animate = () => { requestAnimationFrame(animate); const delta = clock.getDelta(); updateMotionLab(delta); updateDiagnosticsOverlayDynamic(); updateSelectionOverlayPosition(); controls.update(); updateHoverDiagnostics(); renderer.render(scene, camera); }; resize(); void loadManifest(); refreshDiagnosticsUi(); updateMotionUiAvailability(); refreshUpgradeOptions(); updateSelectedTargetPill(null); updateUpgradePreview(); syncUndoRedoButtons(); animate();