From 5bc074b33a2fe4f79dd291c102df04c2908c1327 Mon Sep 17 00:00:00 2001 From: Orion Reed Date: Sun, 24 Mar 2024 18:07:52 -0700 Subject: [PATCH] switch back to React --- .eleventy.js | 13 - __________src/default_store.ts | 141 ++++++++++ index.html | 73 ++++++ package.json | 23 +- src/App.tsx | 17 ++ src/_includes/layout.html | 24 -- src/css/index.css | 64 +++++ src/{assets => }/css/reset.css | 0 src/{assets => }/css/style.css | 0 src/index.html | 53 ---- src/main.tsx | 10 + src/physics/config.ts | 36 +++ src/physics/math.ts | 95 +++++++ src/physics/simulation.ts | 395 +++++++++++++++++++++++++++++ src/physics/ui/PhysicsControls.tsx | 47 ++++ src/physics/ui/overrides.ts | 22 ++ src/vite-env.d.ts | 1 + tsconfig.json | 25 ++ tsconfig.node.json | 10 + vercel.json | 2 +- vite.config.ts | 12 + 21 files changed, 969 insertions(+), 94 deletions(-) delete mode 100644 .eleventy.js create mode 100644 __________src/default_store.ts create mode 100644 index.html create mode 100644 src/App.tsx delete mode 100644 src/_includes/layout.html create mode 100644 src/css/index.css rename src/{assets => }/css/reset.css (100%) rename src/{assets => }/css/style.css (100%) delete mode 100644 src/index.html create mode 100644 src/main.tsx create mode 100644 src/physics/config.ts create mode 100644 src/physics/math.ts create mode 100644 src/physics/simulation.ts create mode 100644 src/physics/ui/PhysicsControls.tsx create mode 100644 src/physics/ui/overrides.ts create mode 100644 src/vite-env.d.ts create mode 100644 tsconfig.json create mode 100644 tsconfig.node.json create mode 100644 vite.config.ts diff --git a/.eleventy.js b/.eleventy.js deleted file mode 100644 index 4c9d6f1..0000000 --- a/.eleventy.js +++ /dev/null @@ -1,13 +0,0 @@ -export default function (eleventyConfig) { - eleventyConfig.addPassthroughCopy('src/assets/css') - eleventyConfig.addPassthroughCopy("src/assets/favicon.ico"); - eleventyConfig.addPassthroughCopy("src/objects"); - eleventyConfig.setServerPassthroughCopyBehavior("passthrough"); - - return { - dir: { - input: "src", - output: "dist" - } - } -}; diff --git a/__________src/default_store.ts b/__________src/default_store.ts new file mode 100644 index 0000000..98176bb --- /dev/null +++ b/__________src/default_store.ts @@ -0,0 +1,141 @@ +export const DEFAULT_STORE = { + store: { + "document:document": { + gridSize: 10, + name: "", + meta: {}, + id: "document:document", + typeName: "document", + }, + "pointer:pointer": { + id: "pointer:pointer", + typeName: "pointer", + x: 0, + y: 0, + lastActivityTimestamp: 0, + meta: {}, + }, + "page:page": { + meta: {}, + id: "page:page", + name: "Page 1", + index: "a1", + typeName: "page", + }, + "camera:page:page": { + x: 0, + y: 0, + z: 1, + meta: {}, + id: "camera:page:page", + typeName: "camera", + }, + "instance_page_state:page:page": { + editingShapeId: null, + croppingShapeId: null, + selectedShapeIds: [], + hoveredShapeId: null, + erasingShapeIds: [], + hintingShapeIds: [], + focusedGroupId: null, + meta: {}, + id: "instance_page_state:page:page", + pageId: "page:page", + typeName: "instance_page_state", + }, + "instance:instance": { + followingUserId: null, + opacityForNextShape: 1, + stylesForNextShape: {}, + brush: null, + scribble: null, + cursor: { + type: "default", + rotation: 0, + }, + isFocusMode: false, + exportBackground: true, + isDebugMode: false, + isToolLocked: false, + screenBounds: { + x: 0, + y: 0, + w: 720, + h: 400, + }, + zoomBrush: null, + isGridMode: false, + isPenMode: false, + chatMessage: "", + isChatting: false, + highlightedUserIds: [], + canMoveCamera: true, + isFocused: true, + devicePixelRatio: 2, + isCoarsePointer: false, + isHoveringCanvas: false, + openMenus: [], + isChangingStyle: false, + isReadonly: false, + meta: {}, + id: "instance:instance", + currentPageId: "page:page", + typeName: "instance", + }, + }, + schema: { + schemaVersion: 1, + storeVersion: 4, + recordVersions: { + asset: { + version: 1, + subTypeKey: "type", + subTypeVersions: { + image: 2, + video: 2, + bookmark: 0, + }, + }, + camera: { + version: 1, + }, + document: { + version: 2, + }, + instance: { + version: 21, + }, + instance_page_state: { + version: 5, + }, + page: { + version: 1, + }, + shape: { + version: 3, + subTypeKey: "type", + subTypeVersions: { + group: 0, + text: 1, + bookmark: 1, + draw: 1, + geo: 7, + note: 4, + line: 1, + frame: 0, + arrow: 1, + highlight: 0, + embed: 4, + image: 2, + video: 1, + }, + }, + instance_presence: { + version: 5, + }, + pointer: { + version: 1, + }, + }, + }, +}; diff --git a/index.html b/index.html new file mode 100644 index 0000000..90be5ff --- /dev/null +++ b/index.html @@ -0,0 +1,73 @@ + + + + Orion Reed + + + + + + + + + + +
+ Orion Reed +
+
+

