fix(installation-demo): synchronize clockwise screw and tool playback

This commit is contained in:
WuYongJie 2026-10-20 15:05:41 +08:00
parent 89eb66c6f6
commit 0509df6408
9 changed files with 288 additions and 60 deletions

View File

@ -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,

View File

@ -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)

View File

@ -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()

View File

@ -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({})
})
})

View File

@ -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
}

View File

@ -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])
})
})

View File

@ -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],
}
}

View File

@ -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,

View File

@ -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))