From c42c4a68b8674efd7862d24c142dd785102b09e0 Mon Sep 17 00:00:00 2001 From: Princeamor Date: Sun, 3 May 2026 20:24:10 -0400 Subject: [PATCH 1/4] Improve rig fitting, helper controls, and weight smoothing --- src/Mesh2MotionEngine.ts | 3 + src/create.html | 12 + src/lib/CustomSkeletonHelper.ts | 75 +- src/lib/EventListeners.ts | 11 + src/lib/UI.ts | 8 + src/lib/Utilities.test.ts | 98 +++ src/lib/Utilities.ts | 119 +++ .../IndependentBoneMovement.test.ts | 50 ++ .../edit-skeleton/IndependentBoneMovement.ts | 10 +- .../MeshDragBonePlacement.test.ts | 41 ++ .../edit-skeleton/MeshDragBonePlacement.ts | 258 ++++++- .../edit-skeleton/StepEditSkeleton.ts | 116 ++- src/lib/solvers/WeightSmoother.test.ts | 368 ++++++++++ src/lib/solvers/WeightSmoother.ts | 675 ++++++++++++++++-- tmp/compare-skeletons.mjs | 92 +++ tmp/compare-weight-profiles.mjs | 121 ++++ tmp/inspect-moo-skeleton.mjs | 60 ++ workspace-change-summary-2026-05-03.md | 124 ++++ 18 files changed, 2150 insertions(+), 91 deletions(-) create mode 100644 src/lib/Utilities.test.ts create mode 100644 src/lib/processes/edit-skeleton/IndependentBoneMovement.test.ts create mode 100644 src/lib/processes/edit-skeleton/MeshDragBonePlacement.test.ts create mode 100644 src/lib/solvers/WeightSmoother.test.ts create mode 100644 tmp/compare-skeletons.mjs create mode 100644 tmp/compare-weight-profiles.mjs create mode 100644 tmp/inspect-moo-skeleton.mjs create mode 100644 workspace-change-summary-2026-05-03.md diff --git a/src/Mesh2MotionEngine.ts b/src/Mesh2MotionEngine.ts index e599dc4..9d99838 100644 --- a/src/Mesh2MotionEngine.ts +++ b/src/Mesh2MotionEngine.ts @@ -214,6 +214,9 @@ export class Mesh2MotionEngine { this.skeleton_helper.setHideRightSideJoints( is_edit_skeleton_step && this.edit_skeleton_step.is_mirror_mode_enabled() ) + this.skeleton_helper.setHiddenChainRoots( + is_edit_skeleton_step ? this.edit_skeleton_step.hidden_bone_chain_root_names() : [] + ) } public update_a_pose_options_visibility (): void { diff --git a/src/create.html b/src/create.html index ff72d1c..7acdf40 100644 --- a/src/create.html +++ b/src/create.html @@ -259,11 +259,23 @@ Selected Bone: None

+
+
+
+
+ + help +
+
+ + 10 +
+
Transform diff --git a/src/lib/CustomSkeletonHelper.ts b/src/lib/CustomSkeletonHelper.ts index 5d68bb3..184d092 100644 --- a/src/lib/CustomSkeletonHelper.ts +++ b/src/lib/CustomSkeletonHelper.ts @@ -5,6 +5,7 @@ import { Color, Matrix4, Vector3, Points, PointsMaterial, BufferGeometry, Float32BufferAttribute, TextureLoader, LineSegments, LineBasicMaterial, type Bone, type ColorRepresentation } from 'three' import { BoneCategory, BoneClassifier } from './solvers/BoneClassifier' +import { Utility } from './Utilities' const _vector = /*@__PURE__*/ new Vector3() const _boneMatrix = /*@__PURE__*/ new Matrix4() @@ -24,8 +25,10 @@ const bone_category_colors: Record = { class CustomSkeletonHelper extends LineSegments { private readonly joint_points: Points + private readonly small_joint_points: Points private readonly jointTexture = new TextureLoader().load('/images/skeleton-joint-point.png') private hide_right_side_joints: boolean = false + private hidden_chain_root_names: Set = new Set() constructor (object: any, options: CustomSkeletonHelperOptions = {}) { const bones = getBoneList(object) @@ -84,6 +87,21 @@ class CustomSkeletonHelper extends LineSegments { const pointPositions = new Float32BufferAttribute(bones.length * 3, 3) pointsGeometry.setAttribute('position', pointPositions) + const smallPointsGeometry = new BufferGeometry() + const smallPointsMaterial = new PointsMaterial({ + size: 10, // smaller circles for detailed finger joints + color: options.jointColor !== undefined ? joint_color : 0xffffff, + depthTest: false, + sizeAttenuation: false, + map: this.jointTexture, + transparent: true, + opacity: 0.8, + vertexColors: options.jointColor === undefined + }) + + const smallPointPositions = new Float32BufferAttribute(bones.length * 3, 3) + smallPointsGeometry.setAttribute('position', smallPointPositions) + // use bone category to color joints to help with seeing a bunch of them if (options.jointColor === undefined) { const bone_classifier = new BoneClassifier(bones as Bone[]) @@ -95,16 +113,21 @@ class CustomSkeletonHelper extends LineSegments { point_colors.push(category_color.r, category_color.g, category_color.b) }) - pointsGeometry.setAttribute('color', new Float32BufferAttribute(point_colors, 3)) + const point_color_attribute = new Float32BufferAttribute(point_colors, 3) + pointsGeometry.setAttribute('color', point_color_attribute) + smallPointsGeometry.setAttribute('color', point_color_attribute.clone()) } this.joint_points = new Points(pointsGeometry, pointsMaterial) + this.small_joint_points = new Points(smallPointsGeometry, smallPointsMaterial) this.add(this.joint_points) + this.add(this.small_joint_points) } updateMatrixWorld (force: boolean): void { const bones = this.bones const pointPositions = this.joint_points.geometry.getAttribute('position') + const smallPointPositions = this.small_joint_points.geometry.getAttribute('position') const geometry = this.geometry const positions = geometry.getAttribute('position') @@ -117,25 +140,45 @@ class CustomSkeletonHelper extends LineSegments { _boneMatrix.multiplyMatrices(_matrixWorldInv, bone.matrixWorld) _vector.setFromMatrixPosition(_boneMatrix) - if (this.hide_right_side_joints && is_right_side_bone(bone)) { + const is_hidden_by_chain = this.is_bone_hidden_by_chain(bone) + + if ((this.hide_right_side_joints && is_right_side_bone(bone)) || is_hidden_by_chain) { pointPositions.setXYZ(i, Number.NaN, Number.NaN, Number.NaN) + smallPointPositions.setXYZ(i, Number.NaN, Number.NaN, Number.NaN) + } else if (is_small_joint_bone(bone)) { + pointPositions.setXYZ(i, Number.NaN, Number.NaN, Number.NaN) + smallPointPositions.setXYZ(i, _vector.x, _vector.y, _vector.z) } else { pointPositions.setXYZ(i, _vector.x, _vector.y, _vector.z) // Update point position + smallPointPositions.setXYZ(i, Number.NaN, Number.NaN, Number.NaN) } if (bone.parent && bone.parent.isBone) { - _boneMatrix.multiplyMatrices(_matrixWorldInv, bone.parent.matrixWorld) - _vector.setFromMatrixPosition(_boneMatrix) - positions.setXYZ(lineIndex * 2, _vector.x, _vector.y, _vector.z) + const parent_bone = bone.parent as Bone + const hide_line = + (this.hide_right_side_joints && (is_right_side_bone(parent_bone) || is_right_side_bone(bone))) || + is_hidden_by_chain || + this.is_bone_hidden_by_chain(parent_bone) + + if (hide_line) { + positions.setXYZ(lineIndex * 2, Number.NaN, Number.NaN, Number.NaN) + positions.setXYZ(lineIndex * 2 + 1, Number.NaN, Number.NaN, Number.NaN) + } else { + _boneMatrix.multiplyMatrices(_matrixWorldInv, parent_bone.matrixWorld) + _vector.setFromMatrixPosition(_boneMatrix) + positions.setXYZ(lineIndex * 2, _vector.x, _vector.y, _vector.z) + + _boneMatrix.multiplyMatrices(_matrixWorldInv, bone.matrixWorld) + _vector.setFromMatrixPosition(_boneMatrix) + positions.setXYZ(lineIndex * 2 + 1, _vector.x, _vector.y, _vector.z) + } - _boneMatrix.multiplyMatrices(_matrixWorldInv, bone.matrixWorld) - _vector.setFromMatrixPosition(_boneMatrix) - positions.setXYZ(lineIndex * 2 + 1, _vector.x, _vector.y, _vector.z) lineIndex++ } } pointPositions.needsUpdate = true + smallPointPositions.needsUpdate = true positions.needsUpdate = true // Update bounding box and bounding sphere @@ -151,6 +194,8 @@ class CustomSkeletonHelper extends LineSegments { this.material.dispose() this.joint_points.geometry.dispose() this.joint_points.material.dispose() + this.small_joint_points.geometry.dispose() + this.small_joint_points.material.dispose() } public show(): void { @@ -163,11 +208,20 @@ class CustomSkeletonHelper extends LineSegments { public setJointsVisible (visible: boolean): void { this.joint_points.visible = visible + this.small_joint_points.visible = visible } public setHideRightSideJoints (value: boolean): void { this.hide_right_side_joints = value } + + public setHiddenChainRoots (chain_root_names: string[]): void { + this.hidden_chain_root_names = new Set(chain_root_names) + } + + private is_bone_hidden_by_chain (bone: Bone): boolean { + return this.hidden_chain_root_names.has(Utility.chain_root_bone_from_bone(bone).name) + } } function getBoneList (object: any): any[] { @@ -190,4 +244,9 @@ function is_right_side_bone (bone: Bone): boolean { return /(^right_|^r_|_right$|_r$|\.right$|\.r$|-right$|-r$)/.test(normalized_bone_name) } +function is_small_joint_bone (bone: Bone): boolean { + const normalized_bone_name = bone.name.toLowerCase() + return /(thumb|index|middle|ring|pinky|finger)/.test(normalized_bone_name) +} + export { CustomSkeletonHelper } diff --git a/src/lib/EventListeners.ts b/src/lib/EventListeners.ts index 94c24e7..674c56e 100644 --- a/src/lib/EventListeners.ts +++ b/src/lib/EventListeners.ts @@ -17,6 +17,7 @@ export class EventListeners { this.bootstrap.load_skeleton_step.addEventListener('skeletonLoaded', () => { this.bootstrap.edit_skeleton_step.load_original_armature_from_model(this.bootstrap.load_skeleton_step.armature()) + this.bootstrap.mesh_drag_bone_placement.snap_primary_centerline_bones_to_mesh_center() this.bootstrap.process_step = this.bootstrap.process_step_changed(ProcessStep.EditSkeleton) }) @@ -43,6 +44,16 @@ export class EventListeners { this.bootstrap.update_edit_bone_interaction_mode() }) + this.bootstrap.edit_skeleton_step.addEventListener('chainVisibilityChanged', () => { + this.bootstrap.sync_skeleton_helper_joint_visibility() + + if (this.bootstrap.transform_controls.object !== undefined && + this.bootstrap.transform_controls.object !== null && + !this.bootstrap.edit_skeleton_step.is_bone_selectable(this.bootstrap.transform_controls.object as Bone)) { + this.bootstrap.transform_controls.detach() + } + }) + // attribution link clicking brings up contributors dialog this.bootstrap.ui.dom_attribution_link?.addEventListener('click', (event: MouseEvent) => { event.preventDefault() diff --git a/src/lib/UI.ts b/src/lib/UI.ts index b6b392e..af6294e 100644 --- a/src/lib/UI.ts +++ b/src/lib/UI.ts @@ -27,6 +27,9 @@ export class UI { dom_mirror_skeleton_checkbox: HTMLInputElement | null = null dom_independent_bone_movement_checkbox: HTMLInputElement | null = null dom_mesh_drag_placement_checkbox: HTMLInputElement | null = null + dom_mesh_drag_snap_strength_input: HTMLInputElement | null = null + dom_mesh_drag_snap_strength_label: HTMLElement | null = null + dom_mesh_drag_snap_strength_container: HTMLElement | null = null dom_scale_skeleton_button: HTMLButtonElement | null = null dom_undo_button: HTMLButtonElement | null = null dom_redo_button: HTMLButtonElement | null = null @@ -42,6 +45,7 @@ export class UI { // edit skeleton UI step controls dom_selected_bone_label: HTMLElement | null = null + dom_bone_chain_visibility_container: HTMLElement | null = null dom_transform_manual_options: HTMLElement | null = null dom_transform_type_radio_group: HTMLElement | null = null dom_transform_space_radio_group: HTMLElement | null = null @@ -148,6 +152,9 @@ export class UI { this.dom_mirror_skeleton_checkbox = document.querySelector('#mirror-skeleton') this.dom_independent_bone_movement_checkbox = document.querySelector('#independent-bone-movement') this.dom_mesh_drag_placement_checkbox = document.querySelector('#mesh-drag-placement') + this.dom_mesh_drag_snap_strength_input = document.querySelector('#mesh-drag-snap-strength-input') + this.dom_mesh_drag_snap_strength_label = document.querySelector('#mesh-drag-snap-strength-label') + this.dom_mesh_drag_snap_strength_container = document.querySelector('#mesh-drag-snap-strength-container') this.dom_scale_skeleton_button = document.querySelector('#scale-skeleton-button') this.dom_reset_skeleton_scale_button = document.querySelector('#reset-skeleton-scale-button') @@ -155,6 +162,7 @@ export class UI { this.dom_redo_button = document.querySelector('#redo-button') this.dom_selected_bone_label = document.querySelector('#edit-selected-bone-label') + this.dom_bone_chain_visibility_container = document.querySelector('#bone-chain-visibility-container') this.dom_transform_manual_options = document.querySelector('#transform-manual-options') this.dom_transform_type_radio_group = document.querySelector('#transform-control-type-group') diff --git a/src/lib/Utilities.test.ts b/src/lib/Utilities.test.ts new file mode 100644 index 0000000..68c822a --- /dev/null +++ b/src/lib/Utilities.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest' +import { Bone, Skeleton } from 'three' + +import { Utility } from './Utilities' + +function build_test_skeleton (): Skeleton { + const root = new Bone() + root.name = 'root' + + const hips = new Bone() + hips.name = 'Hips' + root.add(hips) + + const spine = new Bone() + spine.name = 'Spine' + hips.add(spine) + + const left_leg = new Bone() + left_leg.name = 'LeftLeg' + hips.add(left_leg) + + const right_leg = new Bone() + right_leg.name = 'RightLeg' + hips.add(right_leg) + + const chest = new Bone() + chest.name = 'Chest' + spine.add(chest) + + const left_shoulder = new Bone() + left_shoulder.name = 'LeftShoulder' + chest.add(left_shoulder) + + const left_forearm = new Bone() + left_forearm.name = 'LeftForeArm' + left_shoulder.add(left_forearm) + + const left_hand = new Bone() + left_hand.name = 'LeftHand' + left_forearm.add(left_hand) + + const left_thumb = new Bone() + left_thumb.name = 'LeftHandThumb1' + left_hand.add(left_thumb) + + const left_index = new Bone() + left_index.name = 'LeftHandIndex1' + left_hand.add(left_index) + + const right_shoulder = new Bone() + right_shoulder.name = 'RightShoulder' + chest.add(right_shoulder) + + const head = new Bone() + head.name = 'Head' + chest.add(head) + + return new Skeleton([ + root, + hips, + spine, + left_leg, + right_leg, + chest, + left_shoulder, + left_forearm, + left_hand, + left_thumb, + left_index, + right_shoulder, + head + ]) +} + +describe('Utility chain roots', () => { + it('derives condensed main chains and folds fingers into the hand chain', () => { + const skeleton = build_test_skeleton() + const chain_root_names = Utility.unique_chain_root_bones_from_skeleton(skeleton).map((bone) => bone.name) + + expect(chain_root_names).toEqual(['Hips', 'Spine', 'LeftLeg', 'RightLeg', 'LeftShoulder', 'LeftHand', 'RightShoulder', 'Head']) + }) + + it('maps descendant bones back to their condensed chain root', () => { + const skeleton = build_test_skeleton() + const chest_bone = skeleton.bones.find((bone) => bone.name === 'Chest') + const head_bone = skeleton.bones.find((bone) => bone.name === 'Head') + const thumb_bone = skeleton.bones.find((bone) => bone.name === 'LeftHandThumb1') + const hand_bone = skeleton.bones.find((bone) => bone.name === 'LeftHand') + + expect(chest_bone).toBeDefined() + expect(head_bone).toBeDefined() + expect(thumb_bone).toBeDefined() + expect(hand_bone).toBeDefined() + expect(Utility.chain_root_bone_from_bone(chest_bone!)).toBe(skeleton.bones.find((bone) => bone.name === 'Spine')) + expect(Utility.chain_root_bone_from_bone(head_bone!)).toBe(head_bone) + expect(Utility.chain_root_bone_from_bone(thumb_bone!)).toBe(hand_bone) + }) +}) \ No newline at end of file diff --git a/src/lib/Utilities.ts b/src/lib/Utilities.ts index 149ae95..a33d236 100644 --- a/src/lib/Utilities.ts +++ b/src/lib/Utilities.ts @@ -121,6 +121,125 @@ export class Utility { return bones } + static child_bones_from_object (object: Object3D): Bone[] { + return object.children.filter((child): child is Bone => child instanceof Bone) + } + + static is_chain_root_bone (bone: Bone): boolean { + const grouped_chain_anchor = Utility.grouped_chain_anchor_bone_from_bone(bone) + if (grouped_chain_anchor !== null) { + return grouped_chain_anchor === bone + } + + if (bone.name === 'root') { + return false + } + + const parent = bone.parent + if (!(parent instanceof Bone)) { + return true + } + + if (parent.name === 'root') { + return true + } + + return Utility.child_bones_from_object(parent).length > 1 + } + + static chain_root_bone_from_bone (bone: Bone): Bone { + const grouped_chain_anchor = Utility.grouped_chain_anchor_bone_from_bone(bone) + if (grouped_chain_anchor !== null) { + return grouped_chain_anchor + } + + let current_bone: Bone = bone + + while (current_bone.parent instanceof Bone && !Utility.is_chain_root_bone(current_bone)) { + current_bone = current_bone.parent + } + + return current_bone + } + + static unique_chain_root_bones_from_skeleton (skeleton: Skeleton): Bone[] { + const chain_roots: Bone[] = [] + const seen_names = new Set() + + skeleton.bones.forEach((bone) => { + if (!Utility.is_chain_root_bone(bone) || seen_names.has(bone.name)) { + return + } + + seen_names.add(bone.name) + chain_roots.push(bone) + }) + + return chain_roots + } + + private static grouped_chain_anchor_bone_from_bone (bone: Bone): Bone | null { + const normalized_bone_name = bone.name.toLowerCase() + + if (Utility.is_hand_chain_bone_name(normalized_bone_name)) { + return Utility.find_nearest_bone_ancestor_including_self(bone, (candidate_name) => Utility.is_hand_anchor_bone_name(candidate_name)) + } + + if (Utility.is_foot_chain_bone_name(normalized_bone_name)) { + return Utility.find_nearest_bone_ancestor_including_self(bone, (candidate_name) => Utility.is_foot_anchor_bone_name(candidate_name)) + } + + if (Utility.is_head_accessory_bone_name(normalized_bone_name)) { + return Utility.find_nearest_bone_ancestor_including_self(bone, (candidate_name) => candidate_name.includes('head')) + } + + return null + } + + private static find_nearest_bone_ancestor_including_self ( + bone: Bone, + matcher: (normalized_bone_name: string) => boolean + ): Bone | null { + let current_bone: Bone | null = bone + + while (current_bone !== null) { + if (matcher(current_bone.name.toLowerCase())) { + return current_bone + } + + current_bone = current_bone.parent instanceof Bone ? current_bone.parent : null + } + + return null + } + + private static is_hand_chain_bone_name (normalized_bone_name: string): boolean { + return /(hand|thumb|index|middle|ring|pinky|finger)/.test(normalized_bone_name) + } + + private static is_hand_anchor_bone_name (normalized_bone_name: string): boolean { + return normalized_bone_name.includes('hand') && !/(thumb|index|middle|ring|pinky|finger)/.test(normalized_bone_name) + } + + private static is_foot_chain_bone_name (normalized_bone_name: string): boolean { + return /(foot|toe|ball)/.test(normalized_bone_name) + } + + private static is_foot_anchor_bone_name (normalized_bone_name: string): boolean { + return normalized_bone_name.includes('foot') && !/(toe|ball)/.test(normalized_bone_name) + } + + private static is_head_accessory_bone_name (normalized_bone_name: string): boolean { + return /(head|ear|eye|jaw|chin|mouth|teeth|tongue|horn|antler|nose|brow|lash)/.test(normalized_bone_name) + } + + static format_bone_chain_label (bone_name: string): string { + return bone_name + .replace(/[_-]+/g, ' ') + .replace(/([a-z])([A-Z])/g, '$1 $2') + .trim() + } + static intersection_points_between_positions_and_mesh (positions: BufferAttribute | InterleavedBufferAttribute, envelope_mesh: Mesh): IntersectionPointData { const vertex_positions_inside_bone_envelope: Vector3[] = [] diff --git a/src/lib/processes/edit-skeleton/IndependentBoneMovement.test.ts b/src/lib/processes/edit-skeleton/IndependentBoneMovement.test.ts new file mode 100644 index 0000000..d74ee98 --- /dev/null +++ b/src/lib/processes/edit-skeleton/IndependentBoneMovement.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest' +import { Bone, Quaternion, Skeleton } from 'three' + +import { IndependentBoneMovement } from './IndependentBoneMovement.ts' + +function quaternion_angle_difference (left: Quaternion, right: Quaternion): number { + const dot = Math.min(1, Math.abs(left.dot(right))) + return 2 * Math.acos(dot) +} + +describe('IndependentBoneMovement', () => { + it('keeps a branching chest parent at its rest rotation when moving the clavicle', () => { + const root = new Bone() + root.name = 'root' + + const chest = new Bone() + chest.name = 'chest' + root.add(chest) + + const neck = new Bone() + neck.name = 'neck' + neck.position.set(0, 1, 0) + chest.add(neck) + + const clavicle = new Bone() + clavicle.name = 'leftClavicle' + clavicle.position.set(1, 0.15, 0) + chest.add(clavicle) + + const upper_arm = new Bone() + upper_arm.name = 'leftUpperArm' + upper_arm.position.set(1, 0, 0) + clavicle.add(upper_arm) + + root.updateWorldMatrix(true, true) + + const movement = new IndependentBoneMovement() + movement.set_rest_pose(new Skeleton([root, chest, neck, clavicle, upper_arm])) + + const chest_rest_rotation = chest.getWorldQuaternion(new Quaternion()).clone() + + clavicle.position.set(0.55, 0.35, -0.45) + root.updateWorldMatrix(true, true) + + movement.finalize_drop(clavicle) + + const chest_final_rotation = chest.getWorldQuaternion(new Quaternion()) + expect(quaternion_angle_difference(chest_final_rotation, chest_rest_rotation)).toBeLessThan(1e-5) + }) +}) \ No newline at end of file diff --git a/src/lib/processes/edit-skeleton/IndependentBoneMovement.ts b/src/lib/processes/edit-skeleton/IndependentBoneMovement.ts index 434b969..05c394e 100644 --- a/src/lib/processes/edit-skeleton/IndependentBoneMovement.ts +++ b/src/lib/processes/edit-skeleton/IndependentBoneMovement.ts @@ -105,7 +105,7 @@ export class IndependentBoneMovement { ? bone.parent : null - if (parent_bone !== null) { + if (parent_bone !== null && this._should_update_parent_rotation_from_child_translation(parent_bone)) { // Also snapshot siblings so they can be re-pinned after parent rotates parent_bone.children.forEach((sibling) => { if (sibling === bone || !this._is_bone(sibling)) { return } @@ -293,4 +293,12 @@ export class IndependentBoneMovement { return (value as { isBone?: boolean }).isBone === true } + + private _should_update_parent_rotation_from_child_translation (bone: Bone): boolean { + // Branching parents like chest/pelvis/clavicle hubs should preserve their + // template orientation when a single child is translated; otherwise the + // averaged child-direction solve can twist the torso/shoulder backward. + const bone_children = bone.children.filter((child): child is Bone => this._is_bone(child)) + return bone_children.length <= 1 + } } diff --git a/src/lib/processes/edit-skeleton/MeshDragBonePlacement.test.ts b/src/lib/processes/edit-skeleton/MeshDragBonePlacement.test.ts new file mode 100644 index 0000000..0e744a8 --- /dev/null +++ b/src/lib/processes/edit-skeleton/MeshDragBonePlacement.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' +import { Vector3 } from 'three' + +import { + apply_mesh_centerline_target, + blend_target_with_snap_vertex, + calculate_vertex_snap_influence, + is_centerline_mesh_snap_bone_name +} from './MeshDragBonePlacement.ts' + +describe('MeshDragBonePlacement', () => { + it('maps snap strength from 0 to 20 into a 0 to 1 blend factor', () => { + expect(calculate_vertex_snap_influence(0)).toBe(0) + expect(calculate_vertex_snap_influence(10)).toBe(0.5) + expect(calculate_vertex_snap_influence(20)).toBe(1) + }) + + it('blends the target position toward the snap vertex based on strength', () => { + const midpoint = new Vector3(0, 0, 0) + const snap_vertex = new Vector3(10, 0, 0) + + expect(blend_target_with_snap_vertex(midpoint, snap_vertex, 0).toArray()).toEqual([0, 0, 0]) + expect(blend_target_with_snap_vertex(midpoint, snap_vertex, 10).toArray()).toEqual([5, 0, 0]) + expect(blend_target_with_snap_vertex(midpoint, snap_vertex, 20).toArray()).toEqual([10, 0, 0]) + }) + + it('identifies center-line torso and head bones for mesh centering', () => { + expect(is_centerline_mesh_snap_bone_name('Pelvis')).toBe(true) + expect(is_centerline_mesh_snap_bone_name('Spine2')).toBe(true) + expect(is_centerline_mesh_snap_bone_name('Neck')).toBe(true) + expect(is_centerline_mesh_snap_bone_name('Head')).toBe(true) + expect(is_centerline_mesh_snap_bone_name('LeftHead')).toBe(false) + expect(is_centerline_mesh_snap_bone_name('RightShoulder')).toBe(false) + }) + + it('keeps the clicked height while snapping torso bones onto the mesh centerline', () => { + const target = new Vector3(1.25, 1.8, -0.35) + + expect(apply_mesh_centerline_target(target, 0.1, 0.05).toArray()).toEqual([0.1, 1.8, 0.05]) + }) +}) \ No newline at end of file diff --git a/src/lib/processes/edit-skeleton/MeshDragBonePlacement.ts b/src/lib/processes/edit-skeleton/MeshDragBonePlacement.ts index e08336e..4489d00 100644 --- a/src/lib/processes/edit-skeleton/MeshDragBonePlacement.ts +++ b/src/lib/processes/edit-skeleton/MeshDragBonePlacement.ts @@ -6,7 +6,49 @@ import { Utility } from '../../Utilities.ts' import { type StepEditSkeleton } from './StepEditSkeleton.ts' import { type StepLoadModel } from '../load-model/StepLoadModel.ts' import { type StepWeightSkin } from '../weight-skin/StepWeightSkin.ts' -import { type PerspectiveCamera, type Vector3, type Object3D, type Skeleton } from 'three' +import { Bone, type PerspectiveCamera, Vector3, type Object3D, type Skeleton, type Intersection, type BufferAttribute, type Mesh, type SkinnedMesh } from 'three' + +export function calculate_vertex_snap_influence (snap_strength: number): number { + const clamped_strength = Math.max(0, Math.min(20, snap_strength)) + return clamped_strength / 20 +} + +export function blend_target_with_snap_vertex ( + target_world_position: Vector3, + snap_vertex_world_position: Vector3 | null, + snap_strength: number +): Vector3 { + const snap_influence = calculate_vertex_snap_influence(snap_strength) + if (snap_influence <= 0 || snap_vertex_world_position === null) { + return target_world_position.clone() + } + + return target_world_position.clone().lerp(snap_vertex_world_position, snap_influence) +} + +export function is_centerline_mesh_snap_bone_name (bone_name: string): boolean { + const normalized_bone_name = bone_name.toLowerCase() + const has_side_marker = + normalized_bone_name.startsWith('left') || + normalized_bone_name.startsWith('right') || + /^l[_-]/.test(normalized_bone_name) || + /^r[_-]/.test(normalized_bone_name) || + /(^|[^a-z])(left|right)([^a-z]|$)/.test(normalized_bone_name) + + if (has_side_marker) { + return false + } + + return /(pelvis|hips|spine|chest|torso|abdomen|waist|neck|head)/.test(normalized_bone_name) +} + +export function apply_mesh_centerline_target ( + target_world_position: Vector3, + center_x: number, + center_z: number +): Vector3 { + return new Vector3(center_x, target_world_position.y, center_z) +} export class MeshDragBonePlacement { private orbit_controls: OrbitControls | undefined = undefined @@ -127,6 +169,38 @@ export class MeshDragBonePlacement { return true } + public snap_primary_centerline_bones_to_mesh_center (): void { + const skeleton_to_snap = this.edit_skeleton_step.skeleton() + if (skeleton_to_snap === undefined) { + return + } + + const mesh_targets = this.get_centerline_mesh_targets() + if (mesh_targets.length === 0) { + return + } + + skeleton_to_snap.bones.forEach((bone) => { + if (!is_centerline_mesh_snap_bone_name(bone.name) || !(bone.parent instanceof Bone)) { + return + } + + const centered_world_position = this.get_mesh_centerline_target_at_world_position( + Utility.world_position_from_object(bone), + mesh_targets + ) + + if (centered_world_position === null) { + return + } + + const centered_local_position = centered_world_position.clone() + bone.parent.worldToLocal(centered_local_position) + bone.position.copy(centered_local_position) + bone.updateWorldMatrix(true, true) + }) + } + private move_selected_bone_to_mesh_midpoint (mouse_event: MouseEvent): void { const selected_bone = this.edit_skeleton_step.get_currently_selected_bone() @@ -134,12 +208,21 @@ export class MeshDragBonePlacement { return } - const intersection_segment = this.get_edit_mesh_intersection_segment(mouse_event) + const intersection_target = this.get_edit_mesh_intersection_target(mouse_event) let target_world_position: Vector3 | null = null - if (intersection_segment !== null) { - const [first_intersection, last_intersection] = intersection_segment - target_world_position = first_intersection.clone().add(last_intersection).multiplyScalar(0.5) + if (intersection_target !== null) { + if (is_centerline_mesh_snap_bone_name(selected_bone.name)) { + target_world_position = this.get_mesh_centerline_target_at_world_position(intersection_target.midpoint) + } + + if (target_world_position === null) { + target_world_position = blend_target_with_snap_vertex( + intersection_target.midpoint, + intersection_target.closest_vertex_world_position, + this.edit_skeleton_step.get_mesh_drag_snap_strength() + ) + } } else { target_world_position = this.get_point_on_viewport_plane_from_mouse(selected_bone, mouse_event) } @@ -166,7 +249,110 @@ export class MeshDragBonePlacement { } } - private get_edit_mesh_intersection_segment (mouse_event: MouseEvent): [Vector3, Vector3] | null { + private get_centerline_mesh_targets (): Object3D[] { + const mesh_targets: Object3D[] = [] + mesh_targets.push(this.load_model_step.model_meshes()) + + const weight_painted_mesh = this.weight_skin_step.weight_painted_mesh_group() + if (weight_painted_mesh !== null) { + mesh_targets.push(weight_painted_mesh) + } + + return mesh_targets.filter((target) => target.children.length > 0) + } + + private get_mesh_centerline_target_at_world_position ( + target_world_position: Vector3, + mesh_targets: Object3D[] = this.get_centerline_mesh_targets() + ): Vector3 | null { + if (mesh_targets.length === 0) { + return null + } + + const scene_bounds = new THREE.Box3() + mesh_targets.forEach((target) => { + scene_bounds.expandByObject(target) + }) + + if (scene_bounds.isEmpty()) { + return null + } + + const scene_center = scene_bounds.getCenter(new Vector3()) + const scene_size = scene_bounds.getSize(new Vector3()) + const ray_margin = Math.max(0.25, scene_size.length() * 0.25) + const target_y = target_world_position.y + + let snapped_x = scene_center.x + let snapped_z = scene_center.z + + const initial_z_midpoint = this.get_opposing_surface_midpoint( + mesh_targets, + new Vector3(scene_center.x, target_y, scene_bounds.max.z + ray_margin), + new Vector3(0, 0, -1), + new Vector3(scene_center.x, target_y, scene_bounds.min.z - ray_margin), + new Vector3(0, 0, 1) + ) + + if (initial_z_midpoint !== null) { + snapped_z = initial_z_midpoint.z + } + + const x_midpoint = this.get_opposing_surface_midpoint( + mesh_targets, + new Vector3(scene_bounds.min.x - ray_margin, target_y, snapped_z), + new Vector3(1, 0, 0), + new Vector3(scene_bounds.max.x + ray_margin, target_y, snapped_z), + new Vector3(-1, 0, 0) + ) + + if (x_midpoint !== null) { + snapped_x = x_midpoint.x + } + + const refined_z_midpoint = this.get_opposing_surface_midpoint( + mesh_targets, + new Vector3(snapped_x, target_y, scene_bounds.max.z + ray_margin), + new Vector3(0, 0, -1), + new Vector3(snapped_x, target_y, scene_bounds.min.z - ray_margin), + new Vector3(0, 0, 1) + ) + + if (refined_z_midpoint !== null) { + snapped_z = refined_z_midpoint.z + } + + return apply_mesh_centerline_target(target_world_position, snapped_x, snapped_z) + } + + private get_opposing_surface_midpoint ( + mesh_targets: Object3D[], + forward_origin: Vector3, + forward_direction: Vector3, + reverse_origin: Vector3, + reverse_direction: Vector3 + ): Vector3 | null { + const forward_hit = this.get_axis_surface_hit(mesh_targets, forward_origin, forward_direction) + const reverse_hit = this.get_axis_surface_hit(mesh_targets, reverse_origin, reverse_direction) + + if (forward_hit !== null && reverse_hit !== null) { + return forward_hit.add(reverse_hit).multiplyScalar(0.5) + } + + return forward_hit ?? reverse_hit + } + + private get_axis_surface_hit ( + mesh_targets: Object3D[], + origin: Vector3, + direction: Vector3 + ): Vector3 | null { + const axis_raycaster = new THREE.Raycaster(origin, direction.clone().normalize()) + const intersections = axis_raycaster.intersectObjects(mesh_targets, true) + return intersections.length > 0 ? intersections[0].point.clone() : null + } + + private get_edit_mesh_intersection_target (mouse_event: MouseEvent): MeshIntersectionTarget | null { const mesh_targets: Object3D[] = [] const imported_model = this.load_model_step.model_meshes() @@ -191,7 +377,9 @@ export class MeshDragBonePlacement { return null } - const first_intersection = forward_intersections[0].point.clone() + const first_hit = forward_intersections[0] + const first_intersection = first_hit.point.clone() + const closest_vertex_world_position = this.get_closest_vertex_world_position_from_hit(first_hit) const scene_bounds = new THREE.Box3() mesh_targets.forEach((target) => { @@ -211,11 +399,58 @@ export class MeshDragBonePlacement { const reverse_intersections = reverse_raycaster.intersectObjects(mesh_targets, true) if (reverse_intersections.length === 0) { - return [first_intersection, first_intersection] + return { + midpoint: first_intersection, + closest_vertex_world_position + } } const last_intersection = reverse_intersections[0].point.clone() - return [first_intersection, last_intersection] + return { + midpoint: first_intersection.clone().add(last_intersection).multiplyScalar(0.5), + closest_vertex_world_position + } + } + + private get_closest_vertex_world_position_from_hit (intersection: Intersection): Vector3 | null { + const face = intersection.face + const object = intersection.object + const geometry = 'geometry' in object ? object.geometry : undefined + + if (face === null || geometry === undefined) { + return null + } + + const position_attribute = geometry.getAttribute('position') as BufferAttribute | undefined + if (position_attribute === undefined) { + return null + } + + const vertex_indices = [face.a, face.b, face.c] + let closest_vertex_world_position: Vector3 | null = null + let closest_vertex_distance = Number.POSITIVE_INFINITY + + for (const vertex_index of vertex_indices) { + const vertex_world_position = this.get_vertex_world_position(object as Mesh | SkinnedMesh, position_attribute, vertex_index) + const vertex_distance = vertex_world_position.distanceTo(intersection.point) + + if (vertex_distance < closest_vertex_distance) { + closest_vertex_distance = vertex_distance + closest_vertex_world_position = vertex_world_position + } + } + + return closest_vertex_world_position + } + + private get_vertex_world_position (object: Mesh | SkinnedMesh, positions: BufferAttribute, vertex_index: number): Vector3 { + const vertex_world_position = new Vector3().fromBufferAttribute(positions, vertex_index) + + if ('isSkinnedMesh' in object && object.isSkinnedMesh && typeof object.applyBoneTransform === 'function') { + object.applyBoneTransform(vertex_index, vertex_world_position) + } + + return object.localToWorld(vertex_world_position) } private get_point_on_viewport_plane_from_mouse (selected_bone: THREE.Bone, mouse_event: MouseEvent): Vector3 | null { @@ -234,3 +469,8 @@ export class MeshDragBonePlacement { return intersection_point === null ? null : intersection_point.clone() } } + +interface MeshIntersectionTarget { + midpoint: Vector3 + closest_vertex_world_position: Vector3 | null +} diff --git a/src/lib/processes/edit-skeleton/StepEditSkeleton.ts b/src/lib/processes/edit-skeleton/StepEditSkeleton.ts index 019e665..093f1e9 100644 --- a/src/lib/processes/edit-skeleton/StepEditSkeleton.ts +++ b/src/lib/processes/edit-skeleton/StepEditSkeleton.ts @@ -40,8 +40,10 @@ export class StepEditSkeleton extends EventTarget { private threejs_skeleton: Skeleton = new Skeleton() private mirror_mode_enabled: boolean = true private mesh_drag_placement_enabled: boolean = true + private mesh_drag_snap_strength: number = 10 private skinning_algorithm: string | null = null private show_debug: boolean = true + private readonly bone_chain_visibility = new Map() private currently_selected_bone: Bone | null = null @@ -153,6 +155,13 @@ export class StepEditSkeleton extends EventTarget { this.set_mesh_drag_placement_enabled(this.ui.dom_mesh_drag_placement_checkbox.checked) } + if (this.ui.dom_mesh_drag_snap_strength_input !== null) { + const initial_snap_strength = Number(this.ui.dom_mesh_drag_snap_strength_input.value) + this.set_mesh_drag_snap_strength(Number.isFinite(initial_snap_strength) ? initial_snap_strength : 10) + } + + this.render_bone_chain_visibility_options() + this.update_bind_button_text() // Don't add event listeners again if we are navigating back to this step @@ -240,12 +249,37 @@ export class StepEditSkeleton extends EventTarget { return this.mesh_drag_placement_enabled } + public set_mesh_drag_snap_strength (value: number): void { + const clamped_value = Math.max(0, Math.min(20, Math.round(value))) + this.mesh_drag_snap_strength = clamped_value + + if (this.ui.dom_mesh_drag_snap_strength_input !== null) { + this.ui.dom_mesh_drag_snap_strength_input.value = clamped_value.toString() + } + + if (this.ui.dom_mesh_drag_snap_strength_label !== null) { + this.ui.dom_mesh_drag_snap_strength_label.textContent = clamped_value.toString() + } + } + + public get_mesh_drag_snap_strength (): number { + return this.mesh_drag_snap_strength + } + + public hidden_bone_chain_root_names (): string[] { + return [...this.bone_chain_visibility.entries()] + .filter(([, is_visible]) => !is_visible) + .map(([bone_name]) => bone_name) + } + private update_manual_transform_options_visibility (): void { - if (this.ui.dom_transform_manual_options === null) { - return + if (this.ui.dom_transform_manual_options !== null) { + this.ui.dom_transform_manual_options.style.display = this.mesh_drag_placement_enabled ? 'none' : 'flex' } - this.ui.dom_transform_manual_options.style.display = this.mesh_drag_placement_enabled ? 'none' : 'flex' + if (this.ui.dom_mesh_drag_snap_strength_container !== null) { + this.ui.dom_mesh_drag_snap_strength_container.style.display = this.mesh_drag_placement_enabled ? 'flex' : 'none' + } } public is_bone_selectable (bone: Bone | null): boolean { @@ -253,6 +287,10 @@ export class StepEditSkeleton extends EventTarget { return false } + if (!this.is_bone_chain_visible(bone)) { + return false + } + if (!this.mirror_mode_enabled) { return true } @@ -361,6 +399,27 @@ export class StepEditSkeleton extends EventTarget { }) } + this.ui.dom_mesh_drag_snap_strength_input?.addEventListener('input', (event) => { + const target = event.target as HTMLInputElement | null + + if (target === null) { + return + } + + const snap_strength = Number(target.value) + this.set_mesh_drag_snap_strength(Number.isFinite(snap_strength) ? snap_strength : 0) + }) + + this.ui.dom_bone_chain_visibility_container?.addEventListener('change', (event) => { + const target = event.target as HTMLInputElement | null + + if (target === null || target.dataset.chainRootName === undefined) { + return + } + + this.set_bone_chain_visibility(target.dataset.chainRootName, target.checked) + }) + this.ui.dom_enable_skin_debugging?.addEventListener('change', (event) => { const target = event.target as HTMLInputElement | null @@ -487,6 +546,7 @@ export class StepEditSkeleton extends EventTarget { this.edited_armature = armature.clone() this.create_threejs_skeleton_object() + this.initialize_bone_chain_visibility_state() this.independent_bone_movement.set_rest_pose(this.threejs_skeleton) // Initialize the undo/redo system with the skeleton @@ -513,6 +573,56 @@ export class StepEditSkeleton extends EventTarget { return this.threejs_skeleton } + public is_bone_chain_visible (bone: Bone): boolean { + return this.bone_chain_visibility.get(Utility.chain_root_bone_from_bone(bone).name) !== false + } + + private initialize_bone_chain_visibility_state (): void { + this.bone_chain_visibility.clear() + + Utility.unique_chain_root_bones_from_skeleton(this.threejs_skeleton).forEach((bone) => { + this.bone_chain_visibility.set(bone.name, true) + }) + } + + private render_bone_chain_visibility_options (): void { + if (this.ui.dom_bone_chain_visibility_container === null) { + return + } + + const chain_root_bones = Utility.unique_chain_root_bones_from_skeleton(this.threejs_skeleton) + if (chain_root_bones.length === 0) { + this.ui.dom_bone_chain_visibility_container.style.display = 'none' + this.ui.dom_bone_chain_visibility_container.innerHTML = '' + return + } + + const checkbox_markup = chain_root_bones.map((bone) => { + const checkbox_id = `bone-chain-${bone.name.replace(/[^a-zA-Z0-9_-]/g, '-')}` + const checked_attribute = this.bone_chain_visibility.get(bone.name) === false ? '' : ' checked' + const label = Utility.format_bone_chain_label(bone.name) + return `
` + }).join('') + + this.ui.dom_bone_chain_visibility_container.style.display = 'flex' + this.ui.dom_bone_chain_visibility_container.innerHTML = `
Visible Chains${checkbox_markup}
` + } + + private set_bone_chain_visibility (chain_root_name: string, is_visible: boolean): void { + this.bone_chain_visibility.set(chain_root_name, is_visible) + + const selected_bone = this.get_currently_selected_bone() + if (selected_bone !== null && !this.is_bone_chain_visible(selected_bone)) { + this.set_currently_selected_bone(null) + if (this.ui.dom_selected_bone_label !== null) { + this.ui.dom_selected_bone_label.innerHTML = 'None' + } + this.update_bone_hover_point_position(null) + } + + this.dispatchEvent(new CustomEvent('chainVisibilityChanged')) + } + public apply_mirror_mode (selected_bone: Bone, transform_type: string): void { const mirror_bone = this.find_mirror_bone(selected_bone) diff --git a/src/lib/solvers/WeightSmoother.test.ts b/src/lib/solvers/WeightSmoother.test.ts new file mode 100644 index 0000000..219117c --- /dev/null +++ b/src/lib/solvers/WeightSmoother.test.ts @@ -0,0 +1,368 @@ +import { describe, expect, it } from 'vitest' +import { Bone, BufferGeometry, Float32BufferAttribute, Uint16BufferAttribute } from 'three' + +import { WeightSmoother } from './WeightSmoother.js' + +function count_non_zero_weights (skin_weights: number[], vertex: number): number { + const offset = vertex * 4 + return [ + skin_weights[offset], + skin_weights[offset + 1], + skin_weights[offset + 2], + skin_weights[offset + 3] + ].filter(weight => weight > 1e-6).length +} + +describe('WeightSmoother', () => { + it('accumulates influences across adjacent standard boundaries instead of collapsing back to two weights', () => { + const geometry = new BufferGeometry() + geometry.setAttribute('position', new Float32BufferAttribute([ + 0, 1, 0, + 1, 1, 0, + 2, 1, 0, + 0, 0, 0, + 1, 0, 0, + 2, 0, 0 + ], 3)) + geometry.setIndex(new Uint16BufferAttribute([ + 0, 3, 1, + 1, 3, 4, + 1, 4, 2, + 2, 4, 5 + ], 1)) + + const bones = ['bone-left', 'bone-middle', 'bone-right'].map((name) => { + const bone = new Bone() + bone.name = name + return bone + }) + + const skin_indices = [ + 0, 0, 0, 0, + 1, 0, 0, 0, + 2, 0, 0, 0, + 0, 0, 0, 0, + 1, 0, 0, 0, + 2, 0, 0, 0 + ] + const skin_weights = [ + 1, 0, 0, 0, + 1, 0, 0, 0, + 1, 0, 0, 0, + 1, 0, 0, 0, + 1, 0, 0, 0, + 1, 0, 0, 0 + ] + + const smoother = new WeightSmoother(geometry, bones) + smoother.smooth_bone_weight_boundaries(skin_indices, skin_weights) + + expect(count_non_zero_weights(skin_weights, 1)).toBeGreaterThanOrEqual(3) + expect(count_non_zero_weights(skin_weights, 4)).toBeGreaterThanOrEqual(3) + }) + + it('applies a joint-to-center gradient along a bone chain', () => { + const geometry = new BufferGeometry() + geometry.setAttribute('position', new Float32BufferAttribute([ + 0, 0, 0, + 1, 0, 0, + 2, 0, 0 + ], 3)) + + const root_bone = new Bone() + root_bone.name = 'root' + + const mid_bone = new Bone() + mid_bone.name = 'spine' + mid_bone.position.set(1, 0, 0) + root_bone.add(mid_bone) + + const child_bone = new Bone() + child_bone.name = 'chest' + child_bone.position.set(1, 0, 0) + mid_bone.add(child_bone) + + root_bone.updateWorldMatrix(true, true) + + const bones = [root_bone, mid_bone, child_bone] + const skin_indices = [ + 1, 0, 0, 0, + 1, 0, 0, 0, + 1, 0, 0, 0 + ] + const skin_weights = [ + 1, 0, 0, 0, + 1, 0, 0, 0, + 1, 0, 0, 0 + ] + + const smoother = new WeightSmoother(geometry, bones) + smoother.smooth_bone_weight_boundaries(skin_indices, skin_weights) + + expect(skin_weights[0]).toBeCloseTo(0.2, 5) + expect(skin_weights[1]).toBeCloseTo(0.8, 5) + expect(skin_weights[4]).toBeCloseTo(1.0, 5) + expect(skin_weights[8]).toBeCloseTo(0.2, 5) + expect(skin_weights[9]).toBeCloseTo(0.8, 5) + }) + + it('uses a wider smoothing band between pelvis and thigh bones', () => { + const geometry = new BufferGeometry() + geometry.setAttribute('position', new Float32BufferAttribute([ + 0, 1, 0, + 1, 1, 0, + 2, 1, 0, + 3, 1, 0, + 0, 0, 0, + 1, 0, 0, + 2, 0, 0, + 3, 0, 0 + ], 3)) + geometry.setIndex(new Uint16BufferAttribute([ + 0, 4, 1, + 1, 4, 5, + 1, 5, 2, + 2, 5, 6, + 2, 6, 3, + 3, 6, 7 + ], 1)) + + const pelvis = new Bone() + pelvis.name = 'pelvis' + + const spine = new Bone() + spine.name = 'spine' + pelvis.add(spine) + + const thigh = new Bone() + thigh.name = 'leftThigh' + pelvis.add(thigh) + + const shin = new Bone() + shin.name = 'leftShin' + thigh.add(shin) + + pelvis.updateWorldMatrix(true, true) + + const bones = [pelvis, spine, thigh, shin] + const skin_indices = [ + 0, 0, 0, 0, + 0, 0, 0, 0, + 2, 0, 0, 0, + 2, 0, 0, 0, + 0, 0, 0, 0, + 0, 0, 0, 0, + 2, 0, 0, 0, + 2, 0, 0, 0 + ] + const skin_weights = [ + 1, 0, 0, 0, + 1, 0, 0, 0, + 1, 0, 0, 0, + 1, 0, 0, 0, + 1, 0, 0, 0, + 1, 0, 0, 0, + 1, 0, 0, 0, + 1, 0, 0, 0 + ] + + const smoother = new WeightSmoother(geometry, bones) + smoother.smooth_bone_weight_boundaries(skin_indices, skin_weights) + + expect(skin_weights[1]).toBeCloseTo(0.48, 5) + expect(skin_weights[5]).toBeCloseTo(0.5, 5) + expect(skin_weights[13]).toBeCloseTo(0.48, 5) + }) + + it('adds extra pelvis-basin blending for central glute-area vertices', () => { + const geometry = new BufferGeometry() + geometry.setAttribute('position', new Float32BufferAttribute([ + 0, -0.05, 0, + 0.25, -0.15, 0, + -0.25, -0.15, 0, + 0, -0.3, 0, + 0.5, -0.45, 0, + -0.5, -0.45, 0 + ], 3)) + + const pelvis = new Bone() + pelvis.name = 'pelvis' + + const spine = new Bone() + spine.name = 'spine' + spine.position.set(0, 0.4, 0) + pelvis.add(spine) + + const left_thigh = new Bone() + left_thigh.name = 'leftThigh' + left_thigh.position.set(-0.4, -0.6, 0) + pelvis.add(left_thigh) + + const right_thigh = new Bone() + right_thigh.name = 'rightThigh' + right_thigh.position.set(0.4, -0.6, 0) + pelvis.add(right_thigh) + + pelvis.updateWorldMatrix(true, true) + + const bones = [pelvis, spine, left_thigh, right_thigh] + const skin_indices = [ + 0, 0, 0, 0, + 0, 0, 0, 0, + 0, 0, 0, 0, + 0, 0, 0, 0, + 2, 0, 0, 0, + 3, 0, 0, 0 + ] + const skin_weights = [ + 1, 0, 0, 0, + 1, 0, 0, 0, + 1, 0, 0, 0, + 1, 0, 0, 0, + 1, 0, 0, 0, + 1, 0, 0, 0 + ] + + const smoother = new WeightSmoother(geometry, bones) + smoother.smooth_bone_weight_boundaries(skin_indices, skin_weights) + + expect(skin_weights[4 + 1]).toBeGreaterThan(0.12) + expect([2, 3]).toContain(skin_indices[4 + 1]) + expect(skin_weights[8 + 1]).toBeGreaterThan(0.12) + expect([2, 3]).toContain(skin_indices[8 + 1]) + expect(skin_weights[12 + 1]).toBeGreaterThan(0.12) + expect([2, 3]).toContain(skin_indices[12 + 1]) + }) + + it('uses a wider smoothing band between spine-chain and shoulder-chain bones', () => { + const geometry = new BufferGeometry() + geometry.setAttribute('position', new Float32BufferAttribute([ + 0, 1, 0, + 1, 1, 0, + 2, 1, 0, + 3, 1, 0, + 0, 0, 0, + 1, 0, 0, + 2, 0, 0, + 3, 0, 0 + ], 3)) + geometry.setIndex(new Uint16BufferAttribute([ + 0, 4, 1, + 1, 4, 5, + 1, 5, 2, + 2, 5, 6, + 2, 6, 3, + 3, 6, 7 + ], 1)) + + const chest = new Bone() + chest.name = 'chest' + + const neck = new Bone() + neck.name = 'neck' + chest.add(neck) + + const clavicle = new Bone() + clavicle.name = 'leftClavicle' + chest.add(clavicle) + + const upper_arm = new Bone() + upper_arm.name = 'leftUpperArm' + clavicle.add(upper_arm) + + chest.updateWorldMatrix(true, true) + + const bones = [chest, neck, clavicle, upper_arm] + const skin_indices = [ + 0, 0, 0, 0, + 0, 0, 0, 0, + 2, 0, 0, 0, + 2, 0, 0, 0, + 0, 0, 0, 0, + 0, 0, 0, 0, + 2, 0, 0, 0, + 2, 0, 0, 0 + ] + const skin_weights = [ + 1, 0, 0, 0, + 1, 0, 0, 0, + 1, 0, 0, 0, + 1, 0, 0, 0, + 1, 0, 0, 0, + 1, 0, 0, 0, + 1, 0, 0, 0, + 1, 0, 0, 0 + ] + + const smoother = new WeightSmoother(geometry, bones) + smoother.smooth_bone_weight_boundaries(skin_indices, skin_weights) + + expect(skin_weights[1]).toBeCloseTo(0.5, 5) + expect(skin_weights[5]).toBeCloseTo(0.5, 5) + expect(skin_weights[13]).toBeCloseTo(0.5, 5) + }) + + it('uses a symmetric socket smoothing band between clavicle and upper arm bones', () => { + const geometry = new BufferGeometry() + geometry.setAttribute('position', new Float32BufferAttribute([ + 0, 1, 0, + 1, 1, 0, + 2, 1, 0, + 3, 1, 0, + 0, 0, 0, + 1, 0, 0, + 2, 0, 0, + 3, 0, 0 + ], 3)) + geometry.setIndex(new Uint16BufferAttribute([ + 0, 4, 1, + 1, 4, 5, + 1, 5, 2, + 2, 5, 6, + 2, 6, 3, + 3, 6, 7 + ], 1)) + + const clavicle = new Bone() + clavicle.name = 'leftClavicle' + + const upper_arm = new Bone() + upper_arm.name = 'leftUpperArm' + clavicle.add(upper_arm) + + const forearm = new Bone() + forearm.name = 'leftForeArm' + upper_arm.add(forearm) + + clavicle.updateWorldMatrix(true, true) + + const bones = [clavicle, upper_arm, forearm] + const skin_indices = [ + 0, 0, 0, 0, + 0, 0, 0, 0, + 1, 0, 0, 0, + 1, 0, 0, 0, + 0, 0, 0, 0, + 0, 0, 0, 0, + 1, 0, 0, 0, + 1, 0, 0, 0 + ] + const skin_weights = [ + 1, 0, 0, 0, + 1, 0, 0, 0, + 1, 0, 0, 0, + 1, 0, 0, 0, + 1, 0, 0, 0, + 1, 0, 0, 0, + 1, 0, 0, 0, + 1, 0, 0, 0 + ] + + const smoother = new WeightSmoother(geometry, bones) + smoother.smooth_bone_weight_boundaries(skin_indices, skin_weights) + + expect(skin_weights[1]).toBeCloseTo(0.5, 5) + expect(skin_weights[5]).toBeCloseTo(0.5, 5) + expect(skin_weights[13]).toBeCloseTo(0.5, 5) + }) +}) \ No newline at end of file diff --git a/src/lib/solvers/WeightSmoother.ts b/src/lib/solvers/WeightSmoother.ts index e635a6a..3db4c5b 100644 --- a/src/lib/solvers/WeightSmoother.ts +++ b/src/lib/solvers/WeightSmoother.ts @@ -1,10 +1,12 @@ import { - type Bone, + Bone, type BufferAttribute, - type BufferGeometry + type BufferGeometry, + Vector3 } from 'three' import { BoneClassifier } from './BoneClassifier.js' +import { Utility } from '../Utilities.js' /** * Smooths skin weight boundaries between bone influences using vertex adjacency. @@ -48,7 +50,104 @@ export class WeightSmoother { this.apply_limb_smoothing(skin_indices, skin_weights, position_to_indices, boundary_pairs) // Pass 4: Apply standard smoothing for remaining boundaries - this.apply_standard_smoothing(skin_indices, skin_weights, position_to_indices, boundary_pairs) + this.apply_standard_smoothing(skin_indices, skin_weights, adjacency, position_to_indices, boundary_pairs) + + // Pass 4.5: Add an extra blend through the central pelvis basin so + // buttocks/glute vertices stay connected to both pelvis and upper thighs. + this.apply_pelvis_basin_smoothing(skin_indices, skin_weights, position_to_indices) + + // Pass 5: Apply an along-the-bone gradient so joints stay soft, + // while the center of each bone remains dominant. + this.apply_axial_gradient_smoothing(skin_indices, skin_weights, position_to_indices) + } + + private apply_axial_gradient_smoothing ( + skin_indices: number[], + skin_weights: number[], + position_to_indices: Map + ): void { + const vertex_count = this.geometry_vertex_count() + const processed_vertices = new Set() + + for (let vertex = 0; vertex < vertex_count; vertex++) { + if (processed_vertices.has(vertex)) { + continue + } + + const offset = vertex * 4 + const primary_bone = skin_indices[offset] + if (primary_bone === undefined || primary_bone === 0 && this.bones[0]?.name === 'root') { + continue + } + + const gradient_data = this.get_axial_gradient_data(primary_bone) + if (gradient_data === null) { + continue + } + + const shared_vertices = this.get_shared_vertices(vertex, position_to_indices) + shared_vertices.forEach((shared_vertex) => processed_vertices.add(shared_vertex)) + + for (const shared_vertex of shared_vertices) { + if (!this.can_apply_axial_gradient_to_vertex(shared_vertex, skin_indices, skin_weights, gradient_data)) { + continue + } + + const target_weights = this.calculate_axial_gradient_weights(shared_vertex, gradient_data) + if (target_weights === null) { + continue + } + + this.set_vertex_weights( + skin_indices, + skin_weights, + shared_vertex, + target_weights.primary_bone, + target_weights.secondary_bone, + target_weights.primary_weight, + target_weights.secondary_weight + ) + } + } + } + + private apply_pelvis_basin_smoothing ( + skin_indices: number[], + skin_weights: number[], + position_to_indices: Map + ): void { + const pelvis_data = this.get_pelvis_basin_data() + if (pelvis_data === null) { + return + } + + const processed_vertices = new Set() + const vertex_count = this.geometry_vertex_count() + + for (let vertex = 0; vertex < vertex_count; vertex++) { + if (processed_vertices.has(vertex)) { + continue + } + + const shared_vertices = this.get_shared_vertices(vertex, position_to_indices) + shared_vertices.forEach((shared_vertex) => processed_vertices.add(shared_vertex)) + + for (const shared_vertex of shared_vertices) { + const basin_blend = this.calculate_pelvis_basin_blend(shared_vertex, skin_indices, pelvis_data) + if (basin_blend === null) { + continue + } + + this.merge_vertex_weights( + skin_indices, + skin_weights, + shared_vertex, + basin_blend.primary_bone, + basin_blend.secondary_bone, + basin_blend.secondary_weight + ) + } + } } /** @@ -80,7 +179,9 @@ export class WeightSmoother { visited.add(key) let smoothing_type: SmoothingType = SmoothingType.Standard - if (this.classifier.is_torso_boundary(bone_a, bone_b)) { + if (this.is_special_socket_boundary(bone_a, bone_b)) { + smoothing_type = SmoothingType.Socket + } else if (this.classifier.is_torso_boundary(bone_a, bone_b)) { smoothing_type = SmoothingType.Torso } else if (this.classifier.is_limb_boundary(bone_a, bone_b)) { smoothing_type = SmoothingType.Limb @@ -105,7 +206,7 @@ export class WeightSmoother { position_to_indices: Map, pairs: BoundaryPair[] ): void { - const torso_pairs = pairs.filter(p => p.smoothing_type === SmoothingType.Torso) + const torso_pairs = pairs.filter(p => p.smoothing_type === SmoothingType.Torso || p.smoothing_type === SmoothingType.Socket) if (torso_pairs.length === 0) return // Collect all boundary vertices and their bone assignments @@ -115,10 +216,6 @@ export class WeightSmoother { boundary_vertices.add(pair.vertex_b) } - // Ring 0 (boundary): 50/50 blend - // Ring 1 (one step out): 75/25 blend with neighbor bone - // Ring 2 (two steps out): 90/10 blend with neighbor bone - const ring_weights = [0.5, 0.25, 0.10] const processed = new Set() // Apply blending ring by ring outward from the boundary @@ -126,53 +223,82 @@ export class WeightSmoother { // First, blend the direct boundary pairs (ring 0) for (const pair of torso_pairs) { + const ring_weights = this.get_torso_ring_weights(pair) this.blend_vertex_pair(skin_indices, skin_weights, position_to_indices, pair.vertex_a, pair.vertex_b, pair.bone_a, pair.bone_b, ring_weights[0]) + + if (pair.smoothing_type === SmoothingType.Socket) { + this.expand_boundary_side_multiple_rings( + skin_indices, + skin_weights, + adjacency, + position_to_indices, + pair.vertex_a, + pair.bone_a, + pair.bone_b, + ring_weights.slice(1) + ) + this.expand_boundary_side_multiple_rings( + skin_indices, + skin_weights, + adjacency, + position_to_indices, + pair.vertex_b, + pair.bone_b, + pair.bone_a, + ring_weights.slice(1) + ) + } + processed.add(pair.vertex_a) processed.add(pair.vertex_b) current_ring_vertices.add(pair.vertex_a) current_ring_vertices.add(pair.vertex_b) } - // Expand outward for rings 1 and 2 - for (let ring = 1; ring < ring_weights.length; ring++) { + const max_ring_count = torso_pairs.reduce((max_count, pair) => { + return Math.max(max_count, this.get_torso_ring_weights(pair).length) + }, 0) + + for (let ring = 1; ring < max_ring_count; ring++) { const next_ring_vertices = new Set() - const secondary_weight = ring_weights[ring] - for (const vertex_idx of current_ring_vertices) { - const offset = vertex_idx * 4 - const primary_bone = skin_indices[offset] + for (const pair of torso_pairs) { + const ring_weights = this.get_torso_ring_weights(pair) + const secondary_weight = ring_weights[ring] + if (secondary_weight === undefined) { + continue + } - for (const neighbor of adjacency[vertex_idx]) { - if (processed.has(neighbor)) continue + const pair_vertices = [pair.vertex_a, pair.vertex_b] + for (const vertex_idx of pair_vertices) { + if (!current_ring_vertices.has(vertex_idx)) { + continue + } - const neighbor_offset = neighbor * 4 - const neighbor_bone = skin_indices[neighbor_offset] + const offset = vertex_idx * 4 + const primary_bone = skin_indices[offset] - // Only expand into vertices that share the same primary bone - if (neighbor_bone !== primary_bone) continue - if (skin_weights[neighbor_offset] !== 1.0) continue + for (const neighbor of adjacency[vertex_idx]) { + if (processed.has(neighbor)) continue - // Find the other bone to blend with from the boundary pair info - const other_bone = this.find_neighbor_bone_from_boundary(vertex_idx, skin_indices, primary_bone) - if (other_bone === -1) continue + const neighbor_offset = neighbor * 4 + const neighbor_bone = skin_indices[neighbor_offset] - // Apply tapering blend to this vertex and its shared-position duplicates - const shared = this.get_shared_vertices(neighbor, position_to_indices) - for (const idx of shared) { - const off = idx * 4 - skin_indices[off + 0] = neighbor_bone - skin_indices[off + 1] = other_bone - skin_weights[off + 0] = 1.0 - secondary_weight - skin_weights[off + 1] = secondary_weight - skin_indices[off + 2] = 0 - skin_indices[off + 3] = 0 - skin_weights[off + 2] = 0 - skin_weights[off + 3] = 0 - } + if (neighbor_bone !== primary_bone) continue + if (skin_weights[neighbor_offset] !== 1.0) continue + + const other_bone = this.find_neighbor_bone_from_boundary(vertex_idx, skin_indices, primary_bone) + if (other_bone === -1) continue - processed.add(neighbor) - next_ring_vertices.add(neighbor) + const shared = this.get_shared_vertices(neighbor, position_to_indices) + for (const idx of shared) { + this.merge_vertex_weights(skin_indices, skin_weights, idx, neighbor_bone, other_bone, secondary_weight) + } + + processed.add(neighbor) + next_ring_vertices.add(neighbor) + } } } @@ -222,6 +348,7 @@ export class WeightSmoother { private apply_standard_smoothing ( skin_indices: number[], skin_weights: number[], + adjacency: Array>, position_to_indices: Map, pairs: BoundaryPair[] ): void { @@ -229,6 +356,12 @@ export class WeightSmoother { for (const pair of standard_pairs) { this.blend_vertex_pair(skin_indices, skin_weights, position_to_indices, pair.vertex_a, pair.vertex_b, pair.bone_a, pair.bone_b, 0.5) + + this.expand_boundary_side(skin_indices, skin_weights, adjacency, position_to_indices, + pair.vertex_a, pair.bone_a, pair.bone_b, 0.2) + + this.expand_boundary_side(skin_indices, skin_weights, adjacency, position_to_indices, + pair.vertex_b, pair.bone_b, pair.bone_a, 0.2) } } @@ -246,32 +379,14 @@ export class WeightSmoother { bone_b: number, secondary_weight: number ): void { - const primary_weight = 1.0 - secondary_weight - const shared_a = this.get_shared_vertices(vertex_a, position_to_indices) for (const idx of shared_a) { - const off = idx * 4 - skin_indices[off + 0] = bone_a - skin_indices[off + 1] = bone_b - skin_weights[off + 0] = primary_weight - skin_weights[off + 1] = secondary_weight - skin_indices[off + 2] = 0 - skin_indices[off + 3] = 0 - skin_weights[off + 2] = 0 - skin_weights[off + 3] = 0 + this.merge_vertex_weights(skin_indices, skin_weights, idx, bone_a, bone_b, secondary_weight) } const shared_b = this.get_shared_vertices(vertex_b, position_to_indices) for (const idx of shared_b) { - const off = idx * 4 - skin_indices[off + 0] = bone_b - skin_indices[off + 1] = bone_a - skin_weights[off + 0] = primary_weight - skin_weights[off + 1] = secondary_weight - skin_indices[off + 2] = 0 - skin_indices[off + 3] = 0 - skin_weights[off + 2] = 0 - skin_weights[off + 3] = 0 + this.merge_vertex_weights(skin_indices, skin_weights, idx, bone_b, bone_a, secondary_weight) } } @@ -288,18 +403,129 @@ export class WeightSmoother { secondary_bone: number, secondary_weight: number ): void { - const primary_weight = 1.0 - secondary_weight const shared = this.get_shared_vertices(vertex, position_to_indices) for (const idx of shared) { - const off = idx * 4 - skin_indices[off + 0] = primary_bone - skin_indices[off + 1] = secondary_bone - skin_weights[off + 0] = primary_weight - skin_weights[off + 1] = secondary_weight - skin_indices[off + 2] = 0 - skin_indices[off + 3] = 0 - skin_weights[off + 2] = 0 - skin_weights[off + 3] = 0 + this.merge_vertex_weights(skin_indices, skin_weights, idx, primary_bone, secondary_bone, secondary_weight) + } + } + + private expand_boundary_side ( + skin_indices: number[], + skin_weights: number[], + adjacency: Array>, + position_to_indices: Map, + boundary_vertex: number, + primary_bone: number, + secondary_bone: number, + secondary_weight: number + ): void { + for (const neighbor of adjacency[boundary_vertex]) { + const offset = neighbor * 4 + if (skin_indices[offset] !== primary_bone) continue + if (skin_weights[offset] !== 1.0) continue + + const shared = this.get_shared_vertices(neighbor, position_to_indices) + for (const idx of shared) { + this.merge_vertex_weights(skin_indices, skin_weights, idx, primary_bone, secondary_bone, secondary_weight) + } + } + } + + private expand_boundary_side_multiple_rings ( + skin_indices: number[], + skin_weights: number[], + adjacency: Array>, + position_to_indices: Map, + boundary_vertex: number, + primary_bone: number, + secondary_bone: number, + secondary_weights: number[] + ): void { + let current_frontier = new Set([boundary_vertex]) + const visited = new Set([boundary_vertex]) + + for (const secondary_weight of secondary_weights) { + const next_frontier = new Set() + + for (const frontier_vertex of current_frontier) { + for (const neighbor of adjacency[frontier_vertex]) { + if (visited.has(neighbor)) { + continue + } + + const offset = neighbor * 4 + if (skin_indices[offset] !== primary_bone) { + continue + } + + if (skin_weights[offset] !== 1.0) { + continue + } + + const shared = this.get_shared_vertices(neighbor, position_to_indices) + for (const idx of shared) { + this.merge_vertex_weights(skin_indices, skin_weights, idx, primary_bone, secondary_bone, secondary_weight) + } + + visited.add(neighbor) + next_frontier.add(neighbor) + } + } + + current_frontier = next_frontier + if (current_frontier.size === 0) { + return + } + } + } + + private merge_vertex_weights ( + skin_indices: number[], + skin_weights: number[], + vertex: number, + primary_bone: number, + secondary_bone: number, + secondary_weight: number + ): void { + const offset = vertex * 4 + const existing_entries: Array<{ bone: number, weight: number }> = [] + + for (let slot = 0; slot < 4; slot++) { + const weight = skin_weights[offset + slot] + if (weight <= 0) continue + existing_entries.push({ bone: skin_indices[offset + slot], weight }) + } + + if (existing_entries.length === 0) { + existing_entries.push({ bone: primary_bone, weight: 1.0 }) + } + + const merged_by_bone = new Map() + for (const entry of existing_entries) { + merged_by_bone.set(entry.bone, (merged_by_bone.get(entry.bone) ?? 0) + entry.weight * (1.0 - secondary_weight)) + } + + merged_by_bone.set(secondary_bone, (merged_by_bone.get(secondary_bone) ?? 0) + secondary_weight) + + if (!merged_by_bone.has(primary_bone)) { + merged_by_bone.set(primary_bone, 0) + } + + const sorted_entries = [...merged_by_bone.entries()] + .map(([bone, weight]) => ({ bone, weight })) + .filter((entry) => entry.weight > 1e-6) + .sort((left, right) => right.weight - left.weight) + .slice(0, 4) + + const weight_sum = sorted_entries.reduce((sum, entry) => sum + entry.weight, 0) + const normalized_entries = weight_sum > 0 + ? sorted_entries.map((entry) => ({ bone: entry.bone, weight: entry.weight / weight_sum })) + : [{ bone: primary_bone, weight: 1.0 }] + + for (let slot = 0; slot < 4; slot++) { + const entry = normalized_entries[slot] + skin_indices[offset + slot] = entry?.bone ?? 0 + skin_weights[offset + slot] = entry?.weight ?? 0 } } @@ -372,6 +598,314 @@ export class WeightSmoother { } return adjacency } + + private get_axial_gradient_data (bone_index: number): AxialGradientData | null { + const bone = this.bones[bone_index] + if (bone === undefined || bone.name === 'root') { + return null + } + + const parent_bone = bone.parent instanceof Bone ? bone.parent : null + const child_bones = bone.children.filter((child): child is Bone => child instanceof Bone) + if (child_bones.length > 1) { + return null + } + + const child_bone = child_bones[0] ?? null + + if (parent_bone === null && child_bone === null) { + return null + } + + const bone_world_position = Utility.world_position_from_object(bone) + const start_point = parent_bone !== null + ? Utility.world_position_from_object(parent_bone) + : bone_world_position.clone() + + const end_point = child_bone !== null + ? Utility.world_position_from_object(child_bone) + : bone_world_position.clone() + + if (start_point.distanceToSquared(end_point) < 1e-8) { + return null + } + + return { + primary_bone: bone_index, + parent_bone: parent_bone !== null ? this.bones.indexOf(parent_bone) : null, + child_bone: child_bone !== null ? this.bones.indexOf(child_bone) : null, + start_point, + end_point + } + } + + private get_torso_ring_weights (pair: BoundaryPair): number[] { + if (this.is_pelvis_thigh_boundary(pair.bone_a, pair.bone_b)) { + return [0.5, 0.48, 0.38, 0.28, 0.18, 0.1] + } + + if (this.is_shoulder_spine_boundary(pair.bone_a, pair.bone_b)) { + return [0.5, 0.5, 0.42, 0.32, 0.22, 0.12] + } + + if (this.is_clavicle_upperarm_boundary(pair.bone_a, pair.bone_b)) { + return [0.5, 0.5, 0.42, 0.32, 0.22] + } + + return [0.5, 0.25, 0.10] + } + + private is_special_socket_boundary (bone_index_a: number, bone_index_b: number): boolean { + return this.is_pelvis_thigh_boundary(bone_index_a, bone_index_b) || + this.is_shoulder_spine_boundary(bone_index_a, bone_index_b) || + this.is_clavicle_upperarm_boundary(bone_index_a, bone_index_b) + } + + private is_pelvis_thigh_boundary (bone_index_a: number, bone_index_b: number): boolean { + const bone_a_name = this.bones[bone_index_a]?.name.toLowerCase() ?? '' + const bone_b_name = this.bones[bone_index_b]?.name.toLowerCase() ?? '' + + const is_pelvis_name = (name: string): boolean => /(pelvis|hips)/.test(name) + const is_thigh_name = (name: string): boolean => /(thigh|upleg|upperleg|leg)/.test(name) + + return (is_pelvis_name(bone_a_name) && is_thigh_name(bone_b_name)) || + (is_pelvis_name(bone_b_name) && is_thigh_name(bone_a_name)) + } + + private is_shoulder_spine_boundary (bone_index_a: number, bone_index_b: number): boolean { + const bone_a_name = this.bones[bone_index_a]?.name.toLowerCase() ?? '' + const bone_b_name = this.bones[bone_index_b]?.name.toLowerCase() ?? '' + + const is_spine_chain_name = (name: string): boolean => /(spine|chest|torso|body|neck)/.test(name) + const is_shoulder_chain_name = (name: string): boolean => /(clavicle|shoulder|upperarm|arm)/.test(name) + + return (is_spine_chain_name(bone_a_name) && is_shoulder_chain_name(bone_b_name)) || + (is_spine_chain_name(bone_b_name) && is_shoulder_chain_name(bone_a_name)) + } + + private is_clavicle_upperarm_boundary (bone_index_a: number, bone_index_b: number): boolean { + const bone_a_name = this.bones[bone_index_a]?.name.toLowerCase() ?? '' + const bone_b_name = this.bones[bone_index_b]?.name.toLowerCase() ?? '' + + const is_clavicle_name = (name: string): boolean => /(clavicle|shoulder)/.test(name) && !/(spine|chest|neck|torso|body)/.test(name) + const is_upperarm_name = (name: string): boolean => /(upperarm|arm)/.test(name) && !/(forearm|lowerarm)/.test(name) + + return (is_clavicle_name(bone_a_name) && is_upperarm_name(bone_b_name)) || + (is_clavicle_name(bone_b_name) && is_upperarm_name(bone_a_name)) + } + + private can_apply_axial_gradient_to_vertex ( + vertex: number, + skin_indices: number[], + skin_weights: number[], + gradient_data: AxialGradientData + ): boolean { + const offset = vertex * 4 + const allowed_bones = new Set([ + gradient_data.primary_bone, + gradient_data.parent_bone ?? -1, + gradient_data.child_bone ?? -1 + ]) + + let non_zero_influence_count = 0 + + for (let slot = 0; slot < 4; slot++) { + const weight = skin_weights[offset + slot] + if (weight <= 1e-6) { + continue + } + + non_zero_influence_count++ + const bone_index = skin_indices[offset + slot] + if (!allowed_bones.has(bone_index) && bone_index !== 0) { + return false + } + } + + return non_zero_influence_count <= 2 + } + + private calculate_axial_gradient_weights ( + vertex: number, + gradient_data: AxialGradientData + ): AxialGradientWeights | null { + const position = new Vector3().fromBufferAttribute(this.geometry.attributes.position, vertex) + const segment = gradient_data.end_point.clone().sub(gradient_data.start_point) + const segment_length_squared = segment.lengthSq() + if (segment_length_squared < 1e-8) { + return null + } + + const segment_t = position.clone().sub(gradient_data.start_point).dot(segment) / segment_length_squared + const clamped_t = Math.max(0, Math.min(1, segment_t)) + const center_falloff = Math.abs((clamped_t * 2) - 1) + const primary_weight = 0.2 + (0.8 * (1 - center_falloff)) + + const secondary_bone = clamped_t <= 0.5 + ? gradient_data.parent_bone ?? gradient_data.child_bone + : gradient_data.child_bone ?? gradient_data.parent_bone + + if (secondary_bone === null) { + return { + primary_bone: gradient_data.primary_bone, + secondary_bone: 0, + primary_weight: 1, + secondary_weight: 0 + } + } + + return { + primary_bone: gradient_data.primary_bone, + secondary_bone, + primary_weight, + secondary_weight: Math.max(0, 1 - primary_weight) + } + } + + private set_vertex_weights ( + skin_indices: number[], + skin_weights: number[], + vertex: number, + primary_bone: number, + secondary_bone: number, + primary_weight: number, + secondary_weight: number + ): void { + const offset = vertex * 4 + skin_indices[offset] = primary_bone + skin_weights[offset] = primary_weight + skin_indices[offset + 1] = secondary_bone + skin_weights[offset + 1] = secondary_weight + skin_indices[offset + 2] = 0 + skin_weights[offset + 2] = 0 + skin_indices[offset + 3] = 0 + skin_weights[offset + 3] = 0 + } + + private get_pelvis_basin_data (): PelvisBasinData | null { + const pelvis_bone_index = this.bones.findIndex((bone) => /(pelvis|hips)/.test(bone.name.toLowerCase())) + if (pelvis_bone_index === -1) { + return null + } + + const pelvis_bone = this.bones[pelvis_bone_index] + const thigh_bones = this.bones + .map((bone, bone_index) => ({ bone, bone_index })) + .filter(({ bone }) => /(thigh|upleg|upperleg|leg)/.test(bone.name.toLowerCase())) + + if (thigh_bones.length === 0) { + return null + } + + const pelvis_position = Utility.world_position_from_object(pelvis_bone) + const thigh_positions = thigh_bones.map(({ bone }) => Utility.world_position_from_object(bone)) + + let average_thigh_position = new Vector3() + thigh_positions.forEach((position) => { + average_thigh_position.add(position) + }) + average_thigh_position.divideScalar(thigh_positions.length) + + const pelvis_to_thigh_distance = Math.max(0.0001, pelvis_position.distanceTo(average_thigh_position)) + + return { + pelvis_bone_index, + pelvis_position, + thigh_bones, + average_thigh_position, + pelvis_to_thigh_distance + } + } + + private calculate_pelvis_basin_blend ( + vertex: number, + skin_indices: number[], + pelvis_data: PelvisBasinData + ): PelvisBasinBlend | null { + const offset = vertex * 4 + const primary_bone = skin_indices[offset] + const thigh_bone_indices = new Set(pelvis_data.thigh_bones.map(({ bone_index }) => bone_index)) + + if (primary_bone !== pelvis_data.pelvis_bone_index && !thigh_bone_indices.has(primary_bone)) { + return null + } + + const vertex_position = new Vector3().fromBufferAttribute(this.geometry.attributes.position, vertex) + const thigh_center_distance = vertex_position.distanceTo(pelvis_data.average_thigh_position) + const pelvis_distance = vertex_position.distanceTo(pelvis_data.pelvis_position) + + if (pelvis_distance > pelvis_data.pelvis_to_thigh_distance * 1.45) { + return null + } + + const vertical_min = Math.min(pelvis_data.pelvis_position.y, pelvis_data.average_thigh_position.y) - (pelvis_data.pelvis_to_thigh_distance * 0.35) + const vertical_max = pelvis_data.pelvis_position.y + (pelvis_data.pelvis_to_thigh_distance * 0.2) + if (vertex_position.y < vertical_min || vertex_position.y > vertical_max) { + return null + } + + const basin_center_distance = vertex_position.distanceTo( + pelvis_data.pelvis_position.clone().lerp(pelvis_data.average_thigh_position, 0.45) + ) + const influence_radius = pelvis_data.pelvis_to_thigh_distance * 1.15 + if (basin_center_distance > influence_radius) { + return null + } + + const normalized_distance = Math.min(1, basin_center_distance / influence_radius) + const secondary_weight = 0.12 + ((1 - normalized_distance) * 0.18) + + if (primary_bone === pelvis_data.pelvis_bone_index) { + const nearest_thigh = pelvis_data.thigh_bones.reduce((closest, candidate) => { + const candidate_distance = vertex_position.distanceTo(Utility.world_position_from_object(candidate.bone)) + if (candidate_distance < closest.distance) { + return { bone_index: candidate.bone_index, distance: candidate_distance } + } + return closest + }, { bone_index: pelvis_data.thigh_bones[0].bone_index, distance: Number.POSITIVE_INFINITY }) + + return { + primary_bone, + secondary_bone: nearest_thigh.bone_index, + secondary_weight + } + } + + return { + primary_bone, + secondary_bone: pelvis_data.pelvis_bone_index, + secondary_weight + } + } +} + +interface PelvisBasinData { + pelvis_bone_index: number + pelvis_position: Vector3 + thigh_bones: Array<{ bone: Bone, bone_index: number }> + average_thigh_position: Vector3 + pelvis_to_thigh_distance: number +} + +interface PelvisBasinBlend { + primary_bone: number + secondary_bone: number + secondary_weight: number +} + +interface AxialGradientData { + primary_bone: number + parent_bone: number | null + child_bone: number | null + start_point: Vector3 + end_point: Vector3 +} + +interface AxialGradientWeights { + primary_bone: number + secondary_bone: number + primary_weight: number + secondary_weight: number } interface BoundaryPair { @@ -383,6 +917,7 @@ interface BoundaryPair { } enum SmoothingType { + Socket = 'socket', Torso = 'torso', Limb = 'limb', Standard = 'standard' diff --git a/tmp/compare-skeletons.mjs b/tmp/compare-skeletons.mjs new file mode 100644 index 0000000..a3a59c5 --- /dev/null +++ b/tmp/compare-skeletons.mjs @@ -0,0 +1,92 @@ +import fs from 'node:fs' +import path from 'node:path' +import { Bone, SkinnedMesh, Texture, TextureLoader, ImageLoader } from 'three' +import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader.js' +import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js' + +globalThis.window ??= { + URL: { + createObjectURL: () => 'blob:node', + revokeObjectURL: () => {} + } +} + +globalThis.self ??= globalThis + +TextureLoader.prototype.load = function load (_url, onLoad) { + const texture = new Texture() + if (onLoad) onLoad(texture) + return texture +} + +ImageLoader.prototype.load = function load (_url, onLoad) { + const image = { width: 0, height: 0 } + if (onLoad) onLoad(image) + return image +} + +const toArrayBuffer = (buffer) => buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) +const fbxFile = 'C:/Users/jeffa/Downloads/Meshy_AI_Icebound_Dragon_Knighmix.fbx' +const glbFile = 'C:/Users/jeffa/Downloads/testmixamo.glb' + +const summarize = (root, label) => { + const bones = [] + const skinnedMeshes = [] + const objectLines = [] + + root.traverse((object) => { + if (object.isBone || object instanceof Bone) bones.push(object) + if (object.isSkinnedMesh || object instanceof SkinnedMesh) skinnedMeshes.push(object) + objectLines.push(`${object.type} ${object.name || ''}`) + }) + + const rootBones = bones.filter((bone) => !(bone.parent && (bone.parent.isBone || bone.parent instanceof Bone))) + const byName = new Map() + + for (const bone of bones) { + byName.set(bone.name, (byName.get(bone.name) ?? 0) + 1) + } + + console.log(`=== ${label} ===`) + console.log('total_bones', bones.length) + console.log('root_bones', rootBones.map((bone) => bone.name || '').join(' | ') || 'none') + console.log('skinned_meshes', skinnedMeshes.map((mesh) => mesh.name || '').join(' | ') || 'none') + console.log('object_types') + for (const line of objectLines) console.log(line) + + for (const bone of bones) { + const parentName = bone.parent && (bone.parent.isBone || bone.parent instanceof Bone) + ? bone.parent.name || '' + : '' + const childBones = bone.children + .filter((child) => child.isBone || child instanceof Bone) + .map((child) => child.name || '') + + console.log(`bone ${bone.name || ''} | parent=${parentName} | children=${childBones.join('|') || 'none'}`) + } + + console.log() + + return new Set(bones.map((bone) => bone.name)) +} + +console.log('read fbx') +const fbxBuffer = fs.readFileSync(fbxFile) +console.log('parse fbx') +const fbxRoot = new FBXLoader().parse(toArrayBuffer(fbxBuffer), `${path.dirname(fbxFile)}/`) + +console.log('read glb') +const glbBuffer = fs.readFileSync(glbFile) +console.log('parse glb') +const gltf = await new Promise((resolve, reject) => { + new GLTFLoader().parse(toArrayBuffer(glbBuffer), `${path.dirname(glbFile)}/`, resolve, reject) +}) + +const fbxNames = summarize(fbxRoot, 'fbx') +const glbNames = summarize(gltf.scene, 'glb') +const onlyInFbx = [...fbxNames].filter((name) => !glbNames.has(name)) +const onlyInGlb = [...glbNames].filter((name) => !fbxNames.has(name)) + +console.log('=== name_set_diff ===') +console.log('only_in_fbx', onlyInFbx.join(' | ') || 'none') +console.log('only_in_glb', onlyInGlb.join(' | ') || 'none') diff --git a/tmp/compare-weight-profiles.mjs b/tmp/compare-weight-profiles.mjs new file mode 100644 index 0000000..bfeea80 --- /dev/null +++ b/tmp/compare-weight-profiles.mjs @@ -0,0 +1,121 @@ +import fs from 'node:fs' +import path from 'node:path' +import { JSDOM } from 'jsdom' +import { LoadingManager, Texture, TextureLoader, ImageLoader, SkinnedMesh } from 'three' +import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js' +import { ColladaLoader } from 'three/examples/jsm/loaders/ColladaLoader.js' + +const dom = new JSDOM('', { contentType: 'text/html' }) + +globalThis.window ??= dom.window +globalThis.document ??= dom.window.document +globalThis.DOMParser ??= dom.window.DOMParser +globalThis.XMLSerializer ??= dom.window.XMLSerializer +globalThis.self ??= globalThis + +window.URL ??= { + createObjectURL: () => 'blob:node', + revokeObjectURL: () => {} +} + +TextureLoader.prototype.load = function load (_url, onLoad) { + const texture = new Texture() + if (onLoad) onLoad(texture) + return texture +} + +ImageLoader.prototype.load = function load (_url, onLoad) { + const image = { width: 0, height: 0 } + if (onLoad) onLoad(image) + return image +} + +const toArrayBuffer = (buffer) => buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) + +const findSkinnedMesh = (root) => { + let found = null + root.traverse((object) => { + if (found === null && (object.isSkinnedMesh || object instanceof SkinnedMesh)) { + found = object + } + }) + return found +} + +const summarizeWeights = (mesh, label) => { + const skinWeight = mesh.geometry.getAttribute('skinWeight') + const vertexCount = skinWeight.count + + let activeSum = 0 + let strongestSum = 0 + let secondSum = 0 + const activeHistogram = new Map() + const strongestBuckets = { + rigid_95_plus: 0, + strong_80_95: 0, + mixed_60_80: 0, + blended_under_60: 0 + } + + for (let index = 0; index < vertexCount; index++) { + const weights = [ + skinWeight.getX(index), + skinWeight.getY(index), + skinWeight.getZ(index), + skinWeight.getW(index) + ].filter((value) => value > 1e-4).sort((a, b) => b - a) + + const activeCount = weights.length + const strongest = weights[0] ?? 0 + const second = weights[1] ?? 0 + + activeSum += activeCount + strongestSum += strongest + secondSum += second + activeHistogram.set(activeCount, (activeHistogram.get(activeCount) ?? 0) + 1) + + if (strongest >= 0.95) strongestBuckets.rigid_95_plus++ + else if (strongest >= 0.80) strongestBuckets.strong_80_95++ + else if (strongest >= 0.60) strongestBuckets.mixed_60_80++ + else strongestBuckets.blended_under_60++ + } + + console.log(`=== ${label} ===`) + console.log('mesh_name', mesh.name || '') + console.log('vertex_count', vertexCount) + console.log('avg_active_influences', (activeSum / vertexCount).toFixed(4)) + console.log('avg_strongest_weight', (strongestSum / vertexCount).toFixed(4)) + console.log('avg_second_weight', (secondSum / vertexCount).toFixed(4)) + console.log('active_histogram', JSON.stringify(Object.fromEntries([...activeHistogram.entries()].sort((a, b) => a[0] - b[0])))) + console.log('strongest_buckets', JSON.stringify(strongestBuckets)) + console.log() +} + +const loadGlb = async (file) => { + const buffer = fs.readFileSync(file) + const loader = new GLTFLoader(new LoadingManager()) + return await new Promise((resolve, reject) => { + loader.parse(toArrayBuffer(buffer), `${path.dirname(file)}/`, resolve, reject) + }) +} + +const loadDae = (file) => { + const text = fs.readFileSync(file, 'utf8') + const loader = new ColladaLoader(new LoadingManager()) + return loader.parse(text, path.dirname(file)) +} + +const glbFile = 'C:/Users/jeffa/Downloads/testmixamo.glb' +const daeFile = 'C:/Users/jeffa/Downloads/testmixamoconverted.dae' + +const gltf = await loadGlb(glbFile) +const dae = loadDae(daeFile) + +const glbMesh = findSkinnedMesh(gltf.scene) +const daeMesh = findSkinnedMesh(dae.scene) + +if (glbMesh === null) throw new Error('No skinned mesh found in GLB') +if (daeMesh === null) throw new Error('No skinned mesh found in DAE') + +summarizeWeights(glbMesh, 'glb') +summarizeWeights(daeMesh, 'dae') \ No newline at end of file diff --git a/tmp/inspect-moo-skeleton.mjs b/tmp/inspect-moo-skeleton.mjs new file mode 100644 index 0000000..7dc3fda --- /dev/null +++ b/tmp/inspect-moo-skeleton.mjs @@ -0,0 +1,60 @@ +import fs from 'node:fs' +import path from 'node:path' +import { Bone, ImageLoader, SkinnedMesh, Texture, TextureLoader } from 'three' +import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js' + +globalThis.window ??= { + URL: { + createObjectURL: () => 'blob:node', + revokeObjectURL: () => {} + } +} + +globalThis.self ??= globalThis + +TextureLoader.prototype.load = function load (_url, onLoad) { + const texture = new Texture() + if (onLoad) onLoad(texture) + return texture +} + +ImageLoader.prototype.load = function load (_url, onLoad) { + const image = { width: 0, height: 0 } + if (onLoad) onLoad(image) + return image +} + +const file = 'C:/Users/jeffa/Downloads/moosetest.glb' +const to_array_buffer = (buffer) => buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) + +const loader = new GLTFLoader() +const buffer = fs.readFileSync(file) +const gltf = await new Promise((resolve, reject) => { + loader.parse(to_array_buffer(buffer), `${path.dirname(file)}/`, resolve, reject) +}) + +const bones = [] +const skinned_meshes = [] + +gltf.scene.traverse((object) => { + if (object.isBone || object instanceof Bone) bones.push(object) + if (object.isSkinnedMesh || object instanceof SkinnedMesh) skinned_meshes.push(object) +}) + +const root_bones = bones.filter((bone) => !(bone.parent && (bone.parent.isBone || bone.parent instanceof Bone))) + +console.log('total_bones', bones.length) +console.log('root_bones', root_bones.map((bone) => bone.name || '').join(' | ') || 'none') +console.log('skinned_meshes', skinned_meshes.map((mesh) => mesh.name || '').join(' | ') || 'none') + +for (const bone of bones) { + const parent = bone.parent && (bone.parent.isBone || bone.parent instanceof Bone) + ? bone.parent.name || '' + : '' + const children = bone.children + .filter((child) => child.isBone || child instanceof Bone) + .map((child) => child.name || '') + .join('|') + + console.log(`bone ${bone.name || ''} | parent=${parent} | children=${children || 'none'}`) +} \ No newline at end of file diff --git a/workspace-change-summary-2026-05-03.md b/workspace-change-summary-2026-05-03.md new file mode 100644 index 0000000..06207a0 --- /dev/null +++ b/workspace-change-summary-2026-05-03.md @@ -0,0 +1,124 @@ +# Workspace Change Summary - 2026-05-03 + +This file summarizes the changes made in this workspace since the previous commit. + +## Main outcomes + +- Improved skeleton editing for centerline bones so pelvis, spine, chest, neck, and head snap toward the mesh center instead of drifting toward the front surface. +- Added vertex snap strength controls for mesh-drag joint placement. +- Added condensed bone-chain visibility controls so main chains can be shown or hidden while keeping finger bones grouped under the hand chain. +- Refined skeleton helper rendering so finger joints use smaller circles and hidden chains are respected by both points and lines. +- Expanded skin-weight smoothing substantially for human shoulder, clavicle, torso, pelvis, thigh, buttocks, and glute-area transitions. +- Prevented branching parent bones such as the chest from auto-rotating during independent child movement, which helps preserve the default human rig shoulder orientation. +- Added focused regression tests for utility chain grouping, mesh-drag snapping, independent bone movement, and weight smoothing. +- Added temporary investigation scripts under `tmp/` for comparing rigs and weight profiles outside the runtime app. + +## UI and interaction changes + +### Position Joints workflow + +- Added `Visible Chains` UI in `src/create.html` and wired it through `src/lib/UI.ts` and `src/lib/processes/edit-skeleton/StepEditSkeleton.ts`. +- Added mesh-drag vertex snap slider controls in `src/create.html`, `src/lib/UI.ts`, and `src/lib/processes/edit-skeleton/StepEditSkeleton.ts`. +- Added chain visibility event handling in `src/lib/EventListeners.ts` and helper sync in `src/Mesh2MotionEngine.ts`. + +### Skeleton helper rendering + +- Updated `src/lib/CustomSkeletonHelper.ts` to: + - support hidden chain roots + - hide helper lines for hidden chains + - render finger joints with a dedicated smaller points layer + - keep small finger circles independent from mesh weighting logic + +## Bone placement and orientation changes + +### Mesh drag placement + +- Updated `src/lib/processes/edit-skeleton/MeshDragBonePlacement.ts` to: + - support configurable vertex snap influence + - find nearest hit-face vertex for snapping + - compute mesh centerline targets for centerline bones + - snap primary centerline bones to the mesh center when the skeleton loads + +### Independent bone movement + +- Updated `src/lib/processes/edit-skeleton/IndependentBoneMovement.ts` so branching parents like chest or pelvis preserve their template orientation when a child bone is translated. +- This prevents chest and shoulder hubs from twisting backward due to averaged child-direction reorientation. + +## Weight smoothing changes + +### Broader smoothing model + +- Updated `src/lib/solvers/WeightSmoother.ts` to: + - preserve and merge multiple influences instead of collapsing back to simple two-weight assignments + - expand standard boundary smoothing with extra neighbor rings + - add an axial joint-to-center gradient for simple bone chains + - add symmetric socket smoothing for pelvis-thigh, spine/neck-to-shoulder, and clavicle-to-upperarm boundaries + - widen pelvis-thigh socket smoothing further + - add dedicated pelvis-basin smoothing for glute-area vertices + - bias the front shoulder seam to keep torso-to-upperarm regions better connected + +### Areas specifically improved + +- Pelvis to thigh transition +- Buttocks and central pelvis basin +- Spine, chest, neck to shoulder transition +- Clavicle to upper arm transition +- Front torso to upper arm seam + +## Utility and grouping changes + +- Updated `src/lib/Utilities.ts` to support: + - chain root detection + - grouped hand and foot chains + - condensed chain labels + - mapping descendant bones back to grouped main chains + +## Test coverage added + +- `src/lib/Utilities.test.ts` +- `src/lib/processes/edit-skeleton/IndependentBoneMovement.test.ts` +- `src/lib/processes/edit-skeleton/MeshDragBonePlacement.test.ts` +- `src/lib/solvers/WeightSmoother.test.ts` + +These tests cover: + +- condensed chain grouping +- centerline and vertex snap logic +- shoulder/chest orientation preservation +- boundary accumulation and advanced smoothing regressions + +## Temporary investigation scripts + +Added under `tmp/`: + +- `compare-skeletons.mjs` +- `compare-weight-profiles.mjs` +- `inspect-moo-skeleton.mjs` + +These were used for one-off inspection of imported rigs and weight distributions. + +## Files touched + +Modified: + +- `src/Mesh2MotionEngine.ts` +- `src/create.html` +- `src/lib/CustomSkeletonHelper.ts` +- `src/lib/EventListeners.ts` +- `src/lib/UI.ts` +- `src/lib/Utilities.ts` +- `src/lib/processes/edit-skeleton/IndependentBoneMovement.ts` +- `src/lib/processes/edit-skeleton/MeshDragBonePlacement.ts` +- `src/lib/processes/edit-skeleton/StepEditSkeleton.ts` +- `src/lib/solvers/WeightSmoother.ts` + +Added: + +- `src/lib/Utilities.test.ts` +- `src/lib/processes/edit-skeleton/IndependentBoneMovement.test.ts` +- `src/lib/processes/edit-skeleton/MeshDragBonePlacement.test.ts` +- `src/lib/solvers/WeightSmoother.test.ts` +- `tmp/compare-skeletons.mjs` +- `tmp/compare-weight-profiles.mjs` +- `tmp/inspect-moo-skeleton.mjs` +- `workspace-change-summary-2026-05-03.md` \ No newline at end of file From a386aeaa2553460d8bee1526f95c8e7e4ed84c5d Mon Sep 17 00:00:00 2001 From: Princeamor Date: Sun, 3 May 2026 21:56:14 -0400 Subject: [PATCH 2/4] Add pull request description --- pull-request-description.md | 75 +++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 pull-request-description.md diff --git a/pull-request-description.md b/pull-request-description.md new file mode 100644 index 0000000..83db2f6 --- /dev/null +++ b/pull-request-description.md @@ -0,0 +1,75 @@ +# PR: Improve rig fitting, helper controls, and weight smoothing + +## Summary + +This pull request improves the rig fitting workflow for manually positioned skeletons and softens several problem areas in generated skin weights. + +The main functional changes are: + +- added mesh-drag vertex snapping with a user-controlled snap strength slider +- added condensed main-chain visibility toggles in Position Joints mode +- centered pelvis, spine, chest, neck, and head placement against the mesh instead of the front surface +- reduced finger joint helper circle size and made helper visibility respect hidden chains +- prevented branching parent bones such as chest and pelvis from being auto-rotated when child bones are moved independently +- expanded weight smoothing around pelvis, thighs, buttocks, shoulders, clavicles, neck, and upper arms +- added focused regression tests for the new placement, chain grouping, rotation, and smoothing behavior + +## Why + +These changes target the issues seen during rig fitting and auto-weighting on humanoid avatars: + +- joint placement needed stronger assistance when dragging bones over the mesh +- main chains needed to be easier to isolate visually during joint positioning +- centerline bones were drifting too far toward the front of the character +- shoulder and hip areas needed softer, more natural blending similar to the imported DAE reference +- chest and shoulder orientation could drift when moving branching child bones + +## What changed + +### Position Joints workflow + +- Added a snap strength slider with a `0` to `20` range for mesh-drag snapping. +- Added visible-chain checkboxes for condensed main chains, with fingers grouped into the hand chain. +- Wired helper visibility changes so hidden chains are removed from the active helper display. + +### Bone placement and orientation + +- Added nearest-vertex snap blending during mesh drag. +- Added mesh-centerline targeting for primary centerline bones. +- Applied automatic centerline snapping after skeleton load for pelvis, spine, chest, neck, and head chains. +- Preserved template orientation on branching parent bones during independent child movement. + +### Weight smoothing + +- Expanded boundary smoothing to keep more multi-bone influence at seam regions. +- Added along-bone gradients so bone centers stay dominant while joints retain blended influence. +- Added stronger symmetric socket smoothing for: + - pelvis to thigh + - spine/chest/neck to shoulder chain + - clavicle to upper arm +- Added pelvis-basin smoothing to improve the glute and central pelvis region. +- Strengthened torso-to-upperarm blending in the front shoulder area. + +### Helper and utility updates + +- Added condensed chain grouping utilities for visibility and labeling. +- Added smaller helper points for finger joints. +- Ensured hidden chain roots affect helper rendering consistently. + +### Investigation support + +- Added temporary scripts under `tmp/` for comparing skeleton topology and weight profiles during debugging. + +## Testing + +- `npm run build` +- Focused regression tests were added for: + - chain grouping utilities + - mesh drag snapping and centerline helpers + - independent branching-bone movement behavior + - advanced weight smoothing boundaries + +## Notes for review + +- This PR includes temporary analysis scripts in `tmp/` because they were used to validate skeleton and weight-profile differences during the investigation. +- The changes are concentrated in the rig-editing and skin-weight smoothing flow rather than import/export behavior. \ No newline at end of file From e3532a1f7e86977191f31db8cd0af0881ebbae57 Mon Sep 17 00:00:00 2001 From: Princeamor Date: Fri, 8 May 2026 03:01:49 -0400 Subject: [PATCH 3/4] Top Bar Layouts change --- ...point-2026-05-08-position-joints-topbar.md | 57 +++++ restore-point-2026-05-08.md | 35 ++++ src/Mesh2MotionEngine.ts | 15 ++ src/create-position-joints.css | 160 ++++++++++++++ src/create.html | 197 ++++++++---------- src/lib/EventListeners.ts | 5 + src/lib/Utilities.test.ts | 132 ++++++++++++ src/lib/Utilities.ts | 88 +++++++- .../edit-skeleton/MeshDragBonePlacement.ts | 113 +++++++++- .../edit-skeleton/StepEditSkeleton.ts | 2 +- 10 files changed, 679 insertions(+), 125 deletions(-) create mode 100644 restore-point-2026-05-08-position-joints-topbar.md create mode 100644 restore-point-2026-05-08.md create mode 100644 src/create-position-joints.css diff --git a/restore-point-2026-05-08-position-joints-topbar.md b/restore-point-2026-05-08-position-joints-topbar.md new file mode 100644 index 0000000..2396618 --- /dev/null +++ b/restore-point-2026-05-08-position-joints-topbar.md @@ -0,0 +1,57 @@ +# Restore Point - 2026-05-08 (Position Joints Topbar) + +Date: 2026-05-08 +Workspace: c:\Users\jeffa\mesh2motion-app + +## Summary + +This restore point captures the Position Joints UI refactor into a create-only topbar layout, with a dedicated stylesheet and horizontal Visible Chains layout matching the reference screenshot. + +## Key behavior + +- Position Joints UI renders as a topbar only on the Create page during Edit Skeleton. +- The right-side tool panel is hidden in Edit Skeleton on Create page. +- Visible Chains checkboxes flow horizontally across the topbar row. +- Controls row is centered with compact spacing; Back/Finish sit to the far right after undo/redo. + +## Files touched + +- src/create.html +- src/create-position-joints.css +- src/Mesh2MotionEngine.ts +- src/lib/processes/edit-skeleton/StepEditSkeleton.ts + +## Detailed notes + +### Create page topbar layout + +- Added `create-page` class on `body` in `create.html`. +- Injected a dedicated stylesheet `create-position-joints.css`. +- Moved Edit Skeleton UI (`#skeleton-step-actions`) into `#position-joints-topbar`. +- Topbar rows: + - Row 1: Selected bone label centered. + - Row 2: Visible Chains checkbox list (label removed). + - Row 3: Controls row with Position by mesh volume, Preview toggle, Vertex Snap slider, Mirror/Move options, Undo/Redo, Back/Finish. + +### Scoped CSS + +- All topbar styles scoped under `body.create-page` to avoid bleeding into other pages. +- `body.create-page.edit-skeleton-topbar` toggles topbar visibility and hides `#tool-panel`. +- Visible Chains list forced to horizontal layout via flex row and a custom fieldset class. +- Controls row centered with 10px gaps between items. + +### Process step hook + +- `Mesh2MotionEngine` toggles `edit-skeleton-topbar` on the Create page when entering/leaving `ProcessStep.EditSkeleton`. + +### Visible Chains rendering + +- `StepEditSkeleton` renders the chain checkboxes into a fieldset with class `position-joints-chain-fieldset` to enforce horizontal layout and avoid duplicate labels. + +## Verification checklist + +- Create page (Use Your Model) shows topbar only in Edit Skeleton step. +- Explore and Retarget pages are unaffected. +- Visible Chains checkboxes flow horizontally in one row. +- Back/Finish buttons appear to the right of undo/redo. +- Controls row items are centered and tightly spaced. diff --git a/restore-point-2026-05-08.md b/restore-point-2026-05-08.md new file mode 100644 index 0000000..46f2b7e --- /dev/null +++ b/restore-point-2026-05-08.md @@ -0,0 +1,35 @@ +# Restore Point - 2026-05-08 + +Date: 2026-05-08 +Workspace: c:\Users\jeffa\mesh2motion-app + +## Summary + +This restore point captures the state after tightening the Visible Chains UI and improving default spine layout for the Fox rig. + +## Visible Chains grouping changes + +- Condensed spine, head, and quadruped leg chains into fewer checkboxes by grouping chain roots. +- Spine chains now anchor to the rootmost spine bone so multiple spine segments appear under one checkbox. +- Head chains anchor to the main head bone (tips/end bones grouped under head). +- Quadruped front legs group under front leg shoulder anchors (left/right). +- Quadruped back legs group under back leg pelvis anchors (left/right). + +Files touched: +- src/lib/Utilities.ts +- src/lib/Utilities.test.ts + +## Fox spine default layout + +- Added a fox-specific spine spread on skeleton load to place spine bones horizontally. +- The spine chain spreads along the mesh horizontal axis and interpolates height between pelvis and head. +- Runs after centerline snapping during the skeletonLoaded flow. + +Files touched: +- src/lib/processes/edit-skeleton/MeshDragBonePlacement.ts +- src/lib/EventListeners.ts + +## Notes + +- Fox spine distribution uses mesh bounds and pelvis/head positions as hints. +- If rig bone names differ from spine/pelvis/head naming, adjust the matchers in MeshDragBonePlacement. diff --git a/src/Mesh2MotionEngine.ts b/src/Mesh2MotionEngine.ts index 9d99838..2f0a5e9 100644 --- a/src/Mesh2MotionEngine.ts +++ b/src/Mesh2MotionEngine.ts @@ -346,6 +346,7 @@ export class Mesh2MotionEngine { // update the current process step variable this.update_current_process_step(process_step) + this.update_position_joints_topbar_state(process_step) // clean up things related to steps in since we can navigate back and forth this.edit_skeleton_step.cleanup_on_exit_step() @@ -454,6 +455,20 @@ export class Mesh2MotionEngine { return this.process_step } // end process_step_changed() + private update_position_joints_topbar_state (process_step: ProcessStep): void { + const body = document.body + if (!body.classList.contains('create-page')) { + return + } + + if (process_step === ProcessStep.EditSkeleton) { + body.classList.add('edit-skeleton-topbar') + return + } + + body.classList.remove('edit-skeleton-topbar') + } + private animate (): void { requestAnimationFrame(this.animate) diff --git a/src/create-position-joints.css b/src/create-position-joints.css new file mode 100644 index 0000000..7cc8a02 --- /dev/null +++ b/src/create-position-joints.css @@ -0,0 +1,160 @@ +body.create-page #position-joints-topbar { + display: none; + position: fixed; + top: var(--navigation-height); + left: 0; + right: 0; + z-index: 2; + background: linear-gradient(180deg, var(--bg-primary) 0%, var(--bg-alternate) 100%); + border-bottom: 1px solid var(--bg-tertiary); + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2); +} + +body.create-page.edit-skeleton-topbar #position-joints-topbar { + display: block; +} + +body.create-page.edit-skeleton-topbar #tool-panel { + display: none; +} + +body.create-page .position-joints-layout { + display: flex; + flex-direction: column; + gap: 0.4rem; + padding: 0.6rem 1.2rem 0.8rem; + box-sizing: border-box; +} + +body.create-page .position-joints-row { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; + width: 100%; +} + +body.create-page .position-joints-row--selected { + justify-content: center; +} + +body.create-page .position-joints-row--chains { + justify-content: center; +} + +body.create-page .position-joints-row--actions { + justify-content: center; + gap: 1rem; +} + +body.create-page .position-joints-row--controls { + justify-content: center; + gap: 10px; +} + +body.create-page .position-joints-controls-left, +body.create-page .position-joints-controls-center, +body.create-page .position-joints-controls-right { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +body.create-page .position-joints-controls-center { + justify-content: center; +} + +body.create-page .position-joints-controls-right { + justify-content: center; +} + +body.create-page .position-joints-actions { + display: flex; + align-items: center; + gap: 10px; +} + +body.create-page .position-joints-section-title { + font-size: 0.75rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-alternate); +} + +body.create-page .position-joints-label { + font-size: 0.85rem; + color: var(--text-alternate); +} + +body.create-page .position-joints-undo { + display: flex; + gap: 0.25rem; + align-items: center; +} + +body.create-page .position-joints-chain-list { + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: 0.35rem 0.75rem; + max-height: none; + overflow: visible; + padding-right: 0; + justify-content: center; + flex: 1 1 auto; + min-width: 0; +} + +body.create-page .position-joints-chain-fieldset { + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: 0.35rem 0.75rem; + align-items: center; + margin: 0; + padding: 0; + border: none; +} + +body.create-page .position-joints-chain-list .styled-checkbox { + display: inline-flex; + align-items: center; + margin: 0; +} + +body.create-page .position-joints-chain-list label { + white-space: nowrap; +} + +body.create-page .position-joints-inline { + display: flex; + align-items: center; + gap: 0.5rem; +} + +body.create-page .position-joints-snap { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +body.create-page .position-joints-snap input[type="range"] { + width: 140px; +} + +body.create-page .position-joints-snap-value { + width: 2.5rem; + text-align: right; +} + +body.create-page .position-joints-preview { + display: flex; + align-items: center; + gap: 0.5rem; +} + +body.create-page #bone-selection-section { + background: transparent; + padding: 0; +} diff --git a/src/create.html b/src/create.html index 7acdf40..ccab957 100644 --- a/src/create.html +++ b/src/create.html @@ -7,9 +7,14 @@ - + +
+
+
+
+

+ Selected Bone: None +

+
+ +
+
+
+ +
+
+
+ + +
+ +
+ Preview +
+ + + + +
+
+
+ +
+
+
+ + help +
+
+ + 10 +
+
+
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+
+
+
@@ -242,120 +323,6 @@
- - -
-
- - -
-
- -

- Selected Bone: None -

- -
- -
-
- - -
-
-
- - help -
-
- - 10 -
-
-
-
- Transform -
- - - - -
-
-
- Space -
- - - - -
-
-
-
- -
- -
- Preview - -
- - - - -
-
-
- -
- - -
- -
- - -
- - -
-
- - - help -
-
- - - 0.50 -
-
-
- - -
- - -
-
- diff --git a/src/lib/EventListeners.ts b/src/lib/EventListeners.ts index 674c56e..73cf630 100644 --- a/src/lib/EventListeners.ts +++ b/src/lib/EventListeners.ts @@ -5,6 +5,7 @@ import { TransformSpace } from './enums/TransformSpace' import { Utility } from './Utilities' import { ModelCleanupUtility } from './processes/load-model/ModelCleanupUtility' import { type Bone } from 'three' +import { SkeletonType } from './enums/SkeletonType' export class EventListeners { constructor (private readonly bootstrap: Mesh2MotionEngine) {} @@ -18,6 +19,10 @@ export class EventListeners { this.bootstrap.load_skeleton_step.addEventListener('skeletonLoaded', () => { this.bootstrap.edit_skeleton_step.load_original_armature_from_model(this.bootstrap.load_skeleton_step.armature()) this.bootstrap.mesh_drag_bone_placement.snap_primary_centerline_bones_to_mesh_center() + + if (this.bootstrap.load_skeleton_step.skeleton_type() === SkeletonType.Fox) { + this.bootstrap.mesh_drag_bone_placement.spread_spine_chain_for_fox() + } this.bootstrap.process_step = this.bootstrap.process_step_changed(ProcessStep.EditSkeleton) }) diff --git a/src/lib/Utilities.test.ts b/src/lib/Utilities.test.ts index 68c822a..27bd96a 100644 --- a/src/lib/Utilities.test.ts +++ b/src/lib/Utilities.test.ts @@ -72,6 +72,120 @@ function build_test_skeleton (): Skeleton { ]) } +function build_quadruped_test_skeleton (): Skeleton { + const root = new Bone() + root.name = 'root' + + const pelvis = new Bone() + pelvis.name = 'Pelvis' + root.add(pelvis) + + const spine1 = new Bone() + spine1.name = 'Spine1' + pelvis.add(spine1) + + const spine2 = new Bone() + spine2.name = 'Spine2' + spine1.add(spine2) + + const chest = new Bone() + chest.name = 'Chest' + spine2.add(chest) + + const head = new Bone() + head.name = 'Head' + chest.add(head) + + const head_tip = new Bone() + head_tip.name = 'HeadTip' + head.add(head_tip) + + const front_leg_shoulder_l = new Bone() + front_leg_shoulder_l.name = 'FrontLegShoulder_L' + pelvis.add(front_leg_shoulder_l) + + const front_leg_upper_l = new Bone() + front_leg_upper_l.name = 'FrontLegUpper_L' + front_leg_shoulder_l.add(front_leg_upper_l) + + const front_leg_foot_l = new Bone() + front_leg_foot_l.name = 'FrontLegFoot_L' + front_leg_upper_l.add(front_leg_foot_l) + + const front_leg_foot1_l = new Bone() + front_leg_foot1_l.name = 'FrontLegFoot1_L' + front_leg_foot_l.add(front_leg_foot1_l) + + const front_leg_shoulder_r = new Bone() + front_leg_shoulder_r.name = 'FrontLegShoulder_R' + pelvis.add(front_leg_shoulder_r) + + const front_leg_upper_r = new Bone() + front_leg_upper_r.name = 'FrontLegUpper_R' + front_leg_shoulder_r.add(front_leg_upper_r) + + const front_leg_foot_r = new Bone() + front_leg_foot_r.name = 'FrontLegFoot_R' + front_leg_upper_r.add(front_leg_foot_r) + + const back_leg_pelvis_l = new Bone() + back_leg_pelvis_l.name = 'BackLegPelvis_L' + pelvis.add(back_leg_pelvis_l) + + const back_leg_upper_l = new Bone() + back_leg_upper_l.name = 'BackLegUpper_L' + back_leg_pelvis_l.add(back_leg_upper_l) + + const back_leg_foot_l = new Bone() + back_leg_foot_l.name = 'BackLegFoot_L' + back_leg_upper_l.add(back_leg_foot_l) + + const back_leg_foot1_l = new Bone() + back_leg_foot1_l.name = 'BackLegFoot1_L' + back_leg_foot_l.add(back_leg_foot1_l) + + const back_leg_pelvis_r = new Bone() + back_leg_pelvis_r.name = 'BackLegPelvis_R' + pelvis.add(back_leg_pelvis_r) + + const back_leg_upper_r = new Bone() + back_leg_upper_r.name = 'BackLegUpper_R' + back_leg_pelvis_r.add(back_leg_upper_r) + + const back_leg_foot_r = new Bone() + back_leg_foot_r.name = 'BackLegFoot_R' + back_leg_upper_r.add(back_leg_foot_r) + + const back_leg_foot1_r = new Bone() + back_leg_foot1_r.name = 'BackLegFoot1_R' + back_leg_foot_r.add(back_leg_foot1_r) + + return new Skeleton([ + root, + pelvis, + spine1, + spine2, + chest, + head, + head_tip, + front_leg_shoulder_l, + front_leg_upper_l, + front_leg_foot_l, + front_leg_foot1_l, + front_leg_shoulder_r, + front_leg_upper_r, + front_leg_foot_r, + back_leg_pelvis_l, + back_leg_upper_l, + back_leg_foot_l, + back_leg_foot1_l, + back_leg_pelvis_r, + back_leg_upper_r, + back_leg_foot_r, + back_leg_foot1_r + ]) +} + describe('Utility chain roots', () => { it('derives condensed main chains and folds fingers into the hand chain', () => { const skeleton = build_test_skeleton() @@ -95,4 +209,22 @@ describe('Utility chain roots', () => { expect(Utility.chain_root_bone_from_bone(head_bone!)).toBe(head_bone) expect(Utility.chain_root_bone_from_bone(thumb_bone!)).toBe(hand_bone) }) + + it('condenses spine, head, and quadruped leg chains', () => { + const skeleton = build_quadruped_test_skeleton() + const chest_bone = skeleton.bones.find((bone) => bone.name === 'Chest') + const head_tip_bone = skeleton.bones.find((bone) => bone.name === 'HeadTip') + const front_leg_foot_l = skeleton.bones.find((bone) => bone.name === 'FrontLegFoot_L') + const back_leg_foot1_r = skeleton.bones.find((bone) => bone.name === 'BackLegFoot1_R') + + expect(chest_bone).toBeDefined() + expect(head_tip_bone).toBeDefined() + expect(front_leg_foot_l).toBeDefined() + expect(back_leg_foot1_r).toBeDefined() + + expect(Utility.chain_root_bone_from_bone(chest_bone!)).toBe(skeleton.bones.find((bone) => bone.name === 'Spine1')) + expect(Utility.chain_root_bone_from_bone(head_tip_bone!)).toBe(skeleton.bones.find((bone) => bone.name === 'Head')) + expect(Utility.chain_root_bone_from_bone(front_leg_foot_l!)).toBe(skeleton.bones.find((bone) => bone.name === 'FrontLegShoulder_L')) + expect(Utility.chain_root_bone_from_bone(back_leg_foot1_r!)).toBe(skeleton.bones.find((bone) => bone.name === 'BackLegPelvis_R')) + }) }) \ No newline at end of file diff --git a/src/lib/Utilities.ts b/src/lib/Utilities.ts index a33d236..917eabc 100644 --- a/src/lib/Utilities.ts +++ b/src/lib/Utilities.ts @@ -181,6 +181,42 @@ export class Utility { private static grouped_chain_anchor_bone_from_bone (bone: Bone): Bone | null { const normalized_bone_name = bone.name.toLowerCase() + if (Utility.is_front_leg_chain_bone_name(normalized_bone_name)) { + const front_leg_anchor = Utility.find_nearest_bone_ancestor_including_self(bone, (candidate_name) => + Utility.is_front_leg_anchor_bone_name(candidate_name)) + + if (front_leg_anchor !== null) { + return front_leg_anchor + } + } + + if (Utility.is_back_leg_chain_bone_name(normalized_bone_name)) { + const back_leg_anchor = Utility.find_nearest_bone_ancestor_including_self(bone, (candidate_name) => + Utility.is_back_leg_anchor_bone_name(candidate_name)) + + if (back_leg_anchor !== null) { + return back_leg_anchor + } + } + + if (Utility.is_spine_chain_bone_name(normalized_bone_name)) { + const spine_anchor = Utility.find_rootmost_bone_ancestor_including_self(bone, (candidate_name) => + Utility.is_spine_anchor_bone_name(candidate_name)) + + if (spine_anchor !== null) { + return spine_anchor + } + } + + if (Utility.is_head_chain_bone_name(normalized_bone_name)) { + const head_anchor = Utility.find_nearest_bone_ancestor_including_self(bone, (candidate_name) => + Utility.is_head_anchor_bone_name(candidate_name)) + + if (head_anchor !== null) { + return head_anchor + } + } + if (Utility.is_hand_chain_bone_name(normalized_bone_name)) { return Utility.find_nearest_bone_ancestor_including_self(bone, (candidate_name) => Utility.is_hand_anchor_bone_name(candidate_name)) } @@ -189,10 +225,6 @@ export class Utility { return Utility.find_nearest_bone_ancestor_including_self(bone, (candidate_name) => Utility.is_foot_anchor_bone_name(candidate_name)) } - if (Utility.is_head_accessory_bone_name(normalized_bone_name)) { - return Utility.find_nearest_bone_ancestor_including_self(bone, (candidate_name) => candidate_name.includes('head')) - } - return null } @@ -213,6 +245,24 @@ export class Utility { return null } + private static find_rootmost_bone_ancestor_including_self ( + bone: Bone, + matcher: (normalized_bone_name: string) => boolean + ): Bone | null { + let current_bone: Bone | null = bone + let rootmost_match: Bone | null = null + + while (current_bone !== null) { + if (matcher(current_bone.name.toLowerCase())) { + rootmost_match = current_bone + } + + current_bone = current_bone.parent instanceof Bone ? current_bone.parent : null + } + + return rootmost_match + } + private static is_hand_chain_bone_name (normalized_bone_name: string): boolean { return /(hand|thumb|index|middle|ring|pinky|finger)/.test(normalized_bone_name) } @@ -229,10 +279,38 @@ export class Utility { return normalized_bone_name.includes('foot') && !/(toe|ball)/.test(normalized_bone_name) } - private static is_head_accessory_bone_name (normalized_bone_name: string): boolean { + private static is_head_chain_bone_name (normalized_bone_name: string): boolean { return /(head|ear|eye|jaw|chin|mouth|teeth|tongue|horn|antler|nose|brow|lash)/.test(normalized_bone_name) } + private static is_head_anchor_bone_name (normalized_bone_name: string): boolean { + return normalized_bone_name.includes('head') && !/(tip|end|nub)/.test(normalized_bone_name) + } + + private static is_spine_chain_bone_name (normalized_bone_name: string): boolean { + return /(spine|chest|upperchest|torso|ribcage)/.test(normalized_bone_name) + } + + private static is_spine_anchor_bone_name (normalized_bone_name: string): boolean { + return /(spine|torso)/.test(normalized_bone_name) + } + + private static is_front_leg_chain_bone_name (normalized_bone_name: string): boolean { + return /(front|fore)[-_ ]?leg/.test(normalized_bone_name) + } + + private static is_front_leg_anchor_bone_name (normalized_bone_name: string): boolean { + return /(front|fore)[-_ ]?leg/.test(normalized_bone_name) && /(shoulder|scapula)/.test(normalized_bone_name) + } + + private static is_back_leg_chain_bone_name (normalized_bone_name: string): boolean { + return /(back|rear|hind)[-_ ]?leg/.test(normalized_bone_name) + } + + private static is_back_leg_anchor_bone_name (normalized_bone_name: string): boolean { + return /(back|rear|hind)[-_ ]?leg/.test(normalized_bone_name) && /(pelvis|hip)/.test(normalized_bone_name) + } + static format_bone_chain_label (bone_name: string): string { return bone_name .replace(/[_-]+/g, ' ') diff --git a/src/lib/processes/edit-skeleton/MeshDragBonePlacement.ts b/src/lib/processes/edit-skeleton/MeshDragBonePlacement.ts index 4489d00..4c82f1d 100644 --- a/src/lib/processes/edit-skeleton/MeshDragBonePlacement.ts +++ b/src/lib/processes/edit-skeleton/MeshDragBonePlacement.ts @@ -201,6 +201,88 @@ export class MeshDragBonePlacement { }) } + public spread_spine_chain_for_fox (): void { + const skeleton_to_adjust = this.edit_skeleton_step.skeleton() + if (skeleton_to_adjust === undefined) { + return + } + + const pelvis_bone = this.find_bone_by_name_match(skeleton_to_adjust, /(pelvis|hips)/) + const head_bone = this.find_bone_by_name_match(skeleton_to_adjust, /head/) + const spine_bones = this.find_spine_chain_bones(skeleton_to_adjust) + + const mesh_bounds = this.get_mesh_bounds() + if (mesh_bounds === null) { + return + } + + if (pelvis_bone === null || head_bone === null || spine_bones.length === 0) { + return + } + + const pelvis_world = Utility.world_position_from_object(pelvis_bone) + const head_world = Utility.world_position_from_object(head_bone) + + const bounds_size = mesh_bounds.getSize(new Vector3()) + const bounds_center = mesh_bounds.getCenter(new Vector3()) + const use_x_axis = bounds_size.x >= bounds_size.z + let axis_dir = use_x_axis ? new Vector3(1, 0, 0) : new Vector3(0, 0, 1) + + const head_hint = new Vector3(head_world.x, 0, head_world.z) + const pelvis_hint = new Vector3(pelvis_world.x, 0, pelvis_world.z) + const hint_axis = head_hint.clone().sub(pelvis_hint) + + if (hint_axis.lengthSq() > 0.0001) { + axis_dir = hint_axis.normalize() + } else { + const center_hint = new Vector3(bounds_center.x, 0, bounds_center.z) + if (head_hint.sub(center_hint).dot(axis_dir) < 0) { + axis_dir.multiplyScalar(-1) + } + } + + const axis_length = Math.max(use_x_axis ? bounds_size.x : bounds_size.z, 0.1) + const half_length = axis_length * 0.45 + const tail_position = bounds_center.clone().add(axis_dir.clone().multiplyScalar(-half_length)) + const head_position = bounds_center.clone().add(axis_dir.clone().multiplyScalar(half_length)) + + tail_position.y = pelvis_world.y + head_position.y = head_world.y + + const spine_axis = head_position.clone().sub(tail_position) + if (spine_axis.lengthSq() < 0.0001) { + return + } + + const spine_direction = spine_axis.clone().normalize() + spine_bones.sort((bone_a, bone_b) => { + const bone_a_pos = Utility.world_position_from_object(bone_a) + const bone_b_pos = Utility.world_position_from_object(bone_b) + const bone_a_depth = bone_a_pos.clone().sub(tail_position).dot(spine_direction) + const bone_b_depth = bone_b_pos.clone().sub(tail_position).dot(spine_direction) + + if (bone_a_depth === bone_b_depth) { + return bone_a.name.localeCompare(bone_b.name) + } + + return bone_a_depth - bone_b_depth + }) + + spine_bones.forEach((bone, index) => { + if (!(bone.parent instanceof Bone)) { + return + } + + const lerp_factor = (index + 1) / (spine_bones.length + 1) + const target_world_position = tail_position.clone().lerp(head_position, lerp_factor) + target_world_position.y = pelvis_world.y + (head_world.y - pelvis_world.y) * lerp_factor + const target_local_position = target_world_position.clone() + bone.parent.worldToLocal(target_local_position) + bone.position.copy(target_local_position) + bone.updateWorldMatrix(true, true) + }) + } + private move_selected_bone_to_mesh_midpoint (mouse_event: MouseEvent): void { const selected_bone = this.edit_skeleton_step.get_currently_selected_bone() @@ -261,10 +343,8 @@ export class MeshDragBonePlacement { return mesh_targets.filter((target) => target.children.length > 0) } - private get_mesh_centerline_target_at_world_position ( - target_world_position: Vector3, - mesh_targets: Object3D[] = this.get_centerline_mesh_targets() - ): Vector3 | null { + private get_mesh_bounds (): THREE.Box3 | null { + const mesh_targets = this.get_centerline_mesh_targets() if (mesh_targets.length === 0) { return null } @@ -278,6 +358,22 @@ export class MeshDragBonePlacement { return null } + return scene_bounds + } + + private get_mesh_centerline_target_at_world_position ( + target_world_position: Vector3, + mesh_targets: Object3D[] = this.get_centerline_mesh_targets() + ): Vector3 | null { + if (mesh_targets.length === 0) { + return null + } + + const scene_bounds = this.get_mesh_bounds() + if (scene_bounds === null) { + return null + } + const scene_center = scene_bounds.getCenter(new Vector3()) const scene_size = scene_bounds.getSize(new Vector3()) const ray_margin = Math.max(0.25, scene_size.length() * 0.25) @@ -325,6 +421,15 @@ export class MeshDragBonePlacement { return apply_mesh_centerline_target(target_world_position, snapped_x, snapped_z) } + private find_spine_chain_bones (skeleton: Skeleton): Bone[] { + return skeleton.bones.filter((bone) => /spine/.test(bone.name.toLowerCase())) + } + + private find_bone_by_name_match (skeleton: Skeleton, matcher: RegExp): Bone | null { + const bone_match = skeleton.bones.find((bone) => matcher.test(bone.name.toLowerCase())) + return bone_match ?? null + } + private get_opposing_surface_midpoint ( mesh_targets: Object3D[], forward_origin: Vector3, diff --git a/src/lib/processes/edit-skeleton/StepEditSkeleton.ts b/src/lib/processes/edit-skeleton/StepEditSkeleton.ts index 093f1e9..ce7888c 100644 --- a/src/lib/processes/edit-skeleton/StepEditSkeleton.ts +++ b/src/lib/processes/edit-skeleton/StepEditSkeleton.ts @@ -605,7 +605,7 @@ export class StepEditSkeleton extends EventTarget { }).join('') this.ui.dom_bone_chain_visibility_container.style.display = 'flex' - this.ui.dom_bone_chain_visibility_container.innerHTML = `
Visible Chains${checkbox_markup}
` + this.ui.dom_bone_chain_visibility_container.innerHTML = `
${checkbox_markup}
` } private set_bone_chain_visibility (chain_root_name: string, is_visible: boolean): void { From bc9a3d40b1fe659de780656c43a53d9122011a79 Mon Sep 17 00:00:00 2001 From: Princeamor Date: Sat, 9 May 2026 22:03:16 -0400 Subject: [PATCH 4/4] Add centerline snap controls, orbit toggle, axis gizmo sizing, and use edited armature for skinning --- ...int-2026-05-09-edited-armature-skinning.md | 12 +++ ...oint-2026-05-09-orbit-target-axis-gizmo.md | 15 ++++ restore-point-2026-05-09-view-helper-2x.md | 13 +++ restore-point-2026-05-09.md | 8 ++ src/Mesh2MotionEngine.ts | 18 ++++ src/create.html | 10 +++ src/lib/CustomViewHelper.ts | 2 +- src/lib/EventListeners.ts | 6 +- src/lib/SceneEnvironmentManager.ts | 6 +- src/lib/UI.ts | 4 + .../edit-skeleton/MeshDragBonePlacement.ts | 85 +++++++++++++++---- .../edit-skeleton/StepEditSkeleton.ts | 66 +++++++++++++- .../processes/weight-skin/StepWeightSkin.ts | 38 +++++++++ src/styles.css | 4 +- 14 files changed, 263 insertions(+), 24 deletions(-) create mode 100644 restore-point-2026-05-09-edited-armature-skinning.md create mode 100644 restore-point-2026-05-09-orbit-target-axis-gizmo.md create mode 100644 restore-point-2026-05-09-view-helper-2x.md create mode 100644 restore-point-2026-05-09.md diff --git a/restore-point-2026-05-09-edited-armature-skinning.md b/restore-point-2026-05-09-edited-armature-skinning.md new file mode 100644 index 0000000..e95cd9d --- /dev/null +++ b/restore-point-2026-05-09-edited-armature-skinning.md @@ -0,0 +1,12 @@ +# Restore Point - 2026-05-09 (Edited Armature Skinning) + +Date: 2026-05-09 +Workspace: c:\Users\jeffa\mesh2motion-app + +## Summary + +This restore point captures the fix that uses the edited skeleton transforms when generating the armature for skinning, preventing collapsed meshes after finishing joint placement. + +## Files touched + +- src/lib/processes/edit-skeleton/StepEditSkeleton.ts diff --git a/restore-point-2026-05-09-orbit-target-axis-gizmo.md b/restore-point-2026-05-09-orbit-target-axis-gizmo.md new file mode 100644 index 0000000..0d58913 --- /dev/null +++ b/restore-point-2026-05-09-orbit-target-axis-gizmo.md @@ -0,0 +1,15 @@ +# Restore Point - 2026-05-09 (Orbit Target Axis Gizmo) + +Date: 2026-05-09 +Workspace: c:\Users\jeffa\mesh2motion-app + +## Summary + +This restore point captures the updated work area layout with the axis gizmo snapped to the current orbit target and the view helper sized at 2x. + +## Files touched + +- src/lib/SceneEnvironmentManager.ts +- src/Mesh2MotionEngine.ts +- src/lib/CustomViewHelper.ts +- src/styles.css diff --git a/restore-point-2026-05-09-view-helper-2x.md b/restore-point-2026-05-09-view-helper-2x.md new file mode 100644 index 0000000..28dbb7f --- /dev/null +++ b/restore-point-2026-05-09-view-helper-2x.md @@ -0,0 +1,13 @@ +# Restore Point - 2026-05-09 (View Helper 2x) + +Date: 2026-05-09 +Workspace: c:\Users\jeffa\mesh2motion-app + +## Summary + +This restore point captures the view helper axis gizmo at 2x size with snap-to-axis behavior preserved. + +## Files touched + +- src/lib/CustomViewHelper.ts +- src/styles.css diff --git a/restore-point-2026-05-09.md b/restore-point-2026-05-09.md new file mode 100644 index 0000000..64f8d10 --- /dev/null +++ b/restore-point-2026-05-09.md @@ -0,0 +1,8 @@ +# Restore Point - 2026-05-09 + +Date: 2026-05-09 +Workspace: c:\Users\jeffa\mesh2motion-app + +## Summary + +This restore point captures the state before adding centerline snapping controls, rotation spinner adjustments, and additional joint placement UI updates. diff --git a/src/Mesh2MotionEngine.ts b/src/Mesh2MotionEngine.ts index 2f0a5e9..d6e991c 100644 --- a/src/Mesh2MotionEngine.ts +++ b/src/Mesh2MotionEngine.ts @@ -254,6 +254,7 @@ export class Mesh2MotionEngine { this.is_model_gizmo_active = true this.transform_controls.attach(this.load_model_step.model_meshes()) this.transform_controls.setMode('translate') + this.transform_controls.size = 1 this.transform_controls.enabled = true } @@ -295,6 +296,11 @@ export class Mesh2MotionEngine { this.is_transform_controls_dragging = false } + public update_orbit_controls_state (allow_orbit: boolean): void { + const orbit_disabled = this.edit_skeleton_step.is_orbit_rotation_disabled() + this.enable_orbit_controls(allow_orbit && !orbit_disabled) + } + public get is_mesh_drag_mode_dragging (): boolean { return this.mesh_drag_bone_placement.is_dragging() } @@ -407,6 +413,8 @@ export class Mesh2MotionEngine { this.edit_skeleton_step.begin(this.scene, this.load_skeleton_step.skeleton_type()) this.update_edit_bone_interaction_mode() this.transform_controls.setMode(this.transform_controls_type) // 'translate', 'rotate' + this.update_transform_controls_size() + this.update_orbit_controls_state(true) this.sync_skeleton_helper_joint_visibility() @@ -486,6 +494,10 @@ export class Mesh2MotionEngine { this.renderer.render(this.scene, this.camera) // view helper + const orbit_target = this.scene_environment.get_orbit_target() + if (orbit_target !== null) { + this.view_helper.center.copy(orbit_target) + } this.view_helper.render(this.renderer) // updates current viewport if (this.view_helper.animating) { this.view_helper.update(delta_time) // updates animation when clicking on axis @@ -512,10 +524,12 @@ export class Mesh2MotionEngine { case 'translate': this.transform_controls_type = TransformControlType.Translation this.transform_controls.setMode('translate') + this.update_transform_controls_size() break case 'rotation': this.transform_controls_type = TransformControlType.Rotation this.transform_controls.setMode('rotate') + this.update_transform_controls_size() break default: console.warn(`Unknown transform mode selected: ${radio_button_selected}`) @@ -523,6 +537,10 @@ export class Mesh2MotionEngine { } } + private update_transform_controls_size (): void { + this.transform_controls.size = 1 + } + public changed_transform_controls_space (radio_button_selected: TransformSpace | undefined): void { if (radio_button_selected) { this.transform_space_type = radio_button_selected diff --git a/src/create.html b/src/create.html index ccab957..ff048c1 100644 --- a/src/create.html +++ b/src/create.html @@ -131,6 +131,16 @@
+
+ + +
+ +
+ + +
+
Preview
diff --git a/src/lib/CustomViewHelper.ts b/src/lib/CustomViewHelper.ts index 55e1afb..b695532 100644 --- a/src/lib/CustomViewHelper.ts +++ b/src/lib/CustomViewHelper.ts @@ -60,7 +60,7 @@ export class CustomViewHelper extends Object3D { private readonly negYAxisHelper: Sprite private readonly negZAxisHelper: Sprite private readonly point: Vector3 = new Vector3() - private readonly dim: number = 128 + private readonly dim: number = 256 private readonly turnRate: number = 2 * Math.PI private readonly targetPosition: Vector3 = new Vector3() private readonly targetQuaternion: Quaternion = new Quaternion() diff --git a/src/lib/EventListeners.ts b/src/lib/EventListeners.ts index 73cf630..167d6d3 100644 --- a/src/lib/EventListeners.ts +++ b/src/lib/EventListeners.ts @@ -49,6 +49,10 @@ export class EventListeners { this.bootstrap.update_edit_bone_interaction_mode() }) + this.bootstrap.edit_skeleton_step.addEventListener('orbit-rotation-changed', () => { + this.bootstrap.update_orbit_controls_state(true) + }) + this.bootstrap.edit_skeleton_step.addEventListener('chainVisibilityChanged', () => { this.bootstrap.sync_skeleton_helper_joint_visibility() @@ -121,7 +125,7 @@ export class EventListeners { // we can know about the "mouseup" event with this this.bootstrap.transform_controls?.addEventListener('dragging-changed', (event: any) => { this.bootstrap.is_transform_controls_dragging = event.value - this.bootstrap.enable_orbit_controls(!event.value) + this.bootstrap.update_orbit_controls_state(!event.value) // Store undo state when we start dragging (event.value = true) if (event.value && this.bootstrap.process_step === ProcessStep.EditSkeleton) { diff --git a/src/lib/SceneEnvironmentManager.ts b/src/lib/SceneEnvironmentManager.ts index 486effb..afee821 100644 --- a/src/lib/SceneEnvironmentManager.ts +++ b/src/lib/SceneEnvironmentManager.ts @@ -48,7 +48,7 @@ export class SceneEnvironmentManager { // center orbit controls around mid-section area with target change this.controls = new OrbitControls(this.camera, this.renderer.domElement) - this.controls.target.set(0, 0.9, 0) + this.controls.target.set(0, 1.2, 0) // Set zoom limits to prevent excessive zooming in or out this.controls.minDistance = 0.5 // Minimum zoom (closest to model) @@ -91,6 +91,10 @@ export class SceneEnvironmentManager { this.controls?.update() } + public get_orbit_target (): Vector3 | null { + return this.controls?.target ?? null + } + public set_zoom_limits (min_distance: number, max_distance: number): void { if (this.controls !== undefined) { this.controls.minDistance = min_distance diff --git a/src/lib/UI.ts b/src/lib/UI.ts index af6294e..f6d2615 100644 --- a/src/lib/UI.ts +++ b/src/lib/UI.ts @@ -27,6 +27,8 @@ export class UI { dom_mirror_skeleton_checkbox: HTMLInputElement | null = null dom_independent_bone_movement_checkbox: HTMLInputElement | null = null dom_mesh_drag_placement_checkbox: HTMLInputElement | null = null + dom_mesh_drag_centerline_snap_checkbox: HTMLInputElement | null = null + dom_disable_orbit_rotation_checkbox: HTMLInputElement | null = null dom_mesh_drag_snap_strength_input: HTMLInputElement | null = null dom_mesh_drag_snap_strength_label: HTMLElement | null = null dom_mesh_drag_snap_strength_container: HTMLElement | null = null @@ -152,6 +154,8 @@ export class UI { this.dom_mirror_skeleton_checkbox = document.querySelector('#mirror-skeleton') this.dom_independent_bone_movement_checkbox = document.querySelector('#independent-bone-movement') this.dom_mesh_drag_placement_checkbox = document.querySelector('#mesh-drag-placement') + this.dom_mesh_drag_centerline_snap_checkbox = document.querySelector('#mesh-drag-centerline-snap') + this.dom_disable_orbit_rotation_checkbox = document.querySelector('#disable-rotation-spinner') this.dom_mesh_drag_snap_strength_input = document.querySelector('#mesh-drag-snap-strength-input') this.dom_mesh_drag_snap_strength_label = document.querySelector('#mesh-drag-snap-strength-label') this.dom_mesh_drag_snap_strength_container = document.querySelector('#mesh-drag-snap-strength-container') diff --git a/src/lib/processes/edit-skeleton/MeshDragBonePlacement.ts b/src/lib/processes/edit-skeleton/MeshDragBonePlacement.ts index 4c82f1d..62ee7bb 100644 --- a/src/lib/processes/edit-skeleton/MeshDragBonePlacement.ts +++ b/src/lib/processes/edit-skeleton/MeshDragBonePlacement.ts @@ -2,6 +2,7 @@ import * as THREE from 'three' import { type OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js' import { type TransformControls } from 'three/examples/jsm/controls/TransformControls.js' import { ProcessStep } from '../../enums/ProcessStep.ts' +import { SkeletonType } from '../../enums/SkeletonType.ts' import { Utility } from '../../Utilities.ts' import { type StepEditSkeleton } from './StepEditSkeleton.ts' import { type StepLoadModel } from '../load-model/StepLoadModel.ts' @@ -53,6 +54,11 @@ export function apply_mesh_centerline_target ( export class MeshDragBonePlacement { private orbit_controls: OrbitControls | undefined = undefined private is_dragging_mode_active: boolean = false + private closest_vertex_cache: { + object_uuid: string + face_key: string + closest_vertex_world_position: Vector3 | null + } | null = null constructor ( private readonly camera: PerspectiveCamera, @@ -79,16 +85,12 @@ export class MeshDragBonePlacement { if (using_mesh_drag_mode) { transform_controls.detach() - if (this.orbit_controls !== undefined) { - this.orbit_controls.enabled = true - } + this.set_orbit_controls_enabled(true) } if (this.is_dragging_mode_active && !using_mesh_drag_mode) { this.is_dragging_mode_active = false - if (this.orbit_controls !== undefined) { - this.orbit_controls.enabled = true - } + this.set_orbit_controls_enabled(true) } } @@ -98,6 +100,8 @@ export class MeshDragBonePlacement { return } + this.closest_vertex_cache = null + const skeleton_to_test: Skeleton | undefined = this.edit_skeleton_step.skeleton() if (skeleton_to_test === undefined) { return @@ -133,9 +137,7 @@ export class MeshDragBonePlacement { } this.is_dragging_mode_active = true - if (this.orbit_controls !== undefined) { - this.orbit_controls.enabled = false - } + this.set_orbit_controls_enabled(false) this.move_selected_bone_to_mesh_midpoint(mouse_event) } @@ -162,21 +164,25 @@ export class MeshDragBonePlacement { } this.is_dragging_mode_active = false - if (this.orbit_controls !== undefined) { - this.orbit_controls.enabled = true - } + this.closest_vertex_cache = null + this.set_orbit_controls_enabled(true) return true } public snap_primary_centerline_bones_to_mesh_center (): void { + if (!this.edit_skeleton_step.is_mesh_drag_centerline_snap_enabled()) { + return + } + const skeleton_to_snap = this.edit_skeleton_step.skeleton() if (skeleton_to_snap === undefined) { return } const mesh_targets = this.get_centerline_mesh_targets() - if (mesh_targets.length === 0) { + const is_fox = this.edit_skeleton_step.get_skeleton_type() === SkeletonType.Fox + if (mesh_targets.length === 0 && !is_fox) { return } @@ -185,9 +191,8 @@ export class MeshDragBonePlacement { return } - const centered_world_position = this.get_mesh_centerline_target_at_world_position( - Utility.world_position_from_object(bone), - mesh_targets + const centered_world_position = this.get_centerline_target_at_world_position( + Utility.world_position_from_object(bone) ) if (centered_world_position === null) { @@ -294,8 +299,9 @@ export class MeshDragBonePlacement { let target_world_position: Vector3 | null = null if (intersection_target !== null) { - if (is_centerline_mesh_snap_bone_name(selected_bone.name)) { - target_world_position = this.get_mesh_centerline_target_at_world_position(intersection_target.midpoint) + if (this.edit_skeleton_step.is_mesh_drag_centerline_snap_enabled() && + is_centerline_mesh_snap_bone_name(selected_bone.name)) { + target_world_position = this.get_centerline_target_at_world_position(intersection_target.midpoint) } if (target_world_position === null) { @@ -361,6 +367,17 @@ export class MeshDragBonePlacement { return scene_bounds } + private get_centerline_target_at_world_position (target_world_position: Vector3): Vector3 | null { + if (this.edit_skeleton_step.get_skeleton_type() === SkeletonType.Fox) { + const fox_target = this.get_fox_centerline_target_at_world_position(target_world_position) + if (fox_target !== null) { + return fox_target + } + } + + return this.get_mesh_centerline_target_at_world_position(target_world_position) + } + private get_mesh_centerline_target_at_world_position ( target_world_position: Vector3, mesh_targets: Object3D[] = this.get_centerline_mesh_targets() @@ -421,6 +438,16 @@ export class MeshDragBonePlacement { return apply_mesh_centerline_target(target_world_position, snapped_x, snapped_z) } + private get_fox_centerline_target_at_world_position (target_world_position: Vector3): Vector3 | null { + const mesh_bounds = this.get_mesh_bounds() + if (mesh_bounds === null) { + return null + } + + const center_x = mesh_bounds.getCenter(new Vector3()).x + return new Vector3(center_x, target_world_position.y, target_world_position.z) + } + private find_spine_chain_bones (skeleton: Skeleton): Bone[] { return skeleton.bones.filter((bone) => /spine/.test(bone.name.toLowerCase())) } @@ -531,6 +558,13 @@ export class MeshDragBonePlacement { return null } + const face_key = `${face.a}-${face.b}-${face.c}` + if (this.closest_vertex_cache !== null && + this.closest_vertex_cache.object_uuid === object.uuid && + this.closest_vertex_cache.face_key === face_key) { + return this.closest_vertex_cache.closest_vertex_world_position?.clone() ?? null + } + const vertex_indices = [face.a, face.b, face.c] let closest_vertex_world_position: Vector3 | null = null let closest_vertex_distance = Number.POSITIVE_INFINITY @@ -545,6 +579,12 @@ export class MeshDragBonePlacement { } } + this.closest_vertex_cache = { + object_uuid: object.uuid, + face_key, + closest_vertex_world_position: closest_vertex_world_position?.clone() ?? null + } + return closest_vertex_world_position } @@ -573,6 +613,15 @@ export class MeshDragBonePlacement { const intersection_point = mouse_raycaster.ray.intersectPlane(viewport_plane, new THREE.Vector3()) return intersection_point === null ? null : intersection_point.clone() } + + private set_orbit_controls_enabled (enabled: boolean): void { + if (this.orbit_controls === undefined) { + return + } + + const orbit_allowed = !this.edit_skeleton_step.is_orbit_rotation_disabled() + this.orbit_controls.enabled = enabled && orbit_allowed + } } interface MeshIntersectionTarget { diff --git a/src/lib/processes/edit-skeleton/StepEditSkeleton.ts b/src/lib/processes/edit-skeleton/StepEditSkeleton.ts index ce7888c..a110f72 100644 --- a/src/lib/processes/edit-skeleton/StepEditSkeleton.ts +++ b/src/lib/processes/edit-skeleton/StepEditSkeleton.ts @@ -38,9 +38,12 @@ export class StepEditSkeleton extends EventTarget { // Skeleton created from the armature that Three.js uses private threejs_skeleton: Skeleton = new Skeleton() + private skeleton_type: SkeletonType | null = null private mirror_mode_enabled: boolean = true private mesh_drag_placement_enabled: boolean = true + private mesh_drag_centerline_snap_enabled: boolean = true private mesh_drag_snap_strength: number = 10 + private orbit_rotation_disabled: boolean = false private skinning_algorithm: string | null = null private show_debug: boolean = true private readonly bone_chain_visibility = new Map() @@ -120,6 +123,7 @@ export class StepEditSkeleton extends EventTarget { } public begin (main_scene: Scene, skeleton_type: SkeletonType): void { + this.skeleton_type = skeleton_type this.update_ui_options_on_begin(skeleton_type) // show UI elemnents for editing mesh @@ -155,6 +159,14 @@ export class StepEditSkeleton extends EventTarget { this.set_mesh_drag_placement_enabled(this.ui.dom_mesh_drag_placement_checkbox.checked) } + if (this.ui.dom_mesh_drag_centerline_snap_checkbox !== null) { + this.set_mesh_drag_centerline_snap_enabled(this.ui.dom_mesh_drag_centerline_snap_checkbox.checked) + } + + if (this.ui.dom_disable_orbit_rotation_checkbox !== null) { + this.set_orbit_rotation_disabled(this.ui.dom_disable_orbit_rotation_checkbox.checked) + } + if (this.ui.dom_mesh_drag_snap_strength_input !== null) { const initial_snap_strength = Number(this.ui.dom_mesh_drag_snap_strength_input.value) this.set_mesh_drag_snap_strength(Number.isFinite(initial_snap_strength) ? initial_snap_strength : 10) @@ -245,10 +257,22 @@ export class StepEditSkeleton extends EventTarget { })) } + public set_mesh_drag_centerline_snap_enabled (value: boolean): void { + this.mesh_drag_centerline_snap_enabled = value + } + + public is_mesh_drag_centerline_snap_enabled (): boolean { + return this.mesh_drag_centerline_snap_enabled + } + public is_mesh_drag_placement_enabled (): boolean { return this.mesh_drag_placement_enabled } + public get_skeleton_type (): SkeletonType | null { + return this.skeleton_type + } + public set_mesh_drag_snap_strength (value: number): void { const clamped_value = Math.max(0, Math.min(20, Math.round(value))) this.mesh_drag_snap_strength = clamped_value @@ -266,6 +290,17 @@ export class StepEditSkeleton extends EventTarget { return this.mesh_drag_snap_strength } + public set_orbit_rotation_disabled (value: boolean): void { + this.orbit_rotation_disabled = value + this.dispatchEvent(new CustomEvent('orbit-rotation-changed', { + detail: { disabled: value } + })) + } + + public is_orbit_rotation_disabled (): boolean { + return this.orbit_rotation_disabled + } + public hidden_bone_chain_root_names (): string[] { return [...this.bone_chain_visibility.entries()] .filter(([, is_visible]) => !is_visible) @@ -399,6 +434,30 @@ export class StepEditSkeleton extends EventTarget { }) } + if (this.ui.dom_mesh_drag_centerline_snap_checkbox !== null) { + this.ui.dom_mesh_drag_centerline_snap_checkbox.addEventListener('change', (event) => { + const target = event.target as HTMLInputElement | null + + if (target === null) { + return + } + + this.set_mesh_drag_centerline_snap_enabled(target.checked) + }) + } + + if (this.ui.dom_disable_orbit_rotation_checkbox !== null) { + this.ui.dom_disable_orbit_rotation_checkbox.addEventListener('change', (event) => { + const target = event.target as HTMLInputElement | null + + if (target === null) { + return + } + + this.set_orbit_rotation_disabled(target.checked) + }) + } + this.ui.dom_mesh_drag_snap_strength_input?.addEventListener('input', (event) => { const target = event.target as HTMLInputElement | null @@ -566,7 +625,12 @@ export class StepEditSkeleton extends EventTarget { } public armature (): Object3D { - return this.edited_armature + const skeleton_root = this.threejs_skeleton.bones[0] + const cloned_root = skeleton_root.clone(true) + const armature = new Object3D() + armature.add(cloned_root) + armature.updateMatrixWorld(true) + return armature } public skeleton (): Skeleton { diff --git a/src/lib/processes/weight-skin/StepWeightSkin.ts b/src/lib/processes/weight-skin/StepWeightSkin.ts index bd86d17..c2fc668 100644 --- a/src/lib/processes/weight-skin/StepWeightSkin.ts +++ b/src/lib/processes/weight-skin/StepWeightSkin.ts @@ -168,6 +168,10 @@ export class StepWeightSkin extends EventTarget { this.bone_skinning_formula!.set_geometry(geometry_data) const [final_skin_indices, final_skin_weights]: number[][] = this.calculate_weights() + if (this.ui.dom_enable_skin_debugging?.checked === true) { + this.log_stomach_weight_totals(final_skin_indices, final_skin_weights, geometry_data.name) + } + geometry_data.setAttribute('skinIndex', new Uint16BufferAttribute(final_skin_indices, 4)) geometry_data.setAttribute('skinWeight', new Float32BufferAttribute(final_skin_weights, 4)) @@ -188,4 +192,38 @@ export class StepWeightSkin extends EventTarget { console.log('Final skinned meshes:', this.skinned_meshes) console.log('Preview weight painted mesh re-generated:', this.weight_painted_mesh_preview) } + + private log_stomach_weight_totals (skin_indices: number[], skin_weights: number[], mesh_label: string): void { + if (this.binding_skeleton === undefined) { + return + } + + const stomach_bone_entries = this.binding_skeleton.bones + .map((bone, index) => ({ bone, index })) + .filter(({ bone }) => /stomach|abdomen|belly/.test(bone.name.toLowerCase())) + + if (stomach_bone_entries.length === 0) { + return + } + + const weight_totals: number[] = new Array(this.binding_skeleton.bones.length).fill(0) + for (let i = 0; i < skin_indices.length; i++) { + const bone_index = skin_indices[i] + const weight_value = skin_weights[i] ?? 0 + if (bone_index === undefined) { + continue + } + weight_totals[bone_index] = (weight_totals[bone_index] ?? 0) + weight_value + } + + stomach_bone_entries.forEach(({ bone, index }) => { + const total_weight = weight_totals[index] ?? 0 + if (total_weight <= 0.0001) { + console.warn(`Stomach weight check: ${bone.name} has near-zero weight for ${mesh_label}.`) + return + } + + console.log(`Stomach weight check: ${bone.name} total weight for ${mesh_label} = ${total_weight.toFixed(4)}`) + }) + } } diff --git a/src/styles.css b/src/styles.css index b94cabf..34c17b2 100644 --- a/src/styles.css +++ b/src/styles.css @@ -678,8 +678,8 @@ input[type="file"] { The event listener for the click events latches onto this */ #view-control-hitbox { - height: 120px; - width: 120px; + height: 256px; + width: 256px; position: absolute; bottom: 0px; left: 0;