switch back to React

This commit is contained in:
Orion Reed 2024-03-24 18:07:52 -07:00
parent 74e2c508cb
commit 5bc074b33a
21 changed files with 969 additions and 94 deletions

View File

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

View File

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

73
index.html Normal file
View File

@ -0,0 +1,73 @@
<!DOCTYPE html>
<html>
<head>
<title>Orion Reed</title>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/x-icon" href="src/assets/favicon.ico?v=4" />
<link rel="shortcut icon" type="image/x-icon" href="src/assets/favicon.ico?v=4" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="stylesheet" href="src/css/style.css">
<link
href="https://fonts.googleapis.com/css2?family=Recursive:slnt,wght,CASL,CRSV,MONO@-15..0,300..1000,0..1,0..1,0..1&display=swap"
rel="stylesheet">
</head>
<body>
<header>
Orion Reed
</header>
<main>
<h1>Hello! 👋</h1>
<p>
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.
</p>
<p>
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.
</p>
<h1>My work</h1>
<p>
Alongside my independent work I am a researcher at
<a href="https://block.science/">Block Science</a> building
<i>knowledge organisation infrastructure</i> and at
<a href="https://economicspace.agency/">ECSA</a> working on
<i>computational media</i>. I am also part of the nascent <a href="https://libcomp.org/">Liberatory Computing</a>
collective and a co-organiser of the <a href="https://canvasprotocol.org/">OCWG</a>.
</p>
<h1>Get in touch</h1>
<p>
I am on Twitter as <a href="https://twitter.com/OrionReedOne">@OrionReedOne</a> and on
Mastodon as <a href="https://hci.social/@orion">@orion@hci.social</a>. The best way to reach me is
through Twitter or my email, <a href="mailto:me@orionreed.com">me@orionreed.com</a>
</p>
<span class="dinkus">***</span>
<h1>Talks</h1>
<ul>
<li><a
href="objects/causal-islands-integration-domain.pdf">Spatial
Canvases: Towards an Integration Domain for HCI @ Causal Islands LA</a></li>
<li><a
href="https://www.youtube.com/watch?v=-q-kk-NMFbA">Knowledge Organisation Infrastructure Demo @ NPC
Denver</a></li>
</ul>
<h1>Writing</h1>
<ul>
<li><a
href="https://blog.block.science/objects-as-reference-toward-robust-first-principles-of-digital-organization/">Objects
as Reference: Toward Robust First Principles of Digital Organization</a></li>
</ul>
</main>
</body>
</html>

View File

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

17
src/App.tsx Normal file
View File

@ -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 (
<div className="tldraw__editor">
<Tldraw
overrides={uiOverrides}
>
<SimControls />
</Tldraw>
</div>
);
}

View File

@ -1,24 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Orion Reed</title>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/x-icon" href="/assets/favicon.ico?v=4" />
<link rel="shortcut icon" type="image/x-icon" href="/assets/favicon.ico?v=4" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="stylesheet" href="/assets/css/style.css">
<link
href="https://fonts.googleapis.com/css2?family=Recursive:slnt,wght,CASL,CRSV,MONO@-15..0,300..1000,0..1,0..1,0..1&display=swap"
rel="stylesheet">
</head>
<body>
<header>
Orion Reed
</header>
<main>
{{ content }}
</main>
</body>
</html>

64
src/css/index.css Normal file
View File

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

View File

@ -1,53 +0,0 @@
---
layout: layout.html
---
<h1>Hello! 👋</h1>
<p>
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.
</p>
<p>
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.
</p>
<h1>My work</h1>
<p>
Alongside my independent work I am a researcher at
<a href="https://block.science/">Block Science</a> building
<i>knowledge organisation infrastructure</i> and at
<a href="https://economicspace.agency/">ECSA</a> working on
<i>computational media</i>. I am also part of the nascent <a href="https://libcomp.org/">Liberatory Computing</a>
collective and a co-organiser of the <a href="https://canvasprotocol.org/">OCWG</a>.
</p>
<h1>Get in touch</h1>
<p>
I am on Twitter as <a href="https://twitter.com/OrionReedOne">@OrionReedOne</a> and on
Mastodon as <a href="https://hci.social/@orion">@orion@hci.social</a>. The best way to reach me is
through Twitter or my email, <a href="mailto:me@orionreed.com">me@orionreed.com</a>
</p>
<span class="dinkus">***</span>
<h1>Talks</h1>
<ul>
<li><a
href="objects/causal-islands-integration-domain.pdf">Spatial
Canvases: Towards an Integration Domain for HCI @ Causal Islands LA</a></li>
<li><a
href="https://www.youtube.com/watch?v=-q-kk-NMFbA">Knowledge Organisation Infrastructure Demo @ NPC Denver</a></li>
</ul>
<h1>Writing</h1>
<ul>
<li><a
href="https://blog.block.science/objects-as-reference-toward-robust-first-principles-of-digital-organization/">Objects
as Reference: Toward Robust First Principles of Digital Organization</a></li>
</ul>

10
src/main.tsx Normal file
View File

@ -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(
<React.StrictMode>
<App />
</React.StrictMode>,
);

36
src/physics/config.ts Normal file
View File

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

95
src/physics/math.ts Normal file
View File

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

395
src/physics/simulation.ts Normal file
View File

@ -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<PhysicsWorld>(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),
};
}

View File

@ -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 (
<div className="custom-layout">
<div className="custom-toolbar">
<button
type="button"
className="custom-button"
data-isactive={physicsEnabled}
title="Toggle physics (P)"
onClick={() => setPhysics(!physicsEnabled)}
>
Physics
</button>
<button
type="button"
className="custom-button"
title="Add to physics simulation"
onClick={() => addShapes(editor.getSelectedShapes())}
>
+
</button>
</div>
</div>
);
});

View File

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

1
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

25
tsconfig.json Normal file
View File

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

10
tsconfig.node.json Normal file
View File

@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

View File

@ -1,5 +1,5 @@
{
"buildCommand": "yarn build",
"framework": "eleventy",
"framework": "vite",
"outputDirectory": "dist"
}

12
vite.config.ts Normal file
View File

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