🚀 Initial sveltekit app
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
Vendored
+13
@@ -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 {};
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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} />
|
||||
@@ -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>
|
||||
@@ -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
|
||||
>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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';
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './socket-store';
|
||||
export * from './logging-store';
|
||||
export * from './model-store';
|
||||
@@ -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([]);
|
||||
@@ -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
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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})`);
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './err';
|
||||
export * from './ok';
|
||||
export * from './result';
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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'
|
||||
};
|
||||
};
|
||||
@@ -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>
|
||||
@@ -0,0 +1,5 @@
|
||||
<script>
|
||||
import Model from "$lib/components/Views/Model.svelte";
|
||||
</script>
|
||||
|
||||
<Model />
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
<div>LOGS</div>
|
||||
@@ -0,0 +1 @@
|
||||
<div>metrics</div>
|
||||
Reference in New Issue
Block a user