From 61573431e33a9b3e0f8e786afbc9f00c86578df8 Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Mon, 17 Nov 2025 01:46:01 +0100 Subject: [PATCH] physics --- frontend/src/desktop/useDocumentDrag.ts | 180 +++++++++++++++++++----- frontend/src/desktop/workspaceEngine.ts | 56 ++++++-- 2 files changed, 188 insertions(+), 48 deletions(-) diff --git a/frontend/src/desktop/useDocumentDrag.ts b/frontend/src/desktop/useDocumentDrag.ts index a356f70..b031b31 100644 --- a/frontend/src/desktop/useDocumentDrag.ts +++ b/frontend/src/desktop/useDocumentDrag.ts @@ -12,6 +12,10 @@ import usePointerTap from '../ui/usePointerTap'; import { MIN_TIMESTEP, MAX_TIMESTEP, + MAX_ANGULAR_VELOCITY, + MAX_DYNAMIC_ROTATION, + CARD_BASE_WEIGHT_GRAMS, + CARD_PAGE_WEIGHT_GRAMS, applyDomTransform, type WorkspaceEngine, } from './workspaceEngine'; @@ -21,6 +25,10 @@ type Identifier = string | number; interface DocumentLike { id?: Identifier | null; title?: string; + current_version?: { + metadata?: { page_count?: number | string | null } | null; + } | null; + metadata?: { page_count?: number | string | null } | null; [key: string]: unknown; } @@ -147,6 +155,10 @@ interface DragStateInternal extends EngineDragState { stackDocIds: string[] | null; stackSelectionApplied: boolean; stackReplace: boolean; + massGrams: number; + pointerRadiusScale: number; + lastPointerCanvasX: number; + lastPointerCanvasY: number; } type PointerEventLike = PointerEvent | ReactPointerEvent; @@ -161,6 +173,20 @@ const DRAG_HYSTERESIS_PX = 4; const DRAG_HYSTERESIS_SQUARED = DRAG_HYSTERESIS_PX * DRAG_HYSTERESIS_PX; const EDGE_COLLISION_THRESHOLD = 0.5; +const getDocumentPageCount = (doc?: DocumentLike | null): number | null => { + const raw = doc?.current_version?.metadata?.page_count ?? doc?.metadata?.page_count; + if (raw == null) { + return null; + } + const value = Number(raw); + return Number.isFinite(value) ? value : null; +}; + +const computeDocumentMassGrams = (doc?: DocumentLike | null): number => { + const pages = Math.max(1, Math.round(getDocumentPageCount(doc) ?? 1)); + return CARD_BASE_WEIGHT_GRAMS + pages * CARD_PAGE_WEIGHT_GRAMS; +}; + const getEventTargetElement = (event?: PointerEventLike | null): Element | null => { if (!event) { return null; @@ -319,6 +345,7 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => { if (!doc) { return; } + const massGrams = computeDocumentMassGrams(doc); const stackDocIdsOptionRaw = options?.stackDocIds; const stackDocIdsOption = Array.isArray(stackDocIdsOptionRaw) @@ -512,6 +539,10 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => { stackDocIds: hasStackSource ? stackDocIdsOption : null, stackSelectionApplied: stackSelectionAppliedInitial || !hasStackSource, stackReplace, + massGrams, + pointerRadiusScale: 1, + lastPointerCanvasX: pointerCanvasX, + lastPointerCanvasY: pointerCanvasY, } satisfies DragStateInternal; const state = dragStateRef.current; @@ -585,7 +616,63 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => { if (state.pointerId !== event.pointerId) { return; } - preventAll(event); + preventAll(event); + + const updatePointerAngularVelocity = ( + pointerCanvasX: number, + pointerCanvasY: number, + centerX: number, + centerY: number, + dtSeconds: number, + ) => { + if (!Number.isFinite(dtSeconds) || dtSeconds <= 0) { + return; + } + const leverX = pointerCanvasX - centerX; + const leverY = pointerCanvasY - centerY; + if (!Number.isFinite(leverX) || !Number.isFinite(leverY)) { + return; + } + const prevCanvasX = Number.isFinite(state.lastPointerCanvasX) + ? state.lastPointerCanvasX + : pointerCanvasX; + const prevCanvasY = Number.isFinite(state.lastPointerCanvasY) + ? state.lastPointerCanvasY + : pointerCanvasY; + const velocityCanvasX = (pointerCanvasX - prevCanvasX) / dtSeconds; + const velocityCanvasY = (pointerCanvasY - prevCanvasY) / dtSeconds; + state.lastPointerCanvasX = pointerCanvasX; + state.lastPointerCanvasY = pointerCanvasY; + if (!Number.isFinite(velocityCanvasX) || !Number.isFinite(velocityCanvasY)) { + return; + } + const torque = leverX * velocityCanvasY - leverY * velocityCanvasX; + const influenceRadius = Math.max(state.width, state.height) / 2 || 1; + const radiusScale = clamp(Math.hypot(leverX, leverY) / influenceRadius, 0.2, 2.5); + state.pointerRadiusScale = radiusScale; + const torqueResponse = 0.0025 * radiusScale; + const angularVelocityDeg = clamp( + torque * torqueResponse, + -MAX_ANGULAR_VELOCITY, + MAX_ANGULAR_VELOCITY, + ); + const mass = Math.max(state.massGrams || CARD_BASE_WEIGHT_GRAMS, CARD_BASE_WEIGHT_GRAMS); + const massScale = Math.max(mass / CARD_BASE_WEIGHT_GRAMS, 1); + state.angularVelocity = angularVelocityDeg / massScale; + }; + + const applyDynamicRotation = (dtSeconds: number, dampingFactor = 0.94) => { + if (!Number.isFinite(dtSeconds) || dtSeconds <= 0) { + return; + } + const radiusInfluence = clamp(state.pointerRadiusScale || 1, 0.3, 3); + const response = 1.1 * radiusInfluence; + let nextDynamic = state.dynamicRotation + state.angularVelocity * dtSeconds * response; + nextDynamic = clamp(nextDynamic, -MAX_DYNAMIC_ROTATION, MAX_DYNAMIC_ROTATION); + const adjustedDamping = Math.pow(dampingFactor, 1 / Math.max(radiusInfluence, 0.8)); + state.dynamicRotation = nextDynamic * adjustedDamping; + state.rotation = state.restRotation + state.dynamicRotation; + }; if (state.isGroup) { const containerRect = containerRef.current?.getBoundingClientRect?.(); @@ -701,14 +788,30 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => { applyDomTransform(node, payload); }); - state.lastClientX = event.clientX; - state.lastClientY = event.clientY; - state.lastTimestamp = + const currentTimestampGroup = (Number.isFinite(event?.timeStamp)) ? event.timeStamp : performance?.now ? performance.now() : Date.now(); + const previousTimestampGroup = state.lastTimestamp ?? currentTimestampGroup; + let dtGroup = (currentTimestampGroup - previousTimestampGroup) / 1000; + if (!Number.isFinite(dtGroup) || dtGroup <= 0) { + dtGroup = MIN_TIMESTEP; + } + dtGroup = clamp(dtGroup, MIN_TIMESTEP, MAX_TIMESTEP); + + state.lastClientX = event.clientX; + state.lastClientY = event.clientY; + state.lastTimestamp = currentTimestampGroup; + + updatePointerAngularVelocity(pointerCanvasX, pointerCanvasY, centerX, centerY, dtGroup); + applyDynamicRotation(dtGroup, 0.96); + state.groupItems.forEach((item) => { + if (item.docId === state.docKey) { + item.displayRotation = state.rotation ?? item.displayRotation ?? 0; + } + }); return; } @@ -736,6 +839,35 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => { const pointerCanvasY = event.clientY - containerTop; const entry = layoutRef.current.get(state.docKey) || {}; + const previousCenterX = Number.isFinite(state.currentCenterX) + ? state.currentCenterX + : state.originCenterX; + const previousCenterY = Number.isFinite(state.currentCenterY) + ? state.currentCenterY + : state.originCenterY; + + const currentTimestamp = + (Number.isFinite(event?.timeStamp)) + ? event.timeStamp + : performance?.now + ? performance.now() + : Date.now(); + const previousTimestamp = state.lastTimestamp ?? currentTimestamp; + let dt = (currentTimestamp - previousTimestamp) / 1000; + if (!Number.isFinite(dt) || dt <= 0) { + dt = MIN_TIMESTEP; + } + dt = clamp(dt, MIN_TIMESTEP, MAX_TIMESTEP); + + const torqueCenterX = Number.isFinite(previousCenterX) ? previousCenterX : state.originCenterX; + const torqueCenterY = Number.isFinite(previousCenterY) ? previousCenterY : state.originCenterY; + updatePointerAngularVelocity(pointerCanvasX, pointerCanvasY, torqueCenterX, torqueCenterY, dt); + applyDynamicRotation(dt); + + state.lastClientX = event.clientX; + state.lastClientY = event.clientY; + state.lastTimestamp = currentTimestamp; + const rotationDeg = state.rotation ?? entry.rotation ?? 0; const rotationRad = (rotationDeg * Math.PI) / 180; const cosRot = Math.cos(rotationRad); @@ -745,13 +877,6 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => { const rotatedOffsetY = state.localPointerOffsetX * sinRot + state.localPointerOffsetY * cosRot; - const previousCenterX = Number.isFinite(state.currentCenterX) - ? state.currentCenterX - : state.originCenterX; - const previousCenterY = Number.isFinite(state.currentCenterY) - ? state.currentCenterY - : state.originCenterY; - const absCos = Math.abs(cosRot); const absSin = Math.abs(sinRot); const rotatedHalfWidth = absCos * halfWidth + absSin * halfHeight; @@ -826,23 +951,6 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => { const pointerInsideCard = Math.abs(pointerLocalX) <= halfWidth && Math.abs(pointerLocalY) <= halfHeight; - const currentTimestamp = - (Number.isFinite(event?.timeStamp)) - ? event.timeStamp - : performance?.now - ? performance.now() - : Date.now(); - const previousTimestamp = state.lastTimestamp ?? currentTimestamp; - let dt = (currentTimestamp - previousTimestamp) / 1000; - if (!Number.isFinite(dt) || dt <= 0) { - dt = MIN_TIMESTEP; - } - dt = clamp(dt, MIN_TIMESTEP, MAX_TIMESTEP); - - state.lastClientX = event.clientX; - state.lastClientY = event.clientY; - state.lastTimestamp = currentTimestamp; - const rotationForOffsetDeg = state.rotation || 0; const rotationForOffsetRad = (rotationForOffsetDeg * Math.PI) / 180; const cosInverse = Math.cos(-rotationForOffsetRad); @@ -892,16 +1000,18 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => { if (state.moved) { commitActiveDragTransforms([state.docKey]); + const finalRotation = state.rotation ?? state.restRotation; const inertiaState: EngineInertiaState = { docId: state.docKey, - restRotation: state.restRotation, - dynamicRotation: state.dynamicRotation, + restRotation: finalRotation, + dynamicRotation: 0, angularVelocity: state.angularVelocity, - rotation: state.rotation, + rotation: finalRotation, width: state.width, height: state.height, dragScale: state.dragScale || 1, lastTimestamp: state.lastTimestamp, + massGrams: state.massGrams, }; const docId = state.docKey; finishDrag(event.pointerId); @@ -953,16 +1063,18 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => { } commitActiveDragTransforms([state.docKey]); + const finalRotation = state.rotation ?? state.restRotation; const inertiaState: EngineInertiaState = { docId: state.docKey, - restRotation: state.restRotation, - dynamicRotation: state.dynamicRotation, + restRotation: finalRotation, + dynamicRotation: 0, angularVelocity: state.angularVelocity, - rotation: state.rotation, + rotation: finalRotation, width: state.width, height: state.height, dragScale: state.dragScale || 1, lastTimestamp: state.lastTimestamp, + massGrams: state.massGrams, }; const docId = state.docKey; finishDrag(event.pointerId); diff --git a/frontend/src/desktop/workspaceEngine.ts b/frontend/src/desktop/workspaceEngine.ts index 24a75a4..0bf2ad5 100644 --- a/frontend/src/desktop/workspaceEngine.ts +++ b/frontend/src/desktop/workspaceEngine.ts @@ -90,6 +90,7 @@ interface InertiaSimulationState { dragScale?: number; lastTimestamp: number; frameId?: number; + massGrams?: number; } interface WorkspaceSnapshot { @@ -133,15 +134,25 @@ export const DESK_DEFAULT_CANVAS_WIDTH = 1024; export const DESK_DEFAULT_CANVAS_HEIGHT = 680; export const DESK_CARD_MIN = 240; export const DESK_CARD_MAX = 340; +export const CARD_PAGE_WEIGHT_GRAMS = 5; +export const CARD_BASE_WEIGHT_GRAMS = 30; const DEFAULT_Z_START = 10; export const MIN_TIMESTEP = 1 / 120; export const MAX_TIMESTEP = 1 / 20; -export const MAX_DYNAMIC_ROTATION = 4; -export const MAX_ANGULAR_VELOCITY = 180; -export const ANGULAR_DAMPING = 11; -export const TORQUE_TO_ACCELERATION = 0.006; -export const SETTLE_ANGULAR_VELOCITY = 1.2; +export const MAX_DYNAMIC_ROTATION = 7; +export const MAX_ANGULAR_VELOCITY = 210; +export const ANGULAR_DAMPING = 10; +export const TORQUE_TO_ACCELERATION = 0.007; +export const SETTLE_ANGULAR_VELOCITY = 0.45; + +const getMassScale = (massGrams?: number): number => { + if (!Number.isFinite(massGrams) || Number(massGrams) <= 0) { + return 1; + } + const normalized = Math.max(Number(massGrams), CARD_BASE_WEIGHT_GRAMS) / CARD_BASE_WEIGHT_GRAMS; + return Math.max(normalized, 1); +}; export const applyDomTransform = ( node: HTMLElement | null, @@ -927,9 +938,11 @@ export class WorkspaceEngine { return true; } + const massScale = getMassScale(simulationState.massGrams); const torqueAcceleration = torque * TORQUE_TO_ACCELERATION; let angularVelocity = simulationState.angularVelocity + torqueAcceleration * dt; - angularVelocity = clamp(angularVelocity, -MAX_ANGULAR_VELOCITY, MAX_ANGULAR_VELOCITY); + const maxAngularVelocity = MAX_ANGULAR_VELOCITY / massScale; + angularVelocity = clamp(angularVelocity, -maxAngularVelocity, maxAngularVelocity); const dampingConstant = Number.isFinite(dampingOverride) ? Number(dampingOverride) @@ -938,17 +951,30 @@ export class WorkspaceEngine { angularVelocity *= dampingFactor; let dynamicRotation = simulationState.dynamicRotation + angularVelocity * dt; - if (dynamicRotation > MAX_DYNAMIC_ROTATION) { - dynamicRotation = MAX_DYNAMIC_ROTATION; + const dynamicLimit = MAX_DYNAMIC_ROTATION / Math.sqrt(massScale); + if (dynamicRotation > dynamicLimit) { + dynamicRotation = dynamicLimit; angularVelocity = Math.min(angularVelocity, 0); - } else if (dynamicRotation < -MAX_DYNAMIC_ROTATION) { - dynamicRotation = -MAX_DYNAMIC_ROTATION; + } else if (dynamicRotation < -dynamicLimit) { + dynamicRotation = -dynamicLimit; angularVelocity = Math.max(angularVelocity, 0); } - simulationState.angularVelocity = angularVelocity; - simulationState.dynamicRotation = dynamicRotation; - simulationState.rotation = simulationState.restRotation + dynamicRotation; + const isSettled = Math.abs(angularVelocity) < (SETTLE_ANGULAR_VELOCITY * 0.6) + || Math.abs(dynamicRotation) < (MAX_DYNAMIC_ROTATION * 0.05); + + if (isSettled) { + simulationState.angularVelocity = 0; + simulationState.dynamicRotation = 0; + simulationState.rotation = simulationState.restRotation; + } else { + simulationState.angularVelocity = angularVelocity; + simulationState.dynamicRotation = dynamicRotation; + simulationState.rotation = simulationState.restRotation + dynamicRotation; + if (Math.sign(simulationState.angularVelocity) !== Math.sign(angularVelocity)) { + simulationState.angularVelocity = angularVelocity; + } + } const rotation = simulationState.rotation; const nextEntry = { ...entry, rotation }; @@ -966,7 +992,6 @@ export class WorkspaceEngine { nextEntry.z, ); - const isSettled = Math.abs(angularVelocity) < SETTLE_ANGULAR_VELOCITY; return isSettled; } @@ -989,6 +1014,9 @@ export class WorkspaceEngine { docId: key, dragScale: baseState.dragScale || 1, lastTimestamp: now, + massGrams: Number.isFinite(baseState.massGrams) + ? Math.max(Number(baseState.massGrams), CARD_BASE_WEIGHT_GRAMS) + : CARD_BASE_WEIGHT_GRAMS, }; const step = (timestamp: number) => {