Compare commits

..

24 Commits

Author SHA1 Message Date
05b192e7ab commit to trigger deploy 2025-01-15 19:30:12 +01:00
edcaab4bd4 fix: use correct url 2025-01-15 18:17:04 +01:00
a99040f42e feat: some shit
Some checks failed
Deploy to GitHub Pages / build_site (push) Failing after 47s
2024-12-20 16:35:23 +01:00
fca59e87e5 feat: some shit 2024-12-20 16:35:16 +01:00
05e8970475 feat: use remote registry
All checks were successful
Deploy to GitHub Pages / build_site (push) Successful in 2m4s
2024-12-20 16:11:30 +01:00
385d1dd831 feat: use remote registry
All checks were successful
Deploy to GitHub Pages / build_site (push) Successful in 2m2s
2024-12-20 16:06:18 +01:00
dc46c4b64c feat: some stuff
All checks were successful
Deploy to GitHub Pages / build_site (push) Successful in 2m0s
2024-12-20 15:55:45 +01:00
15ff1cc52d feat: some shit
All checks were successful
Deploy to GitHub Pages / build_site (push) Successful in 2m4s
2024-12-20 15:24:54 +01:00
a70e8195a2 feat: add some more versining stuff 2024-12-20 14:06:33 +01:00
4ca36b324b fix: some shit
All checks were successful
Deploy to GitHub Pages / build_site (push) Successful in 1m55s
2024-12-20 13:40:14 +01:00
221817fc16 fix: some node
All checks were successful
Deploy to GitHub Pages / build_site (push) Successful in 2m1s
2024-12-20 12:51:56 +01:00
7060b37df5 fix: error in schema
Some checks failed
Deploy to GitHub Pages / build_site (push) Failing after 36s
2024-12-20 12:49:25 +01:00
ec037a3bbd fix: error in schema
Some checks failed
Deploy to GitHub Pages / build_site (push) Failing after 36s
2024-12-20 12:46:44 +01:00
2814165ee6 fix: run migrations in code
Some checks failed
Deploy to GitHub Pages / build_site (push) Failing after 36s
2024-12-20 12:42:45 +01:00
c6badff1ee fix: dockerfile
Some checks failed
Deploy to GitHub Pages / build_site (push) Failing after 36s
2024-12-20 12:36:11 +01:00
a0d420517c feat: some shit
Some checks failed
Deploy to GitHub Pages / build_site (push) Failing after 36s
2024-12-20 12:21:50 +01:00
eadd37bfa4 feat: some shit
Some checks failed
Deploy to GitHub Pages / build_site (push) Has been cancelled
2024-12-20 12:21:46 +01:00
9d698be86f fix: dockerfile
Some checks failed
Deploy to GitHub Pages / build_site (push) Failing after 38s
2024-12-20 12:09:30 +01:00
540d0549d7 feat: some shit
Some checks failed
Deploy to GitHub Pages / build_site (push) Failing after 37s
2024-12-19 23:55:07 +01:00
a740da1099 feat: merge svelte-5
Some checks failed
Deploy to GitHub Pages / build_site (push) Failing after 38s
2024-12-19 18:31:19 +01:00
33d5ed14dd WIP 2024-12-19 18:28:17 +01:00
53f400a4f6 fix: some svelte 5 issues 2024-12-19 15:35:22 +01:00
74b7cc4232 WIP 2024-12-19 15:09:17 +01:00
5c1c8c480b feat: some shit 2024-12-17 19:22:57 +01:00
80 changed files with 3705 additions and 3194 deletions

View File

@@ -13,34 +13,34 @@
"@nodes/registry": "link:../packages/registry",
"@nodes/ui": "link:../packages/ui",
"@nodes/utils": "link:../packages/utils",
"@sveltejs/kit": "^2.7.4",
"@sveltejs/kit": "^2.12.2",
"@threlte/core": "8.0.0-next.23",
"@threlte/extras": "9.0.0-next.33",
"@types/three": "^0.169.0",
"@unocss/reset": "^0.63.6",
"comlink": "^4.4.1",
"@types/three": "^0.171.0",
"@unocss/reset": "^0.65.2",
"comlink": "^4.4.2",
"file-saver": "^2.0.5",
"idb": "^8.0.0",
"idb": "^8.0.1",
"jsondiffpatch": "^0.6.0",
"three": "^0.170.0"
"three": "^0.171.0"
},
"devDependencies": {
"@iconify-json/tabler": "^1.2.7",
"@iconify-json/tabler": "^1.2.13",
"@nodes/types": "link:../packages/types",
"@sveltejs/adapter-static": "^3.0.6",
"@sveltejs/vite-plugin-svelte": "^4.0.0",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@tsconfig/svelte": "^5.0.4",
"@types/file-saver": "^2.0.7",
"@unocss/preset-icons": "^0.63.6",
"svelte": "^5.1.9",
"svelte-check": "^4.0.5",
"@unocss/preset-icons": "^0.65.2",
"svelte": "^5.14.4",
"svelte-check": "^4.1.1",
"tslib": "^2.8.1",
"typescript": "^5.6.3",
"unocss": "^0.63.6",
"vite": "^5.4.10",
"typescript": "^5.7.2",
"unocss": "^0.65.2",
"vite": "^6.0.4",
"vite-plugin-comlink": "^5.1.0",
"vite-plugin-glsl": "^1.3.0",
"vite-plugin-glsl": "^1.3.1",
"vite-plugin-wasm": "^3.3.0",
"vitest": "^2.1.4"
"vitest": "^2.1.8"
}
}

View File

@@ -15,7 +15,7 @@
var value = JSON.parse(store);
var themes = ["dark", "light", "catppuccin"];
if (themes[value.theme]) {
document.body.classList.add("theme-" + themes[value.theme]);
document.documentElement.classList.add("theme-" + themes[value.theme]);
}
} catch (e) { }
}

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import type { NodeDefinition, NodeRegistry } from "@nodes/types";
import { onMount } from "svelte";
import { onDestroy, onMount } from "svelte";
let mx = $state(0);
let my = $state(0);
@@ -39,9 +39,10 @@
}
onMount(() => {
wrapper?.parentElement?.setAttribute("style", "cursor:help !important");
const style = wrapper.parentElement?.style;
style?.setProperty("cursor", "help");
return () => {
wrapper?.parentElement?.style.removeProperty("cursor");
style?.removeProperty("cursor");
};
});
</script>
@@ -91,8 +92,9 @@
border-radius: 5px;
top: 10px;
left: 10px;
max-width: 250px;
border: 1px solid var(--outline);
z-index: 1000;
z-index: 10000;
display: none;
}

View File

@@ -1,10 +1,10 @@
<script lang="ts">
import { T } from "@threlte/core";
import BackgroundVert from "./Background.vert";
import BackgroundFrag from "./Background.frag";
import { colors } from "../graph/state.svelte";
import { colors } from "../graph/colors.svelte";
import { Color } from "three";
import { appSettings } from "$lib/settings/app-settings.svelte";
type Props = {
minZoom: number;
@@ -42,10 +42,10 @@
value: [0, 1, 0],
},
backgroundColor: {
value: new Color(0x171717),
value: colors["layer-0"].clone(),
},
lineColor: {
value: new Color(0x111111),
value: colors["outline"].clone(),
},
zoomLimits: {
value: [2, 50],
@@ -55,8 +55,9 @@
},
}}
uniforms.camPos.value={cameraPosition}
uniforms.backgroundColor.value={$colors["layer-0"]}
uniforms.lineColor.value={$colors["outline"]}
uniforms.backgroundColor.value={appSettings.theme &&
colors["layer-0"].clone()}
uniforms.lineColor.value={appSettings.theme && colors["outline"].clone()}
uniforms.zoomLimits.value={[minZoom, maxZoom]}
uniforms.dimensions.value={[width, height]}
/>

View File

