Merge pull request #2 from OrionReed/goofy-physics-button

add more goof
This commit is contained in:
Orion Reed 2024-03-25 04:28:40 -07:00 committed by GitHub
commit 17bc2f3d0d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 1224 additions and 168 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"
}
}
};

19
index.html Normal file
View File

@ -0,0 +1,19 @@
<!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/public/favicon.ico?v=4" />
<link rel="shortcut icon" type="image/x-icon" href="src/public/favicon.ico?v=4" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<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>
<div id="root"></div>
<script type="module" src="/src/App.tsx"></script>
</body>
</html>

View File

@ -4,13 +4,29 @@
"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": "tsc && vite build --base=./ && vite preview"
},
"keywords": [],
"author": "Orion Reed",
"license": "ISC",
"dependencies": {
"@dimforge/rapier2d": "^0.11.2",
"@tldraw/tldraw": "2.0.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.22.3"
},
"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",
"typescript": "^5.0.2",
"vite": "^4.4.5",
"vite-plugin-top-level-await": "^1.3.1",
"vite-plugin-wasm": "^3.2.2"
}
}

53
src/App.tsx Normal file
View File

@ -0,0 +1,53 @@
import "@tldraw/tldraw/tldraw.css";
import "@/css/style.css"
import React, { useEffect, useState } from "react";
import ReactDOM from "react-dom/client";
import { Default } from "@/components/Default";
import { Canvas } from "@/components/Canvas";
import { Toggle } from "@/components/Toggle";
import { useCanvas } from "@/hooks/useCanvas"
import { createShapes } from "@/utils";
import { BrowserRouter, Route, Routes } from 'react-router-dom';
import { Contact } from "@/components/Contact";
ReactDOM.createRoot(document.getElementById("root")!).render(<App />);
function App() {
return (
<React.StrictMode>
<BrowserRouter>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/card/contact" element={<Contact />} />
</Routes>
</BrowserRouter>
</React.StrictMode>
);
};
function Home() {
const { isCanvasEnabled, elementsInfo } = useCanvas();
const shapes = createShapes(elementsInfo)
const [isEditorMounted, setIsEditorMounted] = useState(false);
useEffect(() => {
const handleEditorDidMount = () => {
setIsEditorMounted(true);
};
window.addEventListener('editorDidMountEvent', handleEditorDidMount);
return () => {
window.removeEventListener('editorDidMountEvent', handleEditorDidMount);
};
}, []);
return (
<><Toggle />
<div style={{ zIndex: 999999 }} className={`${isCanvasEnabled && isEditorMounted ? 'transparent' : ''}`}>
{<Default />}
</div>
{isCanvasEnabled && elementsInfo.length > 0 ? <Canvas shapes={shapes} /> : null}</>)
}

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>

View File

@ -1,64 +0,0 @@
@import url("reset.css");
body {
max-width: 60em;
margin: 0 auto;
padding-left: 4em;
padding-right: 4em;
padding-top: 3em;
padding-bottom: 3em;
font-family: "Recursive";
font-variation-settings: "MONO" 1;
font-variation-settings: "CASL" 1;
color: #24292e;
}
h1 {
font-size: 1.5rem;
margin-top: 1em;
}
header {
margin-bottom: 2em;
font-size: 1.5em;
font-variation-settings: "MONO" 1;
font-variation-settings: "CASL" 1;
}
i {
font-variation-settings: "slnt" -15;
}
p {
font-family: Recursive;
margin-top: 0;
margin-bottom: 0.6em;
font-size: 1.1em;
font-variation-settings: "wght" 350;
}
.dinkus {
display: block;
text-align: center;
font-size: 1em;
margin-top: 2em;
margin-bottom: 0em;
}
ul {
padding-left: 0;
list-style: decimal-leading-zero;
& li::marker {
color: rgba(0, 0, 0, 0.322);
}
}
@media (max-width: 600px) {
body {
padding: 2em;
}
header {
margin-bottom: 1em;
}
ul {
list-style-position: inside;
}
}

View File