Hello! 👋

+

+ My research investigates the intersection of computing, human-system + interfaces, and emancipatory politics. I am interested in the + potential of computing as a medium for thought, as a tool for + collective action, and as a means of emancipation. +

+ +

+ My current focus is basic research into the nature of digital + organisation, developing theoretical toolkits to improve shared + infrastructure, and applying this research to the design of new + systems and protocols which support the self-organisation of knowledge + and computational artifacts. +

+ +

My work

+

+ Alongside my independent work I am a researcher at + Block Science building + knowledge organisation infrastructure and at + ECSA working on + computational media. I am also part of the nascent Liberatory Computing + collective and a co-organiser of the OCWG. +

+ +

Get in touch

+

+ I am on Twitter as @OrionReedOne and on + Mastodon as @orion@hci.social. The best way to reach me is + through Twitter or my email, me@orionreed.com +

+ + *** + +

Talks

+ +

Writing

+ +
+ + \ No newline at end of file diff --git a/package.json b/package.json index 1d4c110..d99d5db 100644 --- a/package.json +++ b/package.json @@ -4,13 +4,30 @@ "description": "Orion Reed's personal website", "type": "module", "scripts": { - "dev": "npx @11ty/eleventy --serve", - "build": "npx @11ty/eleventy" + "dev": "vite", + "build": "tsc && vite build --base=./", + "preview": "vite preview" }, "keywords": [], "author": "Orion Reed", "license": "ISC", + "dependencies": { + "@dimforge/rapier2d": "latest", + "@tldraw/tldraw": "2.0.2", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, "devDependencies": { - "@11ty/eleventy": "3.0.0-alpha.5" + "@biomejs/biome": "1.4.1", + "@types/gh-pages": "^6", + "@types/react": "^18.2.15", + "@types/react-dom": "^18.2.7", + "@vitejs/plugin-react": "^4.0.3", + "concurrently": "^8.2.0", + "gh-pages": "^6.1.1", + "typescript": "^5.0.2", + "vite": "^4.4.5", + "vite-plugin-top-level-await": "^1.3.1", + "vite-plugin-wasm": "^3.2.2" } } \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..49d4cc1 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,17 @@ +import { Tldraw, track, useEditor } from "@tldraw/tldraw"; +import "@tldraw/tldraw/tldraw.css"; +import { SimControls } from "./physics/ui/PhysicsControls"; +import { uiOverrides } from "./physics/ui/overrides"; + +export default function Canvas() { + + return ( +
+ + + +
+ ); +} diff --git a/src/_includes/layout.html b/src/_includes/layout.html deleted file mode 100644 index a10ff27..0000000 --- a/src/_includes/layout.html +++ /dev/null @@ -1,24 +0,0 @@ - - - - Orion Reed - - - - - - - - - - -
- Orion Reed -
-
- {{ content }} -
- - \ No newline at end of file diff --git a/src/css/index.css b/src/css/index.css new file mode 100644 index 0000000..2f8e1a1 --- /dev/null +++ b/src/css/index.css @@ -0,0 +1,64 @@ +@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500&display=swap"); + +html, +body { + padding: 0; + margin: 0; + font-family: "Inter", sans-serif; + overscroll-behavior: none; + touch-action: none; + min-height: 100vh; + font-size: 16px; + /* mobile viewport bug fix */ + min-height: -webkit-fill-available; + height: 100%; +} + +html, +* { + box-sizing: border-box; +} + +.tldraw__editor { + position: fixed; + inset: 0px; + overflow: hidden; +} + +.examples { + padding: 16px; +} + +.examples__header { + width: fit-content; + padding-bottom: 32px; +} + +.examples__lockup { + height: 56px; + width: auto; +} + +.examples__list { + display: flex; + flex-direction: column; + padding: 0; + margin: 0; + list-style: none; +} + +.examples__list__item { + padding: 8px 12px; + margin: 0px -12px; +} + +.examples__list__item a { + padding: 8px 12px; + margin: 0px -12px; + text-decoration: none; + color: inherit; +} + +.examples__list__item a:hover { + text-decoration: underline; +} diff --git a/src/assets/css/reset.css b/src/css/reset.css similarity index 100% rename from src/assets/css/reset.css rename to src/css/reset.css diff --git a/src/assets/css/style.css b/src/css/style.css similarity index 100% rename from src/assets/css/style.css rename to src/css/style.css diff --git a/src/index.html b/src/index.html deleted file mode 100644 index 37ab904..0000000 --- a/src/index.html +++ /dev/null @@ -1,53 +0,0 @@ ---- -layout: layout.html ---- - -