@@ -1,13 +1,15 @@
<script module lang="ts">
import { colors } from "../graph/state.svelte";
import { colors } from "../graph/colors.svelte";
const circleMaterial = new MeshBasicMaterial({
color: get(colors).edge,
color: colors.edge.clone(),
toneMapped: false,
});
colors.subscribe((c) => {
circleMaterial.color.copy(c.edge.clone().convertSRGBToLinear());
$effect.root(() => {
$effect(() => {
appSettings.theme;
circleMaterial.color = colors.edge.clone().convertSRGBToLinear();
});
});
const lineCache = new Map<number, BufferGeometry>();
@@ -27,18 +29,21 @@
import { CubicBezierCurve } from "three/src/extras/curves/CubicBezierCurve.js";
import { Vector2 } from "three/src/math/Vector2.js";
import { createEdgeGeometry } from "./createEdgeGeometry.js";
import { get } from "svelte/store";
import { appSettings } from "$lib/settings/app-settings.svelte";
type Props = {
from: { x: number; y: number };
to: { x: number; y: number };
z: number;
};
const { from, to }: Props = $props();
const { from, to, z }: Props = $props();
let samples = 5;
let geometry: BufferGeometry | null = $state(null);
let geometry: BufferGeometry|null = $state(null);
const lineColor = $derived(
appSettings.theme && colors.edge.clone().convertSRGBToLinear(),
);
let lastId: number | null = null;
@@ -63,7 +68,8 @@
const length = Math.floor(
Math.sqrt(Math.pow(new_x, 2) + Math.pow(new_y, 2)) / 4,
);
samples = Math.min(Math.max(10, length), 60) * 2;
const samples = Math.max(length * 16, 10);
curve.v0.set(0, 0);
curve.v1.set(mid.x, 0);
@@ -77,15 +83,13 @@
geometry = createEdgeGeometry(points);
lineCache.set(curveId, geometry);
};
}
$effect(() => {
if (from || to) {
update();
}
});
const lineColor = $derived($colors.edge.clone().convertSRGBToLinear());
</script>
<T.Mesh
@@ -110,6 +114,6 @@
{#if geometry}
<T.Mesh position.x={from.x} position.z={from.y} position.y={0.1} {geometry}>
<MeshLineMaterial width={3} attenuate={false} color={lineColor} />
<MeshLineMaterial width={Math.max(z * 0.0001, 0.00001)} color={lineColor} />
</T.Mesh>
{/if}

View File

@@ -1,8 +1,12 @@
<script lang="ts">
import Edge from "./Edge.svelte";
type Props = { from: { x: number; y: number }; to: { x: number; y: number } };
const { from, to }: Props = $props();
type Props = {
from: { x: number; y: number };
to: { x: number; y: number };
z: number;
};
const { from, to, z }: Props = $props();
</script>
<Edge {from} {to} />
<Edge {from} {to} {z} />

View File

@@ -1,11 +1,29 @@
import { BufferGeometry, Vector3, BufferAttribute } from 'three'
import { setXY, setXYZ, setXYZW, setXYZXYZ } from './utils.js'
import { BufferAttribute, BufferGeometry, Vector3 } from 'three';
import { setXY, setXYZ, setXYZW, setXYZXYZ } from './utils.js';
export function createEdgeGeometry(points: Vector3[]) {
let shape = 'none'
let shapeFunction = (p: number) => 1
const length = points[0].distanceTo(points[points.length - 1]);
const startRadius = 8;
const constantWidth = 2;
const taperFraction = 0.8 / length;
function ease(t: number) {
return t * t * (3 - 2 * t);
}
let shapeFunction = (alpha: number) => {
if (alpha < taperFraction) {
const easedAlpha = ease(alpha / taperFraction);
return startRadius + (constantWidth - startRadius) * easedAlpha;
} else if (alpha > 1 - taperFraction) {
const easedAlpha = ease((alpha - (1 - taperFraction)) / taperFraction);
return constantWidth + (startRadius - constantWidth) * easedAlpha;
} else {
return constantWidth;
}
};
// When the component first runs we create the buffer geometry and allocate the buffer attributes
let pointCount = points.length
@@ -19,9 +37,7 @@ export function createEdgeGeometry(points: Vector3[]) {
let indices: number[] = []
let indicesIndex = 0
if (shape === 'taper') {
shapeFunction = (p: number) => 1 * Math.pow(4 * p * (1 - p), 1)
}
for (let j = 0; j < pointCount; j++) {
const c = j / points.length
@@ -30,7 +46,7 @@ export function createEdgeGeometry(points: Vector3[]) {
counterIndex += 2
setXY(side, doubleIndex, 1, -1)
let width = shape === 'none' ? 1 : shapeFunction(j / (pointCount - 1))
let width = shapeFunction((j / (pointCount - 1)))
setXY(widthArray, doubleIndex, width, width)
doubleIndex += 2

View File

@@ -6,7 +6,6 @@
} from "../helpers/index.js";
import type { OrthographicCamera } from "three";
import Background from "../background/Background.svelte";
import type { GraphManager } from "../graph-manager.js";
import { getContext, onMount, setContext } from "svelte";
import Camera from "../Camera.svelte";
import GraphView from "./GraphView.svelte";
@@ -23,14 +22,13 @@
import { Canvas } from "@threlte/core";
import { getGraphManager } from "./context.js";
const state = getGraphState();
const graphState = getGraphState();
export let snapToGrid = true;
export let showGrid = true;
export let showHelp = false;
let keymap =
getContext<ReturnType<typeof createKeyMap>>("keymap") || createKeyMap([]);
const keymap = getContext<ReturnType<typeof createKeyMap>>("keymap");
const manager = getGraphManager();
@@ -179,7 +177,7 @@
}
setContext("setDownSocket", (socket: Socket) => {
state.activeSocket = socket;
graphState.activeSocket = socket;
let { node, index, position } = socket;
@@ -198,14 +196,14 @@
}
mouseDown = position;
state.activeSocket = {
graphState.activeSocket = {
node,
index,
position,
};
state.possibleSockets = manager
.getPossibleSockets(state.activeSocket)
graphState.possibleSockets = manager
.getPossibleSockets(graphState.activeSocket)
.map(([node, index]) => {
return {
node,
@@ -259,14 +257,15 @@
let my = event.clientY - rect.y;
mousePosition = projectScreenToWorld(mx, my);
hoveredNodeId = getNodeIdFromEvent(event);
if (!mouseDown) return;
// we are creating a new edge here
if (state.activeSocket || state.possibleSockets?.length) {
if (graphState.activeSocket || graphState.possibleSockets?.length) {
let smallestDist = 1000;
let _socket;
for (const socket of state.possibleSockets) {
for (const socket of graphState.possibleSockets) {
const dist = Math.sqrt(
(socket.position[0] - mousePosition[0]) ** 2 +
(socket.position[1] - mousePosition[1]) ** 2,
@@ -279,9 +278,9 @@
if (_socket && smallestDist < 0.9) {
mousePosition = _socket.position;
state.hoveredSocket = _socket;
graphState.hoveredSocket = _socket;
} else {
state.hoveredSocket = null;
graphState.hoveredSocket = null;
}
return;
}
@@ -301,17 +300,17 @@
const y = node.position[1];
const height = getNodeHeight(node.type);
if (x > x1 - 20 && x < x2 && y > y1 - height && y < y2) {
state.selectedNodes?.add(node.id);
graphState.selectedNodes?.add(node.id);
} else {
state.selectedNodes?.delete(node.id);
graphState.selectedNodes?.delete(node.id);
}
}
return;
}
// here we are handling dragging of nodes
if (state.activeNodeId !== -1 && mouseDownId !== -1) {
const node = manager.getNode(state.activeNodeId);
if (graphState.activeNodeId !== -1 && mouseDownId !== -1) {
const node = manager.getNode(graphState.activeNodeId);
if (!node || event.buttons !== 1) return;
node.tmp = node.tmp || {};
@@ -340,8 +339,8 @@
const vecX = oldX - newX;
const vecY = oldY - newY;
if (state.selectedNodes?.size) {
for (const nodeId of state.selectedNodes) {
if (graphState.selectedNodes?.size) {
for (const nodeId of graphState.selectedNodes) {
const n = manager.getNode(nodeId);
if (!n?.tmp) continue;
n.tmp.x = (n?.tmp?.downX || 0) - vecX;
@@ -360,6 +359,7 @@
}
// here we are handling panning of camera
isPanning = true;
let newX = cameraDown[0] - (mx - mouseDown[0]) / cameraPosition[2];
let newY = cameraDown[1] - (my - mouseDown[1]) / cameraPosition[2];
@@ -424,43 +424,46 @@
// if we clicked on a node
if (clickedNodeId !== -1) {
if (state.activeNodeId === -1) {
state.activeNodeId = clickedNodeId;
if (graphState.activeNodeId === -1) {
graphState.activeNodeId = clickedNodeId;
// if the selected node is the same as the clicked node
} else if (state.activeNodeId === clickedNodeId) {
} else if (graphState.activeNodeId === clickedNodeId) {
//$activeNodeId = -1;
// if the clicked node is different from the selected node and secondary
} else if (event.ctrlKey) {
state.selectedNodes = state.selectedNodes || new Set();
state.selectedNodes.add(state.activeNodeId);
state.selectedNodes.delete(clickedNodeId);
state.activeNodeId = clickedNodeId;
graphState.selectedNodes.add(graphState.activeNodeId);
graphState.selectedNodes.delete(clickedNodeId);
graphState.activeNodeId = clickedNodeId;
// select the node
} else if (event.shiftKey) {
const activeNode = manager.getNode(state.activeNodeId);
const activeNode = manager.getNode(graphState.activeNodeId);
const newNode = manager.getNode(clickedNodeId);
if (activeNode && newNode) {
const edge = manager.getNodesBetween(activeNode, newNode);
if (edge) {
const selected = new Set(edge.map((n) => n.id));
selected.add(clickedNodeId);
state.selectedNodes = selected;
graphState.selectedNodes.clear();
for (const node of edge) {
graphState.selectedNodes.add(node.id);
}
graphState.selectedNodes.add(clickedNodeId);
}
}
} else if (!state.selectedNodes?.has(clickedNodeId)) {
state.activeNodeId = clickedNodeId;
state.clearSelection();
} else if (!graphState.selectedNodes.has(clickedNodeId)) {
graphState.activeNodeId = clickedNodeId;
graphState.clearSelection();
}
} else if (event.ctrlKey) {
boxSelection = true;
}
const node = manager.getNode(state.activeNodeId);
const node = manager.getNode(graphState.activeNodeId);
if (!node) return;
node.tmp = node.tmp || {};
node.tmp.downX = node.position[0];
node.tmp.downY = node.position[1];
if (state.selectedNodes) {
for (const nodeId of state.selectedNodes) {
if (graphState.selectedNodes) {
for (const nodeId of graphState.selectedNodes) {
const n = manager.getNode(nodeId);
if (!n) continue;
n.tmp = n.tmp || {};
@@ -471,8 +474,12 @@
}
function copyNodes() {
if (state.activeNodeId === -1 && !state.selectedNodes?.size) return;
let _nodes = [state.activeNodeId, ...(state.selectedNodes?.values() || [])]
if (graphState.activeNodeId === -1 && !graphState.selectedNodes?.size)
return;
let _nodes = [
graphState.activeNodeId,
...(graphState.selectedNodes?.values() || []),
]
.map((id) => manager.getNode(id))
.filter(Boolean) as Node[];
@@ -508,7 +515,10 @@
.filter(Boolean) as Node[];
const newNodes = manager.createGraph(_nodes, clipboard.edges);
state.selectedNodes = new Set(newNodes.map((n) => n.id));
graphState.selectedNodes.clear();
for (const node of newNodes) {
graphState.selectedNodes.add(node.id);
}
}
const isBodyFocused = () => document?.activeElement?.nodeName !== "INPUT";
@@ -517,12 +527,14 @@
key: "l",
description: "Select linked nodes",
callback: () => {
const activeNode = manager.getNode(state.activeNodeId);
const activeNode = manager.getNode(graphState.activeNodeId);
if (activeNode) {
const nodes = manager.getLinkedNodes(activeNode);
state.selectedNodes = new Set(nodes.map((n) => n.id));
graphState.selectedNodes.clear();
for (const node of nodes) {
graphState.selectedNodes.add(node.id);
}
}
console.log(activeNode);
},
});
@@ -552,8 +564,8 @@
key: "Escape",
description: "Deselect nodes",
callback: () => {
state.activeNodeId = -1;
state.clearSelection();
graphState.activeNodeId = -1;
graphState.clearSelection();
(document.activeElement as HTMLElement)?.blur();
},
});
@@ -605,7 +617,9 @@
description: "Select all nodes",
callback: () => {
if (!isBodyFocused()) return;
state.selectedNodes = new Set($nodes.keys());
for (const node of $nodes.keys()) {
graphState.selectedNodes.add(node);
}
},
});
@@ -654,38 +668,39 @@
callback: (event) => {
if (!isBodyFocused()) return;
manager.startUndoGroup();
if (state.activeNodeId !== -1) {
const node = manager.getNode(state.activeNodeId);
if (graphState.activeNodeId !== -1) {
const node = manager.getNode(graphState.activeNodeId);
if (node) {
manager.removeNode(node, { restoreEdges: event.ctrlKey });
state.activeNodeId = -1;
graphState.activeNodeId = -1;
}
}
if (state.selectedNodes) {
for (const nodeId of state.selectedNodes) {
if (graphState.selectedNodes) {
for (const nodeId of graphState.selectedNodes) {
const node = manager.getNode(nodeId);
if (node) {
manager.removeNode(node, { restoreEdges: event.ctrlKey });
}
}
state.clearSelection();
graphState.clearSelection();
}
manager.saveUndoGroup();
},
});
function handleMouseUp(event: MouseEvent) {
isPanning = false;
if (!mouseDown) return;
const activeNode = manager.getNode(state.activeNodeId);
const activeNode = manager.getNode(graphState.activeNodeId);
const clickedNodeId = getNodeIdFromEvent(event);
if (clickedNodeId !== -1) {
if (activeNode) {
if (!activeNode?.tmp?.isMoving && !event.ctrlKey && !event.shiftKey) {
state.clearSelection();
state.activeNodeId = clickedNodeId;
graphState.activeNodeId = clickedNodeId;
graphState.clearSelection();
}
}
}
@@ -708,7 +723,7 @@
activeNode.position[1] = activeNode?.tmp?.y ?? activeNode.position[1];
}
const nodes = [
...[...(state.selectedNodes?.values() || [])].map((id) =>
...[...(graphState.selectedNodes?.values() || [])].map((id) =>
manager.getNode(id),
),
] as NodeType[];
@@ -747,26 +762,26 @@
$edges = $edges;
});
manager.save();
} else if (state.hoveredSocket && state.activeSocket) {
} else if (graphState.hoveredSocket && graphState.activeSocket) {
if (
typeof state.hoveredSocket.index === "number" &&
typeof state.activeSocket.index === "string"
typeof graphState.hoveredSocket.index === "number" &&
typeof graphState.activeSocket.index === "string"
) {
manager.createEdge(
state.hoveredSocket.node,
state.hoveredSocket.index || 0,
state.activeSocket.node,
state.activeSocket.index,
graphState.hoveredSocket.node,
graphState.hoveredSocket.index || 0,
graphState.activeSocket.node,
graphState.activeSocket.index,
);
} else if (
typeof state.activeSocket.index == "number" &&
typeof state.hoveredSocket.index === "string"
typeof graphState.activeSocket.index == "number" &&
typeof graphState.hoveredSocket.index === "string"
) {
manager.createEdge(
state.activeSocket.node,
state.activeSocket.index || 0,
state.hoveredSocket.node,
state.hoveredSocket.index,
graphState.activeSocket.node,
graphState.activeSocket.index || 0,
graphState.hoveredSocket.node,
graphState.hoveredSocket.index,
);
}
manager.save();
@@ -780,22 +795,25 @@
cameraDown[1] === cameraPosition[1] &&
isBodyFocused()
) {
state.activeNodeId = -1;
state.clearSelection();
graphState.activeNodeId = -1;
graphState.clearSelection();
}
mouseDown = null;
boxSelection = false;
state.activeSocket = null;
state.possibleSockets = [];
state.hoveredSocket = null;
graphState.activeSocket = null;
graphState.possibleSockets = [];
graphState.hoveredSocket = null;
addMenuPosition = null;
}
let isPanning = false;
let isDragging = false;
let hoveredNodeId = -1;
function handleMouseLeave() {
isDragging = false;
isPanning = false;
}
function handleDrop(event: DragEvent) {
@@ -865,16 +883,19 @@
function handleDragEnter(e: DragEvent) {
e.preventDefault();
isDragging = true;
isPanning = false;
}
function handlerDragOver(e: DragEvent) {
isDragging = true;
e.preventDefault();
isDragging = true;
isPanning = false;
}
function handleDragEnd(e: DragEvent) {
isDragging = false;
e.preventDefault();
isDragging = true;
isPanning = false;
}
onMount(() => {
@@ -893,6 +914,8 @@
on:wheel={handleMouseScroll}
bind:this={wrapper}
class="graph-wrapper"
class:is-panning={isPanning}
class:is-hovering={hoveredNodeId !== -1}
aria-label="Graph"
role="button"
tabindex="0"
@@ -916,9 +939,6 @@
/>
<label for="drop-zone"></label>
{#if showHelp}
<HelpView registry={manager.registry} />
{/if}
<Canvas shadows={false} renderMode="on-demand" colorManagementEnabled={false}>
<Camera bind:camera position={cameraPosition} />
@@ -943,11 +963,12 @@
<AddMenu bind:position={addMenuPosition} graph={manager} />
{/if}
{#if state.activeSocket}
{#if graphState.activeSocket}
<FloatingEdge
z={cameraPosition[2]}
from={{
x: state.activeSocket.position[0],
y: state.activeSocket.position[1],
x: graphState.activeSocket.position[0],
y: graphState.activeSocket.position[1],
}}
to={{ x: mousePosition[0], y: mousePosition[1] }}
/>
@@ -962,6 +983,10 @@
</Canvas>
</div>
{#if showHelp}
<HelpView registry={manager.registry} />
{/if}
<style>
.graph-wrapper {
position: relative;
@@ -969,6 +994,15 @@
transition: opacity 0.3s ease;
height: 100%;
}
.is-hovering {
cursor: pointer;
}
.is-panning {
cursor: grab;
}
input {
position: absolute;
z-index: 1;

View File

@@ -6,10 +6,23 @@
import { getContext, onMount } from "svelte";
import type { Writable } from "svelte/store";
import { getGraphState } from "./state.svelte";
import { useThrelte } from "@threlte/core";
import { appSettings } from "$lib/settings/app-settings.svelte";
export let nodes: Writable<Map<number, NodeType>>;
export let edges: Writable<EdgeType[]>;
export let cameraPosition = [0, 0, 4];
type Props = {
nodes: Writable<Map<number, NodeType>>;
edges: Writable<EdgeType[]>;
cameraPosition: [number, number, number];
};
const { nodes, edges, cameraPosition = [0, 0, 4] }: Props = $props();
const { invalidate } = useThrelte();
$effect(() => {
appSettings.theme;
invalidate();
});
const graphState = getGraphState();
@@ -23,7 +36,6 @@
function getEdgePosition(edge: EdgeType) {
const pos1 = getSocketPosition(edge[0], edge[1]);
const pos2 = getSocketPosition(edge[2], edge[3]);
return [pos1[0], pos1[1], pos2[0], pos2[1]];
}
@@ -41,6 +53,7 @@
{@const pos = getEdgePosition(edge)}
{@const [x1, y1, x2, y2] = pos}
<Edge
z={cameraPosition[2]}
from={{
x: x1,
y: y1,

View File

@@ -3,19 +3,18 @@
import GraphEl from "./Graph.svelte";
import { GraphManager } from "../graph-manager.js";
import { setContext } from "svelte";
import { type Writable } from "svelte/store";
import { debounce } from "$lib/helpers";
import { createKeyMap } from "$lib/helpers/createKeyMap";
import { GraphState } from "./state.svelte";
const state = new GraphState();
setContext("graphState", state);
const graphState = new GraphState();
setContext("graphState", graphState);
type Props = {
graph: Graph;
registry: NodeRegistry;
settings?: Writable<Record<string, any>>;
settings?: Record<string, any>;
activeNode?: Node;
showGrid?: boolean;
@@ -41,33 +40,32 @@
}: Props = $props();
export const keymap = createKeyMap([]);
setContext("keymap", keymap);
export const manager = new GraphManager(registry);
setContext("graphManager", manager);
$effect(() => {
if (state.activeNodeId !== -1) {
activeNode = manager.getNode(state.activeNodeId);
} else {
if (graphState.activeNodeId !== -1) {
activeNode = manager.getNode(graphState.activeNodeId);
} else if (activeNode) {
activeNode = undefined;
}
});
setContext("keymap", keymap);
const updateSettings = debounce((s) => {
manager.setSettings(s);
}, 200);
$effect(() => {
if (settingTypes && settings) {
updateSettings($settings);
updateSettings($state.snapshot(settings));
}
});
manager.on("settings", (_settings) => {
settingTypes = _settings.types;
settings?.set(_settings.values);
settingTypes = { ...settingTypes, ..._settings.types };
settings = _settings.values;
});
manager.on("result", (result) => onresult?.(result));

View File

@@ -0,0 +1,32 @@
import { appSettings } from "$lib/settings/app-settings.svelte";
import { Color, LinearSRGBColorSpace } from "three";
const variables = [
"layer-0",
"layer-1",
"layer-2",
"layer-3",
"outline",
"active",
"selected",
"edge",
] as const;
function getColor(variable: typeof variables[number]) {
const style = getComputedStyle(document.body.parentElement!);
let color = style.getPropertyValue(`--${variable}`);
return new Color().setStyle(color, LinearSRGBColorSpace);
}
export const colors = Object.fromEntries(variables.map(v => [v, getColor(v)])) as Record<typeof variables[number], Color>;
$effect.root(() => {
$effect(() => {
if (!appSettings.theme || !("getComputedStyle" in globalThis)) return;
const style = getComputedStyle(document.body.parentElement!);
for (const v of variables) {
const hex = style.getPropertyValue(`--${v}`);
colors[v].setStyle(hex, LinearSRGBColorSpace);
}
});
})

View File

@@ -1,48 +0,0 @@
import { readable } from "svelte/store";
import { Color } from "three";
const variables = [
"layer-0",
"layer-1",
"layer-2",
"layer-3",
"outline",
"active",
"selected",
"edge",
] as const;
const store = Object.fromEntries(variables.map(v => [v, new Color()])) as Record<typeof variables[number], Color>;
let lastStyle = "";
function updateColors() {
if (!("getComputedStyle" in globalThis)) return;
const style = getComputedStyle(document.body.parentElement!);
let hash = "";
for (const v of variables) {
let color = style.getPropertyValue(`--${v}`);
hash += color;
store[v].setStyle(color);
}
if (hash === lastStyle) return;
lastStyle = hash;
}
export const colors = readable(store, set => {
updateColors();
set(store);
setTimeout(() => {
updateColors();
set(store);
}, 1000);
window.onload = function () { updateColors(); set(store) };
document.body.addEventListener("transitionstart", () => {
updateColors();
set(store);
})
});

View File

@@ -1,26 +1,22 @@
import type { Socket } from "@nodes/types";
import { getContext } from "svelte";
import { SvelteSet } from 'svelte/reactivity';
export function getGraphState() {
return getContext<GraphState>("graphState");
}
export class GraphState {
activeNodeId = $state(-1);
selectedNodes = $state(new Set<number>());
selectedNodes = new SvelteSet<number>();
activeSocket = $state<Socket | null>(null);
hoveredSocket = $state<Socket | null>(null);
possibleSockets = $state<Socket[]>([]);
possibleSocketIds = $derived(new Set(
this.possibleSockets.map((s) => `${s.node.id}-${s.index}`),
));
clearSelection() {
this.selectedNodes = new Set();
this.selectedNodes.clear();
}
}
export { colors } from "./colors";

View File

@@ -1,12 +1,14 @@
<script lang="ts">
import type { Node } from "@nodes/types";
import { getContext, onMount } from "svelte";
import { colors, getGraphState } from "../graph/state.svelte";
import { getGraphState } from "../graph/state.svelte";
import { T } from "@threlte/core";
import { Color, type Mesh } from "three";
import { type Mesh } from "three";
import NodeFrag from "./Node.frag";
import NodeVert from "./Node.vert";
import NodeHtml from "./NodeHTML.svelte";
import { colors } from "../graph/colors.svelte";
import { appSettings } from "$lib/settings/app-settings.svelte";
const graphState = getGraphState();
@@ -18,7 +20,16 @@
const { node, inView, z }: Props = $props();
const isActive = $derived(graphState.activeNodeId === node.id);
const isSelected = $derived(!!graphState.selectedNodes?.has(node.id));
const isSelected = $derived(graphState.selectedNodes.has(node.id));
let strokeColor = $state(colors.selected);
$effect(() => {
appSettings.theme;
strokeColor = isSelected
? colors.selected
: isActive
? colors.active
: colors.outline;
});
const updateNodePosition =
getContext<(n: Node) => void>("updateNodePosition");
@@ -30,16 +41,11 @@
const height = getNodeHeight?.(node.type);
$effect(() => {
if (node && meshRef) {
node.tmp = node.tmp || {};
node.tmp.mesh = meshRef;
updateNodePosition?.(node);
}
});
const colorBright = $colors["layer-2"];
const colorDark = $colors["layer-1"];
onMount(() => {
node.tmp = node.tmp || {};
node.tmp.mesh = meshRef;
@@ -61,20 +67,14 @@
fragmentShader={NodeFrag}
transparent
uniforms={{
uColorBright: { value: new Color("#171717") },
uColorDark: { value: new Color("#151515") },
uStrokeColor: { value: new Color("#9d5f28") },
uColorBright: { value: colors["layer-2"] },
uColorDark: { value: colors["layer-1"] },
uStrokeColor: { value: colors.outline.clone() },
uStrokeWidth: { value: 1.0 },
uWidth: { value: 20 },
uHeight: { value: height },
}}
uniforms.uColorBright.value={colorBright}
uniforms.uColorDark.value={colorDark}
uniforms.uStrokeColor.value={isSelected
? $colors.selected
: isActive
? $colors.active
: $colors.outline}
uniforms.uStrokeColor.value={strokeColor.clone()}
uniforms.uStrokeWidth.value={(7 - z) / 3}
/>
</T.Mesh>

View File

@@ -3,14 +3,26 @@
import NodeHeader from "./NodeHeader.svelte";
import NodeParameter from "./NodeParameter.svelte";
import { getContext, onMount } from "svelte";
export let isActive = false;
export let isSelected = false;
export let inView = true;
export let z = 2;
let ref: HTMLDivElement;
export let node: Node;
export let position = "absolute";
type Props = {
node: Node;
position?: "absolute" | "fixed" | "relative";
isActive?: boolean;
isSelected?: boolean;
inView?: boolean;
z?: number;
};
let {
node = $bindable(),
position = "absolute",
isActive = false,
isSelected = false,
inView = true,
z = 2,
}: Props = $props();
const zOffset = (node.tmp?.random || 0) * 0.5;
const zLimit = 2 - zOffset;
@@ -25,12 +37,6 @@
const updateNodePosition =
getContext<(n: Node) => void>("updateNodePosition");
$: if (node && ref) {
node.tmp = node.tmp || {};
node.tmp.ref = ref;
updateNodePosition?.(node);
}
onMount(() => {
node.tmp = node.tmp || {};
node.tmp.ref = ref;

View File

@@ -26,9 +26,9 @@
const aspectRatio = 0.25;
const path = createNodePath({
depth: 5,
height: 29,
y: 50,
depth: 5.5,
height: 34,
y: 49,
cornerTop,
rightBump,
aspectRatio,
@@ -42,9 +42,9 @@
aspectRatio,
});
const pathHover = createNodePath({
depth: 9,
depth: 8.5,
height: 50,
y: 50,
y: 49,
cornerTop,
rightBump,
aspectRatio,
@@ -103,12 +103,12 @@
svg {
position: absolute;
top: 0;
left: 0;
top: 1px;
left: 1px;
z-index: -1;
box-sizing: border-box;
width: 100%;
height: 100%;
width: calc(100% - 2px);
height: calc(100% - 1px);
overflow: visible;
}

View File

@@ -19,7 +19,18 @@
const graph = getGraphManager();
let value = $state(node?.props?.[id] ?? input.value);
function getDefaultValue() {
if (node?.props?.[id] !== undefined) return node?.props?.[id] as number;
if ("value" in input && input?.value !== undefined)
return input?.value as number;
if (input.type === "boolean") return 0;
if (input.type === "float") return 0.5;
if (input.type === "integer") return 0;
if (input.type === "select") return 0;
return 0;
}
let value = $state(getDefaultValue());
$effect(() => {
if (value !== undefined && node?.props?.[id] !== value) {

View File

@@ -53,14 +53,14 @@
const path = createNodePath({
depth: 7,
height: 20,
y: 51,
y: 50.5,
cornerBottom,
leftBump,
aspectRatio,
});
const pathDisabled = createNodePath({
depth: 4.5,
height: 14,
depth: 6,
height: 18,
y: 50.5,
cornerBottom,
leftBump,
@@ -172,11 +172,11 @@
svg {
position: absolute;
box-sizing: border-box;
width: 100%;
width: calc(100% - 2px);
height: 100%;
overflow: visible;
top: 0;
left: 0;
left: 1px;
z-index: -1;
}

View File

@@ -18,7 +18,6 @@ export function createKeyMap(keys: Shortcut[]) {
const store = writable(new Map(keys.map(k => [getShortcutId(k), k])));
return {
handleKeyboardEvent: (event: KeyboardEvent) => {
const activeElement = document.activeElement as HTMLElement;

View File

@@ -23,7 +23,7 @@
<div class="wrapper">
{#if !activeUser}
{#await registry.fetchUsers()}
<div>Loading...</div>
<div>Loading Users...</div>
{:then users}
{#each users as user}
<button
@@ -37,7 +37,7 @@
{/await}
{:else if !activeCollection}
{#await registry.fetchUser(activeUser)}
<div>Loading...</div>
<div>Loading User...</div>
{:then user}
{#each user.collections as collection}
<button
@@ -53,11 +53,11 @@
{/await}
{:else if !activeNode}
{#await registry.fetchCollection(`${activeUser}/${activeCollection}`)}
<div>Loading...</div>
<div>Loading Collection...</div>
{:then collection}
{#each collection.nodes as node}
{#await registry.fetchNodeDefinition(node.id)}
<div>Loading... {node.id}</div>
<div>Loading Node... {node.id}</div>
{:then node}
{#if node}
<DraggableNode {node} />

View File

@@ -11,9 +11,9 @@
fps: false,
});
$: vertices = $store?.at(-1)?.["total-vertices"][0] || 0;
$: faces = $store?.at(-1)?.["total-faces"][0] || 0;
$: runtime = $store?.at(-1)?.["runtime"][0] || 0;
$: vertices = $store?.at(-1)?.["total-vertices"]?.[0] || 0;
$: faces = $store?.at(-1)?.["total-faces"]?.[0] || 0;
$: runtime = $store?.at(-1)?.["runtime"]?.[0] || 0;
function getPoints(data: PerformanceData, key: string) {
return data?.map((run) => run[key]?.[0] || 0) || [];

View File

@@ -9,21 +9,28 @@
Box3,
Mesh,
MeshBasicMaterial,
Color,
} from "three";
import { AppSettings } from "../settings/app-settings";
import { appSettings } from "../settings/app-settings.svelte";
import Camera from "./Camera.svelte";
import { colors } from "$lib/graph-interface/graph/colors.svelte";
const { renderStage, invalidate: _invalidate } = useThrelte();
export let fps: number[] = [];
// let renderer = threlte.renderer;
// let rendererRender = renderer.render;
// renderer.render = function (scene, camera) {
// const a = performance.now();
// rendererRender.call(renderer, scene, camera);
// fps.push(performance.now() - a);
// fps = fps.slice(-100);
// };
type Props = {
fps: number[];
lines: Vector3[][];
scene: Group;
centerCamera: boolean;
};
let {
lines,
centerCamera,
fps = $bindable(),
scene = $bindable(),
}: Props = $props();
useTask(
(delta) => {
fps.push(1 / delta);
@@ -53,12 +60,8 @@
_invalidate();
};
let geometries: BufferGeometry[] = [];
export let lines: Vector3[][];
export let scene: Group;
export let centerCamera: boolean = true;
let center = new Vector3(0, 4, 0);
let geometries = $state<BufferGeometry[]>();
let center = $state(new Vector3(0, 4, 0));
function isMesh(child: Mesh | any): child is Mesh {
return child.isObject3D && "material" in child;
@@ -68,14 +71,15 @@
return material.isMaterial && "matcap" in material;
}
$: if ($AppSettings && scene) {
$effect(() => {
const wireframe = appSettings.debug.wireframe;
scene.traverse(function (child) {
if (isMesh(child) && isMatCapMaterial(child.material)) {
child.material.wireframe = $AppSettings.wireframe;
child.material.wireframe = wireframe;
}
});
invalidate();
}
_invalidate();
});
function getPosition(geo: BufferGeometry, i: number) {
return [
@@ -88,13 +92,18 @@
<Camera {center} {centerCamera} />
{#if $AppSettings.showGrid}
<T.GridHelper args={[20, 20]} />
{#if appSettings.showGrid}
<T.GridHelper
args={[20, 20]}
colorGrid={colors["outline"]}
colorCenterLine={new Color("red")}
/>
{/if}
<T.Group>
{#if geometries}
{#each geometries as geo}
{#if $AppSettings.showIndices}
{#if appSettings.debug.showIndices}
{#each geo.attributes.position.array as _, i}
{#if i % 3 === 0}
<Text fontSize={0.25} position={getPosition(geo, i)} />
@@ -102,18 +111,19 @@
{/each}
{/if}
{#if $AppSettings.showVertices}
{#if appSettings.debug.showVertices}
<T.Points visible={true}>
<T is={geo} />
<T.PointsMaterial size={0.25} />
</T.Points>
{/if}
{/each}
{/if}
<T.Group bind:ref={scene}></T.Group>
</T.Group>
{#if $AppSettings.showStemLines && lines}
{#if appSettings.debug.showStemLines && lines}
{#each lines as line}
<T.Mesh>
<MeshLineGeometry points={line} />

View File

@@ -2,12 +2,10 @@
import { Canvas } from "@threlte/core";
import Scene from "./Scene.svelte";
import { Vector3 } from "three";
import { decodeFloat, splitNestedArray } from "@nodes/utils";
import type { PerformanceStore } from "@nodes/utils";
import { AppSettings } from "$lib/settings/app-settings";
import { appSettings } from "$lib/settings/app-settings.svelte";
import SmallPerformanceViewer from "$lib/performance/SmallPerformanceViewer.svelte";
import { MeshMatcapMaterial, TextureLoader, type Group } from "three";
import {
createGeometryPool,
@@ -22,9 +20,11 @@
matcap,
});
let sceneComponent = $state<ReturnType<typeof Scene>>();
let fps = $state<number[]>([]);
let geometryPool: ReturnType<typeof createGeometryPool>;
let instancePool: ReturnType<typeof createInstancedGeometryPool>;
export function updateGeometries(inputs: Int32Array[], group: Group) {
geometryPool = geometryPool || createGeometryPool(group, material);
instancePool = instancePool || createInstancedGeometryPool(group, material);
@@ -38,14 +38,15 @@
};
}
export let centerCamera: boolean = true;
export let perf: PerformanceStore;
export let scene: Group;
let fps: number[] = [];
type Props = {
scene: Group;
centerCamera: boolean;
perf: PerformanceStore;
};
let lines: Vector3[][] = [];
let { scene = $bindable(), centerCamera, perf }: Props = $props();
let invalidate: () => void;
let lines = $state<Vector3[][]>([]);
function createLineGeometryFromEncodedData(encodedData: Int32Array) {
const positions: Vector3[] = [];
@@ -63,12 +64,12 @@
}
export const update = function update(result: Int32Array) {
perf?.addPoint("split-result");
perf.addPoint("split-result");
const inputs = splitNestedArray(result);
perf?.endPoint();
perf.endPoint();
if ($AppSettings.showStemLines) {
perf?.addPoint("create-lines");
if (appSettings.debug.showStemLines) {
perf.addPoint("create-lines");
lines = inputs
.map((input) => {
if (input[0] === 0) {
@@ -79,21 +80,27 @@
perf.endPoint();
}
perf?.addPoint("update-geometries");
perf.addPoint("update-geometries");
const { totalVertices, totalFaces } = updateGeometries(inputs, scene);
perf?.endPoint();
perf.endPoint();
perf?.addPoint("total-vertices", totalVertices);
perf?.addPoint("total-faces", totalFaces);
invalidate();
perf.addPoint("total-vertices", totalVertices);
perf.addPoint("total-faces", totalFaces);
sceneComponent?.invalidate();
};
</script>
{#if $AppSettings.showPerformancePanel}
{#if appSettings.debug.showPerformancePanel}
<SmallPerformanceViewer {fps} store={perf} />
{/if}
<Canvas>
<Scene bind:scene bind:invalidate {lines} {centerCamera} bind:fps />
<Scene
bind:this={sceneComponent}
{lines}
{centerCamera}
bind:scene
bind:fps
/>
</Canvas>

View File

@@ -1,6 +1,5 @@
import { fastHashArrayBuffer } from "@nodes/utils";
import { BufferAttribute, BufferGeometry, Float32BufferAttribute, Group, InstancedMesh, Material, Matrix4, Mesh } from "three"
import { BufferAttribute, BufferGeometry, Float32BufferAttribute, Group, InstancedMesh, Material, Matrix4, Mesh } from "three";
function fastArrayHash(arr: ArrayBuffer) {
let ints = new Uint8Array(arr);
@@ -108,7 +107,6 @@ export function createGeometryPool(parentScene: Group, material: Material) {
scene.add(mesh);
meshes.push(mesh);
}
}

View File

@@ -1,6 +1,5 @@
import type { Graph, NodeRegistry, NodeDefinition, RuntimeExecutor, NodeInput } from "@nodes/types";
import { concatEncodedArrays, encodeFloat, fastHashArrayBuffer, createLogger, type PerformanceStore } from "@nodes/utils"
import type { SyncCache } from "@nodes/types";
import type { Graph, NodeDefinition, NodeInput, NodeRegistry, RuntimeExecutor, SyncCache } from "@nodes/types";
import { concatEncodedArrays, createLogger, encodeFloat, fastHashArrayBuffer, type PerformanceStore } from "@nodes/utils";
const log = createLogger("runtime-executor");
log.mute()
@@ -9,6 +8,7 @@ function getValue(input: NodeInput, value?: unknown) {
if (value === undefined && "value" in input) {
value = input.value
}
if (input.type === "float") {
return encodeFloat(value as number);
}

View File

@@ -5,9 +5,6 @@ import type { Graph, RuntimeExecutor } from "@nodes/types";
export class WorkerRuntimeExecutor implements RuntimeExecutor {
private worker = new ComlinkWorker<typeof import('./worker-runtime-executor-backend.ts')>(new URL(`./worker-runtime-executor-backend.ts`, import.meta.url));
constructor() {
console.log(import.meta.url)
}
async execute(graph: Graph, settings: Record<string, unknown>) {
return this.worker.executeGraph(graph, settings);
}

View File

@@ -0,0 +1,177 @@
<script module lang="ts">
let openSections = localState<Record<string, boolean>>("open-details", {});
</script>
<script lang="ts">
import NestedSettings from "./NestedSettings.svelte";
import { localState } from "$lib/helpers/localState.svelte";
import type { NodeInput } from "@nodes/types";
import Input from "@nodes/ui";
type Button = { type: "button"; label?: string };
type InputType = NodeInput | Button;
interface Nested {
[key: string]: (Nested & { title?: string }) | InputType;
}
type SettingsType = Record<string, Nested>;
type SettingsValue = Record<
string,
Record<string, unknown> | string | number | boolean | number[]
>;
type Props = {
id: string;
key?: string;
value: SettingsValue;
type: SettingsType;
depth?: number;
};
let { id, key = "", value = $bindable(), type, depth = 0 }: Props = $props();
function isNodeInput(v: InputType | Nested): v is InputType {
return v && "type" in v;
}
function getDefaultValue() {
if (key === "") return;
if (key === "title") return;
if (Array.isArray(type[key]?.options)) {
if (value?.[key] !== undefined) {
return type[key]?.options?.indexOf(value?.[key]);
} else {
return 0;
}
}
if (value?.[key] !== undefined) return value?.[key];
if (type[key]?.value !== undefined) return type[key]?.value;
if (isNodeInput(type[key])) {
if (type[key].type === "boolean") return 0;
if (type[key].type === "float") return 0.5;
if (type[key].type === "integer") return 0;
if (type[key].type === "select") return 0;
}
return 0;
}
let internalValue = $state(getDefaultValue());
let open = $state(openSections[id]);
if (depth > 0 && !isNodeInput(type[key])) {
$effect(() => {
if (open !== undefined) {
openSections[id] = open;
}
});
}
$effect(() => {
if (key === "" || internalValue === undefined) return;
if (
isNodeInput(type[key]) &&
Array.isArray(type[key]?.options) &&
typeof internalValue === "number"
) {
value[key] = type[key].options?.[internalValue];
} else {
value[key] = internalValue;
}
});
</script>
{#if key && isNodeInput(type?.[key])}
<div class="input input-{type[key].type}" class:first-level={depth === 1}>
{#if type[key].type === "button"}
<button onclick={() => console.log(type[key])}>
{type[key].label || key}
</button>
{:else}
<label for={id}>{type[key].label || key}</label>
<Input {id} input={type[key]} bind:value={internalValue} />
{/if}
</div>
{:else if depth === 0}
{#each Object.keys(type ?? {}).filter((key) => key !== "title") as childKey}
<NestedSettings
id={`${id}.${childKey}`}
key={childKey}
{value}
{type}
depth={depth + 1}
/>
{/each}
<hr />
{:else if key && type?.[key]}
{#if depth > 0}
<hr />
{/if}
<details bind:open>
<summary><p>{type[key]?.title || key}</p></summary>
<div class="content">
{#each Object.keys(type[key]).filter((key) => key !== "title") as childKey}
<NestedSettings
id={`${id}.${childKey}`}
key={childKey}
value={value[key] as SettingsValue}
type={type[key] as SettingsType}
depth={depth + 1}
/>
{/each}
</div>
</details>
{/if}
<style>
summary {
cursor: pointer;
user-select: none;
margin-bottom: 1em;
}
summary > p {
display: inline;
padding-left: 6px;
}
details {
padding: 1em;
padding-bottom: 0;
padding-left: 21px;
}
.input {
margin-top: 15px;
margin-bottom: 15px;
display: flex;
flex-direction: column;
gap: 10px;
padding-left: 20px;
}
.input-boolean {
display: flex;
flex-direction: row;
align-items: center;
}
.input-boolean > label {
order: 2;
}
.first-level.input {
padding-left: 1em;
padding-right: 1em;
padding-bottom: 1px;
}
hr {
position: absolute;
margin: 0;
left: 0;
right: 0;
border: none;
border-bottom: solid thin var(--outline);
}
</style>

View File

@@ -39,6 +39,7 @@ export const AppSettingTypes = {
}
},
debug: {
title: "Debug",
wireframe: {
type: "boolean",
label: "Wireframe",
@@ -79,7 +80,8 @@ export const AppSettingTypes = {
amount: {
type: "integer",
min: 2,
max: 15
max: 15,
value: 4
},
loadGrid: {
type: "button",
@@ -103,16 +105,26 @@ export const AppSettingTypes = {
}
},
}
} as const
} as const;
type IsInputDefinition<T> = T extends NodeInput ? T : never;
type HasTitle = { title: string };
type Widen<T> = T extends boolean
? boolean
: T extends number
? number
: T extends string
? string
: T;
type ExtractSettingsValues<T> = {
[K in keyof T]: T[K] extends HasTitle
-readonly [K in keyof T]: T[K] extends HasTitle
? ExtractSettingsValues<Omit<T[K], 'title'>>
: T[K] extends IsInputDefinition<T[K]>
? T[K] extends { value: any }
? T[K]['value']
? T[K] extends { value: infer V }
? Widen<V>
: never
: T[K] extends Record<string, any>
? ExtractSettingsValues<T[K]>
@@ -138,8 +150,8 @@ export const appSettings = localState("app-settings", settingsToStore(AppSetting
$effect.root(() => {
$effect(() => {
const { theme } = $state.snapshot(appSettings);
const classes = document.body.parentElement?.classList;
const theme = appSettings.theme;
const classes = document.documentElement.classList;
const newClassName = `theme-${theme}`;
if (classes) {
for (const className of classes) {
@@ -148,6 +160,6 @@ $effect.root(() => {
}
}
}
document.body?.parentElement?.classList.add(newClassName);
document.documentElement.classList.add(newClassName);
});
});

View File

@@ -1,136 +0,0 @@
import localStore from "$lib/helpers/localStore";
export const AppSettings = localStore("node.settings", {
theme: 0,
showGrid: true,
showNodeGrid: true,
snapToGrid: true,
showHelp: false,
wireframe: false,
showIndices: false,
showVertices: false,
showPerformancePanel: false,
showBenchmarkPanel: false,
centerCamera: true,
showStemLines: false,
useWorker: true,
amount: 5
});
const themes = ["dark", "light", "catppuccin", "solarized", "high-contrast", "nord", "dracula"];
AppSettings.subscribe((value) => {
const classes = document.body.parentElement?.classList;
const newClassName = `theme-${themes[value.theme]}`;
if (classes) {
for (const className of classes) {
if (className.startsWith("theme-") && className !== newClassName) {
classes.remove(className);
}
}
}
document.body?.parentElement?.classList.add(newClassName);
});
export const AppSettingTypes = {
theme: {
type: "select",
options: themes,
label: "Theme",
value: themes[0],
},
showGrid: {
type: "boolean",
label: "Show Grid",
value: true,
},
centerCamera: {
type: "boolean",
label: "Center Camera",
value: true
},
nodeInterface: {
__title: "Node Interface",
showNodeGrid: {
type: "boolean",
label: "Show Grid",
value: true
},
snapToGrid: {
type: "boolean",
label: "Snap to Grid",
value: true
},
showHelp: {
type: "boolean",
label: "Show Help",
value: false
}
},
debug: {
wireframe: {
type: "boolean",
label: "Wireframe",
value: false,
},
useWorker: {
type: "boolean",
label: "Execute runtime in worker",
value: true,
},
showIndices: {
type: "boolean",
label: "Show Indices",
value: false,
},
showPerformancePanel: {
type: "boolean",
label: "Show Performance Panel",
value: false,
},
showBenchmarkPanel: {
type: "boolean",
label: "Show Benchmark Panel",
value: false,
},
showVertices: {
type: "boolean",
label: "Show Vertices",
value: false,
},
showStemLines: {
type: "boolean",
label: "Show Stem Lines",
value: false,
},
stressTest: {
__title: "Stress Test",
amount: {
type: "integer",
min: 2,
max: 15
},
loadGrid: {
type: "button",
label: "Load Grid"
},
loadTree: {
type: "button",
label: "Load Tree"
},
lottaFaces: {
type: "button",
label: "Load 'lots of faces'"
},
lottaNodes: {
type: "button",
label: "Load 'lots of nodes'"
},
lottaNodesAndFaces: {
type: "button",
label: "Load 'lots of nodes and faces'"
}
},
}
}

View File

@@ -0,0 +1,13 @@
import type { NodeInput } from "@nodes/types";
type Button = { type: "button"; label?: string };
type InputType = NodeInput | Button;
export interface SettingsType {
[key: string]: (SettingsType & { title?: string }) | InputType;
}
export type SettingsStore = {
[key: string]: SettingsStore | string | number | boolean
};

View File

@@ -1,89 +0,0 @@
<script lang="ts">
import type { Node, NodeInput } from "@nodes/types";
import NestedSettings from "./NestedSettings.svelte";
import { writable } from "svelte/store";
import type { GraphManager } from "$lib/graph-interface/graph-manager";
export let manager: GraphManager;
export let node: Node | undefined;
function filterInputs(inputs: Record<string, NodeInput>) {
return Object.fromEntries(
Object.entries(inputs)
.filter(([_key, value]) => {
return value.hidden === true;
})
.map(([key, value]) => {
//@ts-ignore
value.__node_type = node?.tmp?.type.id;
//@ts-ignore
value.__node_input = key;
return [key, value];
}),
);
}
function createStore(
props: Node["props"],
inputs: Record<string, NodeInput>,
) {
const store: Record<string, unknown> = {};
Object.keys(inputs).forEach((key) => {
if (props) {
//@ts-ignore
store[key] = props[key] || inputs[key].value;
}
});
return writable(store);
}
let nodeDefinition: Record<string, NodeInput> | undefined;
$: nodeDefinition = node?.tmp?.type
? filterInputs(node.tmp.type.inputs)
: undefined;
$: store = node ? createStore(node.props, nodeDefinition) : undefined;
let lastPropsHash = "";
function updateNode() {
if (!node || !$store) return;
let needsUpdate = false;
Object.keys($store).forEach((_key: string) => {
node.props = node.props || {};
const key = _key as keyof typeof $store;
if (node && $store) {
needsUpdate = true;
node.props[key] = $store[key];
}
});
let propsHash = JSON.stringify(node.props);
if (propsHash === lastPropsHash) {
return;
}
lastPropsHash = propsHash;
// console.log(needsUpdate, node.props, $store);
if (needsUpdate) {
manager.execute();
}
}
$: if (store && $store) {
updateNode();
}
</script>
{#if node}
{#key node.id}
{#if nodeDefinition && store && Object.keys(nodeDefinition).length > 0}
<NestedSettings
id="activeNodeSettings"
settings={nodeDefinition}
{store}
/>
{:else}
<p class="mx-4">Active Node has no Settings</p>
{/if}
{/key}
{:else}
<p class="mx-4">No active node</p>
{/if}

View File

@@ -1,40 +0,0 @@
<script lang="ts">
import type { NodeInput } from "@nodes/types";
import NestedSettings from "./NestedSettings.svelte";
import type { Writable } from "svelte/store";
interface Nested {
[key: string]: NodeInput | Nested;
}
export let type: Record<string, NodeInput>;
export let store: Writable<Record<string, any>>;
function constructNested(type: Record<string, NodeInput>) {
const nested: Nested = {};
for (const key in type) {
const parts = key.split(".");
let current = nested;
for (let i = 0; i < parts.length; i++) {
if (i === parts.length - 1) {
current[parts[i]] = type[key];
} else {
current[parts[i]] = current[parts[i]] || {};
current = current[parts[i]] as Nested;
}
}
}
return nested;
}
$: settings = constructNested({
randomSeed: { type: "boolean", value: false },
...type,
});
</script>
{#key settings}
<NestedSettings id="graph-settings" {settings} {store} />
{/key}

View File

@@ -1,60 +0,0 @@
<script lang="ts">
import type { createKeyMap } from "$lib/helpers/createKeyMap";
import { ShortCut } from "@nodes/ui";
export let keymap: ReturnType<typeof createKeyMap>;
const keys = keymap?.keys;
export let title = "Keymap";
</script>
<div class="wrapper">
<h3>{title}</h3>
<section>
{#each $keys as key}
{#if key.description}
<div class="command-wrapper">
<ShortCut
alt={key.alt}
ctrl={key.ctrl}
shift={key.shift}
key={key.key}
/>
</div>
<p>{key.description}</p>
{/if}
{/each}
</section>
</div>
<style>
.wrapper {
padding: 1em;
display: flex;
flex-direction: column;
gap: 1em;
}
section {
display: grid;
grid-template-columns: min-content 1fr;
gap: 1em;
}
h3 {
margin: 0;
}
.command-wrapper {
display: flex;
justify-content: right;
align-items: center;
}
p {
font-size: 0.9em;
margin: 0;
display: flex;
align-items: center;
}
</style>

View File

@@ -1,147 +0,0 @@
<script lang="ts">
import NestedSettings from "./NestedSettings.svelte";
import {localState} from "$lib/helpers/localState.svelte";
import type { NodeInput } from "@nodes/types";
import Input from "@nodes/ui";
type Button = { type: "button"; label?: string };
type InputType = NodeInput | Button;
interface Nested {
[key: string]: (Nested & { title?: string }) | InputType;
}
type Props = {
id: string;
key?: string;
value: Record<string, unknown> | string | number | boolean;
type: Nested;
depth?: number;
};
let { id, key = "", value = $bindable(), type, depth = 0 }: Props = $props();
function isNodeInput(v: InputType | Nested): v is InputType {
return v && "type" in v;
}
let internalValue = $state(Array.isArray(type?.[key]?.options) ? type[key]?.options?.indexOf(value?.[key]) : value?.[key]);
let openSections = localState("open-details", {});
let open = $state(openSections[id]);
if(depth > 0 && !isNodeInput(type[key])){
$effect(() => {
if(open !== undefined){}
openSections[id] = open;
});
}
$effect(() => {
if(key === "" || internalValue === undefined) return;
if(isNodeInput(type[key]) && Array.isArray(type[key]?.options) && typeof internalValue === "number"){
value[key] = type[key].options?.[internalValue];
}else{
value[key] = internalValue;
}
})
</script>
{#if key && isNodeInput(type?.[key]) }
<div class="input input-{type[key].type}">
{#if type[key].type === "button"}
<button onclick={() => console.log(type[key])}>
{type[key].label || key}
</button>
{:else}
<label for={id}>{type[key].label || key}</label>
<Input id={id} input={type[key]} bind:value={internalValue} />
{/if}
</div>
{:else}
{#if depth === 0}
{#each Object.keys(type).filter((key) => key !== "title") as childKey}
<NestedSettings
id={`${id}.${childKey}`}
key={childKey}
value={value as Record<string, unknown>}
type={type as Nested}
depth={depth + 1}
/>
{/each}
{#if depth > 0}
<hr />
{/if}
{:else if key && type?.[key]}
{#if depth > 0}
<hr />
{/if}
<details bind:open>
<summary>{type[key]?.title||key}</summary>
<div class="content">
{#each Object.keys(type[key]).filter((key) => key !== "title") as childKey}
<NestedSettings
id={`${id}.${childKey}`}
key={childKey}
value={value[key] as Record<string, unknown>}
type={type[key] as Nested}
depth={depth + 1}
/>
{/each}
</div>
</details>
{/if}
{/if}
<style>
summary {
cursor: pointer;
user-select: none;
margin-bottom: 1em;
}
details {
padding: 1em;
padding-bottom: 0;
}
.input {
margin-top: 15px;
margin-bottom: 15px;
display: flex;
flex-direction: column;
gap: 10px;
padding-left: 14px;
}
.input-boolean {
display: flex;
flex-direction: row;
align-items: center;
}
.input-boolean > label {
order: 2;
}
.first-level > .input {
padding-right: 1rem;
}
.first-level {
border-bottom: solid thin var(--outline);
}
.first-level > details {
border: none;
}
hr {
position: absolute;
margin: 0;
left: 0;
right: 0;
border: none;
border-bottom: solid thin var(--outline);
}
</style>

View File

@@ -0,0 +1,82 @@
<script lang="ts">
import type { Node, NodeInput } from "@nodes/types";
import NestedSettings from "$lib/settings/NestedSettings.svelte";
import type { GraphManager } from "$lib/graph-interface/graph-manager";
type Props = {
manager: GraphManager;
node: Node;
};
const { manager, node }: Props = $props();
const nodeDefinition = filterInputs(node.tmp?.type?.inputs);
function filterInputs(inputs?: Record<string, NodeInput>) {
const _inputs = $state.snapshot(inputs);
return Object.fromEntries(
Object.entries(structuredClone(_inputs ?? {}))
.filter(([_key, value]) => {
return value.hidden === true;
})
.map(([key, value]) => {
//@ts-ignore
value.__node_type = node?.tmp?.type.id;
//@ts-ignore
value.__node_input = key;
return [key, value];
}),
);
}
type Store = Record<string, number | number[]>;
let store = $state<Store>(createStore(node?.props, nodeDefinition));
function createStore(
props: Node["props"],
inputs: Record<string, NodeInput>,
): Store {
const store: Store = {};
Object.keys(inputs).forEach((key) => {
if (props) {
//@ts-ignore
store[key] = props[key] || inputs[key].value;
}
});
return store;
}
let lastPropsHash = "";
function updateNode() {
if (!node || !store) return;
let needsUpdate = false;
Object.keys(store).forEach((_key: string) => {
node.props = node.props || {};
const key = _key as keyof typeof store;
if (node && store) {
needsUpdate = true;
node.props[key] = store[key];
}
});
let propsHash = JSON.stringify(node.props);
if (propsHash === lastPropsHash) {
return;
}
lastPropsHash = propsHash;
if (needsUpdate) {
manager.execute();
}
}
$effect(() => {
if (store) {
updateNode();
}
});
</script>
<NestedSettings
id="activeNodeSettings"
bind:value={store}
type={nodeDefinition}
/>

View File

@@ -0,0 +1,24 @@
<script lang="ts">
import type { Node } from "@nodes/types";
import type { GraphManager } from "$lib/graph-interface/graph-manager";
import ActiveNodeSelected from "./ActiveNodeSelected.svelte";
type Props = {
manager: GraphManager;
node: Node | undefined;
};
const { manager, node }: Props = $props();
</script>
{#if node}
{#key node.id}
{#if node}
<ActiveNodeSelected {manager} {node} />
{:else}
<p class="mx-4">Active Node has no Settings</p>
{/if}
{/key}
{:else}
<p class="mx-4">No active node</p>
{/if}

View File

@@ -3,7 +3,6 @@
import type { OBJExporter } from "three/addons/exporters/OBJExporter.js";
import type { GLTFExporter } from "three/addons/exporters/GLTFExporter.js";
import FileSaver from "file-saver";
import { appSettings } from "../app-settings.svelte";
// Download
const download = (
@@ -52,8 +51,6 @@
// download .obj file
download(result, "plant", "text/plain", "obj");
}
</script>
<div class="p-2">

View File

@@ -0,0 +1,66 @@
<script lang="ts">
import type { createKeyMap } from "$lib/helpers/createKeyMap";
import { ShortCut } from "@nodes/ui";
import { get } from "svelte/store";
type Props = {
keymaps: {
keymap: ReturnType<typeof createKeyMap>;
title: string;
}[];
};
let { keymaps }: Props = $props();
console.log({ keymaps });
</script>
<table class="wrapper">
<tbody>
{#each keymaps as keymap}
<tr>
<td colspan="2">
<h3>{keymap.title}</h3>
</td>
</tr>
{#each get(keymap.keymap?.keys) as key}
<tr>
{#if key.description}
<td class="command-wrapper">
<ShortCut
alt={key.alt}
ctrl={key.ctrl}
shift={key.shift}
key={key.key}
/>
</td>
<td>{key.description}</td>
{/if}
</tr>
{/each}
{/each}
</tbody>
</table>
<style>
.wrapper {
padding: 1em;
}
h3 {
margin: 0;
}
.command-wrapper {
display: flex;
justify-content: right;
align-items: center;
}
td {
font-size: 0.9em;
margin: 0;
padding: 7px;
padding-left: 0;
align-items: center;
}
</style>

View File

@@ -4,23 +4,20 @@
import * as templates from "$lib/graph-templates";
import type { Graph, Node } from "@nodes/types";
import Viewer from "$lib/result-viewer/Viewer.svelte";
import Settings from "$lib/settings/Settings.svelte";
import { AppSettingTypes, AppSettings } from "$lib/settings/app-settings";
import {
appSettings as _appSettings,
AppSettingTypes as _AppSettingTypes,
appSettings,
AppSettingTypes,
} from "$lib/settings/app-settings.svelte";
import { writable } from "svelte/store";
import Keymap from "$lib/settings/panels/Keymap.svelte";
import Keymap from "$lib/sidebar/panels/Keymap.svelte";
import Sidebar from "$lib/sidebar/Sidebar.svelte";
import { createKeyMap } from "$lib/helpers/createKeyMap";
import NodeStore from "$lib/node-store/NodeStore.svelte";
import ActiveNodeSettings from "$lib/settings/panels/ActiveNodeSettings.svelte";
import ActiveNodeSettings from "$lib/sidebar/panels/ActiveNodeSettings.svelte";
import PerformanceViewer from "$lib/performance/PerformanceViewer.svelte";
import Panel from "$lib/settings/Panel.svelte";
import GraphSettings from "$lib/settings/panels/GraphSettings.svelte";
import NestedSettings from "$lib/settings/panels/NestedSettings.svelte";
import Panel from "$lib/sidebar/Panel.svelte";
import NestedSettings from "$lib/settings/NestedSettings.svelte";
import type { Group } from "three";
import ExportSettings from "$lib/settings/panels/ExportSettings.svelte";
import ExportSettings from "$lib/sidebar/panels/ExportSettings.svelte";
import {
MemoryRuntimeCache,
WorkerRuntimeExecutor,
@@ -28,37 +25,42 @@
} from "$lib/runtime";
import { IndexDBCache, RemoteNodeRegistry } from "@nodes/registry";
import { createPerformanceStore } from "@nodes/utils";
import BenchmarkPanel from "$lib/settings/panels/BenchmarkPanel.svelte";
import BenchmarkPanel from "$lib/sidebar/panels/BenchmarkPanel.svelte";
import { debounceAsyncFunction } from "$lib/helpers";
import { onMount } from "svelte";
let performanceStore = createPerformanceStore();
const registryCache = new IndexDBCache("node-registry");
const nodeRegistry = new RemoteNodeRegistry("");
nodeRegistry.cache = registryCache;
const nodeRegistry = new RemoteNodeRegistry(
"https://node-store.app.max-richter.dev",
registryCache,
);
const workerRuntime = new WorkerRuntimeExecutor();
const runtimeCache = new MemoryRuntimeCache();
const memoryRuntime = new MemoryRuntimeExecutor(nodeRegistry, runtimeCache);
memoryRuntime.perf = performanceStore;
$: runtime = $AppSettings.useWorker ? workerRuntime : memoryRuntime;
const runtime = $derived(
appSettings.debug.useWorker ? workerRuntime : memoryRuntime,
);
let activeNode: Node | undefined;
let scene: Group;
let updateViewerResult: (result: Int32Array) => void;
let activeNode = $state<Node | undefined>(undefined);
let scene = $state<Group>(null!);
let graph = localStorage.getItem("graph")
? JSON.parse(localStorage.getItem("graph")!)
: templates.defaultPlant;
let graphInterface: ReturnType<typeof GraphInterface>;
$: manager = graphInterface?.manager;
$: managerStatus = manager?.status;
$: keymap = graphInterface?.keymap;
let graphInterface = $state<ReturnType<typeof GraphInterface>>(null!);
let viewerComponent = $state<ReturnType<typeof Viewer>>();
const manager = $derived(graphInterface?.manager);
const managerStatus = $derived(manager?.status);
async function randomGenerate() {
if (!manager) return;
const g = manager.serialize();
const s = { ...$graphSettings, randomSeed: true };
const s = { ...graphSettings, randomSeed: true };
await handleUpdate(g, s);
}
@@ -69,18 +71,20 @@
callback: randomGenerate,
},
]);
let graphSettings = writable<Record<string, any>>({});
let graphSettingTypes = {};
let graphSettings = $state<Record<string, any>>({});
let graphSettingTypes = $state({
randomSeed: { type: "boolean", value: false },
});
const handleUpdate = debounceAsyncFunction(
async (g: Graph, s: Record<string, any>) => {
async (g: Graph, s: Record<string, any> = graphSettings) => {
performanceStore.startRun();
try {
let a = performance.now();
const graphResult = await runtime.execute(g, s);
const graphResult = await runtime.execute(g, $state.snapshot(s));
let b = performance.now();
if ($AppSettings.useWorker) {
if (appSettings.debug.useWorker) {
let perfData = await runtime.getPerformanceData();
let lastRun = perfData?.at(-1);
if (lastRun?.total) {
@@ -94,7 +98,7 @@
}
}
updateViewerResult(graphResult);
viewerComponent?.update(graphResult);
} catch (error) {
console.log("errors", error);
} finally {
@@ -103,32 +107,35 @@
},
);
$: if (AppSettings) {
//@ts-ignore
AppSettingTypes.debug.stressTest.loadGrid.callback = () => {
graph = templates.grid($AppSettings.amount, $AppSettings.amount);
};
//@ts-ignore
AppSettingTypes.debug.stressTest.loadTree.callback = () => {
graph = templates.tree($AppSettings.amount);
};
//@ts-ignore
AppSettingTypes.debug.stressTest.lottaFaces.callback = () => {
graph = templates.lottaFaces;
};
//@ts-ignore
AppSettingTypes.debug.stressTest.lottaNodes.callback = () => {
graph = templates.lottaNodes;
};
//@ts-ignore
AppSettingTypes.debug.stressTest.lottaNodesAndFaces.callback = () => {
graph = templates.lottaNodesAndFaces;
};
}
// $ if (AppSettings) {
// //@ts-ignore
// AppSettingTypes.debug.stressTest.loadGrid.callback = () => {
// graph = templates.grid($AppSettings.amount, $AppSettings.amount);
// };
// //@ts-ignore
// AppSettingTypes.debug.stressTest.loadTree.callback = () => {
// graph = templates.tree($AppSettings.amount);
// };
// //@ts-ignore
// AppSettingTypes.debug.stressTest.lottaFaces.callback = () => {
// graph = templates.lottaFaces;
// };
// //@ts-ignore
// AppSettingTypes.debug.stressTest.lottaNodes.callback = () => {
// graph = templates.lottaNodes;
// };
// //@ts-ignore
// AppSettingTypes.debug.stressTest.lottaNodesAndFaces.callback = () => {
// graph = templates.lottaNodesAndFaces;
// };
// }
function handleSave(graph: Graph) {
localStorage.setItem("graph", JSON.stringify(graph));
}
onMount(() => {
handleUpdate(graph);
});
</script>
<svelte:document on:keydown={applicationKeymap.handleKeyboardEvent} />
@@ -137,10 +144,10 @@
<Grid.Row>
<Grid.Cell>
<Viewer
perf={performanceStore}
bind:scene
bind:update={updateViewerResult}
centerCamera={$AppSettings.centerCamera}
bind:this={viewerComponent}
perf={performanceStore}
centerCamera={appSettings.centerCamera}
/>
</Grid.Cell>
<Grid.Cell>
@@ -149,21 +156,21 @@
bind:this={graphInterface}
{graph}
registry={nodeRegistry}
showGrid={appSettings.nodeInterface.showNodeGrid}
snapToGrid={appSettings.nodeInterface.snapToGrid}
bind:activeNode
showGrid={$AppSettings.showNodeGrid}
snapToGrid={$AppSettings.snapToGrid}
bind:showHelp={$AppSettings.showHelp}
bind:showHelp={appSettings.nodeInterface.showHelp}
bind:settings={graphSettings}
bind:settingTypes={graphSettingTypes}
onresult={(result) => handleUpdate(result, $graphSettings)}
onresult={(result) => handleUpdate(result)}
onsave={(graph) => handleSave(graph)}
/>
<Settings>
<Sidebar>
<Panel id="general" title="General" icon="i-tabler-settings">
<NestedSettings
id="general"
value={_appSettings}
type={_AppSettingTypes}
value={appSettings}
type={AppSettingTypes}
/>
</Panel>
<Panel
@@ -171,10 +178,12 @@
title="Keyboard Shortcuts"
icon="i-tabler-keyboard"
>
<Keymap title="Application" keymap={applicationKeymap} />
{#if keymap}
<Keymap title="Node-Editor" {keymap} />
{/if}
<Keymap
keymaps={[
{ keymap: applicationKeymap, title: "Application" },
{ keymap: graphInterface.keymap, title: "Node-Editor" },
]}
/>
</Panel>
<Panel id="exports" title="Exporter" icon="i-tabler-package-export">
<ExportSettings {scene} />
@@ -191,7 +200,7 @@
id="performance"
title="Performance"
classes="text-red-400"
hidden={!$AppSettings.showPerformancePanel}
hidden={!appSettings.debug.showPerformancePanel}
icon="i-tabler-brand-speedtest"
>
{#if $performanceStore}
@@ -202,7 +211,7 @@
id="benchmark"
title="Benchmark"
classes="text-red-400"
hidden={!$AppSettings.showBenchmarkPanel}
hidden={!appSettings.debug.showBenchmarkPanel}
icon="i-tabler-graph"
>
<BenchmarkPanel run={randomGenerate} />
@@ -213,9 +222,11 @@
classes="text-blue-400"
icon="i-custom-graph"
>
{#if Object.keys(graphSettingTypes).length > 0}
<GraphSettings type={graphSettingTypes} store={graphSettings} />
{/if}
<NestedSettings
id="graph-settings"
type={graphSettingTypes}
bind:value={graphSettings}
/>
</Panel>
<Panel
id="active-node"
@@ -225,7 +236,7 @@
>
<ActiveNodeSettings {manager} node={activeNode} />
</Panel>
</Settings>
</Sidebar>
{/key}
</Grid.Cell>
</Grid.Row>

View File

@@ -17,15 +17,15 @@
"type": "float",
"hidden": true,
"min": 0,
"value": 0.5,
"max": 1
"max": 1,
"value": 0.5
},
"depth": {
"type": "integer",
"min": 1,
"max": 10,
"value": 1,
"hidden": true
"hidden": true,
"value": 1
}
}
}

View File

@@ -8,11 +8,5 @@
"build:deploy": "pnpm build",
"dev": "pnpm -r --filter 'app' --filter './packages/node-registry' dev"
},
"packageManager": "pnpm@9.11.0+sha512.0a203ffaed5a3f63242cd064c8fb5892366c103e328079318f78062f24ea8c9d50bc6a47aa3567cabefd824d170e78fa2745ed1f16b132e16436146b7688f19b",
"dependencies": {
"@types/pg": "^8.11.10",
"drizzle-kit": "^0.30.1",
"drizzle-orm": "^0.38.2",
"pg": "^8.13.1"
}
"packageManager": "pnpm@9.15.0+sha512.76e2379760a4328ec4415815bcd6628dee727af3779aaa4c914e3944156c4299921a89f976381ee107d41f12cfa4b66681ca9c718f0668fa0831ed4c6d8ba56c"
}

View File

@@ -13,7 +13,7 @@ export class RemoteNodeRegistry implements NodeRegistry {
fetch: typeof fetch = globalThis.fetch.bind(globalThis);
constructor(private url: string) { }
constructor(private url: string, private cache?: AsyncCache<ArrayBuffer>) { }
async fetchUsers() {
const response = await this.fetch(`${this.url}/nodes/users.json`);
@@ -24,7 +24,7 @@ export class RemoteNodeRegistry implements NodeRegistry {
}
async fetchUser(userId: `${string}`) {
const response = await this.fetch(`${this.url}/nodes/${userId}.json`);
const response = await this.fetch(`${this.url}/user/${userId}.json`);
if (!response.ok) {
throw new Error(`Failed to load user ${userId}`);
}

View File

@@ -0,0 +1,19 @@
{
"name": "store-client",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"generate": "openapi-ts -i ../../store/openapi.json -o src/client -c @hey-api/client-fetch"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@hey-api/client-fetch": "^0.5.6"
},
"devDependencies": {
"@hey-api/openapi-ts": "^0.60.0"
}
}

View File

@@ -0,0 +1,3 @@
// This file is auto-generated by @hey-api/openapi-ts
export * from './sdk.gen';
export * from './types.gen';

View File

@@ -0,0 +1,55 @@
// This file is auto-generated by @hey-api/openapi-ts
import { createClient, createConfig, type OptionsLegacyParser } from '@hey-api/client-fetch';
import type { GetV1NodesByUserJsonData, GetV1NodesByUserJsonError, GetV1NodesByUserJsonResponse, GetV1NodesByUserBySystemJsonData, GetV1NodesByUserBySystemJsonError, GetV1NodesByUserBySystemJsonResponse, GetV1NodesByUserBySystemByNodeIdby-.+jsonData, GetV1NodesByUserBySystemByNodeIdby-.+jsonError, GetV1NodesByUserBySystemByNodeIdby-.+jsonResponse, GetV1NodesByUserBySystemByNodeIdby-.+WasmData, GetV1NodesByUserBySystemByNodeIdby-.+WasmError, GetV1NodesByUserBySystemByNodeIdby-.+WasmResponse, PostV1NodesError, PostV1NodesResponse, GetV1UsersUsersJsonError, GetV1UsersUsersJsonResponse, GetV1UsersByUserIdJsonData, GetV1UsersByUserIdJsonError, GetV1UsersByUserIdJsonResponse } from './types.gen';
export const client = createClient(createConfig());
export const getV1NodesByUserJson = <ThrowOnError extends boolean = false>(options: OptionsLegacyParser<GetV1NodesByUserJsonData, ThrowOnError>) => {
return (options?.client ?? client).get<GetV1NodesByUserJsonResponse, GetV1NodesByUserJsonError, ThrowOnError>({
...options,
url: '/v1/nodes/{user}.json'
});
};
export const getV1NodesByUserBySystemJson = <ThrowOnError extends boolean = false>(options: OptionsLegacyParser<GetV1NodesByUserBySystemJsonData, ThrowOnError>) => {
return (options?.client ?? client).get<GetV1NodesByUserBySystemJsonResponse, GetV1NodesByUserBySystemJsonError, ThrowOnError>({
...options,
url: '/v1/nodes/{user}/{system}.json'
});
};
export const getV1NodesByUserBySystemByNodeIdby-.+Json = <ThrowOnError extends boolean = false>(options: OptionsLegacyParser<GetV1NodesByUserBySystemByNodeIdby-.+jsonData, ThrowOnError>) => {
return (options?.client ?? client).get<GetV1NodesByUserBySystemByNodeIdby-.+jsonResponse, GetV1NodesByUserBySystemByNodeIdby-.+jsonError, ThrowOnError>({
...options,
url: '/v1/nodes/{user}/{system}/{nodeId}{.+\\.json}'
});
};
export const getV1NodesByUserBySystemByNodeIdby-.+Wasm = <ThrowOnError extends boolean = false>(options: OptionsLegacyParser<GetV1NodesByUserBySystemByNodeIdby-.+WasmData, ThrowOnError>) => {
return (options?.client ?? client).get<GetV1NodesByUserBySystemByNodeIdby-.+WasmResponse, GetV1NodesByUserBySystemByNodeIdby-.+WasmError, ThrowOnError>({
...options,
url: '/v1/nodes/{user}/{system}/{nodeId}{.+\\.wasm}'
});
};
export const postV1Nodes = <ThrowOnError extends boolean = false>(options?: OptionsLegacyParser<unknown, ThrowOnError>) => {
return (options?.client ?? client).post<PostV1NodesResponse, PostV1NodesError, ThrowOnError>({
...options,
url: '/v1/nodes'
});
};
export const getV1UsersUsersJson = <ThrowOnError extends boolean = false>(options?: OptionsLegacyParser<unknown, ThrowOnError>) => {
return (options?.client ?? client).get<GetV1UsersUsersJsonResponse, GetV1UsersUsersJsonError, ThrowOnError>({
...options,
url: '/v1/users/users.json'
});
};
export const getV1UsersByUserIdJson = <ThrowOnError extends boolean = false>(options?: OptionsLegacyParser<GetV1UsersByUserIdJsonData, ThrowOnError>) => {
return (options?.client ?? client).get<GetV1UsersByUserIdJsonResponse, GetV1UsersByUserIdJsonError, ThrowOnError>({
...options,
url: '/v1/users/{userId}.json'
});
};

View File

@@ -0,0 +1,173 @@
// This file is auto-generated by @hey-api/openapi-ts
export type NodeDefinition = {
id: string;
inputs?: {
[key: string]: NodeInput;
};
outputs?: Array<(string)>;
meta?: {
description?: string;
title?: string;
};
};
export type NodeInput = {
internal?: boolean;
external?: boolean;
setting?: string;
label?: string;
description?: string;
accepts?: Array<(string)>;
hidden?: boolean;
type: 'seed';
value?: number;
} | {
internal?: boolean;
external?: boolean;
setting?: string;
label?: string;
description?: string;
accepts?: Array<(string)>;
hidden?: boolean;
type: 'boolean';
value?: boolean;
} | {
internal?: boolean;
external?: boolean;
setting?: string;
label?: string;
description?: string;
accepts?: Array<(string)>;
hidden?: boolean;
type: 'float';
element?: 'slider';
value?: number;
min?: number;
max?: number;
step?: number;
} | {
internal?: boolean;
external?: boolean;
setting?: string;
label?: string;
description?: string;
accepts?: Array<(string)>;
hidden?: boolean;
type: 'integer';
element?: 'slider';
value?: number;
min?: number;
max?: number;
} | {
internal?: boolean;
external?: boolean;
setting?: string;
label?: string;
description?: string;
accepts?: Array<(string)>;
hidden?: boolean;
type: 'select';
options?: Array<(string)>;
value?: number;
} | {
internal?: boolean;
external?: boolean;
setting?: string;
label?: string;
description?: string;
accepts?: Array<(string)>;
hidden?: boolean;
type: 'vec3';
value?: Array<(number)>;
} | {
internal?: boolean;
external?: boolean;
setting?: string;
label?: string;
description?: string;
accepts?: Array<(string)>;
hidden?: boolean;
type: 'geometry';
} | {
internal?: boolean;
external?: boolean;
setting?: string;
label?: string;
description?: string;
accepts?: Array<(string)>;
hidden?: boolean;
type: 'path';
};
export type type = 'seed';
export type element = 'slider';
export type User = {
id: string;
name: string;
};
export type GetV1NodesByUserJsonData = {
path: {
user: string;
};
};
export type GetV1NodesByUserJsonResponse = (Array<NodeDefinition>);
export type GetV1NodesByUserJsonError = unknown;
export type GetV1NodesByUserBySystemJsonData = {
path: {
system?: string;
user: string;
};
};
export type GetV1NodesByUserBySystemJsonResponse = (Array<NodeDefinition>);
export type GetV1NodesByUserBySystemJsonError = unknown;
export type GetV1NodesByUserBySystemByNodeIdby-.+jsonData = {
path: {
nodeId: string;
system: string;
user: string;
};
};
export type GetV1NodesByUserBySystemByNodeIdby-.+jsonResponse = (NodeDefinition);
export type GetV1NodesByUserBySystemByNodeIdby-.+jsonError = unknown;
export type GetV1NodesByUserBySystemByNodeIdby-.+WasmData = {
path: {
nodeId: string;
system: string;
user: string;
};
};
export type GetV1NodesByUserBySystemByNodeIdby-.+WasmResponse = (unknown);
export type GetV1NodesByUserBySystemByNodeIdby-.+WasmError = unknown;
export type PostV1NodesResponse = (NodeDefinition);
export type PostV1NodesError = unknown;
export type GetV1UsersUsersJsonResponse = (Array<User>);
export type GetV1UsersUsersJsonError = unknown;
export type GetV1UsersByUserIdJsonData = {
path?: {
userId?: string;
};
};
export type GetV1UsersByUserIdJsonResponse = (User);
export type GetV1UsersByUserIdJsonError = unknown;

View File

@@ -34,6 +34,6 @@
}
.content {
padding-left: 12px;
/* padding-left: 12px; */
}
</style>

3642
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

13
store/Dockerfile Normal file
View File

@@ -0,0 +1,13 @@
FROM denoland/deno:alpine
ARG GIT_REVISION
ENV DENO_DEPLOYMENT_ID=${GIT_REVISION}
WORKDIR /app
COPY . .
RUN deno cache src/server.ts
EXPOSE 8000
CMD ["task", "run"]

View File

@@ -44,7 +44,8 @@ for await (const dir of dirs) {
async function postNode(node: Node) {
const wasmContent = await Deno.readFile(node.path);
const url = `http://localhost:8000/v1/nodes`;
const url = `http://localhost:8000/nodes`;
// const url = "https://node-store.app.max-richter.dev/nodes";
const res = await fetch(url, {
method: "POST",
@@ -55,7 +56,7 @@ async function postNode(node: Node) {
console.log(`Uploaded ${node.id}`);
} else {
const text = await res.text();
console.log(`Failed to upload ${node.id}: ${text}`);
console.log(`Failed to upload ${node.id}: ${res.status} ${text}`);
}
}

View File

@@ -9,7 +9,7 @@ services:
volumes:
- .:/app
- deno-cache:/deno-dir/
command: run --allow-net --allow-env --allow-read --watch src/main.ts
command: task dev
depends_on:
- db

View File

@@ -1,6 +1,7 @@
{
"tasks": {
"dev": "deno run --watch main.ts",
"dev": "deno run -A --watch src/server.ts",
"run": "deno run -A src/server.ts",
"test": "deno run vitest",
"drizzle": "podman-compose exec app deno --env -A --node-modules-dir npm:drizzle-kit",
"upload": "deno run --allow-read --allow-net bin/upload.ts"
@@ -11,6 +12,7 @@
"@hono/zod-openapi": "npm:@hono/zod-openapi@^0.18.3",
"@std/assert": "jsr:@std/assert@1",
"@types/pg": "npm:@types/pg@^8.11.10",
"drizzle-kit": "npm:drizzle-kit@^0.30.1",
"drizzle-orm": "npm:drizzle-orm@^0.38.2",
"hono": "npm:hono@^4.6.14",
"pg": "npm:pg@^8.13.1",

2
store/deno.lock generated
View File

@@ -15,6 +15,7 @@
"npm:@types/node@*": "22.5.4",
"npm:@types/pg@^8.11.10": "8.11.10",
"npm:drizzle-kit@*": "0.30.1_esbuild@0.19.12",
"npm:drizzle-kit@~0.30.1": "0.30.1_esbuild@0.19.12",
"npm:drizzle-orm@~0.38.2": "0.38.2_@types+pg@8.11.10_pg@8.13.1",
"npm:hono@^4.6.14": "4.6.14",
"npm:pg@^8.13.1": "8.13.1",
@@ -871,6 +872,7 @@
"npm:@hono/swagger-ui@0.5",
"npm:@hono/zod-openapi@~0.18.3",
"npm:@types/pg@^8.11.10",
"npm:drizzle-kit@~0.30.1",
"npm:drizzle-orm@~0.38.2",
"npm:hono@^4.6.14",
"npm:pg@^8.13.1",

View File

@@ -1,10 +0,0 @@
CREATE TABLE "nodes" (
"id" serial NOT NULL,
"content" "bytea" NOT NULL,
"definition" json NOT NULL
);
--> statement-breakpoint
CREATE TABLE "users" (
"id" serial PRIMARY KEY NOT NULL,
"name" text
);

View File

@@ -0,0 +1,25 @@
CREATE TABLE "users" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
CONSTRAINT "users_name_unique" UNIQUE("name")
);
--> statement-breakpoint
CREATE TABLE "nodes" (
"id" serial PRIMARY KEY NOT NULL,
"userId" varchar NOT NULL,
"createdAt" timestamp DEFAULT now(),
"systemId" varchar NOT NULL,
"nodeId" varchar NOT NULL,
"content" "bytea" NOT NULL,
"definition" json NOT NULL,
"hash" varchar(16) NOT NULL,
"previous" varchar(16),
CONSTRAINT "nodes_hash_unique" UNIQUE("hash")
);
--> statement-breakpoint
ALTER TABLE "nodes" ADD CONSTRAINT "nodes_userId_users_name_fk" FOREIGN KEY ("userId") REFERENCES "public"."users"("name") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "nodes" ADD CONSTRAINT "node_previous_fk" FOREIGN KEY ("previous") REFERENCES "public"."nodes"("hash") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "user_id_idx" ON "nodes" USING btree ("userId");--> statement-breakpoint
CREATE INDEX "system_id_idx" ON "nodes" USING btree ("systemId");--> statement-breakpoint
CREATE INDEX "node_id_idx" ON "nodes" USING btree ("nodeId");--> statement-breakpoint
CREATE INDEX "hash_idx" ON "nodes" USING btree ("hash");

View File

@@ -1,9 +1,43 @@
{
"id": "53dea8d7-01be-4983-ac75-9de9c9a7f592",
"id": "15ad729d-5756-4c06-87ed-cb8b721201f9",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"users_name_unique": {
"name": "users_name_unique",
"nullsNotDistinct": false,
"columns": [
"name"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.nodes": {
"name": "nodes",
"schema": "",
@@ -11,6 +45,31 @@
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"userId": {
"name": "userId",
"type": "varchar",
"primaryKey": false,
"notNull": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"systemId": {
"name": "systemId",
"type": "varchar",
"primaryKey": false,
"notNull": true
},
"nodeId": {
"name": "nodeId",
"type": "varchar",
"primaryKey": false,
"notNull": true
},
@@ -25,37 +84,120 @@
"type": "json",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"hash": {
"name": "hash",
"type": "varchar(16)",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"previous": {
"name": "previous",
"type": "varchar(16)",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"indexes": {
"user_id_idx": {
"name": "user_id_idx",
"columns": [
{
"expression": "userId",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"system_id_idx": {
"name": "system_id_idx",
"columns": [
{
"expression": "systemId",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"node_id_idx": {
"name": "node_id_idx",
"columns": [
{
"expression": "nodeId",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"hash_idx": {
"name": "hash_idx",
"columns": [
{
"expression": "hash",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"nodes_userId_users_name_fk": {
"name": "nodes_userId_users_name_fk",
"tableFrom": "nodes",
"tableTo": "users",
"columnsFrom": [
"userId"
],
"columnsTo": [
"name"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"node_previous_fk": {
"name": "node_previous_fk",
"tableFrom": "nodes",
"tableTo": "nodes",
"columnsFrom": [
"previous"
],
"columnsTo": [
"hash"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"uniqueConstraints": {
"nodes_hash_unique": {
"name": "nodes_hash_unique",
"nullsNotDistinct": false,
"columns": [
"hash"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false

View File

@@ -5,8 +5,8 @@
{
"idx": 0,
"version": "7",
"when": 1734446124519,
"tag": "0000_dark_squirrel_girl",
"when": 1734703963242,
"tag": "0000_known_kid_colt",
"breakpoints": true
}
]

1
store/openapi.json Normal file

File diff suppressed because one or more lines are too long

View File

@@ -2,14 +2,21 @@ import { drizzle } from "drizzle-orm/node-postgres";
import pg from "pg";
import * as schema from "./schema.ts";
import { migrate } from "drizzle-orm/node-postgres/migrator";
// Use pg driver.
const { Pool } = pg;
// Instantiate Drizzle client with pg driver and schema.
export const db = drizzle({
client: new Pool({
max: 20,
connectionString: Deno.env.get("DATABASE_URL"),
}),
schema,
});
export async function migrateDb() {
await migrate(db, { migrationsFolder: "drizzle" });
console.log("Database migrated");
}

View File

@@ -1,2 +1,2 @@
export * from "../routes/user/user.schema.ts";
export * from "../routes/node/schemas/node.schema.ts";
export * from "../routes/node/node.schema.ts";

View File

@@ -1,25 +0,0 @@
import { OpenAPIHono } from "@hono/zod-openapi";
import { router } from "./routes/router.ts";
import { createUser } from "./routes/user/user.service.ts";
import { swaggerUI } from "@hono/swagger-ui";
import { logger } from "hono/logger";
import { cors } from "hono/cors";
await createUser("max");
const app = new OpenAPIHono();
app.use("/v1/*", cors());
app.use(logger());
app.route("v1", router);
app.doc("/openapi.json", {
openapi: "3.0.0",
info: {
version: "1.0.0",
title: "Nodarium API",
},
});
app.get("/ui", swaggerUI({ url: "/openapi.json" }));
Deno.serve(app.fetch);

View File

@@ -0,0 +1,33 @@
import { StatusCode } from "hono";
export class CustomError extends Error {
constructor(public status: StatusCode, message: string) {
super(message);
this.name = this.constructor.name;
Error.captureStackTrace(this, this.constructor);
}
}
export class NodeNotFoundError extends CustomError {
constructor() {
super(404, "Node not found");
}
}
export class InvalidNodeDefinitionError extends CustomError {
constructor() {
super(400, "Invalid node definition");
}
}
export class WorkerTimeoutError extends CustomError {
constructor() {
super(500, "Worker timed out");
}
}
export class UnknownWorkerResponseError extends CustomError {
constructor() {
super(500, "Unknown worker response");
}
}

View File

@@ -1,172 +1,352 @@
import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
import { HTTPException } from "hono/http-exception";
import { idRegex, NodeDefinitionSchema } from "./schemas/types.ts";
import { idRegex, NodeDefinitionSchema } from "./validations/types.ts";
import * as service from "./node.service.ts";
import { bodyLimit } from "hono/body-limit";
import { ZodSchema } from "zod";
import { CustomError } from "./errors.ts";
const nodeRouter = new OpenAPIHono();
const SingleParam = (name: string) =>
const createParamSchema = (name: string) =>
z
.string()
.min(3)
.max(20)
.refine(
(value) => idRegex.test(value),
"Name should contain only alphabets",
`${name} must contain only letters, numbers, "-", or "_"`,
)
.openapi({ param: { name, in: "path" } });
const ParamsSchema = z.object({
user: SingleParam("user"),
system: SingleParam("system"),
nodeId: SingleParam("nodeId"),
const createResponseSchema = <T extends ZodSchema>(
description: string,
schema: T,
) => ({
200: {
content: { "application/json": { schema } },
description,
},
});
const getUserNodesRoute = createRoute({
async function getNodeByVersion(
user: string,
system: string,
nodeId: string,
hash?: string,
) {
console.log("Get Node by Version", { user, system, nodeId, hash });
if (hash) {
if (nodeId.includes("wasm")) {
return await service.getNodeVersionWasm(
user,
system,
nodeId.replace(".wasm", ""),
hash,
);
} else {
const wasmContent = await service.getNodeVersion(
user,
system,
nodeId,
hash,
);
return wasmContent;
}
} else {
if (nodeId.includes(".wasm")) {
const [id, version] = nodeId.replace(/\.wasm$/, "").split("@");
console.log({ user, system, id, version });
if (version) {
return service.getNodeVersionWasm(user, system, id, version);
} else {
return service.getNodeWasmById(user, system, id);
}
} else {
const [id, version] = nodeId.replace(/\.json$/, "").split("@");
if (!version) {
return service.getNodeDefinitionById(user, system, id);
} else {
return await service.getNodeVersion(user, system, id, version);
}
}
}
}
nodeRouter.openapi(
createRoute({
method: "post",
path: "/",
responses: createResponseSchema(
"Create a single node",
NodeDefinitionSchema,
),
middleware: [
bodyLimit({
maxSize: 128 * 1024, // 128 KB
onError: (c) => c.text("Node content too large", 413),
}),
],
}),
async (c) => {
const buffer = await c.req.arrayBuffer();
const bytes = new Uint8Array(buffer);
try {
const node = await service.createNode(buffer, bytes);
return c.json(node);
} catch (error) {
if (error instanceof CustomError) {
throw new HTTPException(error.status, { message: error.message });
}
throw new HTTPException(500, { message: "Internal server error" });
}
},
);
nodeRouter.openapi(
createRoute({
method: "get",
path: "/{user}.json",
request: {
params: z.object({
user: SingleParam("user"),
user: createParamSchema("user").optional(),
}),
},
responses: {
200: {
content: {
"application/json": {
schema: z.array(NodeDefinitionSchema),
},
},
description: "Retrieve a single node definition",
},
},
});
nodeRouter.openapi(getUserNodesRoute, async (c) => {
const userId = c.req.param("user.json").replace(/\.json$/, "");
const nodes = await service.getNodeDefinitionsByUser(userId);
responses: createResponseSchema(
"Retrieve nodes for a user",
z.array(NodeDefinitionSchema),
),
}),
async (c) => {
const user = c.req.param("user.json").replace(/\.json$/, "");
try {
const nodes = await service.getNodeDefinitionsByUser(user);
return c.json(nodes);
});
} catch (error) {
if (error instanceof CustomError) {
throw new HTTPException(error.status, { message: error.message });
}
throw new HTTPException(500, { message: "Internal server error" });
}
},
);
const getNodeCollectionRoute = createRoute({
nodeRouter.openapi(
createRoute({
method: "get",
path: "/{user}/{system}.json",
request: {
params: z.object({
user: SingleParam("user"),
system: SingleParam("system").optional(),
user: createParamSchema("user"),
system: createParamSchema("system").optional(),
}),
},
responses: {
200: {
content: {
"application/json": {
schema: z.array(NodeDefinitionSchema),
},
},
description: "Retrieve a single node definition",
},
},
});
nodeRouter.openapi(getNodeCollectionRoute, async (c) => {
responses: createResponseSchema(
"Retrieve nodes for a system",
z.array(NodeDefinitionSchema),
),
}),
async (c) => {
const { user } = c.req.valid("param");
const nodeSystemId = c.req.param("system.json").replace(/\.json$/, "");
const system = c.req.param("system.json").replace(/\.json$/, "");
console.log("Get Nodes by System", { user, system });
try {
const nodes = await service.getNodesBySystem(user, system);
return c.json({
id: `${user}/${system}`,
nodes: nodes.map((n) => ({ id: n.id.split("@")[0] })),
});
} catch (error) {
if (error instanceof CustomError) {
throw new HTTPException(error.status, { message: error.message });
}
throw new HTTPException(500, { message: "Internal server error" });
}
},
);
const nodes = await service.getNodesBySystem(user, nodeSystemId);
return c.json(nodes);
});
const getNodeDefinitionRoute = createRoute({
nodeRouter.openapi(
createRoute({
method: "get",
path: "/{user}/{system}/{nodeId}{.+\\.json}",
path: "/{user}/{system}/{nodeId}.json",
request: {
params: ParamsSchema,
params: z.object({
user: createParamSchema("user"),
system: createParamSchema("system"),
nodeId: createParamSchema("nodeId").optional(),
}),
},
responses: {
200: {
content: {
"application/json": {
schema: NodeDefinitionSchema,
responses: createResponseSchema(
"Retrieve a single node definition",
NodeDefinitionSchema,
),
}),
async (c) => {
const { user, system } = c.req.valid("param");
const nodeId = c.req.param("nodeId.json").replace(/\.json$/, "");
console.log("Get Node by Id", { user, system, nodeId });
try {
const res = await getNodeByVersion(user, system, nodeId);
if (res instanceof ArrayBuffer) {
c.header("Content-Type", "application/wasm");
return c.body(res);
} else {
return c.json(res);
}
} catch (error) {
if (error instanceof CustomError) {
throw new HTTPException(error.status, { message: error.message });
}
throw new HTTPException(500, { message: "Internal server error" });
}
},
);
nodeRouter.openapi(
createRoute({
method: "get",
path: "/{user}/{system}/{nodeId}@{version}.json",
request: {
params: z.object({
user: createParamSchema("user"),
system: createParamSchema("system"),
nodeId: createParamSchema("nodeId"),
version: createParamSchema("version").optional(),
}),
},
description: "Retrieve a single node definition",
responses: createResponseSchema(
"Retrieve a single node definition",
NodeDefinitionSchema,
),
}),
async (c) => {
const { user, system, nodeId } = c.req.valid("param");
const hash = c.req.param("version.json");
try {
const res = await getNodeByVersion(user, system, nodeId, hash);
if (res instanceof ArrayBuffer) {
c.header("Content-Type", "application/wasm");
return c.body(res);
} else {
return c.json(res);
}
} catch (error) {
if (error instanceof CustomError) {
throw new HTTPException(error.status, { message: error.message });
}
throw new HTTPException(500, { message: "Internal server error" });
}
},
);
nodeRouter.openapi(
createRoute({
method: "get",
path: "/{user}/{system}/{nodeId}/versions.json",
request: {
params: z.object({
user: createParamSchema("user"),
system: createParamSchema("system"),
nodeId: createParamSchema("nodeId"),
}),
},
});
nodeRouter.openapi(getNodeDefinitionRoute, async (c) => {
responses: createResponseSchema(
"Retrieve a single node definition",
z.array(NodeDefinitionSchema),
),
}),
async (c) => {
const { user, system, nodeId } = c.req.valid("param");
const node = await service.getNodeDefinitionById(
user,
system,
nodeId.replace(/\.json$/, ""),
);
if (!node) {
throw new HTTPException(404);
}
try {
const node = await service.getNodeVersions(user, system, nodeId);
return c.json(node);
});
} catch (error) {
if (error instanceof CustomError) {
throw new HTTPException(error.status, { message: error.message });
}
throw new HTTPException(500, { message: "Internal server error" });
}
},
);
const getNodeWasmRoute = createRoute({
nodeRouter.openapi(
createRoute({
method: "get",
path: "/{user}/{system}/{nodeId}{.+\\.wasm}",
path: "/{user}/{system}/{nodeId}.wasm",
request: {
params: ParamsSchema,
params: z.object({
user: createParamSchema("user"),
system: createParamSchema("system"),
nodeId: createParamSchema("nodeId").optional(),
}),
},
responses: {
200: {
content: {
"application/wasm": {
schema: z.any(),
content: { "application/wasm": { schema: z.any() } },
description: "Retrieve a node's WASM file",
},
},
description: "Retrieve a single node",
},
},
});
nodeRouter.openapi(getNodeWasmRoute, async (c) => {
const { user, system, nodeId } = c.req.valid("param");
const wasmContent = await service.getNodeWasmById(
user,
system,
nodeId.replace(/\.wasm/, ""),
);
c.header("Content-Type", "application/wasm");
return c.body(wasmContent);
});
const createNodeRoute = createRoute({
method: "post",
path: "/",
responses: {
200: {
content: {
"application/json": {
schema: NodeDefinitionSchema,
},
},
description: "Create a single node",
},
},
middleware: [
bodyLimit({
maxSize: 128 * 1024, // 128kb
onError: (c) => {
return c.text("Node content too large", 413);
},
}),
],
});
nodeRouter.openapi(createNodeRoute, async (c) => {
const buffer = await c.req.arrayBuffer();
const bytes = await (await c.req.blob()).bytes();
const node = await service.createNode(buffer, bytes);
return c.json(node);
});
async (c) => {
const { user, system } = c.req.valid("param");
const nodeId = c.req.param("nodeId.wasm");
console.log("Get NodeWasm by Id", { user, system, nodeId });
try {
const res = await getNodeByVersion(user, system, nodeId);
if (res instanceof ArrayBuffer) {
c.header("Content-Type", "application/wasm");
return c.body(res);
} else {
return c.json(res);
}
} catch (error) {
if (error instanceof CustomError) {
throw new HTTPException(error.status, { message: error.message });
}
throw new HTTPException(500, { message: "Internal server error" });
}
},
);
nodeRouter.openapi(
createRoute({
method: "get",
path: "/{user}/{system}/{nodeId}@{version}.wasm",
request: {
params: z.object({
user: createParamSchema("user"),
system: createParamSchema("system"),
nodeId: createParamSchema("nodeId"),
version: createParamSchema("version").optional(),
}),
},
responses: {
200: {
content: { "application/wasm": { schema: z.any() } },
description: "Retrieve a node's WASM file",
},
},
}),
async (c) => {
const { user, system, nodeId } = c.req.valid("param");
const hash = c.req.param("version.wasm");
try {
const res = await getNodeByVersion(user, system, nodeId, hash);
if (res instanceof ArrayBuffer) {
c.header("Content-Type", "application/wasm");
return c.body(res);
} else {
return c.json(res);
}
} catch (error) {
if (error instanceof CustomError) {
throw new HTTPException(error.status, { message: error.message });
}
throw new HTTPException(500, { message: "Internal server error" });
}
},
);
export { nodeRouter };

View File

@@ -0,0 +1,43 @@
import {
customType,
foreignKey,
index,
json,
pgTable,
serial,
timestamp,
varchar,
} from "drizzle-orm/pg-core";
import { usersTable } from "../user/user.schema.ts";
import { NodeDefinition } from "./validations/types.ts";
const bytea = customType<{
data: ArrayBuffer;
default: false;
}>({
dataType() {
return "bytea";
},
});
export const nodeTable = pgTable("nodes", {
id: serial().primaryKey(),
userId: varchar().notNull().references(() => usersTable.name),
createdAt: timestamp().defaultNow(),
systemId: varchar().notNull(),
nodeId: varchar().notNull(),
content: bytea().notNull(),
definition: json().notNull().$type<NodeDefinition>(),
hash: varchar({ length: 16 }).notNull().unique(),
previous: varchar({ length: 16 }),
}, (table) => [
foreignKey({
columns: [table.previous],
foreignColumns: [table.hash],
name: "node_previous_fk",
}),
index("user_id_idx").on(table.userId),
index("system_id_idx").on(table.systemId),
index("node_id_idx").on(table.nodeId),
index("hash_idx").on(table.hash),
]);

View File

@@ -1,9 +1,10 @@
import { db } from "../../db/db.ts";
import { nodeTable } from "./schemas/node.schema.ts";
import { NodeDefinition, NodeDefinitionSchema } from "./schemas/types.ts";
import { and, eq } from "drizzle-orm";
import { nodeTable } from "./node.schema.ts";
import { NodeDefinition, NodeDefinitionSchema } from "./validations/types.ts";
import { and, asc, eq } from "drizzle-orm";
import { createHash } from "node:crypto";
import { WorkerMessage } from "./worker/types.ts";
import { extractDefinition } from "./worker/index.ts";
import { InvalidNodeDefinitionError, NodeNotFoundError } from "./errors.ts";
export type CreateNodeDTO = {
id: string;
@@ -15,75 +16,55 @@ export type CreateNodeDTO = {
function getNodeHash(content: Uint8Array) {
const hash = createHash("sha256");
hash.update(content);
return hash.digest("hex").slice(0, 8);
}
function extractDefinition(content: ArrayBuffer): Promise<NodeDefinition> {
const worker = new Worker(
new URL("./worker/node.worker.ts", import.meta.url).href,
{
type: "module",
},
) as Worker & {
postMessage: (message: WorkerMessage) => void;
};
return new Promise((res, rej) => {
worker.postMessage({ action: "extract-definition", content });
setTimeout(() => {
worker.terminate();
rej(new Error("Worker timeout out"));
}, 100);
worker.onmessage = function (e) {
switch (e.data.action) {
case "result":
res(e.data.result);
break;
case "error":
console.log("Worker error", e.data.error);
rej(e.data.result);
break;
default:
rej(new Error("Unknown worker response"));
}
};
});
return hash.digest("hex").slice(0, 16);
}
export async function createNode(
wasmBuffer: ArrayBuffer,
content: Uint8Array,
): Promise<NodeDefinition> {
try {
const def = await extractDefinition(wasmBuffer);
const [userId, systemId, nodeId] = def.id.split("/");
const hash = getNodeHash(content);
const node: typeof nodeTable.$inferInsert = {
userId,
systemId,
nodeId,
definition: def,
hash: getNodeHash(content),
hash,
content: content,
};
await db.insert(nodeTable).values(node);
console.log("new node created!");
return def;
} catch (error) {
console.log("Creation Error", { error });
throw error;
const previousNode = await db
.select({ hash: nodeTable.hash })
.from(nodeTable)
.orderBy(asc(nodeTable.createdAt))
.limit(1);
if (previousNode[0]) {
node.previous = previousNode[0].hash;
}
await db.insert(nodeTable).values(node);
return def;
}
export function getNodeDefinitionsByUser(userName: string) {
return db.select({ definition: nodeTable.definition }).from(nodeTable)
.where(
and(
eq(nodeTable.userId, userName),
),
);
export async function getNodeDefinitionsByUser(userName: string) {
const nodes = await db
.select({
definition: nodeTable.definition,
hash: nodeTable.hash,
})
.from(nodeTable)
.where(and(eq(nodeTable.userId, userName)));
return nodes.map((n) => ({
...n.definition,
// id: n.definition.id + "@" + n.hash,
}));
}
export async function getNodesBySystem(
@@ -91,16 +72,26 @@ export async function getNodesBySystem(
systemId: string,
): Promise<NodeDefinition[]> {
const nodes = await db
.select()
.selectDistinctOn(
[nodeTable.userId, nodeTable.systemId, nodeTable.nodeId],
{ definition: nodeTable.definition, hash: nodeTable.hash },
)
.from(nodeTable)
.where(
and(eq(nodeTable.systemId, systemId), eq(nodeTable.userId, username)),
);
)
.orderBy(nodeTable.userId, nodeTable.systemId, nodeTable.nodeId);
const definitions = nodes
.map((node) => NodeDefinitionSchema.safeParse(node.definition))
.filter((v) => v.success)
.map((v) => v.data);
.map(
(node) =>
[NodeDefinitionSchema.safeParse(node.definition), node.hash] as const,
)
.filter(([v]) => v.success)
.map(([v, hash]) => ({
...v.data,
// id: v?.data?.id + "@" + hash,
}));
return definitions;
}
@@ -110,17 +101,21 @@ export async function getNodeWasmById(
systemId: string,
nodeId: string,
) {
const node = await db.select({ content: nodeTable.content }).from(nodeTable)
const node = await db
.select({ content: nodeTable.content })
.from(nodeTable)
.where(
and(
eq(nodeTable.userId, userName),
eq(nodeTable.systemId, systemId),
eq(nodeTable.nodeId, nodeId),
),
).limit(1);
)
.orderBy(asc(nodeTable.createdAt))
.limit(1);
if (!node[0]) {
throw new Error("Node not found");
throw new NodeNotFoundError();
}
return node[0].content;
@@ -131,25 +126,116 @@ export async function getNodeDefinitionById(
systemId: string,
nodeId: string,
) {
const node = await db.select({ definition: nodeTable.definition }).from(
nodeTable,
).where(
const node = await db
.select({
definition: nodeTable.definition,
hash: nodeTable.hash,
})
.from(nodeTable)
.where(
and(
eq(nodeTable.userId, userName),
eq(nodeTable.systemId, systemId),
eq(nodeTable.nodeId, nodeId),
),
).limit(1);
)
.orderBy(asc(nodeTable.createdAt))
.limit(1);
if (!node[0]) {
return;
throw new NodeNotFoundError();
}
const definition = NodeDefinitionSchema.safeParse(node[0]?.definition);
if (!definition.data) {
throw new Error("Invalid definition");
if (!definition.success) {
throw new InvalidNodeDefinitionError();
}
return definition.data;
return {
...definition.data,
// id: definition.data.id + "@" + node[0].hash
};
}
export async function getNodeVersions(
user: string,
system: string,
nodeId: string,
) {
const nodes = await db
.select({
definition: nodeTable.definition,
hash: nodeTable.hash,
})
.from(nodeTable)
.where(
and(
eq(nodeTable.userId, user),
eq(nodeTable.systemId, system),
eq(nodeTable.nodeId, nodeId),
),
)
.orderBy(asc(nodeTable.createdAt));
return nodes.map((node) => ({
...node.definition,
// id: node.definition.id + "@" + node.hash,
}));
}
export async function getNodeVersion(
user: string,
system: string,
nodeId: string,
hash: string,
) {
const nodes = await db
.select({
definition: nodeTable.definition,
})
.from(nodeTable)
.where(
and(
eq(nodeTable.userId, user),
eq(nodeTable.systemId, system),
eq(nodeTable.nodeId, nodeId),
eq(nodeTable.hash, hash),
),
)
.limit(1);
if (nodes.length === 0) {
throw new NodeNotFoundError();
}
return nodes[0].definition;
}
export async function getNodeVersionWasm(
user: string,
system: string,
nodeId: string,
hash: string,
) {
const node = await db
.select({
content: nodeTable.content,
})
.from(nodeTable)
.where(
and(
eq(nodeTable.userId, user),
eq(nodeTable.systemId, system),
eq(nodeTable.nodeId, nodeId),
eq(nodeTable.hash, hash),
),
)
.limit(1);
if (node.length === 0) {
throw new NodeNotFoundError();
}
return node[0].content;
}

View File

@@ -1,41 +0,0 @@
import {
customType,
integer,
json,
pgTable,
serial,
varchar,
} from "drizzle-orm/pg-core";
import { relations } from "drizzle-orm/relations";
import { usersTable } from "../../user/user.schema.ts";
const bytea = customType<{
data: ArrayBuffer;
default: false;
}>({
dataType() {
return "bytea";
},
});
export const nodeTable = pgTable("nodes", {
id: serial().primaryKey(),
userId: varchar().notNull(),
systemId: varchar().notNull(),
nodeId: varchar().notNull(),
content: bytea().notNull(),
definition: json().notNull(),
hash: varchar({ length: 8 }).notNull(),
previous: integer(),
});
export const nodeRelations = relations(nodeTable, ({ one }) => ({
userId: one(usersTable, {
fields: [nodeTable.userId],
references: [usersTable.id],
}),
previous: one(nodeTable, {
fields: [nodeTable.previous],
references: [nodeTable.id],
}),
}));

View File

@@ -0,0 +1,36 @@
import { UnknownWorkerResponseError, WorkerTimeoutError } from "../errors.ts";
import { NodeDefinition } from "../validations/types.ts";
import { WorkerMessage } from "./messages.ts";
export function extractDefinition(
content: ArrayBuffer,
): Promise<NodeDefinition> {
const worker = new Worker(
new URL("./node.worker.ts", import.meta.url).href,
{
type: "module",
},
) as Worker & {
postMessage: (message: WorkerMessage) => void;
};
return new Promise((res, rej) => {
worker.postMessage({ action: "extract-definition", content });
setTimeout(() => {
worker.terminate();
rej(new WorkerTimeoutError());
}, 100);
worker.onmessage = function (e) {
switch (e.data.action) {
case "result":
res(e.data.result);
break;
case "error":
rej(e.data.error);
break;
default:
rej(new UnknownWorkerResponseError());
}
};
});
}

View File

@@ -1,4 +1,4 @@
import { NodeDefinition } from "../schemas/types.ts";
import { NodeDefinition } from "../validations/types.ts";
type ExtractDefinitionMessage = {
action: "extract-definition";

View File

@@ -1,7 +1,7 @@
/// <reference lib="webworker" />
import { NodeDefinitionSchema } from "../schemas/types.ts";
import { WorkerMessage } from "./types.ts";
import { NodeDefinitionSchema } from "../validations/types.ts";
import { WorkerMessage } from "./messages.ts";
import { createWasmWrapper } from "./utils.ts";
const workerSelf = self as DedicatedWorkerGlobalScope & {

View File

@@ -1,5 +1,5 @@
// @ts-nocheck: Nocheck
import { NodeDefinition } from "../schemas/types.ts";
import { NodeDefinition } from "../validations/types.ts";
const cachedTextDecoder = new TextDecoder("utf-8", {
ignoreBOM: true,

View File

@@ -1,10 +0,0 @@
import { OpenAPIHono } from "@hono/zod-openapi";
import { nodeRouter } from "./node/node.controller.ts";
import { userRouter } from "./user/user.controller.ts";
const router = new OpenAPIHono();
router.route("nodes", nodeRouter);
router.route("users", userRouter);
export { router };

View File

@@ -1,7 +1,8 @@
import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
import { UserSchema, usersTable } from "./user.schema.ts";
import { usersTable } from "./user.schema.ts";
import { db } from "../../db/db.ts";
import { findUserByName } from "./user.service.ts";
import { UserSchema } from "./user.validation.ts";
const userRouter = new OpenAPIHono();

View File

@@ -1,14 +1,6 @@
import { pgTable, text, uuid } from "drizzle-orm/pg-core";
import { z } from "@hono/zod-openapi";
export const usersTable = pgTable("users", {
id: uuid().primaryKey().defaultRandom(),
name: text().unique().notNull(),
});
export const UserSchema = z
.object({
id: z.string().uuid(),
name: z.string().min(1), // Non-null text with a unique constraint (enforced at the database level)
})
.openapi("User");

View File

@@ -0,0 +1,8 @@
import { z } from "@hono/zod-openapi";
export const UserSchema = z
.object({
id: z.string().uuid(),
name: z.string().min(1),
})
.openapi("User");

37
store/src/server.ts Normal file
View File

@@ -0,0 +1,37 @@
import { createUser } from "./routes/user/user.service.ts";
import { swaggerUI } from "@hono/swagger-ui";
import { logger } from "hono/logger";
import { cors } from "hono/cors";
import { OpenAPIHono } from "@hono/zod-openapi";
import { nodeRouter } from "./routes/node/node.controller.ts";
import { userRouter } from "./routes/user/user.controller.ts";
import { migrateDb } from "./db/db.ts";
const router = new OpenAPIHono();
router.use(logger());
router.use(cors());
router.route("nodes", nodeRouter);
router.route("users", userRouter);
router.doc("/openapi.json", {
openapi: "3.0.0",
info: {
version: "1.0.0",
title: "Nodarium API",
},
});
router.get("/ui", swaggerUI({ url: "/openapi.json" }));
Deno.serve(router.fetch);
async function init() {
await migrateDb();
await createUser("max");
const openapi = await router.request("/openapi.json");
const json = await openapi.text();
Deno.writeTextFile("openapi.json", json);
}
await init();