@ -1,10 +0,0 @@
---
layout: layout.html
---
# Contact
Twitter: [@OrionReedOne](https://twitter.com/OrionReedOne)
Mastodon: [orion@hci.social](https://hci.social/@orion)
Email: [me@orionreed.com](mailto:me@orionreed.com)
GitHub: [OrionReed](https://github.com/orionreed)

40
src/components/Canvas.tsx Normal file
View File

@ -0,0 +1,40 @@
import { Tldraw, TLShape, TLUiComponents } from "@tldraw/tldraw";
import { SimController } from "@/physics/PhysicsControls";
import { HTMLShapeUtil } from "@/shapes/HTMLShapeUtil";
const components: TLUiComponents = {
HelpMenu: null,
StylePanel: null,
PageMenu: null,
NavigationPanel: null,
DebugMenu: null,
ContextMenu: null,
ActionsMenu: null,
QuickActions: null,
MainMenu: null,
MenuPanel: null,
// ZoomMenu: null,
// Minimap: null,
// Toolbar: null,
// KeyboardShortcutsDialog: null,
// HelperButtons: null,
// SharePanel: null,
// TopPanel: null,
}
export function Canvas({ shapes }: { shapes: TLShape[]; }) {
return (
<div className="tldraw__editor">
<Tldraw
components={components}
shapeUtils={[HTMLShapeUtil]}
onMount={() => {
window.dispatchEvent(new CustomEvent('editorDidMountEvent'));
}}
>
<SimController shapes={shapes} />
</Tldraw>
</div>
);
}

View File

@ -0,0 +1,17 @@
export function Contact() {
return (
<main>
<header>
<a href="/">
Orion Reed
</a>
</header>
<h1>Contact</h1>
<p>Twitter: <a href="https://twitter.com/OrionReedOne">@OrionReedOne</a></p>
<p>Mastodon: <a href="https://hci.social/@orion">orion@hci.social</a></p>
<p>Email: <a href="mailto:me@orionreed.com">me@orionreed.com</a></p>
<p>GitHub: <a href="https://github.com/orionreed">OrionReed</a></p>
</main>
);
}

View File

@ -0,0 +1,57 @@
export function Default() {
return (
<main>
<header>
Orion Reed
</header>
<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 className="dinkus">***</span>
<h1>Talks</h1>
<ul>
<li><a
href="artifact/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>
);
}

12
src/components/Toggle.tsx Normal file
View File

@ -0,0 +1,12 @@
export function Toggle() {
return (
<>
<button id="toggle-canvas" onClick={() => window.dispatchEvent(new CustomEvent('toggleCanvasEvent'))}>
<img src="/canvas-button.svg" alt="Toggle Canvas" />
</button>
<button id="toggle-physics" className="hidden" onClick={() => window.dispatchEvent(new CustomEvent('togglePhysicsEvent'))}>
<img src="/gravity-button.svg" alt="Toggle Physics" />
</button>
</>
);
}

168
src/css/style.css Normal file
View File

@ -0,0 +1,168 @@
@import url("reset.css");
html,
body {
padding: 0;
margin: 0;
touch-action: none;
min-height: 100vh;
min-height: -webkit-fill-available;
height: 100%;
/* font-family: "Inter", sans-serif; */
}
.tldraw__editor {
overscroll-behavior: none;
position: fixed;
inset: 0px;
overflow: hidden;
}
.tl-background {
background-color: transparent;
}
.tlui-debug-panel {
display: none;
}
/* Non-tldraw stuff */
main {
max-width: 60em;
margin: 0 auto;
padding-left: 4em;
padding-right: 4em;
padding-top: 3em;
padding-bottom: 3em;
font-family: "Recursive";
font-variation-settings: "MONO" 1;
font-variation-settings: "CASL" 1;
color: #24292e;
}
h1 {
font-size: 1.5rem;
margin-top: 1em;
}
header {
margin-bottom: 2em;
font-size: 1.5rem;
font-variation-settings: "MONO" 1;
font-variation-settings: "CASL" 1;
}
i {
font-variation-settings: "slnt" -15;
}
p {
font-family: Recursive;
margin-top: 0;
margin-bottom: 0.6em;
font-size: 1.1em;
font-variation-settings: "wght" 350;
}
a {
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
p a {
text-decoration: underline;
}
.dinkus {
display: block;
text-align: center;
font-size: 1.1rem;
margin-top: 2em;
margin-bottom: 0em;
}
ul {
padding-left: 0;
font-size: 1rem;
list-style: decimal-leading-zero;
& li::marker {
color: rgba(0, 0, 0, 0.322);
}
}
#toggle-physics,
#toggle-canvas {
position: absolute;
z-index: 99999;
right: 10px;
width: 50px;
height: 50px;
background: none;
border: none;
cursor: pointer;
opacity: 0.25;
&:hover {
opacity: 1;
}
& img {
width: 100%;
height: 100%;
}
}
#toggle-canvas {
top: 10px;
& img {
width: 50%;
height: 50%;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
}
#toggle-physics {
top: 60px;
}
@media (max-width: 600px) {
main {
padding: 2em;
}
header {
margin-bottom: 1em;
}
ul {
list-style-position: inside;
}
}
.tl-html-layer {
font-family: "Recursive";
font-variation-settings: "MONO" 1;
font-variation-settings: "CASL" 1;
& h1,
p,
span,
header,
ul {
margin: 0;
}
& header {
font-size: 1.5rem;
}
& p {
font-size: 1.1rem;
}
}
.transparent {
opacity: 0 !important;
transition: opacity 0.25s ease-in-out;
}
.hidden {
display: none;
}

105
src/hooks/useCanvas.ts Normal file
View File

@ -0,0 +1,105 @@
import { useState, useEffect } from 'react';
interface ElementInfo {
tagName: string;
x: number;
y: number;
w: number;
h: number;
html: string;
}
export function useCanvas() {
const [isCanvasEnabled, setIsCanvasEnabled] = useState(false);
const [elementsInfo, setElementsInfo] = useState<ElementInfo[]>([]);
useEffect(() => {
const toggleCanvas = async () => {
if (!isCanvasEnabled) {
const info = await gatherElementsInfo();
setElementsInfo(info);
setIsCanvasEnabled(true);
document.getElementById('toggle-physics')?.classList.remove('hidden');
} else {
setElementsInfo([]);
setIsCanvasEnabled(false);
document.getElementById('toggle-physics')?.classList.add('hidden');
}
};
// const enableCanvas = async () => {
// const info = await gatherElementsInfo();
// setElementsInfo(info);
// setIsCanvasEnabled(true);
// };
window.addEventListener('toggleCanvasEvent', toggleCanvas);
// window.addEventListener('togglePhysicsEvent', enableCanvas);
return () => {
window.removeEventListener('toggleCanvasEvent', toggleCanvas);
// window.removeEventListener('togglePhysicsEvent', enableCanvas);
};
}, [isCanvasEnabled]);
return { isCanvasEnabled, elementsInfo };
}
async function gatherElementsInfo() {
const rootElement = document.getElementsByTagName('main')[0];
const info: any[] = [];
if (rootElement) {
for (const child of rootElement.children) {
if (['BUTTON'].includes(child.tagName)) continue;
const rect = child.getBoundingClientRect();
let w = rect.width;
if (!['P', 'UL'].includes(child.tagName)) {
w = measureElementTextWidth(child as HTMLElement);
}
// Check if the element is centered
const computedStyle = window.getComputedStyle(child);
let x = rect.left; // Default x position
if (computedStyle.display === 'block' && computedStyle.textAlign === 'center') {
// Adjust x position for centered elements
const parentWidth = child.parentElement ? child.parentElement.getBoundingClientRect().width : 0;
x = (parentWidth - w) / 2 + window.scrollX + (child.parentElement ? child.parentElement.getBoundingClientRect().left : 0);
}
info.push({
tagName: child.tagName,
x: x,
y: rect.top,
w: w,
h: rect.height,
html: child.outerHTML
});
};
}
return info;
}
function measureElementTextWidth(element: HTMLElement) {
// Create a temporary span element
const tempElement = document.createElement('span');
// Get the text content from the passed element
tempElement.textContent = element.textContent || element.innerText;
// Get the computed style of the passed element
const computedStyle = window.getComputedStyle(element);
// Apply relevant styles to the temporary element
tempElement.style.font = computedStyle.font;
tempElement.style.fontWeight = computedStyle.fontWeight;
tempElement.style.fontSize = computedStyle.fontSize;
tempElement.style.fontFamily = computedStyle.fontFamily;
tempElement.style.letterSpacing = computedStyle.letterSpacing;
// Ensure the temporary element is not visible in the viewport
tempElement.style.position = 'absolute';
tempElement.style.visibility = 'hidden';
tempElement.style.whiteSpace = 'nowrap'; // Prevent text from wrapping
// Append to the body to make measurements possible
document.body.appendChild(tempElement);
// Measure the width
const width = tempElement.getBoundingClientRect().width;
// Remove the temporary element from the document
document.body.removeChild(tempElement);
// Return the measured width
return width === 0 ? 10 : width;
}

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>

View File

@ -0,0 +1,48 @@
import { TLUnknownShape, useEditor } from "@tldraw/tldraw";
import { useEffect, useState } from "react";
import { usePhysicsSimulation } from "./simulation";
export const SimController = ({ shapes }: { shapes: TLUnknownShape[] }) => {
const editor = useEditor();
const [isPhysicsActive, setIsPhysicsActive] = useState(false);
const { addShapes, destroy } = usePhysicsSimulation(editor);
useEffect(() => {
editor.createShapes(shapes)
return () => { editor.deleteShapes(editor.getCurrentPageShapes()) }
}, []);
useEffect(() => {
const togglePhysics = () => {
setIsPhysicsActive((currentIsPhysicsActive) => {
if (currentIsPhysicsActive) {
console.log('destroy');
destroy();
return false;
} else {
console.log('activate');
return true;
}
});
};
// Listen for the togglePhysicsEvent to enable/disable physics simulation
window.addEventListener('togglePhysicsEvent', togglePhysics);
return () => {
window.removeEventListener('togglePhysicsEvent', togglePhysics);
};
}, []);
useEffect(() => {
if (isPhysicsActive) {
console.log('adding shapes');
addShapes(editor.getCurrentPageShapes()); // Activate physics simulation
} else {
destroy(); // Deactivate physics simulation
}
}, [isPhysicsActive, addShapes, shapes]);
return (<></>);
};

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

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

@ -0,0 +1,398 @@
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);
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[]) {
console.log('adding shapesss');
for (const shape of shapes) {
console.log('shape');
if ('color' in shape.props && shape.props.color === "violet") {
this.createCharacter(shape as TLGeoShape);
continue;
}
switch (shape.type) {
case "html":
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.meta.fixed) {
const rb = this.createRigidbody(shape, 1);
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);
const isRb = true;
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) {
const sim = useRef<PhysicsWorld>(new PhysicsWorld(editor));
useEffect(() => {
sim.current.start()
}, []);
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),
destroy: () => {
sim.current.stop()
sim.current = new PhysicsWorld(editor); // Replace with a new instance
sim.current.start()
}
};
}

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated by Pixelmator Pro 3.5.6 -->
<svg width="450" height="450" viewBox="0 0 450 450" xmlns="http://www.w3.org/2000/svg">
<path id="Rounded-Rectangle" fill="none" stroke="#000000" stroke-width="34" stroke-linecap="round"
stroke-linejoin="round"
d="M 112.785156 427.964844 C 79.383881 427.964844 62.682236 427.963867 50.160156 420.951172 C 41.310253 415.994995 34.005013 408.689758 29.048828 399.839844 C 22.036131 387.317749 22.035156 370.616119 22.035156 337.214844 L 22.035156 298.828125 L 40.128906 298.828125 L 40.128906 350.470703 C 40.128906 372.333344 40.128616 383.264679 44.71875 391.460938 C 47.962795 397.253601 52.746403 402.037201 58.539063 405.28125 C 66.735329 409.871368 77.666649 409.871094 99.529297 409.871094 L 151.171875 409.871094 L 151.171875 427.964844 L 112.785156 427.964844 Z M 297.949219 427.964844 L 297.949219 409.871094 L 349.599609 409.871094 C 371.462219 409.871094 382.393555 409.871399 390.589844 405.28125 C 396.382538 402.037201 401.166107 397.253601 404.410156 391.460938 C 409.000305 383.264679 409 372.333344 409 350.470703 L 409 298.828125 L 427.09375 298.828125 L 427.09375 337.214844 C 427.09375 370.616119 427.092804 387.317749 420.080078 399.839844 C 415.123901 408.689758 407.818665 415.994995 398.96875 420.951172 C 386.446625 427.963867 369.745026 427.964844 336.34375 427.964844 L 297.949219 427.964844 Z M 22.035156 152.050781 L 22.035156 113.65625 C 22.035156 80.254974 22.036131 63.553375 29.048828 51.03125 C 34.005013 42.181335 41.310253 34.876129 50.160156 29.919922 C 62.682236 22.907196 79.383881 22.90625 112.785156 22.90625 L 151.171875 22.90625 L 151.171875 41 L 99.529297 41 C 77.666649 41 66.735329 40.999695 58.539063 45.589844 C 52.746407 48.833893 47.962795 53.617493 44.71875 59.410156 C 40.12862 67.606445 40.128906 78.537781 40.128906 100.400391 L 40.128906 152.050781 L 22.035156 152.050781 Z M 409 152.050781 L 409 100.400391 C 409 78.537781 409.000305 67.606445 404.410156 59.410156 C 401.166107 53.617462 396.382507 48.833893 390.589844 45.589844 C 382.393555 40.999695 371.462219 41 349.599609 41 L 297.949219 41 L 297.949219 22.90625 L 336.34375 22.90625 C 369.745026 22.90625 386.446625 22.907196 398.96875 29.919922 C 407.818665 34.876099 415.123871 42.181335 420.080078 51.03125 C 427.092804 63.553375 427.09375 80.254974 427.09375 113.65625 L 427.09375 152.050781 L 409 152.050781 Z" />
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 84 KiB

