From ea9f47e48c028cbf9fba6cef0539678a3b6c9716 Mon Sep 17 00:00:00 2001
From: Jeff Emmett <46964190+Jeff-Emmett@users.noreply.github.com>
Date: Fri, 9 Aug 2024 23:14:58 -0400
Subject: [PATCH] oriomimicry
---
.gitattributes | 5 +
.gitignore | 172 ++++++++
README.md | 3 +
build/markdownPlugin.js | 17 +
build/markdownToHtml.js | 42 ++
index.html | 43 +-
package.json | 37 ++
src/App.tsx | 56 +++
src/components/Canvas.tsx | 34 ++
src/components/Contact.tsx | 17 +
src/components/Default.tsx | 73 ++++
src/components/Post.tsx | 45 ++
src/components/Toggle.tsx | 12 +
src/css/reset.css | 89 ++++
src/css/style.css | 304 ++++++++++++++
src/hooks/useCanvas.ts | 98 +++++
src/physics/PhysicsControls.tsx | 66 +++
src/physics/config.ts | 36 ++
src/physics/math.ts | 95 +++++
src/physics/simulation.ts | 395 ++++++++++++++++++
src/posts/scoped-propagators.md | 112 +++++
src/posts/scoped-propagators/bridging.mov | 3 +
src/posts/scoped-propagators/buttons.mp4 | 3 +
src/posts/scoped-propagators/constraints.mp4 | 3 +
src/posts/scoped-propagators/examples.mp4 | 3 +
src/posts/scoped-propagators/game.mp4 | 3 +
src/posts/scoped-propagators/inspection.mp4 | 3 +
src/posts/scoped-propagators/intro.mp4 | 3 +
src/posts/scoped-propagators/lerp.mp4 | 3 +
src/posts/scoped-propagators/tools.mp4 | 3 +
.../causal-islands-integration-domain.pdf | 3 +
.../artifact/tft-rocks-integration-domain.pdf | 3 +
src/public/canvas-button.svg | 5 +
src/public/favicon.ico | Bin 0 -> 85886 bytes
src/public/gravity-button.svg | 8 +
src/public/website-embed.png | 3 +
src/shapes/HTMLShapeUtil.tsx | 58 +++
src/utils.tsx | 16 +
src/utils/readingTime.ts | 9 +
src/vite-env.d.ts | 1 +
tsconfig.json | 31 ++
tsconfig.node.json | 10 +
vercel.json | 12 +-
vite.config.ts | 34 ++
44 files changed, 1967 insertions(+), 4 deletions(-)
create mode 100644 .gitattributes
create mode 100644 .gitignore
create mode 100644 README.md
create mode 100644 build/markdownPlugin.js
create mode 100644 build/markdownToHtml.js
create mode 100644 package.json
create mode 100644 src/App.tsx
create mode 100644 src/components/Canvas.tsx
create mode 100644 src/components/Contact.tsx
create mode 100644 src/components/Default.tsx
create mode 100644 src/components/Post.tsx
create mode 100644 src/components/Toggle.tsx
create mode 100644 src/css/reset.css
create mode 100644 src/css/style.css
create mode 100644 src/hooks/useCanvas.ts
create mode 100644 src/physics/PhysicsControls.tsx
create mode 100644 src/physics/config.ts
create mode 100644 src/physics/math.ts
create mode 100644 src/physics/simulation.ts
create mode 100644 src/posts/scoped-propagators.md
create mode 100644 src/posts/scoped-propagators/bridging.mov
create mode 100644 src/posts/scoped-propagators/buttons.mp4
create mode 100644 src/posts/scoped-propagators/constraints.mp4
create mode 100644 src/posts/scoped-propagators/examples.mp4
create mode 100644 src/posts/scoped-propagators/game.mp4
create mode 100644 src/posts/scoped-propagators/inspection.mp4
create mode 100644 src/posts/scoped-propagators/intro.mp4
create mode 100644 src/posts/scoped-propagators/lerp.mp4
create mode 100644 src/posts/scoped-propagators/tools.mp4
create mode 100644 src/public/artifact/causal-islands-integration-domain.pdf
create mode 100644 src/public/artifact/tft-rocks-integration-domain.pdf
create mode 100644 src/public/canvas-button.svg
create mode 100644 src/public/favicon.ico
create mode 100644 src/public/gravity-button.svg
create mode 100644 src/public/website-embed.png
create mode 100644 src/shapes/HTMLShapeUtil.tsx
create mode 100644 src/utils.tsx
create mode 100644 src/utils/readingTime.ts
create mode 100644 src/vite-env.d.ts
create mode 100644 tsconfig.json
create mode 100644 tsconfig.node.json
create mode 100644 vite.config.ts
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..d04ab78
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,5 @@
+*.pdf filter=lfs diff=lfs merge=lfs -text
+*.mp4 filter=lfs diff=lfs merge=lfs -text
+*.mov filter=lfs diff=lfs merge=lfs -text
+*.png filter=lfs diff=lfs merge=lfs -text
+*.gif filter=lfs diff=lfs merge=lfs -text
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..77773cc
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,172 @@
+dist/
+.DS_Store
+bun.lockb
+yarn.lock
+
+# Logs
+
+logs
+_.log
+npm-debug.log_
+yarn-debug.log*
+yarn-error.log*
+lerna-debug.log*
+.pnpm-debug.log*
+
+# Diagnostic reports (https://nodejs.org/api/report.html)
+
+report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
+
+# Runtime data
+
+pids
+_.pid
+_.seed
+\*.pid.lock
+
+# Directory for instrumented libs generated by jscoverage/JSCover
+
+lib-cov
+
+# Coverage directory used by tools like istanbul
+
+coverage
+\*.lcov
+
+# nyc test coverage
+
+.nyc_output
+
+# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
+
+.grunt
+
+# Bower dependency directory (https://bower.io/)
+
+bower_components
+
+# node-waf configuration
+
+.lock-wscript
+
+# Compiled binary addons (https://nodejs.org/api/addons.html)
+
+build/Release
+
+# Dependency directories
+
+node_modules/
+jspm_packages/
+
+# Snowpack dependency directory (https://snowpack.dev/)
+
+web_modules/
+
+# TypeScript cache
+
+\*.tsbuildinfo
+
+# Optional npm cache directory
+
+.npm
+
+# Optional eslint cache
+
+.eslintcache
+
+# Optional stylelint cache
+
+.stylelintcache
+
+# Microbundle cache
+
+.rpt2_cache/
+.rts2_cache_cjs/
+.rts2_cache_es/
+.rts2_cache_umd/
+
+# Optional REPL history
+
+.node_repl_history
+
+# Output of 'npm pack'
+
+\*.tgz
+
+# Yarn Integrity file
+
+.yarn-integrity
+
+# dotenv environment variable files
+
+.env
+.env.development.local
+.env.test.local
+.env.production.local
+.env.local
+
+# parcel-bundler cache (https://parceljs.org/)
+
+.cache
+.parcel-cache
+
+# Next.js build output
+
+.next
+out
+
+# Nuxt.js build / generate output
+
+.nuxt
+dist
+
+# Gatsby files
+
+.cache/
+
+# Comment in the public line in if your project uses Gatsby and not Next.js
+
+# https://nextjs.org/blog/next-9-1#public-directory-support
+
+# public
+
+# vuepress build output
+
+.vuepress/dist
+
+# vuepress v2.x temp and cache directory
+
+.temp
+.cache
+
+# Docusaurus cache and generated files
+
+.docusaurus
+
+# Serverless directories
+
+.serverless/
+
+# FuseBox cache
+
+.fusebox/
+
+# DynamoDB Local files
+
+.dynamodb/
+
+# TernJS port file
+
+.tern-port
+
+# Stores VSCode versions used for testing VSCode extensions
+
+.vscode-test
+
+# yarn v2
+
+.yarn/cache
+.yarn/unplugged
+.yarn/build-state.yml
+.yarn/install-state.gz
+.pnp.\*
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..507cf72
--- /dev/null
+++ b/README.md
@@ -0,0 +1,3 @@
+A website.
+
+Do `yarn dev`
diff --git a/build/markdownPlugin.js b/build/markdownPlugin.js
new file mode 100644
index 0000000..4db9f11
--- /dev/null
+++ b/build/markdownPlugin.js
@@ -0,0 +1,17 @@
+import matter from "gray-matter";
+import { markdownToHtml } from "./markdownToHtml";
+import path from "path";
+
+export const markdownPlugin = {
+ name: "markdown-plugin",
+ enforce: "pre",
+ transform(code, id) {
+ if (id.endsWith(".md")) {
+ const { data, content } = matter(code);
+ const filename = path.basename(id, ".md");
+ const html = markdownToHtml(filename, content);
+ return `export const html = ${JSON.stringify(html)};
+ export const data = ${JSON.stringify(data)};`;
+ }
+ },
+};
diff --git a/build/markdownToHtml.js b/build/markdownToHtml.js
new file mode 100644
index 0000000..a7e92fd
--- /dev/null
+++ b/build/markdownToHtml.js
@@ -0,0 +1,42 @@
+import MarkdownIt from "markdown-it";
+// import markdownItLatex from "markdown-it-latex";
+import markdownLatex from "markdown-it-latex2img";
+
+const md = new MarkdownIt({
+ html: true,
+ breaks: true,
+ linkify: true,
+});
+
+md.use(
+ markdownLatex,
+ // {style: "width: 200%; height: 200%;",}
+);
+
+// const mediaSrc = (folderName, fileName) => {
+// return `/posts/${folderName}/${fileName}`;
+// };
+
+md.renderer.rules.code_block = (tokens, idx, options, env, self) => {
+ console.log("tokens", tokens);
+ return `${tokens[idx].content}`;
+};
+md.renderer.rules.image = (tokens, idx, options, env, self) => {
+ const token = tokens[idx];
+ const src = token.attrGet("src");
+ const alt = token.content;
+ const postName = env.postName;
+ const formattedSrc = `/posts/${postName}/${src}`;
+
+ if (src.endsWith(".mp4") || src.endsWith(".mov")) {
+ return `
+
+ `;
+ }
+
+ return ` `;
+};
+
+export function markdownToHtml(postName, content) {
+ return md.render(content, { postName: postName });
+}
diff --git a/index.html b/index.html
index 43686f3..e61ec34 100644
--- a/index.html
+++ b/index.html
@@ -1 +1,42 @@
-Content is here
+
+
+
+ Orion Reed
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..00cbdf5
--- /dev/null
+++ b/package.json
@@ -0,0 +1,37 @@
+{
+ "name": "orionreed",
+ "version": "1.0.0",
+ "description": "Orion Reed's personal website",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc && vite build",
+ "preview": "tsc && vite build && vite preview"
+ },
+ "keywords": [],
+ "author": "Orion Reed",
+ "license": "ISC",
+ "dependencies": {
+ "@dimforge/rapier2d": "^0.11.2",
+ "@tldraw/tldraw": "2.0.2",
+ "@types/markdown-it": "^14.1.1",
+ "@vercel/analytics": "^1.2.2",
+ "gray-matter": "^4.0.3",
+ "markdown-it": "^14.1.0",
+ "markdown-it-latex2img": "^0.0.6",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0",
+ "react-router-dom": "^6.22.3"
+ },
+ "devDependencies": {
+ "@biomejs/biome": "1.4.1",
+ "@types/react": "^18.2.15",
+ "@types/react-dom": "^18.2.7",
+ "@vitejs/plugin-react": "^4.0.3",
+ "typescript": "^5.0.2",
+ "vite": "^5.3.3",
+ "vite-plugin-static-copy": "^1.0.6",
+ "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..022c0ff
--- /dev/null
+++ b/src/App.tsx
@@ -0,0 +1,56 @@
+import { inject } from '@vercel/analytics';
+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";
+import { Post } from '@/components/Post';
+inject();
+
+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/components/Canvas.tsx b/src/components/Canvas.tsx
new file mode 100644
index 0000000..ce74f51
--- /dev/null
+++ b/src/components/Canvas.tsx
@@ -0,0 +1,34 @@
+import { Editor, 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,
+}
+
+export function Canvas({ shapes }: { shapes: TLShape[]; }) {
+
+ return (
+
+ {
+ window.dispatchEvent(new CustomEvent('editorDidMountEvent'));
+ editor.user.updateUserPreferences({ isDarkMode: false })
+ }}
+ >
+
+
+
+ );
+}
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 (
+
+
+ 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..38fe221
--- /dev/null
+++ b/src/components/Default.tsx
@@ -0,0 +1,73 @@
+export function Default() {
+ return (
+
+
+ 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 an engineer-in-residence at tldraw . I am also part of the nascent Liberatory Computing {' '}
+ collective, occasional collaborator with ECSA and a co-organiser of the OCWG .
+
+
+ Get in touch
+
+ I am on Twitter @OrionReedOne ,
+ Mastodon @orion@hci.social and GitHub @orionreed . You can also shoot me an email me@orionreed.com
+
+
+ ***
+
+ Talks
+
+ Spatial
+ Canvases: Towards an Integration Domain for HCI @ TfT Rocks 2024 (slides )
+
+ Knowledge Organisation Infrastructure Demo @ NPC
+ Denver
+
+ Writing
+
+ Scoped Propagators: A programming model for spatial canvases
+ Objects
+ as Reference: Toward Robust First Principles of Digital Organization
+
+ Things I made recently
+
+ Tiny 3D HTML/DOM viewer (you can paste it into your console)
+
+ DOM to canvas translation (it's that button in the top right of this page)
+
+ Constraint-based graph layout in tldraw
+
+ Physics in tldraw
+
+
+
+ );
+}
diff --git a/src/components/Post.tsx b/src/components/Post.tsx
new file mode 100644
index 0000000..ef4e118
--- /dev/null
+++ b/src/components/Post.tsx
@@ -0,0 +1,45 @@
+import { calcReadingTime } from '@/utils/readingTime';
+import { useEffect, useState } from 'react';
+import { useParams } from 'react-router-dom';
+
+export function Post() {
+ const { slug } = useParams<{ slug: string }>();
+ const [post, setPost] = useState<{ html: string, data: Record } | null>(null);
+ const [isLoading, setIsLoading] = useState(true);
+
+ useEffect(() => {
+ import(`../posts/${slug}.md`)
+ .then((module) => {
+ setPost({ html: module.html, data: module.data });
+ setIsLoading(false);
+ })
+ .catch((error) => {
+ console.error('Failed to load post:', error);
+ setIsLoading(false);
+ });
+ }, [slug]);
+
+
+ if (isLoading) {
+ return hold on...
;
+ }
+
+ if (!post) {
+ return post not found :(
;
+ }
+
+ document.title = post.data.title;
+
+ return (
+
+
+
+
{post.data.title}
+ {calcReadingTime(post.html)}
+
+
+
+ );
+}
\ No newline at end of file
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 (
+ <>
+ window.dispatchEvent(new CustomEvent('toggleCanvasEvent'))}>
+
+
+ window.dispatchEvent(new CustomEvent('togglePhysicsEvent'))}>
+
+
+ >
+ );
+}
diff --git a/src/css/reset.css b/src/css/reset.css
new file mode 100644
index 0000000..48b60c5
--- /dev/null
+++ b/src/css/reset.css
@@ -0,0 +1,89 @@
+/* Box sizing rules */
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+}
+
+/* Prevent font size inflation */
+html {
+ -moz-text-size-adjust: none;
+ -webkit-text-size-adjust: none;
+ text-size-adjust: none;
+}
+
+/* Remove default margin in favour of better control in authored CSS */
+body,
+h1,
+h2,
+h3,
+h4,
+p,
+figure,
+blockquote,
+dl,
+dd {
+ margin-block-end: 0;
+}
+
+/* Remove list styles on ul, ol elements with a list role, which suggests default styling will be removed */
+ul[role="list"],
+ol[role="list"] {
+ list-style: none;
+}
+
+/* Set core body defaults */
+body {
+ min-height: 100vh;
+ line-height: 1.5;
+}
+
+/* Set shorter line heights on headings and interactive elements */
+h1,
+h2,
+h3,
+h4,
+button,
+input,
+label {
+ line-height: 1.1;
+}
+
+/* Balance text wrapping on headings */
+h1,
+h2,
+h3,
+h4 {
+ text-wrap: balance;
+}
+
+/* A elements that don't have a class get default styles */
+a:not([class]) {
+ text-decoration-skip-ink: auto;
+ color: currentColor;
+}
+
+/* Make images easier to work with */
+img,
+picture {
+ max-width: 100%;
+ display: block;
+}
+
+/* Inherit fonts for inputs and buttons */
+input,
+button,
+textarea,
+select {
+ font: inherit;
+}
+
+/* Make sure textareas without a rows attribute are not tiny */
+textarea:not([rows]) {
+ min-height: 10em;
+}
+
+/* Anything that has been anchored to should have extra scroll margin */
+:target {
+ scroll-margin-block: 5ex;
+}
diff --git a/src/css/style.css b/src/css/style.css
new file mode 100644
index 0000000..39fd45e
--- /dev/null
+++ b/src/css/style.css
@@ -0,0 +1,304 @@
+@import url("reset.css");
+
+html,
+body {
+ padding: 0;
+ margin: 0;
+ min-height: 100vh;
+ min-height: -webkit-fill-available;
+ height: 100%;
+}
+
+video {
+ width: 100%;
+ height: auto;
+}
+
+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: 2rem;
+}
+h2 {
+ font-size: 1.5rem;
+}
+h1,
+h2,
+h3,
+h4,
+h5,
+h6 {
+ margin-top: 0;
+ margin-bottom: 0.5em;
+}
+
+header {
+ margin-bottom: 2em;
+ font-size: 1.5rem;
+ font-variation-settings: "MONO" 1;
+ font-variation-settings: "CASL" 1;
+}
+
+i {
+ font-variation-settings: "slnt" -15;
+}
+
+pre > code {
+ width: 100%;
+ padding: 1em;
+ display: block;
+ white-space: pre-wrap;
+ word-wrap: break-word;
+}
+
+code {
+ background-color: #e4e9ee;
+ width: 100%;
+ color: #38424c;
+ padding: 0.2em 0.4em;
+ border-radius: 4px;
+}
+
+b,
+strong {
+ font-variation-settings: "wght" 600;
+}
+
+blockquote {
+ margin: -1em;
+ padding: 1em;
+ background-color: #f1f1f1;
+ margin-top: 1em;
+ margin-bottom: 1em;
+ border-radius: 4px;
+ & p {
+ font-variation-settings: "CASL" 1;
+ margin: 0;
+ }
+}
+
+p {
+ font-family: Recursive;
+ margin-top: 0;
+ margin-bottom: 1.5em;
+ font-size: 1.1em;
+ font-variation-settings: "wght" 350;
+}
+
+table {
+ width: 100%;
+ border-collapse: collapse;
+ text-align: left;
+ margin-bottom: 1em;
+ font-variation-settings: "mono" 1;
+ font-variation-settings: "casl" 0;
+ th,
+ td {
+ padding: 0.5em;
+ border: 1px solid #ddd;
+ }
+
+ th {
+ background-color: #f4f4f4;
+ font-weight: bold;
+ }
+
+ tr:nth-child(even) {
+ background-color: #f9f9f9;
+ }
+}
+
+a {
+ font-variation-settings: "CASL" 0;
+ &:hover {
+ animation: casl-forward 0.2s ease forwards;
+ }
+ &:not(:hover) {
+ /* text-decoration: none; */
+ animation: casl-reverse 0.2s ease backwards;
+ }
+}
+
+@keyframes casl-forward {
+ from {
+ font-variation-settings:
+ "CASL" 0,
+ "wght" 400;
+ }
+ to {
+ font-variation-settings:
+ "CASL" 1,
+ "wght" 600;
+ }
+}
+@keyframes casl-reverse {
+ from {
+ font-variation-settings:
+ "CASL" 1,
+ "wght" 600;
+ }
+ to {
+ font-variation-settings:
+ "CASL" 0,
+ "wght" 400;
+ }
+}
+
+p a {
+ text-decoration: underline;
+}
+
+.dinkus {
+ display: block;
+ text-align: center;
+ font-size: 1.1rem;
+ margin-top: 2em;
+ margin-bottom: 0em;
+}
+
+ol,
+ul {
+ padding-left: 0;
+ margin-top: 0;
+ font-size: 1rem;
+ & li::marker {
+ color: rgba(0, 0, 0, 0.322);
+ }
+}
+
+img {
+ display: block;
+ margin: 0 auto;
+}
+
+@media (max-width: 600px) {
+ main {
+ padding: 2em;
+ }
+ header {
+ margin-bottom: 1em;
+ }
+ ol {
+ list-style-position: inside;
+ }
+}
+
+/* Some conditional spacing */
+table:not(:has(+ p)) {
+ margin-bottom: 2em;
+}
+
+p:has(+ ul) {
+ margin-bottom: 0.5em;
+}
+p:has(+ ol) {
+ margin-bottom: 0.5em;
+}
+
+.loading {
+ font-family: "Recursive";
+ font-variation-settings: "CASL" 1;
+ font-size: 1rem;
+ text-align: center;
+ position: absolute;
+ top: 40%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ background-color: #f1f1f1;
+ border: 1px solid #c0c9d1;
+ padding: 0.5em;
+ border-radius: 4px;
+}
+
+/* CANVAS SHENANIGANS */
+#toggle-physics,
+#toggle-canvas {
+ position: fixed;
+ z-index: 999;
+ right: 10px;
+ width: 2.5rem;
+ height: 2.5rem;
+ background: none;
+ border: none;
+ cursor: pointer;
+ opacity: 0.25;
+ &:hover {
+ opacity: 1;
+ }
+ & img {
+ width: 100%;
+ height: 100%;
+ }
+}
+#toggle-canvas {
+ top: 10px;
+}
+#toggle-physics {
+ top: 60px;
+ display: none;
+}
+
+.tl-html-layer {
+ font-family: "Recursive";
+ font-variation-settings: "MONO" 1;
+ font-variation-settings: "CASL" 1;
+ & h1,
+ p,
+ span,
+ header,
+ ul,
+ ol {
+ margin: 0;
+ }
+
+ & header {
+ font-size: 1.5rem;
+ }
+ & p {
+ font-size: 1.1rem;
+ }
+}
+
+.transparent {
+ opacity: 0 !important;
+ transition: opacity 0.25s ease-in-out;
+}
+
+.canvas-mode {
+ overflow: hidden;
+ & #toggle-physics {
+ display: block;
+ }
+}
+
+.tldraw__editor {
+ overscroll-behavior: none;
+ position: fixed;
+ inset: 0px;
+ overflow: hidden;
+}
+
+.tl-background {
+ background-color: transparent;
+}
+
+.tlui-debug-panel {
+ display: none;
+}
+
+.overflowing {
+ box-shadow: 0 0px 16px rgba(0, 0, 0, 0.15);
+ overflow: hidden;
+ background-color: white;
+}
diff --git a/src/hooks/useCanvas.ts b/src/hooks/useCanvas.ts
new file mode 100644
index 0000000..6807c3e
--- /dev/null
+++ b/src/hooks/useCanvas.ts
@@ -0,0 +1,98 @@
+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.body.classList.add('canvas-mode');
+ } else {
+ setElementsInfo([]);
+ setIsCanvasEnabled(false);
+ document.body.classList.remove('canvas-mode');
+ }
+ };
+
+ window.addEventListener('toggleCanvasEvent', toggleCanvas);
+
+ return () => {
+ window.removeEventListener('toggleCanvasEvent', toggleCanvas);
+ };
+ }, [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', 'OL'].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/physics/PhysicsControls.tsx b/src/physics/PhysicsControls.tsx
new file mode 100644
index 0000000..6aeba5f
--- /dev/null
+++ b/src/physics/PhysicsControls.tsx
@@ -0,0 +1,66 @@
+import { Editor, TLUnknownShape, createShapeId, 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) {
+ destroy();
+ return false;
+ }
+ createFloor(editor);
+ return true;
+ });
+ };
+
+ // Listen for the togglePhysicsEvent to enable/disable physics simulation
+ window.addEventListener('togglePhysicsEvent', togglePhysics);
+
+ return () => {
+ window.removeEventListener('togglePhysicsEvent', togglePhysics);
+ };
+ }, []);
+
+ useEffect(() => {
+ if (isPhysicsActive) {
+ addShapes(editor.getCurrentPageShapes()); // Activate physics simulation
+ } else {
+ destroy(); // Deactivate physics simulation
+ }
+ }, [isPhysicsActive, addShapes, shapes]);
+
+ return (<>>);
+};
+
+function createFloor(editor: Editor) {
+
+ const viewBounds = editor.getViewportPageBounds();
+
+ editor.createShape({
+ id: createShapeId(),
+ type: 'geo',
+ x: viewBounds.minX,
+ y: viewBounds.maxY,
+ props: {
+ w: viewBounds.width,
+ h: 50,
+ color: 'grey',
+ fill: 'solid'
+ },
+ meta: {
+ fixed: true
+ }
+ });
+}
+
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..2a07a7b
--- /dev/null
+++ b/src/physics/simulation.ts
@@ -0,0 +1,395 @@
+import RAPIER from "@dimforge/rapier2d";
+import { CHARACTER, GRAVITY, MATERIAL, getFrictionFromColor, getGravityFromColor, getRestitutionFromColor, isRigidbody } from "./config";
+import { Editor, Geometry2d, TLDrawShape, TLGeoShape, TLGroupShape, TLShape, TLShapeId, VecLike } from "@tldraw/tldraw";
+import { useEffect, useRef } from "react";
+import { centerToCorner, convertVerticesToFloat32Array, cornerToCenter, getDisplacement } from "./math";
+
+type BodyWithShapeData = RAPIER.RigidBody & {
+ userData: { id: TLShapeId; type: TLShape["type"]; w: number; h: number };
+};
+type RigidbodyLookup = { [key: TLShapeId]: RAPIER.RigidBody };
+
+export class PhysicsWorld {
+ private editor: Editor;
+ private world: RAPIER.World;
+ private rigidbodyLookup: RigidbodyLookup;
+ private animFrame = -1; // Store the animation frame id
+ private character: {
+ rigidbody: RAPIER.RigidBody | null;
+ collider: RAPIER.Collider | null;
+ };
+ constructor(editor: Editor) {
+ this.editor = editor
+ this.world = new RAPIER.World(GRAVITY)
+ this.rigidbodyLookup = {}
+ this.character = { rigidbody: null, collider: null }
+ }
+
+ public start() {
+ this.world = new RAPIER.World(GRAVITY);
+
+ 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 "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/posts/scoped-propagators.md b/src/posts/scoped-propagators.md
new file mode 100644
index 0000000..405d82b
--- /dev/null
+++ b/src/posts/scoped-propagators.md
@@ -0,0 +1,112 @@
+---
+title: Scoped Propagators
+---
+
+> this research is a work-in-progress (play with the [live demo](https://orionreed.github.io/scoped-propagators/))
+
+## Abstract
+Graphs, as a model of computation and as a means of interaction and authorship, have found success in specific domains such as shader programming and signal processing. In these systems, computation is often expressed on nodes of specific types, with edges representing the flow of information. This is a powerful and general-purpose model, but is typically a closed-world environment where both node and edge types are decided at design-time. By choosing an alternate topology where computation is represented by edges, the incentive for a closed environment is reduced.
+
+I present *Scoped Propagators (SPs)*, a programming model designed to be embedded within existing environments and user interfaces. By representing computation as mappings between nodes along edges, SPs make it possible to add behaviour and interactivity to environments which were not designed with liveness in mind. I demonstrate an implementation of the SP model in an infinite canvas environment, where users can create arrows between arbitrary shapes and define SPs as Javascript object literals on these arrows.
+
+
+
+## Introduction
+A scoped propagator is formed of a function which takes a *source* and *target* node and returns an partial update to the *target* node, and a scope which defines some subset of events which trigger propagation.
+
+the Scoped Propagator model is based on two key insights:
+1. by representing computation as mappings between nodes along edges, you do not need to know at design-time what node types exist.
+2. by scoping the propagation to events, you can augment nodes with interactive behaviour suitable for the environment in which SPs have been embedded.
+
+Below are the four event scopes which are currently implemented, which I have found to be appropriate and useful for an infinite canvas environment.
+
+| Scope | Firing Condition |
+|----------|----------|
+| change (default) | Properties of the source node change |
+| click | A source node is clicked |
+| tick | A tick (frame render) event fires |
+| geo | A node changes whose bounds overlap the target |
+
+The syntax for SPs in this implementation is a *scope* followed by a *JS object literal*:
+```
+scope { property1: value1, property2: value2 }
+```
+Each propagator is passed the *source* and *target* nodes (named "from" and "to" for brevity) which can be accessed like so:
+```
+click {x: from.x + 10, rotation: to.rotation + 1 }
+```
+The propagator above will, when the source is clicked, set the targets `x` value to be 10 units greater than the source, and increment the targets rotation. Here is an example of this basic idea:
+
+
+
+## Demonstration
+
+By passing the target as well as the source node, it makes it trivial to create toggles and counters. We can do this by creating an arrow from a node *to itself* and getting a value from either the source or target nodes (which are now the same).
+
+Note that by allowing nodes from `self -> self` we do not have to worry about the layout of nodes, as the arrow will move wherever the node moves. This is in contrast to, for example, needing to move a button node alongside the node of interest, or have some suitable grouping primitive available.
+
+
+
+This is already sufficient for many primitive constraint-based layouts, with the caveat that constraints do not, without the addition of a backwards propagator, work in both directions.
+
+
+
+Being able to take a property from one node, transform it, and set the property of another node to that value, is useful not just for adding behaviour but also for debugging. Here we are formatting the full properties of one node and setting the text property of the target whenever the source updates.
+
+
+
+If we wish to create dynamic behaviours as a function of time, we can use an appropriate scope such as `tick` and pass a readonly `deltaTime` value to these propagators. Which here we are using to implement a classic linear interpolation equation.
+
+Note that, as with all of the examples, 100% of the behaviour is encoded in the text of the arrows. This creates a kind of diagrammatic specification of behaviour, where all behaviours could be re-created from a static screenshot.
+
+
+
+While pure functions make reasoning about a system of SPs easier, we may in practice want to allow side effects. Here we have extended the syntax to support arbitrary Javascript:
+
+```
+scope () {
+ /* arbitrary JS can be executed in this function body */
+
+ // optional return:
+ return { /* update */ }
+}
+```
+
+This is useful if we want to, for example, create utilities or DIY tools out of existing nodes, such as this "paintbrush" which creates a new shape at the top-left corner whenever the brush is not overlapping with another shape.
+
+
+
+Scoped Propagators are interesting in part because of their ability to cross the boundaries of otherwise siloed systems and to do so without the use of an escape hatch — all additional behaviour happens in-situ, in the same environment as the interface elements, not from editing source code.
+
+Here is an example of a Petri Net (left box) which is being mapped to a chart primitive (right box). By merit of knowing some specifics of both systems, an author can create a mapping from one to the other without any explicit relationship existing prior to the creation of the propagator (here mapping the number of tokens in a box to the height of a rectangle in a chart)
+
+>NOTE: the syntax here is slightly older and not consistent with the other examples.
+
+
+
+Let's now combine some of these examples to create something less trivial. In this example, we have:
+- a joystick (constrained to a box)
+- fish movement controlled by the joystick, based on the red circles position relative to the center of the joystick box
+- a shark with a fish follow behaviour
+- an on/off toggle
+- a dead state, which resets the score, and swaps the fish image source to a dead fish
+- a score counter which increments over time for as long as the fish is alive
+
+This small game consists of nine relatively terse arrows, propagating between nodes of different types. Propagators were also used to build the game, as it was unclear if or how I could change an image source URL until I used a propagator to inspect the internal state of the image and discovered the property to change.
+
+
+
+## Prior Work
+Scoped Propagators are related to [Propagator Networks](https://dspace.mit.edu/handle/1721.1/54635) but differ in three key ways:
+- propagation happens along *edges* instead of *nodes*
+- propagation is only fired when to a scope condition is met.
+- instead of stateful *cell nodes* and *propagator nodes*, all nodes can be stateful and can be of an arbitrary type
+
+This is also not the first application of propagators to infinite canvas environments, [Dennis Hansen](https://x.com/dennizor/status/1793389346881417323) built [Holograph](https://www.holograph.so), an implementation of propagator networks in [tldraw](https://tldraw.com), and motivated the use of the term "propagator" in this model.
+
+## Open Questions
+Many questions about this model have yet to be answered including questions of *function reuse*, modeling of *side-effects*, handling of *multi-input-multi-output* propagation (which is trivial in traditional propagator networks), and applications to other domains such as graph-databases.
+
+This model has not yet been formalised, and while the propagators themselves can be simply expressed as a function $f(a,b) \mapsto b'$, I have not yet found an appropriate way to express *scopes* and the relationship between the two.
+
+These questions, along with formalisation of the model and an examination of real-world usage is left to future work.
\ No newline at end of file
diff --git a/src/posts/scoped-propagators/bridging.mov b/src/posts/scoped-propagators/bridging.mov
new file mode 100644
index 0000000..6ee10ad
--- /dev/null
+++ b/src/posts/scoped-propagators/bridging.mov
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:c8a5fea015bcf937fbce5b0a233067e31059fb2e4d0f32f6471395fb82c6407c
+size 1708542
diff --git a/src/posts/scoped-propagators/buttons.mp4 b/src/posts/scoped-propagators/buttons.mp4
new file mode 100644
index 0000000..bf91bd3
--- /dev/null
+++ b/src/posts/scoped-propagators/buttons.mp4
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:8763ad95ef6e31e4c66f9298d0ad22296edd83ab2fd6d7aa1549c8845521c932
+size 61302
diff --git a/src/posts/scoped-propagators/constraints.mp4 b/src/posts/scoped-propagators/constraints.mp4
new file mode 100644
index 0000000..99c3e98
--- /dev/null
+++ b/src/posts/scoped-propagators/constraints.mp4
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:fbc1493d124f92abe9c35534b4b6dca4a8081c570f4e3f07c4d9559d60dea3eb
+size 87493
diff --git a/src/posts/scoped-propagators/examples.mp4 b/src/posts/scoped-propagators/examples.mp4
new file mode 100644
index 0000000..d15e5cf
--- /dev/null
+++ b/src/posts/scoped-propagators/examples.mp4
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:55e5c2ce64c027a808951ad11f8757f3a8d7d739639a72517a4b39c35aad815d
+size 21393177
diff --git a/src/posts/scoped-propagators/game.mp4 b/src/posts/scoped-propagators/game.mp4
new file mode 100644
index 0000000..f6c2754
--- /dev/null
+++ b/src/posts/scoped-propagators/game.mp4
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:0c27fbc53fb819ffc3f83a52a939c52bc5f92788e5212a602c31f94ff0f108d8
+size 386809
diff --git a/src/posts/scoped-propagators/inspection.mp4 b/src/posts/scoped-propagators/inspection.mp4
new file mode 100644
index 0000000..958f002
--- /dev/null
+++ b/src/posts/scoped-propagators/inspection.mp4
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:2f50fe4800e2a5957e0b96aa18121ea3dc40f514162914e9f771497e4cc54d1f
+size 224988
diff --git a/src/posts/scoped-propagators/intro.mp4 b/src/posts/scoped-propagators/intro.mp4
new file mode 100644
index 0000000..03d89ab
--- /dev/null
+++ b/src/posts/scoped-propagators/intro.mp4
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:29771060ae2ddb4c8056b4a68e24da75066e96b0a9264ab5577efba66de34379
+size 131411
diff --git a/src/posts/scoped-propagators/lerp.mp4 b/src/posts/scoped-propagators/lerp.mp4
new file mode 100644
index 0000000..cad9b1c
--- /dev/null
+++ b/src/posts/scoped-propagators/lerp.mp4
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:3725438bab72654d31e56303f4694cb481da03ed52668a3235c0510769e43ef2
+size 213429
diff --git a/src/posts/scoped-propagators/tools.mp4 b/src/posts/scoped-propagators/tools.mp4
new file mode 100644
index 0000000..dc6afe5
--- /dev/null
+++ b/src/posts/scoped-propagators/tools.mp4
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:6c4030b83ec1ae36a9475a1165dc791f8d651da70cae91c1ab90c50a54d5bbd3
+size 127229
diff --git a/src/public/artifact/causal-islands-integration-domain.pdf b/src/public/artifact/causal-islands-integration-domain.pdf
new file mode 100644
index 0000000..8a97a6b
--- /dev/null
+++ b/src/public/artifact/causal-islands-integration-domain.pdf
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:c8140e8b76c29fa3c828553e8a6a87651af6e7ee18d1ccb82e79125eb532194b
+size 19738445
diff --git a/src/public/artifact/tft-rocks-integration-domain.pdf b/src/public/artifact/tft-rocks-integration-domain.pdf
new file mode 100644
index 0000000..3447d8f
--- /dev/null
+++ b/src/public/artifact/tft-rocks-integration-domain.pdf
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:d63dd9f37a74b85680a7c1df823f69936619ecab5e8d4d06eeeedea4d908f1de
+size 18268955
diff --git a/src/public/canvas-button.svg b/src/public/canvas-button.svg
new file mode 100644
index 0000000..37fe119
--- /dev/null
+++ b/src/public/canvas-button.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/src/public/favicon.ico b/src/public/favicon.ico
new file mode 100644
index 0000000000000000000000000000000000000000..7032ae5821da0c6cfa24be01eed73aed0f4904ab
GIT binary patch
literal 85886
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
+
+
+
+
+
+
+
diff --git a/src/public/website-embed.png b/src/public/website-embed.png
new file mode 100644
index 0000000..da6b9bd
--- /dev/null
+++ b/src/public/website-embed.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:4b55a408375712bc169bc5c987d85c71c353028e611f216817f13ca0fb284604
+size 28423
diff --git a/src/shapes/HTMLShapeUtil.tsx b/src/shapes/HTMLShapeUtil.tsx
new file mode 100644
index 0000000..3e4e829
--- /dev/null
+++ b/src/shapes/HTMLShapeUtil.tsx
@@ -0,0 +1,58 @@
+import { Rectangle2d, resizeBox, TLBaseShape, TLOnBeforeUpdateHandler, TLOnResizeHandler } from '@tldraw/tldraw';
+import { 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 onTranslate: TLOnBeforeUpdateHandler = (prev, next) => {
+ if (prev.x !== next.x || prev.y !== next.y) {
+ this.editor.bringToFront([next.id]);
+ }
+ }
+
+ override onResize: TLOnResizeHandler = (shape: HTMLShape, info) => {
+ const element = document.getElementById(shape.id);
+ if (!element || !element.parentElement) return resizeBox(shape, info);
+ const { width, height } = element.parentElement.getBoundingClientRect();
+ if (element) {
+ const isOverflowing = element.scrollWidth > width || element.scrollHeight > height;
+ if (isOverflowing) {
+ element.parentElement?.classList.add('overflowing');
+ } else {
+ element.parentElement?.classList.remove('overflowing');
+ }
+ }
+ 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..fe4c44f
--- /dev/null
+++ b/src/utils.tsx
@@ -0,0 +1,16 @@
+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,
+ }
+ }));
+ return shapes;
+}
\ No newline at end of file
diff --git a/src/utils/readingTime.ts b/src/utils/readingTime.ts
new file mode 100644
index 0000000..8a9480a
--- /dev/null
+++ b/src/utils/readingTime.ts
@@ -0,0 +1,9 @@
+export const calcReadingTime = (text: string): string => {
+ if (!text) return "∞ min read";
+
+ const wordsPerMinute = 300;
+ const wordCount = text.split(/\s+/).length;
+ const minutes = Math.ceil(wordCount / wordsPerMinute);
+
+ return `${minutes} min read`;
+};
\ 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..eca6668
--- /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 ac9478d..42e7df3 100644
--- a/vercel.json
+++ b/vercel.json
@@ -1,5 +1,11 @@
{
- "buildCommand": "",
- "framework" : null,
- "outputDirectory": "."
+ "buildCommand": "yarn build",
+ "framework": "vite",
+ "outputDirectory": "dist",
+ "rewrites": [
+ {
+ "source": "/posts/(.*)",
+ "destination": "/"
+ }
+ ]
}
\ No newline at end of file
diff --git a/vite.config.ts b/vite.config.ts
new file mode 100644
index 0000000..a90f1b9
--- /dev/null
+++ b/vite.config.ts
@@ -0,0 +1,34 @@
+import { markdownPlugin } from './build/markdownPlugin';
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+import wasm from "vite-plugin-wasm";
+import topLevelAwait from "vite-plugin-top-level-await";
+import { viteStaticCopy } from 'vite-plugin-static-copy';
+
+
+export default defineConfig({
+ plugins: [
+ react(),
+ wasm(),
+ topLevelAwait(),
+ markdownPlugin,
+ viteStaticCopy({
+ targets: [
+ {
+ src: 'src/posts/',
+ dest: '.'
+ }
+ ]
+ })
+ ],
+ build: {
+ sourcemap: true,
+ },
+ base: '/',
+ publicDir: 'src/public',
+ resolve: {
+ alias: {
+ '@': '/src',
+ },
+ },
+})