fix(installation-demo): synchronize clockwise screw and tool playback
This commit is contained in:
parent
89eb66c6f6
commit
0509df6408
@ -438,6 +438,7 @@ export function InstallationPreviewStage({
|
|||||||
removedToolNodeNames: toolAttachmentRemovedNodeNames,
|
removedToolNodeNames: toolAttachmentRemovedNodeNames,
|
||||||
followHardwarePath: view === 'preview' && Boolean(playbackAction),
|
followHardwarePath: view === 'preview' && Boolean(playbackAction),
|
||||||
screwDriveProgress: view === 'preview' && playbackAction ? playbackScrewDriveProgress : undefined,
|
screwDriveProgress: view === 'preview' && playbackAction ? playbackScrewDriveProgress : undefined,
|
||||||
|
playbackStep: view === 'preview' ? activeStep : null,
|
||||||
visible: toolAttachmentsVisible,
|
visible: toolAttachmentsVisible,
|
||||||
sceneKey: `${modelUrl ?? 'no-model'}:${modelNodeNames.join('\u001f')}`,
|
sceneKey: `${modelUrl ?? 'no-model'}:${modelNodeNames.join('\u001f')}`,
|
||||||
onAssetLoadError: handleToolAssetLoadError,
|
onAssetLoadError: handleToolAssetLoadError,
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import {
|
|||||||
} from '@/lib/installation-demo-tool-motion'
|
} from '@/lib/installation-demo-tool-motion'
|
||||||
import type {
|
import type {
|
||||||
InstallationPartItem,
|
InstallationPartItem,
|
||||||
|
InstallationStepDraft,
|
||||||
InstallationToolActionTransform,
|
InstallationToolActionTransform,
|
||||||
InstallationToolItem,
|
InstallationToolItem,
|
||||||
} from '../types'
|
} from '../types'
|
||||||
@ -26,6 +27,11 @@ import {
|
|||||||
resolveInstallationToolFollowTransform,
|
resolveInstallationToolFollowTransform,
|
||||||
} from '../lib/installationNodeGeometry'
|
} from '../lib/installationNodeGeometry'
|
||||||
import { isInstallationToolObjAsset } from '../lib/installationToolAssetFormat'
|
import { isInstallationToolObjAsset } from '../lib/installationToolAssetFormat'
|
||||||
|
import { getPartNodeNames } from '../lib/installationPartCatalogHelpers'
|
||||||
|
import {
|
||||||
|
buildInstallationScrewDriveAxes,
|
||||||
|
resolveInstallationScrewDriveWorldAxis,
|
||||||
|
} from '../lib/installationScrewDriveAxes'
|
||||||
import {
|
import {
|
||||||
prepareInstallationToolModel,
|
prepareInstallationToolModel,
|
||||||
resolveInstallationToolBoundsPoint,
|
resolveInstallationToolBoundsPoint,
|
||||||
@ -63,6 +69,7 @@ interface InstallationToolAttachmentOptions {
|
|||||||
removedToolNodeNames: string[]
|
removedToolNodeNames: string[]
|
||||||
followHardwarePath: boolean
|
followHardwarePath: boolean
|
||||||
screwDriveProgress?: number
|
screwDriveProgress?: number
|
||||||
|
playbackStep?: InstallationStepDraft | null
|
||||||
visible: boolean
|
visible: boolean
|
||||||
sceneKey: string
|
sceneKey: string
|
||||||
onAssetLoadError?: (tool: InstallationToolItem) => void
|
onAssetLoadError?: (tool: InstallationToolItem) => void
|
||||||
@ -147,6 +154,7 @@ export function useInstallationToolAttachments({
|
|||||||
removedToolNodeNames,
|
removedToolNodeNames,
|
||||||
followHardwarePath,
|
followHardwarePath,
|
||||||
screwDriveProgress,
|
screwDriveProgress,
|
||||||
|
playbackStep = null,
|
||||||
visible,
|
visible,
|
||||||
sceneKey,
|
sceneKey,
|
||||||
onAssetLoadError,
|
onAssetLoadError,
|
||||||
@ -157,6 +165,11 @@ export function useInstallationToolAttachments({
|
|||||||
const toolMaterialConfigsRef = useRef(toolMaterialConfigsByToolId)
|
const toolMaterialConfigsRef = useRef(toolMaterialConfigsByToolId)
|
||||||
const followHardwarePathRef = useRef(followHardwarePath)
|
const followHardwarePathRef = useRef(followHardwarePath)
|
||||||
const screwDriveProgressRef = useRef(screwDriveProgress)
|
const screwDriveProgressRef = useRef(screwDriveProgress)
|
||||||
|
const screwDriveAxes = useMemo(() => buildInstallationScrewDriveAxes(
|
||||||
|
playbackStep,
|
||||||
|
hardwareParts.filter(part => part.screwDrive).flatMap(getPartNodeNames),
|
||||||
|
), [hardwareParts, playbackStep])
|
||||||
|
const screwDriveAxesRef = useRef(screwDriveAxes)
|
||||||
const assetLoadErrorToolIdsRef = useRef(new Set<string>())
|
const assetLoadErrorToolIdsRef = useRef(new Set<string>())
|
||||||
const fallbackToolId = tools.length === 1 ? tools[0]?.id : undefined
|
const fallbackToolId = tools.length === 1 ? tools[0]?.id : undefined
|
||||||
const attachmentIdentities = useMemo(
|
const attachmentIdentities = useMemo(
|
||||||
@ -212,6 +225,10 @@ export function useInstallationToolAttachments({
|
|||||||
screwDriveProgressRef.current = screwDriveProgress
|
screwDriveProgressRef.current = screwDriveProgress
|
||||||
}, [screwDriveProgress])
|
}, [screwDriveProgress])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
screwDriveAxesRef.current = screwDriveAxes
|
||||||
|
}, [screwDriveAxes])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
toolMaterialConfigsRef.current = toolMaterialConfigsByToolId
|
toolMaterialConfigsRef.current = toolMaterialConfigsByToolId
|
||||||
if (!engine) return
|
if (!engine) return
|
||||||
@ -290,6 +307,11 @@ export function useInstallationToolAttachments({
|
|||||||
toolSpinTurns: record.toolSpinTurns,
|
toolSpinTurns: record.toolSpinTurns,
|
||||||
toolStrikeDistance: record.toolStrikeDistance,
|
toolStrikeDistance: record.toolStrikeDistance,
|
||||||
toolMotionProgress: screwDriveProgressRef.current,
|
toolMotionProgress: screwDriveProgressRef.current,
|
||||||
|
screwDriveAxisWorld: Object.hasOwn(screwDriveAxesRef.current, record.hardwareNodeName)
|
||||||
|
? resolveInstallationScrewDriveWorldAxis(
|
||||||
|
screwDriveAxesRef.current[record.hardwareNodeName], record.hardwareObject,
|
||||||
|
)
|
||||||
|
: undefined,
|
||||||
})
|
})
|
||||||
record.root.position.copy(transform.position)
|
record.root.position.copy(transform.position)
|
||||||
record.root.quaternion.copy(transform.rotation)
|
record.root.quaternion.copy(transform.rotation)
|
||||||
|
|||||||
@ -1,6 +1,11 @@
|
|||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
import { isHighlightHelperObjectName } from '@/lib/3d/engine-highlight-resources'
|
import { isHighlightHelperObjectName } from '@/lib/3d/engine-highlight-resources'
|
||||||
import type { InstallationToolMotionType } from '@/lib/installation-demo-tool-motion'
|
import type { InstallationToolMotionType } from '@/lib/installation-demo-tool-motion'
|
||||||
|
import {
|
||||||
|
createInstallationClockwiseSpin,
|
||||||
|
easeInstallationScrewDriveProgress,
|
||||||
|
INSTALLATION_SCREW_DRIVE_TURNS,
|
||||||
|
} from './installationScrewDriveMotion'
|
||||||
|
|
||||||
export function resolveInstallationNodeLocalGeometryCenter(object: THREE.Object3D) {
|
export function resolveInstallationNodeLocalGeometryCenter(object: THREE.Object3D) {
|
||||||
object.updateMatrixWorld(true)
|
object.updateMatrixWorld(true)
|
||||||
@ -49,6 +54,7 @@ export function resolveInstallationToolFollowTransform({
|
|||||||
toolSpinTurns,
|
toolSpinTurns,
|
||||||
toolStrikeDistance,
|
toolStrikeDistance,
|
||||||
toolMotionProgress,
|
toolMotionProgress,
|
||||||
|
screwDriveAxisWorld,
|
||||||
}: {
|
}: {
|
||||||
hardwareObject: THREE.Object3D
|
hardwareObject: THREE.Object3D
|
||||||
hardwareAnchorLocalPosition: THREE.Vector3
|
hardwareAnchorLocalPosition: THREE.Vector3
|
||||||
@ -61,6 +67,7 @@ export function resolveInstallationToolFollowTransform({
|
|||||||
toolSpinTurns: number
|
toolSpinTurns: number
|
||||||
toolStrikeDistance: number
|
toolStrikeDistance: number
|
||||||
toolMotionProgress: number | undefined
|
toolMotionProgress: number | undefined
|
||||||
|
screwDriveAxisWorld?: THREE.Vector3 | null
|
||||||
}) {
|
}) {
|
||||||
hardwareObject.updateMatrixWorld(true)
|
hardwareObject.updateMatrixWorld(true)
|
||||||
const currentHardwareAnchorPosition = hardwareAnchorLocalPosition
|
const currentHardwareAnchorPosition = hardwareAnchorLocalPosition
|
||||||
@ -77,18 +84,18 @@ export function resolveInstallationToolFollowTransform({
|
|||||||
toolMotionProgress > 0 &&
|
toolMotionProgress > 0 &&
|
||||||
toolMotionProgress < 1
|
toolMotionProgress < 1
|
||||||
) {
|
) {
|
||||||
const easedProgress = Math.min(
|
const easedProgress = easeInstallationScrewDriveProgress(toolMotionProgress)
|
||||||
Math.max(toolMotionProgress + 0.11 * Math.sin(Math.PI * 2 * toolMotionProgress), 0),
|
|
||||||
1,
|
|
||||||
)
|
|
||||||
const motionAxis = toolMotionAxisLocal
|
const motionAxis = toolMotionAxisLocal
|
||||||
.clone()
|
.clone()
|
||||||
.applyQuaternion(toolStartWorldRotation)
|
.applyQuaternion(toolStartWorldRotation)
|
||||||
.normalize()
|
.normalize()
|
||||||
if (toolMotionType === 'spin') {
|
if (toolMotionType === 'spin') {
|
||||||
const spin = new THREE.Quaternion().setFromAxisAngle(
|
// A screw-bound tool shares the screw's axis and turn count, including reversed mounts.
|
||||||
motionAxis,
|
// null means the screw has no insertion path; undefined is a standalone tool motion.
|
||||||
-Math.PI * 2 * toolSpinTurns * easedProgress,
|
const spin = createInstallationClockwiseSpin(
|
||||||
|
screwDriveAxisWorld === undefined ? motionAxis : screwDriveAxisWorld ?? new THREE.Vector3(),
|
||||||
|
screwDriveAxisWorld === undefined ? toolSpinTurns : INSTALLATION_SCREW_DRIVE_TURNS,
|
||||||
|
easedProgress,
|
||||||
)
|
)
|
||||||
const basePivotOffset = toolMotionPivotLocal
|
const basePivotOffset = toolMotionPivotLocal
|
||||||
.clone()
|
.clone()
|
||||||
|
|||||||
@ -0,0 +1,58 @@
|
|||||||
|
import * as THREE from 'three'
|
||||||
|
import type { InstallationNodeTransformState, InstallationStepDraft } from '../types'
|
||||||
|
import { buildInstallationScrewDriveAxes, resolveInstallationScrewDriveWorldAxis } from './installationScrewDriveAxes'
|
||||||
|
import { buildInstallationStepActionPlaybackFrame, resolveInstallationStepActionPlaybackTimeline } from './installationStepActionPlayback'
|
||||||
|
|
||||||
|
function transform(position: [number, number, number]): InstallationNodeTransformState {
|
||||||
|
return { position, rotation: [0, 0, 0, 1], offset: [0, 0, 0] }
|
||||||
|
}
|
||||||
|
function step(): InstallationStepDraft {
|
||||||
|
return {
|
||||||
|
id: 'step', order: 1, title: '', note: '', direction: 'front', cameraPreset: 'medium', durationMs: 1800,
|
||||||
|
workflowVersion: 2, transformSpace: 'local', fixedNodeNames: ['base'], installNodeNames: [], hardwareNodeNames: ['screw'],
|
||||||
|
scopeNodeNames: ['base', 'screw'],
|
||||||
|
assembledTransforms: { base: transform([0, 0, 0]), screw: transform([0, 0, 0]) },
|
||||||
|
expandedTransforms: { base: transform([20, 30, 40]), screw: transform([20, 30, 100]) },
|
||||||
|
actions: [{ id: 'drive', order: 1, title: '', nodeNames: ['screw'], startTransforms: { screw: transform([20, 30, 100]) } }],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('binding tools to the actual screw installation axis', () => {
|
||||||
|
it('uses the expanded installation target and action start, preserving saved data', () => {
|
||||||
|
const saved = step()
|
||||||
|
const before = JSON.stringify(saved)
|
||||||
|
expect(buildInstallationScrewDriveAxes(saved, ['screw', 'missing'])).toEqual({ screw: [0, 0, -1], missing: null })
|
||||||
|
const timeline = resolveInstallationStepActionPlaybackTimeline(saved, undefined, ['screw'])
|
||||||
|
const phase = timeline.phases[0]
|
||||||
|
const progress = ((phase.screwDriveApproachDurationMs ?? 0) + 15) / timeline.durationMs
|
||||||
|
const frame = buildInstallationStepActionPlaybackFrame(saved, progress, ['screw'])
|
||||||
|
const screw = frame.transforms.find(item => item.nodeName === 'screw')!
|
||||||
|
// From the head at +Z, an initial +X mark moves towards -Y: clockwise.
|
||||||
|
const mark = new THREE.Vector3(1, 0, 0).applyQuaternion(new THREE.Quaternion(...screw.rotation))
|
||||||
|
expect(mark.y).toBeLessThan(0)
|
||||||
|
expect(JSON.stringify(saved)).toBe(before)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('converts the installation axis through rotated/scaled parent transforms', () => {
|
||||||
|
const parent = new THREE.Group()
|
||||||
|
parent.rotation.set(0.2, 0.7, -0.3)
|
||||||
|
parent.scale.set(2, 3, 4)
|
||||||
|
parent.position.set(20, 10, 30)
|
||||||
|
const child = new THREE.Object3D()
|
||||||
|
parent.add(child)
|
||||||
|
parent.updateMatrixWorld(true)
|
||||||
|
const expected = new THREE.Vector3(20, 30, 40).applyMatrix4(parent.matrixWorld)
|
||||||
|
.sub(new THREE.Vector3(20, 30, 100).applyMatrix4(parent.matrixWorld)).normalize()
|
||||||
|
expect(resolveInstallationScrewDriveWorldAxis([0, 0, -1], child)!.distanceTo(expected)).toBeLessThan(1e-8)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not invent an insertion axis for stationary or held hardware', () => {
|
||||||
|
const saved = step()
|
||||||
|
saved.actions![0].startTransforms.screw = transform([20, 30, 40])
|
||||||
|
expect(buildInstallationScrewDriveAxes(saved, ['screw']).screw).toBeNull()
|
||||||
|
saved.actions![0].startTransforms.screw = transform([20, 30, 100])
|
||||||
|
saved.holdExpandedNodeNames = ['screw']
|
||||||
|
expect(buildInstallationScrewDriveAxes(saved, ['screw']).screw).toBeNull()
|
||||||
|
expect(buildInstallationScrewDriveAxes(null, ['screw'])).toEqual({})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -0,0 +1,48 @@
|
|||||||
|
import * as THREE from 'three'
|
||||||
|
import type { InstallationStepDraft, InstallationVector3 } from '../types'
|
||||||
|
import {
|
||||||
|
buildInstallationStepActionEditLocalTransforms,
|
||||||
|
buildInstallationStepActionInProgressLocalTransforms,
|
||||||
|
buildInstallationStepActionPreviewEndLocalTransforms,
|
||||||
|
} from './installationStepActionWorkflow'
|
||||||
|
|
||||||
|
export type InstallationScrewDriveAxes = Record<string, InstallationVector3 | null>
|
||||||
|
|
||||||
|
/** Use the same saved start and installation target as screw playback, before final assembly. */
|
||||||
|
export function buildInstallationScrewDriveAxes(
|
||||||
|
step: InstallationStepDraft | null,
|
||||||
|
screwNodeNames: string[],
|
||||||
|
): InstallationScrewDriveAxes {
|
||||||
|
if (!step) return {}
|
||||||
|
const startMap = new Map(buildInstallationStepActionEditLocalTransforms(step).map(item => [item.nodeName, item]))
|
||||||
|
const endMap = new Map(buildInstallationStepActionPreviewEndLocalTransforms(step).map(item => [item.nodeName, item]))
|
||||||
|
const targetMap = new Map(buildInstallationStepActionInProgressLocalTransforms(step).map(item => [item.nodeName, item]))
|
||||||
|
const heldNodes = new Set([
|
||||||
|
...(step.holdExpandedNodeNames ?? []),
|
||||||
|
...(step.hingeCloseActions ?? []).flatMap(action => action.nodeNames),
|
||||||
|
])
|
||||||
|
return Object.fromEntries(screwNodeNames.map(nodeName => {
|
||||||
|
const start = startMap.get(nodeName) ?? endMap.get(nodeName)
|
||||||
|
const end = endMap.get(nodeName) ?? start
|
||||||
|
const installationTarget = targetMap.get(nodeName) ?? end
|
||||||
|
const target = heldNodes.has(nodeName)
|
||||||
|
? step.expandedTransforms?.[nodeName] ?? installationTarget
|
||||||
|
: installationTarget
|
||||||
|
if (!start?.position || !target?.position) return [nodeName, null]
|
||||||
|
const axis = new THREE.Vector3(...target.position).sub(new THREE.Vector3(...start.position))
|
||||||
|
return [nodeName, axis.lengthSq() > 1e-8 ? axis.normalize().toArray() : null]
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveInstallationScrewDriveWorldAxis(
|
||||||
|
localAxis: InstallationVector3 | null,
|
||||||
|
hardwareObject: THREE.Object3D,
|
||||||
|
) {
|
||||||
|
if (!localAxis) return null
|
||||||
|
const axis = new THREE.Vector3(...localAxis)
|
||||||
|
if (hardwareObject.parent) {
|
||||||
|
hardwareObject.parent.updateWorldMatrix(true, false)
|
||||||
|
axis.transformDirection(hardwareObject.parent.matrixWorld)
|
||||||
|
}
|
||||||
|
return axis
|
||||||
|
}
|
||||||
@ -0,0 +1,85 @@
|
|||||||
|
import * as THREE from 'three'
|
||||||
|
import type { NodeLocalTransformState } from '@/lib/3d/engine'
|
||||||
|
import { applyInstallationScrewDriveRotation, createInstallationClockwiseSpin, easeInstallationScrewDriveProgress } from './installationScrewDriveMotion'
|
||||||
|
import { resolveInstallationToolFollowTransform } from './installationNodeGeometry'
|
||||||
|
|
||||||
|
const directions: Array<[string, [number, number, number]]> = [
|
||||||
|
['right', [1, 0, 0]], ['left', [-1, 0, 0]],
|
||||||
|
['up', [0, 1, 0]], ['down', [0, -1, 0]],
|
||||||
|
['front', [0, 0, 1]], ['back', [0, 0, -1]],
|
||||||
|
]
|
||||||
|
const state = (position: [number, number, number]): NodeLocalTransformState => ({
|
||||||
|
nodeName: 'screw', position, rotation: [0, 0, 0, 1],
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('clockwise installation from the screw head towards its tip', () => {
|
||||||
|
it.each(directions)('screw and tool turn clockwise together when inserting %s', (_, vector) => {
|
||||||
|
const axis = new THREE.Vector3(...vector)
|
||||||
|
const radial = new THREE.Vector3(1, 1, 1).cross(axis).normalize()
|
||||||
|
const observerNormal = axis.clone().negate()
|
||||||
|
const start = state([0, 0, 0])
|
||||||
|
const end = state(vector)
|
||||||
|
const toolBase = new THREE.Quaternion().setFromEuler(new THREE.Euler(0.4, 0.3, -0.2))
|
||||||
|
const spin = (progress: number) => {
|
||||||
|
const screw = new THREE.Quaternion(...applyInstallationScrewDriveRotation(start, start, end, progress).rotation)
|
||||||
|
const tool = resolveInstallationToolFollowTransform({
|
||||||
|
hardwareObject: new THREE.Object3D(), hardwareAnchorLocalPosition: new THREE.Vector3(),
|
||||||
|
hardwareStartWorldAnchorPosition: new THREE.Vector3(), toolStartWorldPosition: new THREE.Vector3(),
|
||||||
|
toolStartWorldRotation: toolBase, toolMotionType: 'spin', toolMotionPivotLocal: new THREE.Vector3(),
|
||||||
|
// A reversed/configured shaft and different catalogue turn count cannot reverse a bound screw tool.
|
||||||
|
toolMotionAxisLocal: axis.clone().negate(), toolSpinTurns: 8, toolStrikeDistance: 0,
|
||||||
|
toolMotionProgress: progress, screwDriveAxisWorld: axis,
|
||||||
|
}).rotation.multiply(toolBase.clone().invert())
|
||||||
|
expect(tool.angleTo(screw)).toBeLessThan(1e-7)
|
||||||
|
return screw
|
||||||
|
}
|
||||||
|
let previous = radial.clone()
|
||||||
|
for (let frame = 1; frame <= 80; frame++) {
|
||||||
|
const progress = frame / 81
|
||||||
|
const current = radial.clone().applyQuaternion(spin(progress))
|
||||||
|
// A negative screen-plane signed angle is clockwise for an observer at the head.
|
||||||
|
const signedScreenAngle = Math.atan2(observerNormal.dot(previous.clone().cross(current)), previous.dot(current))
|
||||||
|
expect(signedScreenAngle).toBeLessThan(0)
|
||||||
|
previous = current
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves the saved endpoint, geometry pivot and input objects', () => {
|
||||||
|
const start = state([0, 0, 10])
|
||||||
|
const end = state([0, 0, 0])
|
||||||
|
const current = state([0, 0, 5])
|
||||||
|
const snapshot = JSON.stringify({ start, end, current })
|
||||||
|
const pivot = new THREE.Vector3(2, 3, 4)
|
||||||
|
const result = applyInstallationScrewDriveRotation(current, start, end, 0.13, pivot.toArray())
|
||||||
|
const movedCenter = pivot.clone().applyQuaternion(new THREE.Quaternion(...result.rotation)).add(new THREE.Vector3(...result.position))
|
||||||
|
expect(movedCenter.distanceTo(pivot.clone().add(new THREE.Vector3(...current.position)))).toBeLessThan(1e-8)
|
||||||
|
expect(applyInstallationScrewDriveRotation(end, start, end, 1)).toBe(end)
|
||||||
|
expect(applyInstallationScrewDriveRotation(start, start, end, 0)).toBe(start)
|
||||||
|
expect(JSON.stringify({ start, end, current })).toBe(snapshot)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses the same catalogue preview direction for an independent tool', () => {
|
||||||
|
const axis = new THREE.Vector3(0, -1, 0)
|
||||||
|
const progress = 0.07
|
||||||
|
const result = resolveInstallationToolFollowTransform({
|
||||||
|
hardwareObject: new THREE.Object3D(), hardwareAnchorLocalPosition: new THREE.Vector3(),
|
||||||
|
hardwareStartWorldAnchorPosition: new THREE.Vector3(), toolStartWorldPosition: new THREE.Vector3(),
|
||||||
|
toolStartWorldRotation: new THREE.Quaternion(), toolMotionType: 'spin',
|
||||||
|
toolMotionPivotLocal: new THREE.Vector3(), toolMotionAxisLocal: axis,
|
||||||
|
toolSpinTurns: 2, toolStrikeDistance: 0, toolMotionProgress: progress,
|
||||||
|
})
|
||||||
|
expect(result.rotation.angleTo(createInstallationClockwiseSpin(axis, 2, easeInstallationScrewDriveProgress(progress)))).toBeLessThan(1e-7)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not spin a screw-bound tool with no insertion path', () => {
|
||||||
|
const result = resolveInstallationToolFollowTransform({
|
||||||
|
hardwareObject: new THREE.Object3D(), hardwareAnchorLocalPosition: new THREE.Vector3(),
|
||||||
|
hardwareStartWorldAnchorPosition: new THREE.Vector3(), toolStartWorldPosition: new THREE.Vector3(),
|
||||||
|
toolStartWorldRotation: new THREE.Quaternion(), toolMotionType: 'spin',
|
||||||
|
toolMotionPivotLocal: new THREE.Vector3(1, 2, 3), toolMotionAxisLocal: new THREE.Vector3(1, 0, 0),
|
||||||
|
toolSpinTurns: 3, toolStrikeDistance: 0, toolMotionProgress: 0.13, screwDriveAxisWorld: null,
|
||||||
|
})
|
||||||
|
expect(result.rotation.angleTo(new THREE.Quaternion())).toBe(0)
|
||||||
|
expect(result.position.toArray()).toEqual([0, 0, 0])
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -0,0 +1,54 @@
|
|||||||
|
import * as THREE from 'three'
|
||||||
|
import type { NodeLocalTransformState } from '@/lib/3d/engine'
|
||||||
|
|
||||||
|
export const INSTALLATION_SCREW_DRIVE_TURNS = 3
|
||||||
|
|
||||||
|
export function easeInstallationScrewDriveProgress(progress: number) {
|
||||||
|
const clamped = THREE.MathUtils.clamp(progress, 0, 1)
|
||||||
|
return THREE.MathUtils.clamp(clamped + 0.11 * Math.sin(Math.PI * 2 * clamped), 0, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Positive right-hand rotation looks clockwise from the head along the insertion axis. */
|
||||||
|
export function createInstallationClockwiseSpin(
|
||||||
|
insertionAxis: THREE.Vector3,
|
||||||
|
turns: number,
|
||||||
|
progress: number,
|
||||||
|
) {
|
||||||
|
if (insertionAxis.lengthSq() <= 1e-8) return new THREE.Quaternion()
|
||||||
|
return new THREE.Quaternion().setFromAxisAngle(
|
||||||
|
insertionAxis.clone().normalize(),
|
||||||
|
Math.PI * 2 * turns * progress,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyInstallationScrewDriveRotation(
|
||||||
|
transform: NodeLocalTransformState,
|
||||||
|
start: NodeLocalTransformState,
|
||||||
|
end: NodeLocalTransformState,
|
||||||
|
progress: number | undefined,
|
||||||
|
rotationPivot?: [number, number, number],
|
||||||
|
): NodeLocalTransformState {
|
||||||
|
if (progress === undefined || progress <= 0 || progress >= 1) return transform
|
||||||
|
const insertionAxis = new THREE.Vector3(...end.position).sub(new THREE.Vector3(...start.position))
|
||||||
|
if (insertionAxis.lengthSq() <= 1e-8) return transform
|
||||||
|
|
||||||
|
const spin = createInstallationClockwiseSpin(
|
||||||
|
insertionAxis,
|
||||||
|
INSTALLATION_SCREW_DRIVE_TURNS,
|
||||||
|
easeInstallationScrewDriveProgress(progress),
|
||||||
|
)
|
||||||
|
const baseRotation = new THREE.Quaternion(...transform.rotation).normalize()
|
||||||
|
const rotation = spin.multiply(baseRotation)
|
||||||
|
const position = new THREE.Vector3(...transform.position)
|
||||||
|
if (rotationPivot) {
|
||||||
|
const pivot = new THREE.Vector3(...rotationPivot)
|
||||||
|
const basePivotPosition = pivot.clone().applyQuaternion(baseRotation)
|
||||||
|
const spunPivotPosition = pivot.clone().applyQuaternion(rotation)
|
||||||
|
position.add(basePivotPosition.sub(spunPivotPosition))
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...transform,
|
||||||
|
position: [position.x, position.y, position.z],
|
||||||
|
rotation: [rotation.x, rotation.y, rotation.z, rotation.w],
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -21,12 +21,12 @@ import {
|
|||||||
import { resolveInstallationHingeCloseTransform } from './installationHingeCloseMotion'
|
import { resolveInstallationHingeCloseTransform } from './installationHingeCloseMotion'
|
||||||
import { resolveInstallationStepActionCarryInSummary } from './installationStepActionHistory'
|
import { resolveInstallationStepActionCarryInSummary } from './installationStepActionHistory'
|
||||||
import { applyHardwareHingePlayback } from './installationHardwareHingeRuntime'
|
import { applyHardwareHingePlayback } from './installationHardwareHingeRuntime'
|
||||||
|
import { applyInstallationScrewDriveRotation } from './installationScrewDriveMotion'
|
||||||
|
|
||||||
const ACTION_APPEAR_END = 0.22
|
const ACTION_APPEAR_END = 0.22
|
||||||
const ACTION_MOTION_START = 0.38
|
const ACTION_MOTION_START = 0.38
|
||||||
const ACTION_MOTION_END = 0.86
|
const ACTION_MOTION_END = 0.86
|
||||||
const SCREW_DRIVE_CONTACT_PROGRESS = 0.62
|
const SCREW_DRIVE_CONTACT_PROGRESS = 0.62
|
||||||
const SCREW_DRIVE_TURNS = 3
|
|
||||||
export const SCREW_DRIVE_DURATION_MS = 1500
|
export const SCREW_DRIVE_DURATION_MS = 1500
|
||||||
const SCREW_DRIVE_CONTACT_ACTION_PROGRESS =
|
const SCREW_DRIVE_CONTACT_ACTION_PROGRESS =
|
||||||
ACTION_MOTION_START +
|
ACTION_MOTION_START +
|
||||||
@ -71,13 +71,6 @@ function easeProgress(progress: number) {
|
|||||||
return easeInstallationActionMagneticProgress(clampProgress(progress))
|
return easeInstallationActionMagneticProgress(clampProgress(progress))
|
||||||
}
|
}
|
||||||
|
|
||||||
function easeScrewDriveProgress(progress: number) {
|
|
||||||
const clampedProgress = clampProgress(progress)
|
|
||||||
return clampProgress(
|
|
||||||
clampedProgress + 0.11 * Math.sin(Math.PI * 2 * clampedProgress),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function collectCarryInOnlyActionIds(
|
function collectCarryInOnlyActionIds(
|
||||||
step: InstallationStepDraft,
|
step: InstallationStepDraft,
|
||||||
steps: InstallationStepDraft[],
|
steps: InstallationStepDraft[],
|
||||||
@ -131,48 +124,6 @@ function interpolateTransform(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyScrewDriveRotation(
|
|
||||||
transform: NodeLocalTransformState,
|
|
||||||
start: NodeLocalTransformState,
|
|
||||||
end: NodeLocalTransformState,
|
|
||||||
screwDriveProgress: number | undefined,
|
|
||||||
rotationPivot?: [number, number, number],
|
|
||||||
): NodeLocalTransformState {
|
|
||||||
if (
|
|
||||||
screwDriveProgress === undefined ||
|
|
||||||
screwDriveProgress <= 0 ||
|
|
||||||
screwDriveProgress >= 1 ||
|
|
||||||
!start.position ||
|
|
||||||
!end.position
|
|
||||||
) {
|
|
||||||
return transform
|
|
||||||
}
|
|
||||||
|
|
||||||
const insertionAxis = new THREE.Vector3(...end.position).sub(new THREE.Vector3(...start.position))
|
|
||||||
if (insertionAxis.lengthSq() <= 1e-8) return transform
|
|
||||||
|
|
||||||
const spin = new THREE.Quaternion().setFromAxisAngle(
|
|
||||||
insertionAxis.normalize(),
|
|
||||||
-Math.PI * 2 * SCREW_DRIVE_TURNS * easeScrewDriveProgress(screwDriveProgress),
|
|
||||||
)
|
|
||||||
const baseRotation = new THREE.Quaternion(...transform.rotation).normalize()
|
|
||||||
const rotation = spin.multiply(baseRotation)
|
|
||||||
const position = new THREE.Vector3(...transform.position)
|
|
||||||
|
|
||||||
if (rotationPivot) {
|
|
||||||
const pivot = new THREE.Vector3(...rotationPivot)
|
|
||||||
const basePivotPosition = pivot.clone().applyQuaternion(baseRotation)
|
|
||||||
const spunPivotPosition = pivot.clone().applyQuaternion(rotation)
|
|
||||||
position.add(basePivotPosition.sub(spunPivotPosition))
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...transform,
|
|
||||||
position: [position.x, position.y, position.z],
|
|
||||||
rotation: [rotation.x, rotation.y, rotation.z, rotation.w],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveInstallationStepActionPlaybackTimeline(
|
export function resolveInstallationStepActionPlaybackTimeline(
|
||||||
step: InstallationStepDraft,
|
step: InstallationStepDraft,
|
||||||
durationMs?: number,
|
durationMs?: number,
|
||||||
@ -564,7 +515,7 @@ export function buildInstallationStepActionPlaybackFrame(
|
|||||||
const transform = interpolateTransform(start, installationTarget, motionProgress)
|
const transform = interpolateTransform(start, installationTarget, motionProgress)
|
||||||
return [
|
return [
|
||||||
screwDriveNodeNameSet.has(nodeName) && !isCarryInOnlyAction
|
screwDriveNodeNameSet.has(nodeName) && !isCarryInOnlyAction
|
||||||
? applyScrewDriveRotation(
|
? applyInstallationScrewDriveRotation(
|
||||||
transform,
|
transform,
|
||||||
start,
|
start,
|
||||||
installationTarget,
|
installationTarget,
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import type {
|
|||||||
InstallationToolMotionConfig,
|
InstallationToolMotionConfig,
|
||||||
} from '@/lib/installation-demo-tool-motion'
|
} from '@/lib/installation-demo-tool-motion'
|
||||||
import type { InstallationToolItem } from '../../types'
|
import type { InstallationToolItem } from '../../types'
|
||||||
|
import { createInstallationClockwiseSpin } from '../../lib/installationScrewDriveMotion'
|
||||||
import {
|
import {
|
||||||
buildInstallationHingeNodeTransforms,
|
buildInstallationHingeNodeTransforms,
|
||||||
resolveModelLocalBounds,
|
resolveModelLocalBounds,
|
||||||
@ -230,9 +231,10 @@ export function InstallationToolMotionPreview({
|
|||||||
root.position.copy(basePosition)
|
root.position.copy(basePosition)
|
||||||
root.quaternion.copy(baseRotation)
|
root.quaternion.copy(baseRotation)
|
||||||
if (config.motionType === 'spin') {
|
if (config.motionType === 'spin') {
|
||||||
const spin = new THREE.Quaternion().setFromAxisAngle(
|
const spin = createInstallationClockwiseSpin(
|
||||||
axis,
|
axis,
|
||||||
Math.PI * 2 * config.spinTurns * phase,
|
config.spinTurns,
|
||||||
|
phase,
|
||||||
)
|
)
|
||||||
root.position.copy(pivot.clone().add(basePosition.clone().sub(pivot).applyQuaternion(spin)))
|
root.position.copy(pivot.clone().add(basePosition.clone().sub(pivot).applyQuaternion(spin)))
|
||||||
root.quaternion.copy(spin.multiply(baseRotation))
|
root.quaternion.copy(spin.multiply(baseRotation))
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user