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/index.html b/index.html new file mode 100644 index 0000000..0a02ffe --- /dev/null +++ b/index.html @@ -0,0 +1,19 @@ + + + + Orion Reed + + + + + + + + + +
+ + + \ No newline at end of file diff --git a/package.json b/package.json index 1d4c110..9cdf5ed 100644 --- a/package.json +++ b/package.json @@ -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" } } \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..fa69277 --- /dev/null +++ b/src/App.tsx @@ -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(); + +function App() { + + + return ( + + + + } /> + } /> + + + + ); +}; + +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 ( + <> +
+ {} +
+ {isCanvasEnabled && elementsInfo.length > 0 ? : null}) +} \ No newline at end of file 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/assets/css/style.css b/src/assets/css/style.css deleted file mode 100644 index 8926a69..0000000 --- a/src/assets/css/style.css +++ /dev/null @@ -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; - } -} diff --git a/src/card/contact.md b/src/card/contact.md deleted file mode 100644 index bbe37b0..0000000 --- a/src/card/contact.md +++ /dev/null @@ -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) \ No newline at end of file diff --git a/src/components/Canvas.tsx b/src/components/Canvas.tsx new file mode 100644 index 0000000..67492b9 --- /dev/null +++ b/src/components/Canvas.tsx @@ -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 ( +
+ { + window.dispatchEvent(new CustomEvent('editorDidMountEvent')); + }} + > + + +
+ ); +} diff --git a/src/components/Contact.tsx b/src/components/Contact.tsx new file mode 100644 index 0000000..f592cd2 --- /dev/null +++ b/src/components/Contact.tsx @@ -0,0 +1,17 @@ +export function Contact() { + return ( +
+
+ + Orion Reed + +
+

Contact

+

Twitter: @OrionReedOne

+

Mastodon: orion@hci.social

+

Email: me@orionreed.com

+

GitHub: OrionReed

+
+ + ); +} diff --git a/src/components/Default.tsx b/src/components/Default.tsx new file mode 100644 index 0000000..3fe1071 --- /dev/null +++ b/src/components/Default.tsx @@ -0,0 +1,57 @@ +export function Default() { + return ( +
+
+ 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

+ +
+ ); +} diff --git a/src/components/Toggle.tsx b/src/components/Toggle.tsx new file mode 100644 index 0000000..8b27dbd --- /dev/null +++ b/src/components/Toggle.tsx @@ -0,0 +1,12 @@ +export function Toggle() { + return ( + <> + + + + ); +} 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/css/style.css b/src/css/style.css new file mode 100644 index 0000000..8dc7347 --- /dev/null +++ b/src/css/style.css @@ -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; +} diff --git a/src/hooks/useCanvas.ts b/src/hooks/useCanvas.ts new file mode 100644 index 0000000..6d01834 --- /dev/null +++ b/src/hooks/useCanvas.ts @@ -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([]); + + 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; +} \ No newline at end of file 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/physics/PhysicsControls.tsx b/src/physics/PhysicsControls.tsx new file mode 100644 index 0000000..590cd76 --- /dev/null +++ b/src/physics/PhysicsControls.tsx @@ -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 (<>); +}; 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..31c318e --- /dev/null +++ b/src/physics/simulation.ts @@ -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(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() + } + }; +} \ No newline at end of file diff --git a/src/objects/causal-islands-integration-domain.pdf b/src/public/artifact/causal-islands-integration-domain.pdf similarity index 100% rename from src/objects/causal-islands-integration-domain.pdf rename to src/public/artifact/causal-islands-integration-domain.pdf diff --git a/src/public/canvas-button.svg b/src/public/canvas-button.svg new file mode 100644 index 0000000..3325f46 --- /dev/null +++ b/src/public/canvas-button.svg @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/src/assets/favicon.ico b/src/public/favicon.ico similarity index 100% rename from src/assets/favicon.ico rename to src/public/favicon.ico diff --git a/src/public/gravity-button.svg b/src/public/gravity-button.svg new file mode 100644 index 0000000..5395ac1 --- /dev/null +++ b/src/public/gravity-button.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/src/shapes/HTMLShapeUtil.tsx b/src/shapes/HTMLShapeUtil.tsx new file mode 100644 index 0000000..d62c033 --- /dev/null +++ b/src/shapes/HTMLShapeUtil.tsx @@ -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 { + 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: "
" + } + } + + override onResize: TLOnResizeHandler = (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
+ + } + + indicator(shape: HTMLShape) { + return + } +} \ No newline at end of file diff --git a/src/utils.tsx b/src/utils.tsx new file mode 100644 index 0000000..04e80dc --- /dev/null +++ b/src/utils.tsx @@ -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; +} \ 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..ae3bf28 --- /dev/null +++ b/tsconfig.json @@ -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" }] +} 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..9b55a11 --- /dev/null +++ b/vite.config.ts @@ -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', + }, + }, +})