View File

@ -0,0 +1,10 @@
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
<svg fill="#000000" width="800px" height="800px" viewBox="0 0 24 24" id="gravity" data-name="Line Color" xmlns="http://www.w3.org/2000/svg" class="icon line-color">
<g id="SVGRepo_bgCarrier" stroke-width="0"/>
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"/>
<g id="SVGRepo_iconCarrier">
<path id="secondary" d="M12,3V9m4-5v6M8,4v6" style="fill: none; stroke: #000000; stroke-linecap: round; stroke-linejoin: round; stroke-width: 2;"/>
<circle id="primary" cx="12" cy="17" r="4" style="fill: none; stroke: #000000; stroke-linecap: round; stroke-linejoin: round; stroke-width: 2;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 816 B

View File

@ -0,0 +1,41 @@
import { Rectangle2d, resizeBox, TLBaseShape, TLOnResizeHandler } from '@tldraw/tldraw';
import { HTMLContainer, ShapeUtil } from 'tldraw'
export type HTMLShape = TLBaseShape<'html', { w: number; h: number, html: string }>
export class HTMLShapeUtil extends ShapeUtil<HTMLShape> {
static override type = 'html' as const
override canBind = () => true
override canEdit = () => false
override canResize = () => true
override isAspectRatioLocked = () => false
getDefaultProps(): HTMLShape['props'] {
return {
w: 100,
h: 100,
html: "<div></div>"
}
}
override onResize: TLOnResizeHandler<any> = (shape, info) => {
return resizeBox(shape, info)
}
getGeometry(shape: HTMLShape) {
return new Rectangle2d({
width: shape.props.w,
height: shape.props.h,
isFilled: true,
})
}
component(shape: HTMLShape) {
return <div dangerouslySetInnerHTML={{ __html: shape.props.html }}></div>
}
indicator(shape: HTMLShape) {
return <rect width={shape.props.w} height={shape.props.h} />
}
}

35
src/utils.tsx Normal file
View File

@ -0,0 +1,35 @@
import { createShapeId } from "@tldraw/tldraw";
export function createShapes(elementsInfo: any) {
const shapes = elementsInfo.map((element: any) => ({
id: createShapeId(),
type: 'html',
x: element.x,
y: element.y,
props: {
w: element.w,
h: element.h,
html: element.html,
}
}));
const extend = 150;
shapes.push({
id: createShapeId(),
type: 'geo',
x: -extend,
y: window.innerHeight,
props: {
w: window.innerWidth + (extend * 2),
h: 50,
color: 'grey',
fill: 'solid'
},
meta: {
fixed: true
}
});
return shapes;
}

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

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

31
tsconfig.json Normal file
View File

@ -0,0 +1,31 @@
{
"compilerOptions": {
"baseUrl": "./src",
"paths": {
"@/*": ["*"],
"src/*": ["./src/*"],
},
"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"
}

21
vite.config.ts Normal file
View File

@ -0,0 +1,21 @@
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()
],
build: {
sourcemap: true, // Enable source maps for production
},
publicDir: 'src/public',
resolve: {
alias: {
'@': '/src',
},
},
})