🚀 Initial sveltekit app

This commit is contained in:
Rune Harlyk
2024-03-28 01:04:52 +01:00
committed by Rune Harlyk
parent 23806e366b
commit 0acbb4c83a
85 changed files with 6096 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
+13
View File
@@ -0,0 +1,13 @@
// See https://kit.svelte.dev/docs/types#app
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+7
View File
@@ -0,0 +1,7 @@
import { describe, it, expect } from 'vitest';
describe('sum test', () => {
it('adds 1 + 2 to equal 3', () => {
expect(1 + 2).toBe(3);
});
});
+112
View File
@@ -0,0 +1,112 @@
<script lang="ts">
import nipplejs from 'nipplejs';
import { onMount } from 'svelte';
import { capitalize, throttler, toInt8 } from '$lib/utilities';
import { input, outControllerData, mode, modes, type Modes } from '$lib/stores';
import type { vector } from '$lib/models';
import Range from './input/Range.svelte';
import Button from './input/Button.svelte';
let throttle = new throttler();
let left: nipplejs.JoystickManager;
let right: nipplejs.JoystickManager;
let throttle_timing = 40;
let data = new Int8Array(7);
onMount(() => {
left = nipplejs.create({
zone: document.getElementById('left') as HTMLElement,
color: 'grey',
dynamicPage: true,
mode: 'static',
restOpacity: 0.3
});
right = nipplejs.create({
zone: document.getElementById('right') as HTMLElement,
color: 'grey',
dynamicPage: true,
mode: 'static',
restOpacity: 0.3
});
left.on('move', (_, data) => handleJoyMove('left', data.vector));
left.on('end', (_, __) => handleJoyMove('left', { x: 0, y: 0 }));
right.on('move', (_, data) => handleJoyMove('right', data.vector));
right.on('end', (_, __) => handleJoyMove('right', { x: 0, y: 0 }));
});
const handleJoyMove = (key: 'left' | 'right', data: vector) => {
input.update((inputData) => {
inputData[key] = data;
return inputData;
});
throttle.throttle(updateData, throttle_timing);
};
const updateData = () => {
data[0] = 0;
data[1] = toInt8($input.left.x, -1, 1);
data[2] = toInt8($input.left.y, -1, 1);
data[3] = toInt8($input.right.x, -1, 1);
data[4] = toInt8($input.right.y, -1, 1);
data[5] = toInt8($input.height, 0, 100);
data[6] = toInt8($input.speed, 0, 100);
outControllerData.set(data);
};
const handleKeyup = (event: KeyboardEvent) => {
const down = event.type === 'keydown';
input.update((data) => {
if (event.key === 'w') data.left.y = down ? -1 : 0;
if (event.key === 'a') data.left.x = down ? -1 : 0;
if (event.key === 's') data.left.y = down ? 1 : 0;
if (event.key === 'd') data.left.x = down ? 1 : 0;
return data;
});
throttle.throttle(updateData, throttle_timing);
};
const handleRange = (event:CustomEvent, key: 'speed' | 'height') => {
const value:number = event.detail
input.update((inputData) => {
inputData[key] = value;
return inputData;
});
throttle.throttle(updateData, throttle_timing);
}
const changeMode = (modeValue: Modes) => {
mode.set(modeValue);
};
</script>
<div class="absolute top-0 left-0 w-screen h-screen">
<div class="absolute top-0 left-0 h-full w-full flex portrait:hidden">
<div id="left" class="flex w-60 items-center justify-end" />
<div class="flex-1" />
<div id="right" class="flex w-60 items-center" />
</div>
<div class="absolute bottom-0 z-10 p-4 gap-4 flex items-end">
{#each modes as modeValue}
<div>
<Button
on:click={() => changeMode(modeValue)}
active={$mode === modeValue}
>
{capitalize(modeValue)}
</Button>
</div>
{/each}
<div>
{#if $mode === 'walk'}
<Range label="Speed" on:value={(e) => handleRange(e, 'speed')}></Range>
{/if}
<Range label="Height" on:value={(e) => handleRange(e, 'height')}></Range>
</div>
</div>
</div>
<svelte:window on:keyup={handleKeyup} on:keydown={handleKeyup} />
+17
View File
@@ -0,0 +1,17 @@
<script lang="ts">
import MdiHamburgerMenu from '~icons/mdi/hamburger-menu';
</script>
<div class="topbar absolute left-0 top-0 w-full z-10 flex justify-between bg-zinc-800">
<div class="flex gap-2 p-2">
<a href="/">
<svelte:component this={MdiHamburgerMenu} class="h-8 w-8"/>
</a>
</div>
</div>
<style>
.topbar {
height: 50px;
}
</style>
+175
View File
@@ -0,0 +1,175 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { BufferGeometry, CanvasTexture, CircleGeometry, CubicBezierCurve3, Line, LineBasicMaterial, Mesh, MeshBasicMaterial, Vector3, type NormalBufferAttributes } from 'three';
import socketService from '$lib/services/socket-service';
import uzip from 'uzip';
import { model } from '$lib/stores';
import { footColor, location, toeWorldPositions } from '$lib/utilities';
import { isEmbeddedApp } from '$lib/utilities/svelte-utilities';
import { fileService } from '$lib/services';
import { servoAngles, mpu, jointNames } from '$lib/stores';
import SceneBuilder from '$lib/sceneBuilder';
import { lerp, degToRad } from 'three/src/math/MathUtils';
import { GUI } from 'three/addons/libs/lil-gui.module.min.js';
let sceneManager = new SceneBuilder();
let canvas: HTMLCanvasElement, streamCanvas: HTMLCanvasElement, stream: HTMLImageElement;
let context: CanvasRenderingContext2D, texture: CanvasTexture;
let modelAngles: number[] | Int16Array = new Array(12).fill(0);
let modelTargetAngles: number[] | Int16Array = new Array(12).fill(0);
let feet_trace = new Array(4).fill([]);
let trace_lines: BufferGeometry<NormalBufferAttributes>[] = []
const videoStream = `//${location}/api/stream`;
let showStream = false;
let settings = {
'Trace feet':true,
'Trace points': 30
}
onMount(async () => {
await cacheModelFiles()
await createScene();
if (!isEmbeddedApp) createPanel();
});
onDestroy(() => {
canvas.remove();
});
const createPanel = () => {
const panel = new GUI({width: 310});
panel.close();
panel.domElement.id = 'three-gui-panel';
const visibility = panel.addFolder('Visualization');
visibility.add(settings, 'Trace feet')
visibility.add(settings, 'Trace points', 1, 1000, 1)
}
const cacheModelFiles = async () => {
let data = await fetch('/stl.zip').then((data) => data.arrayBuffer());
var files = uzip.parse(data);
for (const [path, data] of Object.entries(files) as [path: string, data: Uint8Array][]) {
const url = new URL(path, window.location.href);
fileService.saveFile(url.toString(), data);
}
};
const updateAngles = (name: string, angle: number) => {
modelTargetAngles[$jointNames.indexOf(name)] = angle * (180 / Math.PI);
socketService.send(
JSON.stringify({
type: 'kinematic/angle',
angle: angle * (180 / Math.PI),
id: $jointNames.indexOf(name)
})
);
};
const createScene = async () => {
sceneManager
.addRenderer({ antialias: true, canvas: canvas, alpha: true })
.addPerspectiveCamera({ x: -0.5, y: 0.5, z: 1 })
.addOrbitControls(8, 30)
.addSky()
.addGroundPlane()
.addGridHelper({ size: 250, divisions: 125 })
.addAmbientLight({ color: 0xffffff, intensity: 0.7 })
.addDirectionalLight({ x: 10, y: 100, z: 10, color: 0xffffff, intensity: 1 })
.addArrowHelper({ origin: { x: 0, y: 0, z: 0 }, direction: { x: 0, y: -2, z: 0 } })
.addFogExp2(0xcccccc, 0.015)
.addModel($model)
.addDragControl(updateAngles)
.handleResize()
.addRenderCb(render)
.startRenderLoop();
addVideoStream();
for (let i = 0; i < 4; i++) {
const geometry = new BufferGeometry();
const material = new LineBasicMaterial({ color: footColor() });
const line = new Line(geometry, material);
trace_lines.push(geometry);
sceneManager.scene.add(line);
}
};
const addVideoStream = () => {
context = streamCanvas.getContext('2d')!;
texture = new CanvasTexture(stream);
const liveStream = new Mesh(
new CircleGeometry(35, 32),
new MeshBasicMaterial({ map: texture })
);
liveStream.position.z = -50;
liveStream.visible = showStream;
sceneManager.scene.add(liveStream);
};
const handleVideoStream = () => {
if (!showStream) return;
context.drawImage(stream, 0, 0);
texture.needsUpdate = true;
};
const renderTraceLines = (foot_positions: Vector3[]) => {
if (!settings['Trace feet']) {
if (!feet_trace.length) return
trace_lines.forEach((line, i) => line.setFromPoints(feet_trace[i].slice(-1)))
feet_trace = new Array(4).fill([])
return
}
trace_lines.forEach((line, i) => {
feet_trace[i].push(foot_positions[i])
feet_trace[i] = feet_trace[i].slice(-settings['Trace points'])
line.setFromPoints(feet_trace[i]);
})
}
const render = () => {
const robot = sceneManager.model;
if (!robot) return;
const toes = toeWorldPositions(robot)
renderTraceLines(toes)
robot.position.y = robot.position.y - Math.min(...toes.map(toe => toe.y));
robot.rotation.z = lerp(robot.rotation.z, degToRad($mpu.heading + 90), 0.1);
modelTargetAngles = $servoAngles;
handleVideoStream();
for (let i = 0; i < $jointNames.length; i++) {
modelAngles[i] = lerp(
(robot.joints[$jointNames[i]].angle as number) * (180 / Math.PI),
modelTargetAngles[i],
0.1
);
robot.joints[$jointNames[i]].setJointValue(degToRad(modelAngles[i]));
}
};
</script>
<svelte:window on:resize={sceneManager.handleResize} />
{#if showStream}
<img
bind:this={stream}
src={videoStream}
class="hidden"
alt="Live stream is down"
crossorigin="anonymous"
/>
{/if}
<canvas bind:this={streamCanvas} class="hidden"></canvas>
<canvas bind:this={canvas} class="absolute"></canvas>
@@ -0,0 +1,19 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import { location } from '$lib/utilities';
let videoStream = `//${location}/api/stream`;
onDestroy(() => {
videoStream = '#';
});
</script>
<div class="w-full h-full">
<img
src={videoStream}
class="absolute object-cover blur-3xl w-full h-full -z-10"
alt="Live stream is down"
/>
<img src={videoStream} class="object-contain w-full h-full" alt="Live stream is down" />
</div>
@@ -0,0 +1,175 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { BufferGeometry, CanvasTexture, CircleGeometry, CubicBezierCurve3, Line, LineBasicMaterial, Mesh, MeshBasicMaterial, Vector3, type NormalBufferAttributes } from 'three';
import socketService from '$lib/services/socket-service';
import uzip from 'uzip';
import { model } from '$lib/stores';
import { footColor, isEmbeddedApp, location, toeWorldPositions } from '$lib/utilities';
import { fileService } from '$lib/services';
import { servoAngles, mpu, jointNames } from '$lib/stores';
import SceneBuilder from '$lib/sceneBuilder';
import { lerp, degToRad } from 'three/src/math/MathUtils';
import { GUI } from 'three/addons/libs/lil-gui.module.min.js';
let sceneManager = new SceneBuilder();
let canvas: HTMLCanvasElement, streamCanvas: HTMLCanvasElement, stream: HTMLImageElement;
let context: CanvasRenderingContext2D, texture: CanvasTexture;
let modelAngles: number[] | Int16Array = new Array(12).fill(0);
let modelTargetAngles: number[] | Int16Array = new Array(12).fill(0);
let feet_trace = new Array(4).fill([]);
let trace_lines: BufferGeometry<NormalBufferAttributes>[] = []
const videoStream = `//${location}/api/stream`;
let showStream = false;
let settings = {
'Trace feet':true,
'Trace points': 30
}
onMount(async () => {
await cacheModelFiles()
await createScene();
if (!isEmbeddedApp) createPanel();
});
onDestroy(() => {
canvas.remove();
});
const createPanel = () => {
const panel = new GUI({width: 310});
panel.close();
panel.domElement.id = 'three-gui-panel';
const visibility = panel.addFolder('Visualization');
visibility.add(settings, 'Trace feet')
visibility.add(settings, 'Trace points', 1, 1000, 1)
}
const cacheModelFiles = async () => {
let data = await fetch('/stl.zip').then((data) => data.arrayBuffer());
var files = uzip.parse(data);
for (const [path, data] of Object.entries(files) as [path: string, data: Uint8Array][]) {
const url = new URL(path, window.location.href);
fileService.saveFile(url.toString(), data);
}
};
const updateAngles = (name: string, angle: number) => {
modelTargetAngles[$jointNames.indexOf(name)] = angle * (180 / Math.PI);
socketService.send(
JSON.stringify({
type: 'kinematic/angle',
angle: angle * (180 / Math.PI),
id: $jointNames.indexOf(name)
})
);
};
const createScene = async () => {
sceneManager
.addRenderer({ antialias: true, canvas: canvas, alpha: true })
.addPerspectiveCamera({ x: -0.5, y: 0.5, z: 1 })
.addOrbitControls(8, 30)
// .addSky()
.setEnvironment("Asphalt")
.addGroundPlane()
.addGridHelper({ grid:{size: 250, divisions: 125 }})
.addAmbientLight({ color: 0xffffff, intensity: 0.7 })
.addDirectionalLight({ x: 10, y: 100, z: 10, color: 0xffffff, intensity: 1 })
.addArrowHelper({ origin: { x: 0, y: 0, z: 0 }, direction: { x: 0, y: -2, z: 0 } })
.addFogExp2(0xcccccc, 0.015)
.addModel($model)
.addDragControl(updateAngles)
.handleResize()
.addRenderCb(render)
.startRenderLoop();
addVideoStream();
for (let i = 0; i < 4; i++) {
const geometry = new BufferGeometry();
const material = new LineBasicMaterial({ color: footColor() });
const line = new Line(geometry, material);
trace_lines.push(geometry);
sceneManager.scene.add(line);
}
};
const addVideoStream = () => {
context = streamCanvas.getContext('2d')!;
texture = new CanvasTexture(stream);
const liveStream = new Mesh(
new CircleGeometry(35, 32),
new MeshBasicMaterial({ map: texture })
);
liveStream.position.z = -50;
liveStream.visible = showStream;
sceneManager.scene.add(liveStream);
};
const handleVideoStream = () => {
if (!showStream) return;
context.drawImage(stream, 0, 0);
texture.needsUpdate = true;
};
const renderTraceLines = (foot_positions: Vector3[]) => {
if (!settings['Trace feet']) {
if (!feet_trace.length) return
trace_lines.forEach((line, i) => line.setFromPoints(feet_trace[i].slice(-1)))
feet_trace = new Array(4).fill([])
return
}
trace_lines.forEach((line, i) => {
feet_trace[i].push(foot_positions[i])
feet_trace[i] = feet_trace[i].slice(-settings['Trace points'])
line.setFromPoints(feet_trace[i]);
})
}
const render = () => {
const robot = sceneManager.model;
if (!robot) return;
const toes = toeWorldPositions(robot)
renderTraceLines(toes)
robot.position.y = robot.position.y - Math.min(...toes.map(toe => toe.y));
robot.rotation.z = lerp(robot.rotation.z, degToRad($mpu.heading + 90), 0.1);
modelTargetAngles = $servoAngles;
handleVideoStream();
for (let i = 0; i < $jointNames.length; i++) {
modelAngles[i] = lerp(
(robot.joints[$jointNames[i]].angle as number) * (180 / Math.PI),
modelTargetAngles[i],
0.1
);
robot.joints[$jointNames[i]].setJointValue(degToRad(modelAngles[i]));
}
};
</script>
<svelte:window on:resize={sceneManager.handleResize} />
{#if showStream}
<img
bind:this={stream}
src={videoStream}
class="hidden"
alt="Live stream is down"
crossorigin="anonymous"
/>
{/if}
<canvas bind:this={streamCanvas} class="hidden"></canvas>
<canvas bind:this={canvas} class="absolute"></canvas>
@@ -0,0 +1,11 @@
<script lang="ts">
export let active = false
</script>
<button
on:click
class={$$restProps.class + ' rounded-md outline outline-2 text-zinc-200 outline-zinc-600 p-2' +
(active ? ' bg-zinc-600' : '')}
>
<slot/>
</button>
@@ -0,0 +1,29 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte'
const dispatch = createEventDispatcher()
export let value = 50;
export let min = 0;
export let max = 100;
export let label = '';
const dispatchValueInput = () => {
dispatch('value', value)
}
</script>
<div class="">
<input
id="range"
type="range"
{min}
{max}
bind:value
on:change
on:input={dispatchValueInput}
class="w-32 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700"
/>
</div>
<label for="range" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">{label}</label
>
+1
View File
@@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.
+393
View File
@@ -0,0 +1,393 @@
export default class Kinematic {
private l1: number;
private l2: number;
private l3: number;
private l4: number;
private L: number;
private W: number;
constructor() {
this.l1 = 50;
this.l2 = 20;
this.l3 = 120;
this.l4 = 155;
this.L = 140;
this.W = 75;
}
bodyIK(
omega: number,
phi: number,
psi: number,
xm: number,
ym: number,
zm: number
): number[][][] {
const { cos, sin } = Math;
const Rx: number[][] = [
[1, 0, 0, 0],
[0, cos(omega), -sin(omega), 0],
[0, sin(omega), cos(omega), 0],
[0, 0, 0, 1]
];
const Ry: number[][] = [
[cos(phi), 0, sin(phi), 0],
[0, 1, 0, 0],
[-sin(phi), 0, cos(phi), 0],
[0, 0, 0, 1]
];
const Rz: number[][] = [
[cos(psi), -sin(psi), 0, 0],
[sin(psi), cos(psi), 0, 0],
[0, 0, 1, 0],
[0, 0, 0, 1]
];
const Rxyz: number[][] = this.matrixMultiply(this.matrixMultiply(Rx, Ry), Rz);
const T: number[][] = [
[0, 0, 0, xm],
[0, 0, 0, ym],
[0, 0, 0, zm],
[0, 0, 0, 0]
];
const Tm: number[][] = this.matrixAdd(T, Rxyz);
const sHp = sin(Math.PI / 2);
const cHp = cos(Math.PI / 2);
const L = this.L;
const W = this.W;
return [
this.matrixMultiply(Tm, [
[cHp, 0, sHp, L / 2],
[0, 1, 0, 0],
[-sHp, 0, cHp, W / 2],
[0, 0, 0, 1]
]),
this.matrixMultiply(Tm, [
[cHp, 0, sHp, L / 2],
[0, 1, 0, 0],
[-sHp, 0, cHp, -W / 2],
[0, 0, 0, 1]
]),
this.matrixMultiply(Tm, [
[cHp, 0, sHp, -L / 2],
[0, 1, 0, 0],
[-sHp, 0, cHp, W / 2],
[0, 0, 0, 1]
]),
this.matrixMultiply(Tm, [
[cHp, 0, sHp, -L / 2],
[0, 1, 0, 0],
[-sHp, 0, cHp, -W / 2],
[0, 0, 0, 1]
])
];
}
private legIK(point: number[]): number[] {
const [x, y, z] = point;
const { atan2, cos, sin, sqrt, acos } = Math;
const { l1, l2, l3, l4 } = this;
let F;
try {
F = sqrt(x ** 2 + y ** 2 - l1 ** 2);
if (isNaN(F)) throw new Error('F is NaN');
} catch (error) {
//console.log(error)
F = l1;
}
const G = F - l2;
const H = sqrt(G ** 2 + z ** 2);
const theta1 = -atan2(y, x) - atan2(F, -l1);
const D = (H ** 2 - l3 ** 2 - l4 ** 2) / (2 * l3 * l4);
let theta3: number;
try {
theta3 = acos(D);
if (isNaN(theta3)) throw new Error('theta3 is NaN');
} catch (error) {
theta3 = 0;
}
const theta2 = atan2(z, G) - atan2(l4 * sin(theta3), l3 + l4 * cos(theta3));
return [theta1, theta2, theta3];
}
matrixMultiply(a: number[][], b: number[][]): number[][] {
const result: number[][] = [];
for (let i = 0; i < a.length; i++) {
const row: number[] = [];
for (let j = 0; j < b[0].length; j++) {
let sum = 0;
for (let k = 0; k < a[i].length; k++) {
sum += a[i][k] * b[k][j];
}
row.push(sum);
}
result.push(row);
}
return result;
}
multiplyVector(matrix: number[][], vector: number[]): number[] {
const rows = matrix.length;
const cols = matrix[0].length;
const vectorLength = vector.length;
if (cols !== vectorLength) {
throw new Error('Matrix and vector dimensions do not match for multiplication.');
}
const result = [];
for (let i = 0; i < rows; i++) {
let sum = 0;
for (let j = 0; j < cols; j++) {
sum += matrix[i][j] * vector[j];
}
result.push(sum);
}
return result;
}
private matrixAdd(a: number[][], b: number[][]): number[][] {
const result: number[][] = [];
for (let i = 0; i < a.length; i++) {
const row: number[] = [];
for (let j = 0; j < a[i].length; j++) {
row.push(a[i][j] + b[i][j]);
}
result.push(row);
}
return result;
}
public calcLegPoints(angles: number[]): number[][] {
const [theta1, theta2, theta3] = angles;
const theta23 = theta2 + theta3;
const T0: number[] = [0, 0, 0, 1];
const T1: number[] = this.vectorAdd(T0, [
-this.l1 * Math.cos(theta1),
this.l1 * Math.sin(theta1),
0,
0
]);
const T2: number[] = this.vectorAdd(T1, [
-this.l2 * Math.sin(theta1),
-this.l2 * Math.cos(theta1),
0,
0
]);
const T3: number[] = this.vectorAdd(T2, [
-this.l3 * Math.sin(theta1) * Math.cos(theta2),
-this.l3 * Math.cos(theta1) * Math.cos(theta2),
this.l3 * Math.sin(theta2),
0
]);
const T4: number[] = this.vectorAdd(T3, [
-this.l4 * Math.sin(theta1) * Math.cos(theta23),
-this.l4 * Math.cos(theta1) * Math.cos(theta23),
this.l4 * Math.sin(theta23),
0
]);
return [T0, T1, T2, T3, T4];
}
public calcIK(Lp: number[][], angles: number[], center: number[]): number[][] {
const [omega, phi, psi] = angles;
const [xm, ym, zm] = center;
const [Tlf, Trf, Tlb, Trb] = this.bodyIK(omega, phi, psi, xm, ym, zm);
const Ix: number[][] = [
[-1, 0, 0, 0],
[0, 1, 0, 0],
[0, 0, 1, 0],
[0, 0, 0, 1]
];
return [
this.legIK(this.multiplyVector(this.matrixInverse(Tlf), Lp[0])),
this.legIK(this.multiplyVector(Ix, this.multiplyVector(this.matrixInverse(Trf), Lp[1]))),
this.legIK(this.multiplyVector(this.matrixInverse(Tlb), Lp[2])),
this.legIK(this.multiplyVector(Ix, this.multiplyVector(this.matrixInverse(Trb), Lp[3])))
];
}
private vectorAdd(a: number[], b: number[]): number[] {
return a.map((val, index) => val + b[index]);
}
private matrixInverse(matrix: number[][]): number[][] {
const det = this.determinant(matrix);
const adjugate = this.adjugate(matrix);
const scalar = 1 / det;
const inverse: number[][] = [];
for (let i = 0; i < matrix.length; i++) {
const row: number[] = [];
for (let j = 0; j < matrix[i].length; j++) {
row.push(adjugate[i][j] * scalar);
}
inverse.push(row);
}
return inverse;
}
private determinant(matrix: number[][]): number {
if (matrix.length !== matrix[0].length) {
throw new Error('The matrix is not square.');
}
if (matrix.length === 2) {
return matrix[0][0] * matrix[1][1] - matrix[0][1] * matrix[1][0];
}
let det = 0;
for (let i = 0; i < matrix.length; i++) {
const sign = i % 2 === 0 ? 1 : -1;
const subMatrix: number[][] = [];
for (let j = 1; j < matrix.length; j++) {
const row: number[] = [];
for (let k = 0; k < matrix.length; k++) {
if (k !== i) {
row.push(matrix[j][k]);
}
}
subMatrix.push(row);
}
det += sign * matrix[0][i] * this.determinant(subMatrix);
}
return det;
}
private adjugate(matrix: number[][]): number[][] {
if (matrix.length !== matrix[0].length) {
throw new Error('The matrix is not square.');
}
const adjugate: number[][] = [];
for (let i = 0; i < matrix.length; i++) {
const row: number[] = [];
for (let j = 0; j < matrix[i].length; j++) {
const sign = (i + j) % 2 === 0 ? 1 : -1;
const subMatrix: number[][] = [];
for (let k = 0; k < matrix.length; k++) {
if (k !== i) {
const subRow: number[] = [];
for (let l = 0; l < matrix.length; l++) {
if (l !== j) {
subRow.push(matrix[k][l]);
}
}
subMatrix.push(subRow);
}
}
const cofactor = sign * this.determinant(subMatrix);
row.push(cofactor);
}
adjugate.push(row);
}
return this.transpose(adjugate);
}
private transpose(matrix: number[][]): number[][] {
const transposed: number[][] = [];
for (let i = 0; i < matrix.length; i++) {
const row: number[] = [];
for (let j = 0; j < matrix[i].length; j++) {
row.push(matrix[j][i]);
}
transposed.push(row);
}
return transposed;
}
}
export class ForwardKinematics {
private l1: number;
private l2: number;
private l3: number;
private l4: number;
constructor() {
this.l1 = 50;
this.l2 = 20;
this.l3 = 120;
this.l4 = 155;
}
public calculateFootpoint(theta1: number, theta2: number, theta3: number): number[] {
const { cos, sin } = Math;
const x =
this.l1 * cos(theta1) +
this.l2 * cos(theta1) +
this.l3 * cos(theta1 + theta2) +
this.l4 * cos(theta1 + theta2 + theta3);
const y =
this.l1 * sin(theta1) +
this.l2 * sin(theta1) +
this.l3 * sin(theta1 + theta2) +
this.l4 * sin(theta1 + theta2 + theta3);
const z = 0;
return [x, y, z];
}
public calculateFootpoints(angles: number[]): number[][] {
const footpoints: number[][] = [];
for (let i = 0; i < angles.length; i += 3) {
const theta1 = angles[i];
const theta2 = angles[i + 1];
const theta3 = angles[i + 2];
const footpoint = this.calculateFootpoint(theta1, theta2, theta3);
footpoints.push(footpoint);
}
return footpoints;
}
}
+22
View File
@@ -0,0 +1,22 @@
export type vector = { x: number; y: number };
export interface ControllerInput {
left: vector;
right: vector;
height: number;
speed: number;
}
export type angles = number[] | Int16Array;
export type AnglesData = {
type: 'angles';
data: angles;
};
export type LogData = {
type: 'log';
data: string;
};
export type WebSocketJsonMsg = AnglesData | LogData;
+318
View File
@@ -0,0 +1,318 @@
import {
Mesh,
PerspectiveCamera,
PlaneGeometry,
Scene,
ShadowMaterial,
WebGLRenderer,
AmbientLight,
DirectionalLight,
PCFSoftShadowMap,
GridHelper,
ArrowHelper,
Vector3,
LoaderUtils,
Object3D,
FogExp2,
CanvasTexture,
type ColorRepresentation,
type WebGLRendererParameters,
MeshPhongMaterial,
EquirectangularReflectionMapping,
ACESFilmicToneMapping,
MathUtils
} from 'three';
import { Sky } from 'three/addons/objects/Sky.js';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls';
import { type URDFJoint, type URDFMimicJoint, type URDFRobot } from 'urdf-loader';
import { PointerURDFDragControls } from 'urdf-loader/src/URDFDragControls';
export const addScene = () => new Scene();
interface position {
x?: number;
y?: number;
z?: number;
}
interface light {
color?: ColorRepresentation;
intensity?: number;
}
interface gridOptions {
divisions?: number;
size?: number;
}
interface arrowOptions {
origin: position;
direction: position;
length?: number;
color?: ColorRepresentation;
}
type directionalLight = position & light;
type gridHelperOptions = gridOptions & position;
function calculateCurrentSunElevation() {
let now = new Date();
let decimalTime = now.getHours() + now.getMinutes() / 60;
let normalizedTime = (decimalTime % 12) / 6 - 1;
return 10 * Math.sin(normalizedTime * Math.PI);
}
export default class SceneBuilder {
public scene: Scene;
public camera: PerspectiveCamera;
public ground: Mesh;
public renderer: WebGLRenderer;
public controls: OrbitControls;
public callback: Function;
public gridHelper: GridHelper;
public model: URDFRobot;
public liveStreamTexture: CanvasTexture;
private fog: FogExp2;
private isLoaded: boolean = false;
highlightMaterial: any;
constructor() {
this.scene = new Scene();
if (this.scene.environment?.mapping) {
this.scene.environment.mapping = EquirectangularReflectionMapping;
}
return this;
}
public addRenderer = (parameters?: WebGLRendererParameters) => {
this.renderer = new WebGLRenderer(parameters);
this.renderer.outputColorSpace = 'srgb';
this.renderer.shadowMap.enabled = true;
this.renderer.shadowMap.type = PCFSoftShadowMap;
this.renderer.toneMapping = ACESFilmicToneMapping;
this.renderer.toneMappingExposure = 0.85;
document.body.appendChild(this.renderer.domElement);
return this;
};
public addSky = () => {
const sky = new Sky();
sky.scale.setScalar(450000);
this.scene.add(sky);
const effectController = {
turbidity: 10,
rayleigh: 3,
mieCoefficient: 0.005,
mieDirectionalG: 0.7,
elevation: calculateCurrentSunElevation(),
azimuth: 180,
exposure: this.renderer.toneMappingExposure
};
const uniforms = sky.material.uniforms;
uniforms['turbidity'].value = effectController.turbidity;
uniforms['rayleigh'].value = effectController.rayleigh;
uniforms['mieCoefficient'].value = effectController.mieCoefficient;
uniforms['mieDirectionalG'].value = effectController.mieDirectionalG;
this.renderer.toneMappingExposure = 0.5;
const phi = MathUtils.degToRad(90 - effectController.elevation);
const theta = MathUtils.degToRad(effectController.azimuth);
const sun = new Vector3();
sun.setFromSphericalCoords(1, phi, theta);
uniforms['sunPosition'].value.copy(sun);
return this;
};
public addPerspectiveCamera = (options: position) => {
this.camera = new PerspectiveCamera();
this.camera.position.set(options.x ?? 0, options.y ?? 0, options.z ?? 0);
this.scene.add(this.camera);
return this;
};
public addGroundPlane = (options?: position) => {
this.ground = new Mesh(new PlaneGeometry(), new ShadowMaterial({ side: 2 }));
this.ground.rotation.x = -Math.PI / 2;
this.ground.scale.setScalar(30);
this.ground.position.set(options?.x ?? 0, options?.y ?? 0, options?.z ?? 0);
this.ground.receiveShadow = true;
this.scene.add(this.ground);
return this;
};
public addOrbitControls = (minDistance: number, maxDistance: number) => {
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
this.controls.minDistance = minDistance;
this.controls.maxDistance = maxDistance;
this.controls.update();
return this;
};
public addAmbientLight = (options: light) => {
const ambientLight = new AmbientLight(options.color, options.intensity);
this.scene.add(ambientLight);
return this;
};
public addDirectionalLight = (options: directionalLight) => {
const directionalLight = new DirectionalLight(options.color, options.intensity);
directionalLight.castShadow = true;
directionalLight.shadow.mapSize.setScalar(2048);
directionalLight.shadow.mapSize.width = 1024;
directionalLight.shadow.mapSize.height = 1024;
directionalLight.position.set(options.x ?? 0, options.y ?? 0, options.z ?? 0);
directionalLight.shadow.radius = 5;
this.scene.add(directionalLight);
return this;
};
public addGridHelper = (options: gridHelperOptions) => {
this.gridHelper = new GridHelper(options.size, options.divisions);
this.gridHelper.position.set(options.x ?? 0, options.y ?? 0, options.z ?? 0);
this.gridHelper.material.opacity = 0.2;
this.gridHelper.material.depthWrite = false;
this.gridHelper.material.transparent = true;
this.scene.add(this.gridHelper);
return this;
};
public addFogExp2 = (color: ColorRepresentation, density?: number) => {
this.scene.fog = new FogExp2(color, density);
return this;
};
public handleResize = () => {
this.renderer.setSize(window.innerWidth, window.innerHeight);
this.renderer.setPixelRatio(window.devicePixelRatio);
this.camera.aspect = window.innerWidth / window.innerHeight;
this.camera.updateProjectionMatrix();
return this;
};
public addRenderCb = (callback: Function) => {
this.callback = callback;
return this;
};
public startRenderLoop = () => {
this.renderer.setAnimationLoop(() => {
this.renderer.render(this.scene, this.camera);
this.handleRobotShadow();
if (this.callback) this.callback();
if (!this.liveStreamTexture) return;
});
return this;
};
public addArrowHelper = (options?: arrowOptions) => {
const dir = new Vector3(
options?.direction.x ?? 0,
options?.direction.y ?? 0,
options?.direction.z ?? 0
);
const origin = new Vector3(
options?.origin.x ?? 0,
options?.origin.y ?? 0,
options?.origin.z ?? 0
);
const arrowHelper = new ArrowHelper(
dir,
origin,
options?.length ?? 1.5,
options?.color ?? 0xff0000
);
this.scene.add(arrowHelper);
return this;
};
private setJointValue(jointName: string, angle: number) {
if (!this.model) return;
if (!this.model.joints[jointName]) return;
this.model.joints[jointName].setJointValue(angle);
}
isJoint = (j: URDFJoint) => j.isURDFJoint && j.jointType !== 'fixed';
highlightLinkGeometry = (m: URDFMimicJoint, revert: boolean, material: MeshPhongMaterial) => {
const traverse = (c: any) => {
if (c.type === 'Mesh') {
if (revert) {
c.material = c.__origMaterial;
delete c.__origMaterial;
} else {
c.__origMaterial = c.material;
c.material = material;
}
}
if (c === m || !this.isJoint(c)) {
for (let i = 0; i < c.children.length; i++) {
const child = c.children[i];
if (!child.isURDFCollider) {
traverse(c.children[i]);
}
}
}
};
traverse(m);
};
public addModel = (model: any) => {
this.model = model;
this.scene.add(model);
return this;
};
public addDragControl = (updateAngle: any) => {
const highlightColor = '#FFFFFF';
const highlightMaterial = new MeshPhongMaterial({
shininess: 10,
color: highlightColor,
emissive: highlightColor,
emissiveIntensity: 0.25
});
const dragControls = new PointerURDFDragControls(
this.scene,
this.camera,
this.renderer.domElement
);
dragControls.updateJoint = (joint: URDFMimicJoint, angle: number) => {
this.setJointValue(joint.name, angle);
updateAngle(joint.name, angle);
};
dragControls.onDragStart = () => (this.controls.enabled = false);
dragControls.onDragEnd = () => (this.controls.enabled = true);
dragControls.onHover = (joint: URDFMimicJoint) =>
this.highlightLinkGeometry(joint, false, highlightMaterial);
dragControls.onUnhover = (joint: URDFMimicJoint) =>
this.highlightLinkGeometry(joint, true, highlightMaterial);
this.renderer.domElement.addEventListener('touchstart', (data) =>
dragControls._mouseDown(data.touches[0])
);
this.renderer.domElement.addEventListener('touchmove', (data) =>
dragControls._mouseMove(data.touches[0])
);
this.renderer.domElement.addEventListener('touchend', (data) =>
dragControls._mouseUp(data.touches[0])
);
return this;
};
public toggleFog = () => {
this.scene.fog = this.scene.fog ? null : this.fog;
};
private handleRobotShadow = () => {
if (this.isLoaded) return;
const intervalId = setInterval(() => {
this.model?.traverse((c) => (c.castShadow = true));
}, 10);
setTimeout(() => {
clearInterval(intervalId);
}, 1000);
this.isLoaded = true;
};
}
+71
View File
@@ -0,0 +1,71 @@
import { Result } from '$lib/utilities/result';
class FileService {
private dbName = 'fileStorageDB';
private dbVersion = 1;
private storeName = 'files';
private dbPromise: Promise<Result<IDBDatabase, string>>;
constructor() {
this.dbPromise = this.openDatabase();
}
private async openDatabase(): Promise<Result<IDBDatabase, string>> {
return new Promise((resolve) => {
const request = indexedDB.open(this.dbName, this.dbVersion);
request.onerror = () => resolve(Result.err('Error opening database'));
request.onsuccess = () => resolve(Result.ok(request.result));
request.onupgradeneeded = (event) => {
const db = request.result;
if (!db.objectStoreNames.contains(this.storeName)) {
db.createObjectStore(this.storeName);
}
};
});
}
private async getStore(mode: IDBTransactionMode): Promise<Result<IDBObjectStore, string>> {
const dbResult = await this.dbPromise;
if (dbResult.isErr()) {
return Result.err('Database not initialized properly');
}
const db = dbResult.inner;
const transaction = db.transaction(this.storeName, mode);
return Result.ok(transaction.objectStore(this.storeName));
}
public async saveFile(key: string, file: Uint8Array): Promise<Result<IDBValidKey, string>> {
const storeResult = await this.getStore('readwrite');
if (storeResult.isErr()) {
return Result.err('Failed to access object store for writing');
}
const store = storeResult.inner;
return new Promise((resolve) => {
const request = store.put(file, key);
request.onsuccess = () => resolve(Result.ok(request.result));
request.onerror = () => resolve(Result.err('Failed to save file'));
});
}
public async getFile(key: string): Promise<Result<Uint8Array | undefined, string>> {
const storeResult = await this.getStore('readonly');
if (storeResult.isErr()) {
return Result.err('Failed to access object store for reading');
}
const store = storeResult.inner;
return new Promise((resolve) => {
const request = store.get(key);
request.onsuccess = () =>
resolve(request.result ? Result.ok(request.result) : Result.err('File content not found'));
request.onerror = () => resolve(Result.err('Failed to retrieve file'));
});
}
}
export default new FileService();
+3
View File
@@ -0,0 +1,3 @@
export { default as fileService } from './file-service';
export { default as socketService } from './socket-service';
export { default as resultService } from './result-service';
+19
View File
@@ -0,0 +1,19 @@
import { errorLogs, latestErrorLog } from '$lib/stores';
import type { Result } from '$lib/utilities';
class ResultService {
public handleResult(result: Result<unknown, string>, tag?: string) {
if (result.isErr()) {
const errorLogEntry = { tag, message: result.inner, exception: result.exception };
latestErrorLog.set(errorLogEntry);
errorLogs.update((entries) => {
entries.push(errorLogEntry);
return entries;
});
}
return result;
}
}
export default new ResultService();
+95
View File
@@ -0,0 +1,95 @@
import { isConnected, socketData } from '$lib/stores';
import { Result, Ok } from '$lib/utilities';
import { resultService } from '$lib/services';
import { type WebSocketJsonMsg } from '$lib/models';
import type { Writable } from 'svelte/store';
type WebsocketOutData = string | ArrayBufferLike | Blob | ArrayBufferView;
// TODO
/**
* MOVE THE store to a store.ts file
*
* Make an object on the class that encapsulate all the stores
*
* Make the handle message function look up the type and set the value, to simplify the code
*/
class SocketService {
private socket!: WebSocket;
constructor() {}
public connect(url: string): void {
if (typeof WebSocket === 'undefined') return;
this.socket = new WebSocket(url);
this.socket.binaryType = 'arraybuffer';
this.socket.onopen = () => this.handleConnected();
this.socket.onclose = () => this.handleDisconnected();
this.socket.onmessage = (event: MessageEvent) =>
resultService.handleResult(this.handleMessage(event), 'SocketService');
this.socket.onerror = (error: Event) => console.log(error);
}
public send(data: WebsocketOutData): Result<void, string> {
if (typeof WebSocket === 'undefined') return Result.err('The connection is not open');
if (this.socket?.readyState === WebSocket.OPEN) {
this.socket.send(data);
return Ok.void();
}
return Result.err('The connection is not open');
}
public addPublisher(store: Writable<WebsocketOutData>, type?: string) {
const publish = (data: WebsocketOutData) =>
this.send(type ? JSON.stringify({ type, data }) : data);
store.subscribe(publish);
}
private handleConnected(): void {
isConnected.set(true);
}
private handleDisconnected(): void {
isConnected.set(false);
}
private getJsonFromMessage(msg: string): Result<WebSocketJsonMsg, string> {
try {
return Result.ok(JSON.parse(msg) as WebSocketJsonMsg);
} catch (error) {
return Result.err('Failed to parse socket message', error);
}
}
private handleBufferMessage(buffer: ArrayBuffer): Result<void, string> {
console.log(buffer);
return Ok.void();
}
private handleMessage(event: MessageEvent): Result<void, string> {
if (event.data instanceof ArrayBuffer) {
return this.handleBufferMessage(event.data);
}
let msgRes = this.getJsonFromMessage(event.data);
if (msgRes.isErr()) {
return msgRes;
}
const msg = msgRes.inner;
if (msg.type === 'log') {
socketData.logs.update((entries) => {
entries.push(msg.data);
return entries;
});
return Ok.void();
} else if (msg.data && msg.type in socketData) {
socketData[msg.type].set(msg.data);
return Ok.void();
}
return Result.err(`Got invalid msg: ${JSON.stringify(msg)}`);
}
}
export default new SocketService();
+3
View File
@@ -0,0 +1,3 @@
export * from './socket-store';
export * from './logging-store';
export * from './model-store';
+11
View File
@@ -0,0 +1,11 @@
import { writable, type Writable } from 'svelte/store';
export interface errorLog {
message: unknown;
tag?: string;
exception?: unknown;
}
export const latestErrorLog: Writable<errorLog> = writable();
export const errorLogs: Writable<errorLog[]> = writable([]);
+24
View File
@@ -0,0 +1,24 @@
import type { ControllerInput } from '$lib/models';
import { persistentStore } from '$lib/utilities/svelte-utilities';
import { writable, type Writable } from 'svelte/store';
export const emulateModel = writable(true);
export const jointNames = persistentStore('joint_names', []);
export const model = writable();
export const modes = ['idle', 'rest', 'stand', 'walk'] as const;
export type Modes = (typeof modes)[number];
export const mode: Writable<Modes> = writable('idle');
export const outControllerData = writable(new Int8Array([0, 0, 0, 0, 0, 70, 0]));
export const input: Writable<ControllerInput> = writable({
left: { x: 0, y: 0 },
right: { x: 0, y: 0 },
height: 70,
speed: 0
});
+31
View File
@@ -0,0 +1,31 @@
import { writable, type Writable } from 'svelte/store';
import { type angles } from '$lib/models';
export const isConnected = writable(false);
export const servoAngles: Writable<angles> = writable(new Int16Array(12).fill(0));
export const logs = writable([] as string[]);
export const battery = writable({});
export const mpu = writable({ heading: 0 });
export const distances = writable({});
export const settings = writable({});
export const systemInfo = writable({} as number);
export interface socketDataCollection {
angles: Writable<angles>;
logs: Writable<string[]>;
battery: Writable<unknown>;
mpu: Writable<unknown>;
distances: Writable<unknown>;
settings: Writable<unknown>;
systemInfo: Writable<unknown>;
}
export const socketData = {
angles: servoAngles,
logs,
battery,
mpu,
distances,
settings,
systemInfo
};
@@ -0,0 +1,15 @@
export class throttler {
private _throttlePause: boolean;
constructor() {
this._throttlePause = false;
}
throttle = (callback: Function, time: number) => {
if (this._throttlePause) return;
this._throttlePause = true;
setTimeout(() => {
callback();
this._throttlePause = false;
}, time);
};
}
+7
View File
@@ -0,0 +1,7 @@
export * from './result';
export * from './string-utilities';
export * from './svelte-utilities';
export * from './math-utilities';
export * from './buffer-utilities';
export * from './model-utilities';
export * from './location-utilities';
@@ -0,0 +1,9 @@
export const hostname = 'localhost'; //window.location.hostname;
export const isSecure = true; // window.location.protocol === 'https:';
export const location = 'localhost:5173'; //window.location; //import.meta.env.VITE_API_URL.replace('hostname', hostname);
const socketScheme = isSecure ? 'wss://' : 'ws://';
export const socketLocation = socketScheme + location; // import.meta.env.VITE_SOCKET_URL.replace('hostname', hostname);
+11
View File
@@ -0,0 +1,11 @@
export const toUint8 = (number: number, min: number, max: number) => {
number = Math.max(min, Math.min(max, number));
let scaled = ((number - min) / (max - min)) * 255;
return Math.round(scaled) & 0xff;
};
export const toInt8 = (number: number, min: number, max: number) => {
number = Math.max(min, Math.min(max, number));
let scaled = ((number - min) / (max - min)) * 255 - 128;
return Math.max(-128, Math.min(127, Math.round(scaled))) | 0;
};
+63
View File
@@ -0,0 +1,63 @@
import { Color, LoaderUtils, Vector3 } from 'three';
import URDFLoader, { type URDFRobot } from 'urdf-loader';
import { XacroLoader } from 'xacro-parser';
import { Result } from '$lib/utilities';
let model_xml: XMLDocument;
export const loadModelAsync = async (
url: string
): Promise<Result<[URDFRobot, string[]], string>> => {
return new Promise((resolve, reject) => {
const xacroLoader = new XacroLoader();
const urdfLoader = new URDFLoader();
urdfLoader.workingPath = LoaderUtils.extractUrlBase(url);
xacroLoader.load(
url,
async (xml) => {
model_xml = xml;
try {
const model = urdfLoader.parse(xml);
model.rotation.x = -Math.PI / 2;
model.rotation.z = Math.PI / 2;
model.traverse((c) => (c.castShadow = true));
model.updateMatrixWorld(true);
model.scale.setScalar(10);
const joints = Object.entries(model.joints)
.filter((joint) => joint[1].jointType !== 'fixed')
.map((joint) => joint[0]);
resolve(Result.ok([model, joints]));
} catch (error) {
resolve(Result.err('Failed to load model', error));
}
},
(error) => reject(error)
);
});
};
export const toeWorldPositions = (robot: URDFRobot) => {
const toe_positions: Vector3[] = [];
robot.traverse((child) => {
if (child.name.includes('toe') && !child.name.includes('_link')) {
const worldPosition = new Vector3();
child.getWorldPosition(worldPosition);
toe_positions.push(worldPosition);
}
});
return toe_positions;
};
export const footColor = () => {
const colorElem = model_xml.querySelector('material[name=foot_color] > color') as Element;
const colorAttrStr = colorElem.getAttribute('rgba') as string;
const colorStr = colorAttrStr
.split(' ')
.slice(0, 3)
.map((val) => Math.floor(+val * 255))
.join(', ');
return new Color(`rgb(${colorStr})`);
};
+42
View File
@@ -0,0 +1,42 @@
export class Err<T, U> {
#inner: T;
#exception?: U;
constructor(inner: T, exception?: U) {
this.#inner = inner;
this.#exception = exception;
}
get inner(): T {
return this.#inner;
}
get exception(): U | undefined {
return this.#exception;
}
/**
* Type guard for `Ok`
* @returns `true` if `Ok`; `false` if `Err`
*/
isOk(): false {
return false;
}
/**
* Type guard for `Err`
* @returns `true` if `Err`; `false` if `Ok`
*/
isErr(): this is Err<T, U> {
return true;
}
/**
* Create an `Err`
* @param inner
* @returns `Err(inner)`
*/
static new<E, F>(inner: E, exception: F): Err<E, F> {
return new Err<E, F>(inner, exception);
}
}
+3
View File
@@ -0,0 +1,3 @@
export * from './err';
export * from './ok';
export * from './result';
+44
View File
@@ -0,0 +1,44 @@
export class Ok<T> {
#inner: T;
constructor(inner: T) {
this.#inner = inner;
}
get inner(): T {
return this.#inner;
}
/**
* Type guard for `Ok`
* @returns `true` if `Ok`; `false` if `Err`
*/
isOk(): this is Ok<T> {
return true;
}
/**
* Type guard for `Err`
* @returns `true` if `Err`; `false` if `Ok`
*/
isErr(): false {
return false;
}
/**
* Create an `Ok`
* @param inner
* @returns `Ok(inner)`
*/
static new<T>(inner: T): Ok<T> {
return new Ok<T>(inner);
}
/**
* Create an empty `Ok`
* @returns `Ok(void)`
*/
static void(): Ok<void> {
return new Ok(undefined);
}
}
+20
View File
@@ -0,0 +1,20 @@
import { Err } from './err';
import { Ok } from './ok';
export type Result<T = unknown, E = unknown, F = unknown> = Ok<T> | Err<E, F>;
export namespace Result {
/**
* @returns `Ok<T>`
*/
export function ok<T = unknown>(value: T) {
return Ok.new(value);
}
/**
* @returns `Err<E, F>`
*/
export function err<E = unknown, F = unknown>(error: E, exception?: F) {
return Err.new(error, exception);
}
}
@@ -0,0 +1,9 @@
export const humanFileSize = (size: number): string => {
const units = ['B', 'kB', 'MB', 'GB', 'TB'];
var i = size == 0 ? 0 : Math.floor(Math.log(size) / Math.log(1024));
return Number((size / Math.pow(1024, i)).toFixed(2)) * 1 + units[i];
};
export const capitalize = (str: string): string => {
return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase();
};
@@ -0,0 +1,16 @@
import { writable } from 'svelte/store';
import { browser } from '$app/environment';
export const isEmbeddedApp = import.meta.env.VITE_EMBEDDED_BUILD === 'true';
export const persistentStore = (key: string, initialValue: any) => {
const savedValue = browser ? JSON.parse(localStorage.getItem(key) as string) : null;
const data = savedValue !== null ? savedValue : initialValue;
const store = writable(data);
store.subscribe((value) => {
browser && localStorage.setItem(key, JSON.stringify(value));
});
return store;
};
+15
View File
@@ -0,0 +1,15 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
onMount(() => {
setTimeout(() => {
goto('/');
}, 3000);
});
</script>
<div class="flex justify-center items-center w-full h-full">
<h1 class="text-4xl">404 - Page not found</h1>
<p>You will be redirected to the home page in 3 seconds</p>
</div>
+34
View File
@@ -0,0 +1,34 @@
<script lang="ts">
import { page } from '$app/stores';
import TopBar from '$lib/components/TopBar.svelte';
import Menu from './menu.svelte';
import '../app.css';
let menuOpen = false;
</script>
<TopBar />
<svelte:head>
<title>{$page.data.title}</title>
</svelte:head>
<div class="drawer lg:drawer-open">
<input id="main-menu" type="checkbox" class="drawer-toggle" bind:checked={menuOpen} />
<div class="drawer-content flex flex-col">
<!-- Status bar content here -->
<!-- <Statusbar /> -->
<!-- Main page content here -->
<slot />
</div>
<!-- Side Navigation -->
<div class="drawer-side z-30 shadow-lg">
<label for="main-menu" class="drawer-overlay" />
<Menu
on:menuClicked={() => {
menuOpen = false;
}}
/>
</div>
</div>
+50
View File
@@ -0,0 +1,50 @@
import { jointNames, model } from '$lib/stores';
import { loadModelAsync } from '$lib/utilities/model-utilities';
import type { Result } from '$lib/utilities/result';
export const prerender = true;
export const ssr = false;
const registerFetchIntercept = async () => {
if (typeof WebSocket === 'undefined') return;
const { fetch: originalFetch } = window;
const fileService = (await import('$lib/services/file-service')).default;
window.fetch = async (...args) => {
const [resource, config] = args;
let file: Result<Uint8Array | undefined, string>;
file = await fileService.getFile(resource.toString());
return file.isOk() ? new Response(file.inner) : originalFetch(resource, config);
};
};
const setup = async () => {
if (typeof WebSocket === 'undefined') return;
const outControllerData = (await import('$lib/stores/model-store')).outControllerData;
const mode = (await import('$lib/stores/model-store')).mode;
const socketLocation = (await import('$lib/utilities/location-utilities')).socketLocation;
const socketService = (await import('$lib/services/socket-service')).default;
socketService.connect(socketLocation);
socketService.addPublisher(outControllerData);
socketService.addPublisher(mode, 'mode');
await registerFetchIntercept();
const modelRes = await loadModelAsync('/spot_micro.urdf.xacro');
if (modelRes.isOk()) {
const [urdf, JOINT_NAME] = modelRes.inner;
jointNames.set(JOINT_NAME);
model.set(urdf);
} else {
console.error(modelRes.inner, { exception: modelRes.exception });
}
};
export const load = async () => {
await setup();
// const result = await fetch('/rest/features');
const item = {}; //await result.json();
return {
features: item,
title: 'spot-micro-controller',
github: 'runeharlyk/spotmicro'
};
};
+2
View File
@@ -0,0 +1,2 @@
<h1>Welcome to SvelteKit</h1>
<p>Visit <a href="https://kit.svelte.dev">kit.svelte.dev</a> to read the documentation</p>
@@ -0,0 +1,8 @@
<script lang="ts">
import Controls from '$lib/components/Controls.svelte';
</script>
<div class="flex justify-center items-center w-full h-full">
<slot/>
<Controls />
</div>
+5
View File
@@ -0,0 +1,5 @@
<script>
import Model from "$lib/components/Views/Model.svelte";
</script>
<Model />
+3
View File
@@ -0,0 +1,3 @@
export const load = async () => {
return { title: 'Spot Micro' };
};
@@ -0,0 +1,19 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import { location } from '$lib/utilities';
let videoStream = `//${location}/api/stream`;
onDestroy(() => {
videoStream = '#';
});
</script>
<div class="w-full h-full">
<img
src={videoStream}
class="absolute object-cover blur-3xl w-full h-full -z-10"
alt="Live stream is down"
/>
<img src={videoStream} class="object-contain w-full h-full" alt="Live stream is down" />
</div>
+266
View File
@@ -0,0 +1,266 @@
<script lang="ts">
// import logo from '$lib/assets/logo.png';
import Github from '~icons/tabler/brand-github';
import Discord from '~icons/tabler/brand-discord';
import Users from '~icons/tabler/users';
import Settings from '~icons/tabler/settings';
import Health from '~icons/tabler/stethoscope';
import Update from '~icons/tabler/refresh-alert';
import WiFi from '~icons/tabler/wifi';
import Router from '~icons/tabler/router';
import AP from '~icons/tabler/access-point';
import Remote from '~icons/tabler/network';
import Control from '~icons/tabler/adjustments';
import Avatar from '~icons/tabler/user-circle';
import Logout from '~icons/tabler/logout';
import Copyright from '~icons/tabler/copyright';
import MQTT from '~icons/tabler/topology-star-3';
import NTP from '~icons/tabler/clock-check';
import Metrics from '~icons/tabler/report-analytics';
import { page } from '$app/stores';
import { onMount } from 'svelte';
// import { user } from '$lib/stores/user';
import { createEventDispatcher } from 'svelte';
const appName = 'ESP32 SvelteKit';
const copyright = '2023 theelims';
const github = { href: 'https://github.com/' + $page.data.github, active: true };
const discord = { href: '.', active: false };
type menuItem = {
title: string;
icon: object;
href?: string;
feature: boolean;
active?: boolean;
submenu?: subMenuItem[];
};
type subMenuItem = {
title: string;
icon: object;
href: string;
feature: boolean;
active: boolean;
};
let menuItems = [
{
title: 'Demo App',
icon: Control,
href: '/demo',
feature: true,
active: false
},
{
title: 'Connections',
icon: Remote,
feature: $page.data.features.mqtt || $page.data.features.ntp,
submenu: [
{
title: 'MQTT',
icon: MQTT,
href: '/connections/mqtt',
feature: $page.data.features.mqtt,
active: false
},
{
title: 'NTP',
icon: NTP,
href: '/connections/ntp',
feature: $page.data.features.ntp,
active: false
}
]
},
{
title: 'WiFi',
icon: WiFi,
feature: true,
submenu: [
{
title: 'WiFi Station',
icon: Router,
href: '/wifi/sta',
feature: true,
active: false
},
{
title: 'Access Point',
icon: AP,
href: '/wifi/ap',
feature: true,
active: false
}
]
},
{
title: 'Users',
icon: Users,
href: '/user',
feature: $page.data.features.security, //&& $user.admin,
active: false
},
{
title: 'System',
icon: Settings,
feature: true,
submenu: [
{
title: 'System Status',
icon: Health,
href: '/system/status',
feature: true,
active: false
},
{
title: 'System Metrics',
icon: Metrics,
href: '/system/metrics',
feature: $page.data.features.analytics,
active: false
},
{
title: 'Firmware Update',
icon: Update,
href: '/system/update',
feature:
($page.data.features.ota ||
$page.data.features.upload_firmware ||
$page.data.features.download_firmware) &&
(!$page.data.features.security),// || $user.admin),
active: false
}
]
}
];
const dispatch = createEventDispatcher();
function setActiveMenuItem(menuItems: menuItem[], targetTitle: string) {
for (let i = 0; i < menuItems.length; i++) {
const menuItem = menuItems[i];
// Clear any previous set active flags
menuItem.active = false;
// Check if the current menu item's title matches the target title
if (menuItem.title === targetTitle) {
menuItem.active = true; // Set the active property to true
dispatch('menuClicked');
}
// Check if the current menu item has a submenu
if (menuItem.submenu && menuItem.submenu.length > 0) {
// Recursively call the function for each submenu item
setActiveMenuItem(menuItem.submenu, targetTitle);
}
}
if (targetTitle == '') {
dispatch('menuClicked');
}
menuItems = menuItems;
}
onMount(() => {
setActiveMenuItem(menuItems, $page.data.title);
menuItems = menuItems;
});
</script>
<div class="bg-base-200 text-base-content flex h-full w-80 flex-col p-4">
<!-- Sidebar content here -->
<a
href="/"
class="rounded-box mb-4 flex items-center hover:scale-[1.02] active:scale-[0.98]"
on:click={() => setActiveMenuItem(menuItems, '')}
>
<!-- <img src={logo} alt="Logo" class="h-12 w-12" /> -->
<h1 class="px-4 text-2xl font-bold">{appName}</h1>
</a>
<ul class="menu rounded-box menu-vertical flex-nowrap overflow-y-auto">
{#each menuItems as menuItem, i (menuItem.title)}
{#if menuItem.feature}
{#if menuItem.submenu}
<li>
<details>
<summary class="text-lg font-bold"
><svelte:component this={menuItem.icon} class="h-6 w-6" />{menuItem.title}</summary
>
<ul>
{#each menuItem.submenu as subMenuItem}
{#if subMenuItem.feature}
<li class="hover-bordered">
<a
href={subMenuItem.href}
class="text-ml font-bold {subMenuItem.active ? 'bg-base-100' : ''}"
on:click={() => {
setActiveMenuItem(menuItems, subMenuItem.title);
menuItems = menuItems;
}}
><svelte:component
this={subMenuItem.icon}
class="h-5 w-5"
/>{subMenuItem.title}</a
>
</li>
{/if}
{/each}
</ul>
</details>
</li>
{:else}
<li class="hover-bordered">
<a
href={menuItem.href}
class="text-lg font-bold {menuItem.active ? 'bg-base-100' : ''}"
on:click={() => {
setActiveMenuItem(menuItems, menuItem.title);
menuItems = menuItems;
}}><svelte:component this={menuItem.icon} class="h-6 w-6" />{menuItem.title}</a
>
</li>
{/if}
{/if}
{/each}
</ul>
<div class="flex-col" />
<div class="flex-grow" />
{#if $page.data.features.security}
<div class="flex items-center">
<Avatar class="h-8 w-8" />
<span class="flex-grow px-4 text-xl font-bold">$user.username</span>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="btn btn-ghost"
on:click={() => {
// user.invalidate();
}}
>
<Logout class="h-8 w-8 rotate-180" />
</div>
</div>
{/if}
<div class="divider my-0" />
<div class="flex items-center">
{#if github.active}
<a href={github.href} class="btn btn-ghost" target="_blank" rel="noopener noreferrer"
><Github class="h-5 w-5" /></a
>
{/if}
{#if discord.active}
<a href={discord.href} class="btn btn-ghost" target="_blank" rel="noopener noreferrer"
><Discord class="h-5 w-5" /></a
>
{/if}
<div class="inline-flex flex-grow items-center justify-end text-sm">
<Copyright class="h-4 w-4" /><span class="px-2">{copyright}</span>
</div>
</div>
</div>
+56
View File
@@ -0,0 +1,56 @@
<!-- <script lang="ts">
import Info from '$lib/components/settings/Info.svelte';
import Log from '$lib/components/settings/Log.svelte';
import Configuration from '$lib/components/settings/Configuration.svelte';
import Calibration from '$lib/components/settings/Calibration.svelte';
export const page = '';
const menu = [
{
title: 'Calibration',
path: '/calibration',
// icon: AdjustmentsVertical,
component: Calibration
},
{
title: 'System info',
path: '/info',
// icon: InformationCircle,
component: Info
},
{
title: 'Log',
path: '/log',
// icon: BookOpen,
component: Log
},
{
title: 'Settings',
path: '/settings',
// icon: Cog6Tooth,
component: Configuration
}
];
</script> -->
<h1 class="text-2xl font-bold">Settings</h1>
<div class="pt-14 flex h-full">
<nav class="w-1/6 flex flex-col">
<!-- {#each menu as link} -->
<!-- <Link to={'/settings' + link.path}> -->
<div class="px-4 py-2 flex gap-2 items-center">
<!-- <Icon src={link.icon} size="24" />{link.title} -->
</div>
<!-- </Link> -->
<!-- {/each} -->
</nav>
<main class="w-full h-full">
<!-- <Router> -->
<!-- {#each menu as link} -->
<!-- <Route path={link.path} component={link.component}></Route> -->
<!-- {/each} -->
<!-- </Router> -->
</main>
</div>
View File
+1
View File
@@ -0,0 +1 @@
<div>LOGS</div>
@@ -0,0 +1 @@
<div>metrics</div>