From 65b9c7788c05e074b7b92d3aa4aaff5d4190d127 Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Tue, 20 Oct 2026 15:05:41 +0800 Subject: [PATCH] fix(installation-demo): synchronize clockwise screw and tool playback --- .../components/InstallationPreviewStage.tsx | 1 + .../hooks/useInstallationToolAttachments.ts | 22 +++++ .../lib/installationNodeGeometry.ts | 21 +++-- .../lib/installationScrewDriveAxes.test.ts | 58 +++++++++++++ .../lib/installationScrewDriveAxes.ts | 48 +++++++++++ .../lib/installationScrewDriveMotion.test.ts | 85 +++++++++++++++++++ .../lib/installationScrewDriveMotion.ts | 54 ++++++++++++ .../lib/installationStepActionPlayback.ts | 53 +----------- .../InstallationToolMotionPreview.tsx | 6 +- 9 files changed, 288 insertions(+), 60 deletions(-) create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/lib/installationScrewDriveAxes.test.ts create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/lib/installationScrewDriveAxes.ts create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/lib/installationScrewDriveMotion.test.ts create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/lib/installationScrewDriveMotion.ts diff --git a/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationPreviewStage.tsx b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationPreviewStage.tsx index 26f1b340..560ea0ee 100644 --- a/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationPreviewStage.tsx +++ b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationPreviewStage.tsx @@ -438,6 +438,7 @@ export function InstallationPreviewStage({ removedToolNodeNames: toolAttachmentRemovedNodeNames, followHardwarePath: view === 'preview' && Boolean(playbackAction), screwDriveProgress: view === 'preview' && playbackAction ? playbackScrewDriveProgress : undefined, + playbackStep: view === 'preview' ? activeStep : null, visible: toolAttachmentsVisible, sceneKey: `${modelUrl ?? 'no-model'}:${modelNodeNames.join('\u001f')}`, onAssetLoadError: handleToolAssetLoadError, diff --git a/furniture-3d-system/src/app/installation-demo-workbench/hooks/useInstallationToolAttachments.ts b/furniture-3d-system/src/app/installation-demo-workbench/hooks/useInstallationToolAttachments.ts index de16ace7..200d84ac 100644 --- a/furniture-3d-system/src/app/installation-demo-workbench/hooks/useInstallationToolAttachments.ts +++ b/furniture-3d-system/src/app/installation-demo-workbench/hooks/useInstallationToolAttachments.ts @@ -15,6 +15,7 @@ import { } from '@/lib/installation-demo-tool-motion' import type { InstallationPartItem, + InstallationStepDraft, InstallationToolActionTransform, InstallationToolItem, } from '../types' @@ -26,6 +27,11 @@ import { resolveInstallationToolFollowTransform, } from '../lib/installationNodeGeometry' import { isInstallationToolObjAsset } from '../lib/installationToolAssetFormat' +import { getPartNodeNames } from '../lib/installationPartCatalogHelpers' +import { + buildInstallationScrewDriveAxes, + resolveInstallationScrewDriveWorldAxis, +} from '../lib/installationScrewDriveAxes' import { prepareInstallationToolModel, resolveInstallationToolBoundsPoint, @@ -63,6 +69,7 @@ interface InstallationToolAttachmentOptions { removedToolNodeNames: string[] followHardwarePath: boolean screwDriveProgress?: number + playbackStep?: InstallationStepDraft | null visible: boolean sceneKey: string onAssetLoadError?: (tool: InstallationToolItem) => void @@ -147,6 +154,7 @@ export function useInstallationToolAttachments({ removedToolNodeNames, followHardwarePath, screwDriveProgress, + playbackStep = null, visible, sceneKey, onAssetLoadError, @@ -157,6 +165,11 @@ export function useInstallationToolAttachments({ const toolMaterialConfigsRef = useRef(toolMaterialConfigsByToolId) const followHardwarePathRef = useRef(followHardwarePath) 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()) const fallbackToolId = tools.length === 1 ? tools[0]?.id : undefined const attachmentIdentities = useMemo( @@ -212,6 +225,10 @@ export function useInstallationToolAttachments({ screwDriveProgressRef.current = screwDriveProgress }, [screwDriveProgress]) + useEffect(() => { + screwDriveAxesRef.current = screwDriveAxes + }, [screwDriveAxes]) + useEffect(() => { toolMaterialConfigsRef.current = toolMaterialConfigsByToolId if (!engine) return @@ -290,6 +307,11 @@ export function useInstallationToolAttachments({ toolSpinTurns: record.toolSpinTurns, toolStrikeDistance: record.toolStrikeDistance, 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.quaternion.copy(transform.rotation) diff --git a/furniture-3d-system/src/app/installation-demo-workbench/lib/installationNodeGeometry.ts b/furniture-3d-system/src/app/installation-demo-workbench/lib/installationNodeGeometry.ts index c31be836..9d1344f0 100644 --- a/furniture-3d-system/src/app/installation-demo-workbench/lib/installationNodeGeometry.ts +++ b/furniture-3d-system/src/app/installation-demo-workbench/lib/installationNodeGeometry.ts @@ -1,6 +1,11 @@ import * as THREE from 'three' import { isHighlightHelperObjectName } from '@/lib/3d/engine-highlight-resources' import type { InstallationToolMotionType } from '@/lib/installation-demo-tool-motion' +import { + createInstallationClockwiseSpin, + easeInstallationScrewDriveProgress, + INSTALLATION_SCREW_DRIVE_TURNS, +} from './installationScrewDriveMotion' export function resolveInstallationNodeLocalGeometryCenter(object: THREE.Object3D) { object.updateMatrixWorld(true) @@ -49,6 +54,7 @@ export function resolveInstallationToolFollowTransform({ toolSpinTurns, toolStrikeDistance, toolMotionProgress, + screwDriveAxisWorld, }: { hardwareObject: THREE.Object3D hardwareAnchorLocalPosition: THREE.Vector3 @@ -61,6 +67,7 @@ export function resolveInstallationToolFollowTransform({ toolSpinTurns: number toolStrikeDistance: number toolMotionProgress: number | undefined + screwDriveAxisWorld?: THREE.Vector3 | null }) { hardwareObject.updateMatrixWorld(true) const currentHardwareAnchorPosition = hardwareAnchorLocalPosition @@ -77,18 +84,18 @@ export function resolveInstallationToolFollowTransform({ toolMotionProgress > 0 && toolMotionProgress < 1 ) { - const easedProgress = Math.min( - Math.max(toolMotionProgress + 0.11 * Math.sin(Math.PI * 2 * toolMotionProgress), 0), - 1, - ) + const easedProgress = easeInstallationScrewDriveProgress(toolMotionProgress) const motionAxis = toolMotionAxisLocal .clone() .applyQuaternion(toolStartWorldRotation) .normalize() if (toolMotionType === 'spin') { - const spin = new THREE.Quaternion().setFromAxisAngle( - motionAxis, - -Math.PI * 2 * toolSpinTurns * easedProgress, + // A screw-bound tool shares the screw's axis and turn count, including reversed mounts. + // null means the screw has no insertion path; undefined is a standalone tool motion. + const spin = createInstallationClockwiseSpin( + screwDriveAxisWorld === undefined ? motionAxis : screwDriveAxisWorld ?? new THREE.Vector3(), + screwDriveAxisWorld === undefined ? toolSpinTurns : INSTALLATION_SCREW_DRIVE_TURNS, + easedProgress, ) const basePivotOffset = toolMotionPivotLocal .clone() diff --git a/furniture-3d-system/src/app/installation-demo-workbench/lib/installationScrewDriveAxes.test.ts b/furniture-3d-system/src/app/installation-demo-workbench/lib/installationScrewDriveAxes.test.ts new file mode 100644 index 00000000..881aaee3 --- /dev/null +++ b/furniture-3d-system/src/app/installation-demo-workbench/lib/installationScrewDriveAxes.test.ts @@ -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({}) + }) +}) diff --git a/furniture-3d-system/src/app/installation-demo-workbench/lib/installationScrewDriveAxes.ts b/furniture-3d-system/src/app/installation-demo-workbench/lib/installationScrewDriveAxes.ts new file mode 100644 index 00000000..fa4b2993 --- /dev/null +++ b/furniture-3d-system/src/app/installation-demo-workbench/lib/installationScrewDriveAxes.ts @@ -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 + +/** 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 +} diff --git a/furniture-3d-system/src/app/installation-demo-workbench/lib/installationScrewDriveMotion.test.ts b/furniture-3d-system/src/app/installation-demo-workbench/lib/installationScrewDriveMotion.test.ts new file mode 100644 index 00000000..328a59db --- /dev/null +++ b/furniture-3d-system/src/app/installation-demo-workbench/lib/installationScrewDriveMotion.test.ts @@ -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]) + }) +}) diff --git a/furniture-3d-system/src/app/installation-demo-workbench/lib/installationScrewDriveMotion.ts b/furniture-3d-system/src/app/installation-demo-workbench/lib/installationScrewDriveMotion.ts new file mode 100644 index 00000000..4e588e50 --- /dev/null +++ b/furniture-3d-system/src/app/installation-demo-workbench/lib/installationScrewDriveMotion.ts @@ -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], + } +} diff --git a/furniture-3d-system/src/app/installation-demo-workbench/lib/installationStepActionPlayback.ts b/furniture-3d-system/src/app/installation-demo-workbench/lib/installationStepActionPlayback.ts index e34d5864..b6e7d664 100644 --- a/furniture-3d-system/src/app/installation-demo-workbench/lib/installationStepActionPlayback.ts +++ b/furniture-3d-system/src/app/installation-demo-workbench/lib/installationStepActionPlayback.ts @@ -21,12 +21,12 @@ import { import { resolveInstallationHingeCloseTransform } from './installationHingeCloseMotion' import { resolveInstallationStepActionCarryInSummary } from './installationStepActionHistory' import { applyHardwareHingePlayback } from './installationHardwareHingeRuntime' +import { applyInstallationScrewDriveRotation } from './installationScrewDriveMotion' const ACTION_APPEAR_END = 0.22 const ACTION_MOTION_START = 0.38 const ACTION_MOTION_END = 0.86 const SCREW_DRIVE_CONTACT_PROGRESS = 0.62 -const SCREW_DRIVE_TURNS = 3 export const SCREW_DRIVE_DURATION_MS = 1500 const SCREW_DRIVE_CONTACT_ACTION_PROGRESS = ACTION_MOTION_START + @@ -71,13 +71,6 @@ function easeProgress(progress: number) { 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( step: 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( step: InstallationStepDraft, durationMs?: number, @@ -564,7 +515,7 @@ export function buildInstallationStepActionPlaybackFrame( const transform = interpolateTransform(start, installationTarget, motionProgress) return [ screwDriveNodeNameSet.has(nodeName) && !isCarryInOnlyAction - ? applyScrewDriveRotation( + ? applyInstallationScrewDriveRotation( transform, start, installationTarget, diff --git a/furniture-3d-system/src/app/installation-demo-workbench/tools/components/InstallationToolMotionPreview.tsx b/furniture-3d-system/src/app/installation-demo-workbench/tools/components/InstallationToolMotionPreview.tsx index 260cbd92..e4b4245e 100644 --- a/furniture-3d-system/src/app/installation-demo-workbench/tools/components/InstallationToolMotionPreview.tsx +++ b/furniture-3d-system/src/app/installation-demo-workbench/tools/components/InstallationToolMotionPreview.tsx @@ -10,6 +10,7 @@ import type { InstallationToolMotionConfig, } from '@/lib/installation-demo-tool-motion' import type { InstallationToolItem } from '../../types' +import { createInstallationClockwiseSpin } from '../../lib/installationScrewDriveMotion' import { buildInstallationHingeNodeTransforms, resolveModelLocalBounds, @@ -230,9 +231,10 @@ export function InstallationToolMotionPreview({ root.position.copy(basePosition) root.quaternion.copy(baseRotation) if (config.motionType === 'spin') { - const spin = new THREE.Quaternion().setFromAxisAngle( + const spin = createInstallationClockwiseSpin( axis, - Math.PI * 2 * config.spinTurns * phase, + config.spinTurns, + phase, ) root.position.copy(pivot.clone().add(basePosition.clone().sub(pivot).applyQuaternion(spin))) root.quaternion.copy(spin.multiply(baseRotation))