feat: Implement enhanced card physics with rotational dynamics, mass scaling, stacking behavior, and drag torque.

This commit is contained in:
2025-11-28 01:10:57 +01:00
parent e164a88a97
commit e512c8215e
5 changed files with 278 additions and 63 deletions
+16 -23
View File
@@ -4,7 +4,8 @@ export const handleDragStart = (store: LayoutStore, selection: string[], leading
const leadingCard = store.items.get(leadingId);
if (!leadingCard) return;
// Snap all non-leading cards to the leading card's position
// 1. Snap all followers to leader's center
// 2. Attach them as followers to the leader's physics
selection.forEach(id => {
if (id === leadingId) return;
const card = store.items.get(id);
@@ -19,30 +20,17 @@ export const handleDragStart = (store: LayoutStore, selection: string[], leading
const targetY = leadingCenterY - card.height / 2;
card.snapTo(targetX, targetY);
// Stop any existing physics on the follower
card.physics.stop();
// Attach as follower
leadingCard.physics.addFollower(card);
}
});
selection.forEach(id => {
const card = store.items.get(id);
if (!card) return;
// If card is already dragging by another pointer, skip it
if (card.physics.isDragging && card.physics.dragPointerId !== pointerId) return;
let myOffset = offset;
if (id !== leadingId) {
const pointerX = leadingCard.x + offset.x;
const pointerY = leadingCard.y + offset.y;
myOffset = {
x: pointerX - card.x,
y: pointerY - card.y
};
}
card.physics.beginDrag(myOffset, pointerId);
});
// 3. Begin drag ONLY on the leader
leadingCard.physics.beginDrag(offset, pointerId);
};
export const attachToDragGroup = (store: LayoutStore, selection: string[], leadingId: string, pointerId: number) => {
@@ -62,7 +50,12 @@ export const attachToDragGroup = (store: LayoutStore, selection: string[], leadi
const targetY = leadingCenterY - card.height / 2;
card.snapTo(targetX, targetY);
card.physics.beginDrag({ x: 0, y: 0 }, pointerId);
// Stop any existing physics
card.physics.stop();
// Attach as follower
leadingCard.physics.addFollower(card);
}
});
};
+241 -32
View File
@@ -1,28 +1,66 @@
import { LayoutCard } from './LayoutSystem';
export class CardPhysics {
// State
private velocity: { x: number, y: number, rotation: number } = { x: 0, y: 0, rotation: 0 };
private _isDragging: boolean = false;
public dragPointerId: number | null = null;
private pendingDelta: { x: number, y: number } = { x: 0, y: 0 };
public mass: number = 30;
private baseMass: number = 30;
private massScale: number = 1;
private angularVelocity: number = 0;
private lastTimestamp: number = 0;
private dragOffset: { x: number, y: number } | null = null;
get isDragging(): boolean {
return this._isDragging;
}
// Loop State
private physicsRafId: number | null = null;
private lastTickTime: number = 0;
private card: LayoutCard;
constructor(card: LayoutCard) {
this.card = card;
this.updateMass(card.pageCount);
}
updateMass(pageCount: number) {
const pages = Math.max(1, pageCount);
this.baseMass = 30 + 5 * pages;
this.mass = this.baseMass;
this.updateMassScale();
}
private updateMassScale() {
this.massScale = Math.max(this.mass / 30, 1);
}
private normalizeAngle(angle: number): number {
let a = angle % 360;
if (a > 180) a -= 360;
if (a <= -180) a += 360;
return a;
}
beginDrag(offset: { x: number, y: number }, pointerId: number) {
this._isDragging = true;
this.dragPointerId = pointerId;
// Store the offset from center where we grabbed the card
// Convert world offset to local offset (rotate by -rotation)
const rad = -this.card.rotation * Math.PI / 180;
const cos = Math.cos(rad);
const sin = Math.sin(rad);
this.dragOffset = {
x: offset.x * cos - offset.y * sin,
y: offset.x * sin + offset.y * cos
};
this.angularVelocity = 0;
this.startPhysicsLoop();
}
@@ -34,7 +72,9 @@ export class CardPhysics {
finishDrag() {
this._isDragging = false;
this.dragPointerId = null;
// Loop continues for decay/settling if needed
// Keep dragOffset for inertia pivot correction
this.lastTimestamp = performance.now();
this.angularVelocity = 0; // Kill momentum on release
}
private startPhysicsLoop() {
@@ -43,50 +83,219 @@ export class CardPhysics {
this.physicsRafId = requestAnimationFrame(this.physicsTick);
}
public stop() {
this.stopPhysicsLoop();
}
private stopPhysicsLoop() {
if (this.physicsRafId) {
cancelAnimationFrame(this.physicsRafId);
this.physicsRafId = null;
}
this.dragOffset = null;
this.clearFollowers();
}
private physicsTick = (time: number) => {
const dt = Math.min((time - this.lastTickTime) / 1000, 0.1);
const rawDt = (time - this.lastTickTime) / 1000;
const dt = Math.max(1 / 120, Math.min(rawDt, 1 / 20));
this.lastTickTime = time;
if (this._isDragging) {
// Minimal Physics: Apply pending delta directly
const dx = this.pendingDelta.x;
const dy = this.pendingDelta.y;
this.updatePhysics(dt);
// Update velocity (simple instantaneous)
if (dt > 0.001) {
this.velocity.x = dx / dt;
this.velocity.y = dy / dt;
}
if (this.physicsRafId) {
this.physicsRafId = requestAnimationFrame(this.physicsTick);
}
};
// Reset pending delta
this.pendingDelta = { x: 0, y: 0 };
private readonly ROTATION_LIMIT = 5;
private minLimit: number = -5;
private maxLimit: number = 5;
// Update position
const newX = this.card.x + dx;
const newY = this.card.y + dy;
private followers: { card: LayoutCard, offsetRotation: number }[] = [];
// Apply Constraints
const constrained = this.card.getConstrainedPosition(newX, newY);
addFollower(card: LayoutCard) {
// Calculate relative rotation
// follower = leader + offset => offset = follower - leader
const offset = this.normalizeAngle(card.rotation - this.card.rotation);
this.followers.push({ card, offsetRotation: offset });
this.card.update({
x: constrained.x,
y: constrained.y,
rotation: this.card.rotation // No rotation change for now
}, { markDirty: true });
} else {
// Minimal Decay: Stop immediately for now (skeleton behavior)
this.stopPhysicsLoop();
return;
// Add follower mass to leader
this.mass += card.physics.mass;
this.updateMassScale();
// Constrain leader limits to ensure follower stays within [-5, 5]
// -5 <= leader + offset <= 5
// -5 - offset <= leader <= 5 - offset
this.minLimit = Math.max(this.minLimit, -this.ROTATION_LIMIT - offset);
this.maxLimit = Math.min(this.maxLimit, this.ROTATION_LIMIT - offset);
}
clearFollowers() {
this.followers = [];
// Reset mass to base mass
this.mass = this.baseMass;
this.updateMassScale();
// Reset limits
this.minLimit = -this.ROTATION_LIMIT;
this.maxLimit = this.ROTATION_LIMIT;
}
private updatePhysics(dt: number) {
const dx = this.pendingDelta.x;
const dy = this.pendingDelta.y;
this.pendingDelta = { x: 0, y: 0 };
const vx = dx / dt;
const vy = dy / dt;
// 1. Update Position (Direct 1:1 movement)
const newX = this.card.x + dx;
const newY = this.card.y + dy;
const constrained = this.card.getConstrainedPosition(newX, newY);
// 2. Calculate Torque & Forces
let torque = 0;
let recoveryTorque = 0;
let isRecovering = false;
// Drag Torque
if (this.dragOffset && this._isDragging) {
const rad = this.card.rotation * Math.PI / 180;
const cos = Math.cos(rad);
const sin = Math.sin(rad);
const worldLeverX = this.dragOffset.x * cos - this.dragOffset.y * sin;
const worldLeverY = this.dragOffset.x * sin + this.dragOffset.y * cos;
torque = worldLeverX * vy - worldLeverY * vx;
}
// Request next frame
this.physicsRafId = requestAnimationFrame(this.physicsTick);
};
// Recovery Torque
const normRot = this.normalizeAngle(this.card.rotation);
if (normRot > this.maxLimit) {
recoveryTorque = (this.maxLimit - normRot) * 2500;
isRecovering = true;
} else if (normRot < this.minLimit) {
recoveryTorque = (this.minLimit - normRot) * 2500;
isRecovering = true;
}
const totalTorque = torque + recoveryTorque;
const alpha = (totalTorque * 0.05) / this.massScale;
this.angularVelocity += alpha * dt;
// Friction
this.angularVelocity *= 0.85;
if (isRecovering) {
this.angularVelocity *= 0.6;
}
// Deadzone
if (Math.abs(this.angularVelocity) < 1) {
this.angularVelocity = 0;
}
// 3. Update Rotation
let newRot = this.card.rotation + this.angularVelocity * dt;
// Ratchet clamping
const newNormRot = this.normalizeAngle(newRot);
if (newNormRot > this.maxLimit) {
// If moving further past max, clamp
if (newNormRot > normRot) {
newRot = this.card.rotation + (this.maxLimit - normRot);
this.angularVelocity = 0;
}
} else if (newNormRot < this.minLimit) {
// If moving further past min, clamp
if (newNormRot < normRot) {
newRot = this.card.rotation + (this.minLimit - normRot);
this.angularVelocity = 0;
}
}
// 4. Pivot Correction
let correctionX = 0;
let correctionY = 0;
if (this.dragOffset) {
const oldRad = this.card.rotation * Math.PI / 180;
const oldCos = Math.cos(oldRad);
const oldSin = Math.sin(oldRad);
const newRad = newRot * Math.PI / 180;
const newCos = Math.cos(newRad);
const newSin = Math.sin(newRad);
const oldLeverX = this.dragOffset.x * oldCos - this.dragOffset.y * oldSin;
const oldLeverY = this.dragOffset.x * oldSin + this.dragOffset.y * oldCos;
const newLeverX = this.dragOffset.x * newCos - this.dragOffset.y * newSin;
const newLeverY = this.dragOffset.x * newSin + this.dragOffset.y * newCos;
correctionX = oldLeverX - newLeverX;
correctionY = oldLeverY - newLeverY;
}
// Stop Condition
if (!this._isDragging) {
const currentNormRot = this.normalizeAngle(this.card.rotation);
const isOutside = currentNormRot > this.maxLimit || currentNormRot < this.minLimit;
if (isOutside) {
const targetAngle = currentNormRot > this.maxLimit ? this.maxLimit : this.minLimit;
const dist = Math.abs(this.normalizeAngle(currentNormRot - targetAngle));
if (Math.abs(this.angularVelocity) < 0.5 && dist < 0.1) {
this.card.update({ rotation: targetAngle }, { markDirty: true });
this.angularVelocity = 0;
this.stopPhysicsLoop();
return;
}
} else {
// Inside range - just stop if slow
if (Math.abs(this.angularVelocity) < 0.5) {
this.angularVelocity = 0;
this.stopPhysicsLoop();
return;
}
}
}
// 5. Apply to Leader
const finalX = constrained.x + correctionX;
const finalY = constrained.y + correctionY;
const finalConstrained = this.card.getConstrainedPosition(finalX, finalY);
this.card.update({
x: finalConstrained.x,
y: finalConstrained.y,
rotation: newRot
}, { markDirty: true });
// 6. Apply to Followers
for (const follower of this.followers) {
// Followers match leader's position exactly (center aligned)
// But we need to account for their own dimensions if we want center-to-center alignment
// The LayoutCard.x/y is top-left.
// Leader Center: finalConstrained.x + leader.width/2, finalConstrained.y + leader.height/2
const leaderCenterX = finalConstrained.x + this.card.width / 2;
const leaderCenterY = finalConstrained.y + this.card.height / 2;
const followerX = leaderCenterX - follower.card.width / 2;
const followerY = leaderCenterY - follower.card.height / 2;
const followerRot = newRot + follower.offsetRotation;
follower.card.update({
x: followerX,
y: followerY,
rotation: followerRot
}, { markDirty: true });
}
}
}
+6 -1
View File
@@ -252,9 +252,14 @@ const DesktopWorkspaceContent: React.FC<DesktopWorkspaceProps> = ({
const isSelected = selectedDocumentIds.includes(docId);
const size = ensureDocumentSize(doc);
// Extract page count
const metadata = doc.current_version?.metadata as { page_count?: number } | undefined;
const pageCount = metadata?.page_count ?? 1;
const layoutCard = layoutStore.initialize(docId, null, {
width: Number.isFinite(size.width) && size.width > 0 ? size.width : 200,
height: Number.isFinite(size.height) && size.height > 0 ? size.height : 200
height: Number.isFinite(size.height) && size.height > 0 ? size.height : 200,
pageCount
});
return (
+14 -6
View File
@@ -11,6 +11,7 @@ export interface LayoutCardState {
rotation: number;
width: number;
height: number;
pageCount: number;
}
export class LayoutCard implements LayoutCardState {
@@ -21,6 +22,7 @@ export class LayoutCard implements LayoutCardState {
rotation: number = 0;
width: number = 0;
height: number = 0;
pageCount: number = 1;
ref: HTMLElement | null = null;
private _innerRadius: number = 0;
@@ -50,8 +52,6 @@ export class LayoutCard implements LayoutCardState {
this.applyTransform();
}
getConstrainedPosition(x: number, y: number): { x: number, y: number } {
const rad = (this.rotation * Math.PI) / 180;
const sin = Math.abs(Math.sin(rad));
@@ -90,6 +90,10 @@ export class LayoutCard implements LayoutCardState {
this.recalculateCenters();
}
if (changes.pageCount !== undefined) {
this.physics.updateMass(changes.pageCount);
}
if (options.markDirty) {
this.isDirty = true;
}
@@ -240,7 +244,8 @@ export class LayoutCard implements LayoutCardState {
z: this.z,
rotation: this.rotation,
width: this.width,
height: this.height
height: this.height,
pageCount: this.pageCount
};
}
@@ -278,6 +283,7 @@ export class LayoutStore {
initialize(id: string, ref: HTMLElement | null, config: {
width: number;
height: number;
pageCount: number;
}) {
let card = this.items.get(id);
@@ -309,14 +315,16 @@ export class LayoutStore {
rotation,
width,
height,
z
z,
pageCount: config.pageCount
}, ref);
} else {
// Create card with temporary position
card = new LayoutCard(id, this, {
width,
height,
z: this.zCounter++
z: this.zCounter++,
pageCount: config.pageCount
}, ref);
// Calculate initial position using the card instance
@@ -333,7 +341,7 @@ export class LayoutStore {
this.items.set(id, card);
} else {
card.update({ width, height }, { markDirty: false });
card.update({ width, height, pageCount: config.pageCount }, { markDirty: false });
}
// Always update ref and ensure transform is applied
@@ -16,7 +16,7 @@
}
.desk-item--swoop {
transition: transform 0.3s cubic-bezier(0.2, 0.8, 0.2, 1);
transition: transform 0.5s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.desk-item__card img {