Hello! 👋

-

- My research investigates the intersection of computing, human-system - interfaces, and emancipatory politics. I am interested in the - potential of computing as a medium for thought, as a tool for - collective action, and as a means of emancipation. -

- -

- My current focus is basic research into the nature of digital - organisation, developing theoretical toolkits to improve shared - infrastructure, and applying this research to the design of new - systems and protocols which support the self-organisation of knowledge - and computational artifacts. -

- -

My work

-

- Alongside my independent work I am a researcher at - Block Science building - knowledge organisation infrastructure and at - ECSA working on - computational media. I am also part of the nascent Liberatory Computing - collective and a co-organiser of the OCWG. -

- -

Get in touch

-

- I am on Twitter as @OrionReedOne and on - Mastodon as @orion@hci.social. The best way to reach me is - through Twitter or my email, me@orionreed.com -

- -*** - -

Talks

- -

Writing

- diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..07e30df --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,10 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import App from "./App.tsx"; +import "./css/index.css"; + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/src/physics/config.ts b/src/physics/config.ts new file mode 100644 index 0000000..1876589 --- /dev/null +++ b/src/physics/config.ts @@ -0,0 +1,36 @@ +export const GRAVITY = { x: 0.0, y: 98 }; +export const DEFAULT_RESTITUTION = 0; +export const DEFAULT_FRICTION = 0.1; + +export function isRigidbody(color: string) { + return !color || color === "black" ? false : true; +} +export function getGravityFromColor(color: string) { + return color === 'grey' ? 0 : 1 +} +export function getRestitutionFromColor(color: string) { + return color === "orange" ? 0.9 : 0; +} +export function getFrictionFromColor(color: string) { + return color === "blue" ? 0.1 : 0.8; +} +export const MATERIAL = { + defaultRestitution: 0, + defaultFriction: 0.1, +}; +export const CHARACTER = { + up: { x: 0.0, y: -1.0 }, + additionalMass: 20, + maxSlopeClimbAngle: 1, + slideEnabled: true, + minSlopeSlideAngle: 0.9, + applyImpulsesToDynamicBodies: true, + autostepHeight: 5, + autostepMaxClimbAngle: 1, + snapToGroundDistance: 3, + maxMoveSpeedX: 100, + moveAcceleration: 600, + moveDeceleration: 500, + jumpVelocity: 300, + gravityMultiplier: 10, +}; diff --git a/src/physics/math.ts b/src/physics/math.ts new file mode 100644 index 0000000..bb44fd0 --- /dev/null +++ b/src/physics/math.ts @@ -0,0 +1,95 @@ +import { Geometry2d, Vec, VecLike } from "@tldraw/tldraw"; + +type ShapeTransform = { + x: number; + y: number; + width: number; + height: number; + rotation: number; + parent?: Geometry2d; +} + +// Define rotatePoint as a standalone function +const rotatePoint = (cx: number, cy: number, x: number, y: number, angle: number) => { + const cos = Math.cos(angle); + const sin = Math.sin(angle); + return { + x: cos * (x - cx) - sin * (y - cy) + cx, + y: sin * (x - cx) + cos * (y - cy) + cy, + }; +} + +export const cornerToCenter = ({ + x, + y, + width, + height, + rotation, + parent +}: ShapeTransform): { x: number; y: number } => { + const centerX = x + width / 2; + const centerY = y + height / 2; + const rotatedCenter = rotatePoint(x, y, centerX, centerY, rotation); + + if (parent) { + rotatedCenter.x -= parent.center.x; + rotatedCenter.y -= parent.center.y; + } + + return rotatedCenter; +} + +export const centerToCorner = ({ + x, + y, + width, + height, + rotation, +}: ShapeTransform): { x: number; y: number } => { + + const cornerX = x - width / 2; + const cornerY = y - height / 2; + + return rotatePoint(x, y, cornerX, cornerY, rotation); +} + +export const getDisplacement = ( + velocity: VecLike, + acceleration: VecLike, + timeStep: number, + speedLimitX: number, + decelerationX: number, +): VecLike => { + let newVelocityX = + acceleration.x === 0 && velocity.x !== 0 + ? Math.max(Math.abs(velocity.x) - decelerationX * timeStep, 0) * + Math.sign(velocity.x) + : velocity.x + acceleration.x * timeStep; + + newVelocityX = + Math.min(Math.abs(newVelocityX), speedLimitX) * Math.sign(newVelocityX); + + const averageVelocityX = (velocity.x + newVelocityX) / 2; + const x = averageVelocityX * timeStep; + const y = + velocity.y * timeStep + 0.5 * acceleration.y * timeStep ** 2; + + return { x, y } +} + +export const convertVerticesToFloat32Array = ( + vertices: Vec[], + width: number, + height: number, +) => { + const vec2Array = new Float32Array(vertices.length * 2); + const hX = width / 2; + const hY = height / 2; + + for (let i = 0; i < vertices.length; i++) { + vec2Array[i * 2] = vertices[i].x - hX; + vec2Array[i * 2 + 1] = vertices[i].y - hY; + } + + return vec2Array; +} diff --git a/src/physics/simulation.ts b/src/physics/simulation.ts new file mode 100644 index 0000000..f6ec6b8 --- /dev/null +++ b/src/physics/simulation.ts @@ -0,0 +1,395 @@ +import RAPIER from "@dimforge/rapier2d"; +import { CHARACTER, GRAVITY, MATERIAL, getFrictionFromColor, getGravityFromColor, getRestitutionFromColor, isRigidbody } from "./config"; +import { Editor, Geometry2d, TLDrawShape, TLGeoShape, TLGroupShape, TLShape, TLShapeId, VecLike } from "@tldraw/tldraw"; +import { useEffect, useRef } from "react"; +import { centerToCorner, convertVerticesToFloat32Array, cornerToCenter, getDisplacement } from "./math"; + +type BodyWithShapeData = RAPIER.RigidBody & { + userData: { id: TLShapeId; type: TLShape["type"]; w: number; h: number }; +}; +type RigidbodyLookup = { [key: TLShapeId]: RAPIER.RigidBody }; + +export class PhysicsWorld { + private editor: Editor; + private world: RAPIER.World; + private rigidbodyLookup: RigidbodyLookup; + private animFrame = -1; // Store the animation frame id + private character: { + rigidbody: RAPIER.RigidBody | null; + collider: RAPIER.Collider | null; + }; + constructor(editor: Editor) { + this.editor = editor + this.world = new RAPIER.World(GRAVITY) + this.rigidbodyLookup = {} + this.character = { rigidbody: null, collider: null } + } + + public start() { + this.world = new RAPIER.World(GRAVITY); + + this.addShapes(this.editor.getSelectedShapes()); + + const simLoop = () => { + this.world.step(); + this.updateCharacterControllers(); + this.updateRigidbodies(); + this.animFrame = requestAnimationFrame(simLoop); + }; + simLoop(); + return () => cancelAnimationFrame(this.animFrame); + }; + + public stop() { + if (this.animFrame !== -1) { + cancelAnimationFrame(this.animFrame); + this.animFrame = -1; + } + } + + public addShapes(shapes: TLShape[]) { + for (const shape of shapes) { + if ('color' in shape.props && shape.props.color === "violet") { + this.createCharacter(shape as TLGeoShape); + continue; + } + + switch (shape.type) { + case "geo": + case "image": + case "video": + this.createShape(shape as TLGeoShape); + break; + case "draw": + this.createCompoundLine(shape as TLDrawShape); + break; + case "group": + this.createGroup(shape as TLGroupShape); + break; + // Add cases for any new shape types here + } + } + } + + createShape(shape: TLGeoShape | TLDrawShape) { + if (shape.props.dash === "dashed") return; // Skip dashed shapes + if (isRigidbody(shape.props.color)) { + const gravity = getGravityFromColor(shape.props.color) + const rb = this.createRigidbody(shape, gravity); + this.createCollider(shape, rb); + } else { + this.createCollider(shape); + } + } + + createCharacter(characterShape: TLGeoShape) { + const initialPosition = cornerToCenter({ + x: characterShape.x, + y: characterShape.y, + width: characterShape.props.w, + height: characterShape.props.h, + rotation: characterShape.rotation, + }); + const vertices = this.editor.getShapeGeometry(characterShape).vertices; + const vec2Array = convertVerticesToFloat32Array( + vertices, + characterShape.props.w, + characterShape.props.h, + ); + const colliderDesc = RAPIER.ColliderDesc.convexHull(vec2Array); + if (!colliderDesc) { + console.error("Failed to create collider description."); + return; + } + const rigidBodyDesc = RAPIER.RigidBodyDesc.kinematicPositionBased() + .setTranslation(initialPosition.x, initialPosition.y) + .setAdditionalMass(CHARACTER.additionalMass); + const charRigidbody = this.world.createRigidBody(rigidBodyDesc); + const charCollider = this.world.createCollider(colliderDesc, charRigidbody); + const char = this.world.createCharacterController(0.1); + char.setUp(CHARACTER.up); + char.setMaxSlopeClimbAngle(CHARACTER.maxSlopeClimbAngle); + char.setSlideEnabled(CHARACTER.slideEnabled); + char.setMinSlopeSlideAngle(CHARACTER.minSlopeSlideAngle); + char.setApplyImpulsesToDynamicBodies(CHARACTER.applyImpulsesToDynamicBodies); + char.enableAutostep( + CHARACTER.autostepHeight, + CHARACTER.autostepMaxClimbAngle, + true, + ); + char.enableSnapToGround(CHARACTER.snapToGroundDistance); + // Setup references so we can update character position in sim loop + this.character.rigidbody = charRigidbody; + this.character.collider = charCollider; + charRigidbody.userData = { + id: characterShape.id, + type: characterShape.type, + w: characterShape.props.w, + h: characterShape.props.h, + }; + } + + createGroup(group: TLGroupShape) { + // create rigidbody for group + const rigidbody = this.createRigidbody(group); + const rigidbodyGeometry = this.editor.getShapeGeometry(group); + + this.editor.getSortedChildIdsForParent(group.id).forEach((childId) => { + // create collider for each + const child = this.editor.getShape(childId); + if (!child) return; + const isRb = "color" in child.props && isRigidbody(child?.props.color); + if (isRb) { + this.createCollider(child, rigidbody, rigidbodyGeometry); + } else { + this.createCollider(child); + } + }); + } + createCompoundLine(drawShape: TLDrawShape) { + const rigidbody = this.createRigidbody(drawShape); + const drawnGeo = this.editor.getShapeGeometry(drawShape); + const verts = drawnGeo.vertices; + const isRb = + "color" in drawShape.props && isRigidbody(drawShape.props.color); + verts.forEach((point) => { + if (isRb) this.createColliderAtPoint(point, drawShape, rigidbody); + else this.createColliderAtPoint(point, drawShape); + }); + } + + updateRigidbodies() { + this.world.bodies.forEach((rb) => { + if (rb === this.character?.rigidbody) return; + if (!rb.userData) return; + const body = rb as BodyWithShapeData; + const position = body.translation(); + const rotation = body.rotation(); + + const cornerPos = centerToCorner({ + x: position.x, + y: position.y, + width: body.userData?.w, + height: body.userData?.h, + rotation: rotation, + }); + + this.editor.updateShape({ + id: body.userData?.id, + type: body.userData?.type, + rotation: rotation, + x: cornerPos.x, + y: cornerPos.y, + }); + }); + } + + updateCharacterControllers() { + const right = this.editor.inputs.keys.has("ArrowRight") ? 1 : 0; + const left = this.editor.inputs.keys.has("ArrowLeft") ? -1 : 0; + const acceleration: VecLike = { + x: (right + left) * CHARACTER.moveAcceleration, + y: CHARACTER.gravityMultiplier * GRAVITY.y, + } + + this.world.characterControllers.forEach((char) => { + if (!this.character.rigidbody || !this.character.collider) return; + const charRigidbody = this.character.rigidbody as BodyWithShapeData; + const charCollider = this.character.collider; + const grounded = char.computedGrounded(); + const isJumping = this.editor.inputs.keys.has("ArrowUp") && grounded; + const velocity: VecLike = { + x: charRigidbody.linvel().x, + y: isJumping ? -CHARACTER.jumpVelocity : charRigidbody.linvel().y, + } + const displacement = getDisplacement( + velocity, + acceleration, + 1 / 60, + CHARACTER.maxMoveSpeedX, + CHARACTER.moveDeceleration, + ); + + char.computeColliderMovement( + charCollider as RAPIER.Collider, // The collider we would like to move. + new RAPIER.Vector2(displacement.x, displacement.y), + ); + const correctedDisplacement = char.computedMovement(); + const currentPos = charRigidbody.translation(); + const nextX = currentPos.x + correctedDisplacement.x; + const nextY = currentPos.y + correctedDisplacement.y; + charRigidbody?.setNextKinematicTranslation({ x: nextX, y: nextY }); + + const w = charRigidbody.userData.w; + const h = charRigidbody.userData.h; + this.editor.updateShape({ + id: charRigidbody.userData.id, + type: charRigidbody.userData.type, + x: nextX - w / 2, + y: nextY - h / 2, + }); + }); + } + private getShapeDimensions( + shape: TLShape, + ): { width: number; height: number } { + const geo = this.editor.getShapeGeometry(shape); + const width = geo.center.x * 2; + const height = geo.center.y * 2; + return { width, height }; + } + private shouldConvexify(shape: TLShape): boolean { + return !( + shape.type === "geo" && (shape as TLGeoShape).props.geo === "rectangle" + ); + } + private createRigidbody( + shape: TLShape, + gravity = 1, + ): RAPIER.RigidBody { + const dimensions = this.getShapeDimensions(shape); + const centerPosition = cornerToCenter({ + x: shape.x, + y: shape.y, + width: dimensions.width, + height: dimensions.height, + rotation: shape.rotation, + }); + const rigidBodyDesc = RAPIER.RigidBodyDesc.dynamic() + .setTranslation(centerPosition.x, centerPosition.y) + .setRotation(shape.rotation) + .setGravityScale(gravity); + const rigidbody = this.world.createRigidBody(rigidBodyDesc); + this.rigidbodyLookup[shape.id] = rigidbody; + rigidbody.userData = { + id: shape.id, + type: shape.type, + w: dimensions.width, + h: dimensions.height, + }; + return rigidbody; + } + private createColliderAtPoint( + point: VecLike, + relativeToParent: TLDrawShape, + parentRigidBody: RAPIER.RigidBody | null = null, + ) { + const radius = 5; + const parentGeo = this.editor.getShapeGeometry(relativeToParent); + const center = cornerToCenter({ + x: point.x, + y: point.y, + width: radius, + height: radius, + rotation: 0, + parent: parentGeo, + }); + let colliderDesc: RAPIER.ColliderDesc | null = null; + colliderDesc = RAPIER.ColliderDesc.ball(radius); + + if (!colliderDesc) { + console.error("Failed to create collider description."); + return; + } + + if (parentRigidBody) { + colliderDesc.setTranslation(center.x, center.y); + this.world.createCollider(colliderDesc, parentRigidBody); + } else { + colliderDesc.setTranslation( + relativeToParent.x + center.x, + relativeToParent.y + center.y, + ); + this.world.createCollider(colliderDesc); + } + } + private createCollider( + shape: TLShape, + parentRigidBody: RAPIER.RigidBody | null = null, + parentGeo: Geometry2d | null = null, + ) { + const dimensions = this.getShapeDimensions(shape); + const centerPosition = cornerToCenter({ + x: shape.x, + y: shape.y, + width: dimensions.width, + height: dimensions.height, + rotation: shape.rotation, + parent: parentGeo || undefined, + }); + + const restitution = + "color" in shape.props + ? getRestitutionFromColor(shape.props.color) + : MATERIAL.defaultRestitution; + const friction = + "color" in shape.props + ? getFrictionFromColor(shape.props.color) + : MATERIAL.defaultFriction; + + let colliderDesc: RAPIER.ColliderDesc | null = null; + + if (this.shouldConvexify(shape)) { + // Convert vertices for convex shapes + const vertices = this.editor.getShapeGeometry(shape).vertices; + const vec2Array = convertVerticesToFloat32Array( + vertices, + dimensions.width, + dimensions.height, + ); + colliderDesc = RAPIER.ColliderDesc.convexHull(vec2Array); + } else { + // Cuboid for rectangle shapes + colliderDesc = RAPIER.ColliderDesc.cuboid( + dimensions.width / 2, + dimensions.height / 2, + ); + } + if (!colliderDesc) { + console.error("Failed to create collider description."); + return; + } + + colliderDesc + .setRestitution(restitution) + .setRestitutionCombineRule(RAPIER.CoefficientCombineRule.Max) + .setFriction(friction) + .setFrictionCombineRule(RAPIER.CoefficientCombineRule.Min); + if (parentRigidBody) { + if (parentGeo) { + colliderDesc.setTranslation(centerPosition.x, centerPosition.y); + colliderDesc.setRotation(shape.rotation); + } + this.world.createCollider(colliderDesc, parentRigidBody); + } else { + colliderDesc + .setTranslation(centerPosition.x, centerPosition.y) + .setRotation(shape.rotation); + this.world.createCollider(colliderDesc); + } + } + public setEditor(editor: Editor) { + this.editor = editor; + } +} + +export function usePhysicsSimulation(editor: Editor, enabled: boolean) { + const sim = useRef(new PhysicsWorld(editor)); + + useEffect(() => { + if (enabled) { + sim.current.start(); + editor.selectNone(); + return () => sim.current.stop(); + } + }, [enabled, sim]); + + useEffect(() => { + sim.current.setEditor(editor); + }, [editor, sim]); + + // Return any values or functions that the UI components might need + return { + addShapes: (shapes: TLShape[]) => sim.current.addShapes(shapes), + }; +} \ No newline at end of file diff --git a/src/physics/ui/PhysicsControls.tsx b/src/physics/ui/PhysicsControls.tsx new file mode 100644 index 0000000..2ccf828 --- /dev/null +++ b/src/physics/ui/PhysicsControls.tsx @@ -0,0 +1,47 @@ +import { track, useEditor } from "@tldraw/tldraw"; +import { useEffect, useState } from "react"; +import { usePhysicsSimulation } from "../simulation"; +import "../../css/physics-ui.css"; + +export const SimControls = track(() => { + const editor = useEditor(); + const [physicsEnabled, setPhysics] = useState(false); + + useEffect(() => { + const togglePhysics = () => { + setPhysics(prev => !prev); + }; + + window.addEventListener('togglePhysicsEvent', togglePhysics); + + return () => { + window.removeEventListener('togglePhysicsEvent', togglePhysics); + }; + }, []); + + const { addShapes } = usePhysicsSimulation(editor, physicsEnabled); + + return ( +
+
+ + +
+
+ ); +}); diff --git a/src/physics/ui/overrides.ts b/src/physics/ui/overrides.ts new file mode 100644 index 0000000..ae905e7 --- /dev/null +++ b/src/physics/ui/overrides.ts @@ -0,0 +1,22 @@ +import { + TLUiEventSource, + TLUiOverrides, + TLUiTranslationKey, +} from "@tldraw/tldraw"; + +// In order to see select our custom shape tool, we need to add it to the ui. +export const uiOverrides: TLUiOverrides = { + actions(_editor, actions) { + actions['toggle-physics'] = { + id: 'toggle-physics', + label: 'Toggle Physics' as TLUiTranslationKey, + readonlyOk: true, + kbd: 'p', + onSelect(_source: TLUiEventSource) { + const event = new CustomEvent('togglePhysicsEvent'); + window.dispatchEvent(event); + }, + } + return actions + }, +} \ No newline at end of file diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..54de499 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": false, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..42872c5 --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/vercel.json b/vercel.json index ec27fad..7c98098 100644 --- a/vercel.json +++ b/vercel.json @@ -1,5 +1,5 @@ { "buildCommand": "yarn build", - "framework": "eleventy", + "framework": "vite", "outputDirectory": "dist" } \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..7726f73 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import wasm from "vite-plugin-wasm"; +import topLevelAwait from "vite-plugin-top-level-await"; + +export default defineConfig({ + plugins: [ + react(), + wasm(), + topLevelAwait() + ], +})