Merge pull request #18 from Team-1280/beta

Beta
This commit is contained in:
Youwen Wu 2024-03-01 12:57:13 -08:00 committed by GitHub
commit a454d3085e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
95 changed files with 1181 additions and 537 deletions

View file

@ -32,20 +32,23 @@ If you would like to contribute to Jankboard 2, there's only a few simple steps
- If you don't have access to a development environment that supports running standalone executables (eg. Github Codespaces), you can try running `npm run dev` instead of `npm run tauri dev`, which will open a development server at `localhost:5173` with the frontend running in the web. However, this may break at any time as critical functionality is more directly attached to the Rust backend.
- If for some reason you need to install and use the Python backend while we are migrating to Rust, run `poetry install --no-root` in the root directory of the project to install dependencies. You can start the server with `poetry run flask --app app/server.py run --host localhost --port 1280` (it must be running at port `1280` for the frontend to detect it).
## Current progress
## Current progress and improvements over (original) Jankboard
- Basic UI layout complete
- Media player working with a few small issues
- App system working smoothly
- Camera feed likely working
- Frontend syncs basic telemetry data with robot through the same Socket-IO code that powered Jankboard v1
- Notification service installed, with Toast and audio capability
- Layout, toasts/notifications, music player, and app system ported.
- Toast and audio cue system is much more robust
- Transitions added almost everywhere to make things smoother
- Tauri app created successfully, currently still using Flask backend
- Visualization vastly improved with Threlte (Three.js) powered 3D robot simulation
- Robot model ported successfully via massive optimization through polygon decimation
- Added settings app with options to disable certain features and developer tools for testing
## TODO
- Camera cutout overlay
- Overhaul audio player system
- Robot visualization (3D, in Threlte).
- Overhaul backend
- Overhaul visualization (especially camera)
- Overhaul backend in Rust
- Further integrate telemetry (like GPWS, collision warning, etc)
- Finish re-creating / adding various voice alerts and sequences
- Create dynamic voice prompt system to support new languages very easily
- Add dynamic voice prompt fallback to support incremental voice prompt migration

View file

@ -9,8 +9,10 @@
"version": "0.0.0",
"dependencies": {
"@fontsource/roboto": "^5.0.8",
"@tauri-apps/api": "^1.5.3",
"@threlte/core": "^7.1.0",
"@threlte/extras": "^8.8.0",
"camera-controls": "^2.8.3",
"howler": "^2.2.4",
"material-icons": "^1.13.12",
"material-symbols": "^0.15.0",
@ -810,6 +812,20 @@
"vite": "^5.0.0"
}
},
"node_modules/@tauri-apps/api": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-1.5.3.tgz",
"integrity": "sha512-zxnDjHHKjOsrIzZm6nO5Xapb/BxqUq1tc7cGkFXsFkGTsSWgCPH1D8mm0XS9weJY2OaR73I3k3S+b7eSzJDfqA==",
"engines": {
"node": ">= 14.6.0",
"npm": ">= 6.6.0",
"yarn": ">= 1.19.1"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/tauri"
}
},
"node_modules/@tauri-apps/cli": {
"version": "1.5.10",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-1.5.10.tgz",
@ -1300,6 +1316,14 @@
"node": ">= 6"
}
},
"node_modules/camera-controls": {
"version": "2.8.3",
"resolved": "https://registry.npmjs.org/camera-controls/-/camera-controls-2.8.3.tgz",
"integrity": "sha512-zFjqUR6onLkG+z1A6vAWfzovxZxWVSvp6e5t3lfZgfgPZtX3n74aykNAUaoRbq8Y3tOxadHkDjbfGDOP9hFf2w==",
"peerDependencies": {
"three": ">=0.126.1"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001588",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001588.tgz",

View file

@ -29,8 +29,10 @@
},
"dependencies": {
"@fontsource/roboto": "^5.0.8",
"@tauri-apps/api": "^1.5.3",
"@threlte/core": "^7.1.0",
"@threlte/extras": "^8.8.0",
"camera-controls": "^2.8.3",
"howler": "^2.2.4",
"material-icons": "^1.13.12",
"material-symbols": "^0.15.0",

View file

@ -66,10 +66,14 @@ checksum = "5ad32ce52e4161730f7098c077cd2ed6229b5804ccf99e5366be1ab72a98b4e1"
name = "app"
version = "0.1.0"
dependencies = [
"network-tables",
"serde",
"serde_json",
"tauri",
"tauri-build",
"tokio",
"tracing",
"tracing-subscriber",
]
[[package]]
@ -788,6 +792,12 @@ dependencies = [
"syn 2.0.50",
]
[[package]]
name = "futures-sink"
version = "0.3.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5"
[[package]]
name = "futures-task"
version = "0.3.30"
@ -802,6 +812,7 @@ checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48"
dependencies = [
"futures-core",
"futures-macro",
"futures-sink",
"futures-task",
"pin-project-lite",
"pin-utils",
@ -1184,6 +1195,12 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573"
[[package]]
name = "httparse"
version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d897f394bad6a705d5f4104762e116a75639e470d80901eed05a860a95cb1904"
[[package]]
name = "iana-time-zone"
version = "0.1.60"
@ -1528,6 +1545,17 @@ dependencies = [
"simd-adler32",
]
[[package]]
name = "mio"
version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f3d0b296e374a4e6f3c7b0a1f5a51d748a0d34c85e7dc48fc3fa9a87657fe09"
dependencies = [
"libc",
"wasi 0.11.0+wasi-snapshot-preview1",
"windows-sys 0.48.0",
]
[[package]]
name = "ndk"
version = "0.6.0"
@ -1556,6 +1584,26 @@ dependencies = [
"jni-sys",
]
[[package]]
name = "network-tables"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7d34242b4ee3505f5d9f6eeb8cc409cfa1f18a517825c78e6001fe304c3977b"
dependencies = [
"futures-util",
"parking_lot",
"rand 0.8.5",
"rmp",
"rmp-serde",
"rmpv",
"serde",
"serde_json",
"thiserror",
"tokio",
"tokio-tungstenite",
"tracing",
]
[[package]]
name = "new_debug_unreachable"
version = "1.0.4"
@ -1721,6 +1769,12 @@ dependencies = [
"windows-targets 0.48.5",
]
[[package]]
name = "paste"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de3145af08024dea9fa9914f381a17b8fc6034dfb00f3a84013f7ff43f29ed4c"
[[package]]
name = "percent-encoding"
version = "2.3.1"
@ -2142,6 +2196,40 @@ version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08c74e62047bb2de4ff487b251e4a92e24f48745648451635cec7d591162d9f"
[[package]]
name = "rmp"
version = "0.8.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f9860a6cc38ed1da53456442089b4dfa35e7cedaa326df63017af88385e6b20"
dependencies = [
"byteorder",
"num-traits",
"paste",
]
[[package]]
name = "rmp-serde"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bffea85eea980d8a74453e5d02a8d93028f3c34725de143085a844ebe953258a"
dependencies = [
"byteorder",
"rmp",
"serde",
]
[[package]]
name = "rmpv"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e0e0214a4a2b444ecce41a4025792fc31f77c7bb89c46d253953ea8c65701ec"
dependencies = [
"num-traits",
"rmp",
"serde",
"serde_bytes",
]
[[package]]
name = "rustc-demangle"
version = "0.1.23"
@ -2247,6 +2335,15 @@ dependencies = [
"serde_derive",
]
[[package]]
name = "serde_bytes"
version = "0.11.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b8497c313fd43ab992087548117643f6fcd935cbf36f176ffda0aacf9591734"
dependencies = [
"serde",
]
[[package]]
name = "serde_derive"
version = "1.0.197"
@ -2352,6 +2449,17 @@ dependencies = [
"stable_deref_trait",
]
[[package]]
name = "sha1"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "sha2"
version = "0.10.8"
@ -2372,6 +2480,15 @@ dependencies = [
"lazy_static",
]
[[package]]
name = "signal-hook-registry"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8229b473baa5980ac72ef434c4415e70c4b5e71b423043adb4ba059f89c99a1"
dependencies = [
"libc",
]
[[package]]
name = "simd-adler32"
version = "0.3.7"
@ -2399,6 +2516,16 @@ version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6ecd384b10a64542d77071bd64bd7b231f4ed5940fba55e98c3de13824cf3d7"
[[package]]
name = "socket2"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05ffd9c0a93b7543e062e759284fcf5f5e3b098501104bfbdde4d404db792871"
dependencies = [
"libc",
"windows-sys 0.52.0",
]
[[package]]
name = "soup2"
version = "0.2.1"
@ -2895,8 +3022,38 @@ checksum = "61285f6515fa018fb2d1e46eb21223fff441ee8db5d0f1435e8ab4f5cdb80931"
dependencies = [
"backtrace",
"bytes",
"libc",
"mio",
"num_cpus",
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
"socket2",
"tokio-macros",
"windows-sys 0.48.0",
]
[[package]]
name = "tokio-macros"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.50",
]
[[package]]
name = "tokio-tungstenite"
version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54319c93411147bced34cb5609a80e0a8e44c5999c93903a81cd866630ec0bfd"
dependencies = [
"futures-util",
"log",
"tokio",
"tungstenite",
]
[[package]]
@ -3037,6 +3194,25 @@ dependencies = [
"serde_json",
]
[[package]]
name = "tungstenite"
version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30ee6ab729cd4cf0fd55218530c4522ed30b7b6081752839b68fcec8d0960788"
dependencies = [
"base64 0.13.1",
"byteorder",
"bytes",
"http",
"httparse",
"log",
"rand 0.8.5",
"sha1",
"thiserror",
"url",
"utf-8",
]
[[package]]
name = "typenum"
version = "1.17.0"

View file

@ -18,6 +18,10 @@ tauri-build = { version = "1.5.1", features = [] }
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
tauri = { version = "1.6.0", features = [] }
tokio = { version = "1.23.0", features = ["full"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3.16", features = ["env-filter"] }
network-tables = { version = "=0.1.3", features = ["client-v4"] }
[features]
# this feature is used for production builds or when `devPath` points to the filesystem and the built-in dev server is disabled.

View file

@ -1,3 +1,3 @@
fn main() {
tauri_build::build()
tauri_build::build()
}

View file

@ -1,8 +1,30 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
tauri::Builder::default()
.run(tauri::generate_context!())
.expect("error while running tauri application");
use tauri::Manager;
mod telemetry;
#[derive(Clone, serde::Serialize)]
struct Payload {
message: String,
}
fn main() {
let rt = tokio::runtime::Runtime::new().expect("Failed to create Tokio runtime");
rt.block_on(async {
tauri::Builder::default()
.setup(|app| {
// create app handle and send it to our event listeners
let app_handle = app.app_handle();
tokio::spawn(async move {
crate::telemetry::subscribe_topics(app_handle.clone()).await;
});
Ok(())
})
.run(tauri::generate_context!())
.expect("failed to run app")
})
}

View file

@ -0,0 +1,75 @@
use network_tables::v4::client_config::Config;
use network_tables::v4::{Client, SubscriptionOptions};
use serde_json::to_string;
use std::net::{Ipv4Addr, SocketAddrV4};
use tauri::{AppHandle, Manager};
use tokio::time::{sleep, Duration};
const NTABLE_IP: (u8, u8, u8, u8) = (10, 12, 80, 2);
const NTABLE_PORT: u16 = 5810;
pub async fn subscribe_topics(app_handle: AppHandle) {
loop {
// I hope this doesn't lead to a catastrophic infinite loop failure
let client = loop {
match Client::try_new_w_config(
SocketAddrV4::new(
Ipv4Addr::new(NTABLE_IP.0, NTABLE_IP.1, NTABLE_IP.2, NTABLE_IP.3),
NTABLE_PORT,
),
Config {
..Default::default()
},
)
.await
{
Ok(client) => {
println!("Client created");
app_handle
.emit_all("telemetry_connected", "connected")
.expect("Failed to emit telemetry_status connected event");
break client; // Exit the loop if the client is successfully created
}
Err(e) => {
println!("Failed to create client: {}. Retrying in 3 seconds...", e);
app_handle
.emit_all("telemetry_status", "disconnected")
.expect("Failed to emit telemetry_status disconnected event");
sleep(Duration::from_secs(3)).await; // Wait for 3 seconds before retrying
continue; // Continue the loop to retry
}
};
};
let mut subscription = client
.subscribe_w_options(
&["/SmartDashboard"],
Some(SubscriptionOptions {
all: Some(true),
prefix: Some(true),
..Default::default()
}),
)
.await
.expect("Failed to subscribe");
while let Some(message) = subscription.next().await {
let mut modified_message = message.clone();
if let Some(stripped) = modified_message.topic_name.strip_prefix("/SmartDashboard/") {
modified_message.topic_name = stripped.to_string();
}
let json_message = to_string(&modified_message).expect("Failed to serialize message");
app_handle
.emit_all("telemetry_data", json_message.clone())
.expect("Failed to send telemetry message");
println!("{}", json_message);
}
println!("disconnected");
app_handle
.emit_all("telemetry_status", "disconnected")
.expect("Failed to emit telemetry_disconnected event");
}
}

View file

@ -6,13 +6,14 @@
import AppBar from './lib/Apps/AppBar.svelte'
import { appList } from './lib/Apps/appList'
import { initializeTelemetry } from './lib/utils/initializeTelemetry'
import { onMount } from 'svelte'
import { onDestroy, onMount } from 'svelte'
import { Toaster } from 'svelte-french-toast'
import { initializationSequence } from './lib/Sequences/sequences'
import Loading from './lib/Loading/Loading.svelte'
import { settingsStore } from './lib/stores/settingsStore'
import getSettings from './lib/utils/getSettings'
import { Canvas } from '@threlte/core'
import { emit } from '@tauri-apps/api/event'
let activeApp: App = 'camera'
let topics: TelemetryTopics = {
@ -32,6 +33,7 @@
}
let loading = $settingsStore.fastStartup ? false : true
let unlistenAll: () => void
onMount(() => {
let savedSettings = getSettings()
@ -40,11 +42,21 @@
}
window.ResizeObserver = ResizeObserver
// disabled while migrating away from python
initializeTelemetry(topics, 200)
initializeTelemetry(topics, 200).then((unsubFunction: () => void) => {
unlistenAll = unsubFunction
})
setTimeout(() => {
loading = false
initializationSequence()
}, 3000)
settingsStore.subscribe(value => {
localStorage.setItem('settings', JSON.stringify(value))
})
})
onDestroy(() => {
unlistenAll && unlistenAll()
})
</script>

View file

@ -21,6 +21,8 @@
overflow: auto; /* or 'scroll' if you always want scrollability */
scrollbar-width: none; /* Hide scrollbar for Firefox */
-ms-overflow-style: none; /* Hide scrollbar for IE 10+ and Edge */
overscroll-behavior: none;
}
body::-webkit-scrollbar {
display: none;

View file

@ -49,11 +49,12 @@ interface TelemetryData {
'jerk-x': number
'jerk-y': number
'voltage': number
'acc-profile': Mode | '-999'
'gear': Gear | '-999'
'acc-profile': Mode
'gear': Gear
'ebrake': boolean
'reorient': boolean
'gpws': boolean
'connected': boolean
}
type CardinalDirection =

View file

@ -1,4 +1,9 @@
<script lang="ts">
import { onDestroy } from 'svelte'
import {
cameraControls,
cameraState,
} from '../../Dashboard/Visualization/CameraControls/utils/cameraStore'
import AppContainer from '../AppContainer.svelte'
import { simulateMotion } from './simulateMotion'
import {
@ -6,6 +11,16 @@
increaseSpeedTo,
setStationaryTelemetry,
} from './telemetrySimulators'
let value: typeof $cameraState.mode = $cameraState.mode
$: {
cameraState.set('mode', value)
}
const unsubscribe = cameraState.subscribe(state => {
if (value !== state.mode) value = state.mode
})
onDestroy(unsubscribe)
</script>
<AppContainer
@ -49,6 +64,11 @@
<button class="button" on:click={simulateMotion}>
Simulate random motion
</button>
<select bind:value class="bg-slate-300">
<option value="orbit">Orbit</option>
<option value="follow-facing">Follow Facing</option>
<option value="follow-direction">Follow Direction</option>
</select>
</AppContainer>
<style lang="postcss">

View file

@ -1,15 +1,18 @@
import { get } from 'svelte/store'
import { telemetryStore } from '../../stores/telemetryStore'
import { increaseRotationTo, increaseSpeedTo } from './telemetrySimulators'
// simulate some turning for testing
export const simulateMotion = () => {
let delay = Math.random() * 4500 + 500
let randOffset = Math.random() * 360
telemetryStore.update({
...get(telemetryStore),
'orientation': randOffset,
'chassis-x-speed': Math.random() * 4 * (Math.random() < 0.5 ? -1 : 1),
'chassis-y-speed': Math.random() * 4 * (Math.random() < 0.5 ? -1 : 1),
})
increaseSpeedTo(
Math.random() * 4 * (Math.random() < 0.5 ? -1 : 1),
Math.random() * 4 * (Math.random() < 0.5 ? -1 : 1)
)
increaseRotationTo(randOffset)
setTimeout(simulateMotion, delay)
}

View file

@ -18,6 +18,7 @@ export const setStationaryTelemetry = () => {
'ebrake': false,
'reorient': false,
'gpws': false,
'connected': true,
})
}
@ -37,7 +38,7 @@ export const increaseSpeedTo = async (targetX: number, targetY: number) => {
}
}
const delay = () => new Promise(resolve => setTimeout(resolve, 1000)) // Assuming a 100ms tick for demonstration
const delay = () => new Promise(resolve => setTimeout(resolve, 500)) // Assuming a 100ms tick for demonstration
const lerp = (start: number, end: number, alpha: number) =>
start + (end - start) * alpha
@ -68,3 +69,47 @@ export const changeGear = (gear: Gear) => {
gear: gear,
})
}
let cancelPreviousCall = () => {} // Function to cancel the previous interpolation
const getAngle = () => {
return get(telemetryStore)['orientation']
}
const setAngle = (angle: number) => {
telemetryStore.update({
...get(telemetryStore),
orientation: angle,
})
}
export const increaseRotationTo = async (targetAngle: number) => {
let isCancelled = false
cancelPreviousCall() // Cancel any ongoing interpolation
cancelPreviousCall = () => {
isCancelled = true
} // Setup cancellation for the current call
const lerp = (start: number, end: number, alpha: number) =>
start + (end - start) * alpha
const tick = () => new Promise(resolve => setTimeout(resolve, 1000)) // Assuming a 100ms tick for demonstration
let currentAngle = getAngle() // Assume getAngle() retrieves the current angle
const steps = 10 // Number of steps for the interpolation
for (let i = 1; i <= steps; i++) {
if (isCancelled) return // Exit if a new target angle is set
const alpha = i / steps // Calculate interpolation fraction
// Interpolate angle
const nextAngle = lerp(currentAngle, targetAngle, alpha)
setAngle(nextAngle) // Update angle
await tick() // Wait for state update synchronization
// Update current angle for the next iteration
currentAngle = nextAngle
}
}

View file

@ -2,6 +2,7 @@
import { Notifications } from '../../Notifications/notifications'
import { settingsStore } from '../../stores/settingsStore'
import AppContainer from '../AppContainer.svelte'
import SettingsSelector from './SettingsSelector.svelte'
import SettingsInput from './SettingsInput.svelte'
import SettingsToggle from './SettingsToggle.svelte'
@ -20,6 +21,7 @@
class="flex gap-6 bg-blue-200 bg-opacity-25 backdrop-blur-xl media-background rounded-3xl flex-wrap px-10 py-20"
>
<h1 class="text-5xl font-medium text-slate-100 basis-full">Settings</h1>
<p class="text-slate-300">Hover over setting names to see helpful tooltips</p>
<h2 class="text-2xl font-medium text-slate-200 mt-4 basis-full">General</h2>
<div class="flex flex-col gap-2">
<SettingsToggle
@ -39,6 +41,17 @@
>
RNG Weight
</SettingsInput>
<SettingsSelector
setting="voiceLang"
options={['en-US', 'en-RU']}
tooltip="Selects the language/locale used for Jankboard voice prompts. Does not affect application language (ie. Jankboard itself will always be in English)."
>Voice Prompt Language</SettingsSelector
>
<SettingsToggle
setting="sentry"
tooltip="Sentry mode protects the robot and operator from foreign threats."
>Sentry Mode</SettingsToggle
>
<button
class="mt-10 px-4 py-2 bg-amber-600 hover:brightness-75 text-medium rounded-lg w-min"
on:click={resetSettings}>Reset</button

View file

@ -0,0 +1,46 @@
<!--
@component
A selector component that updates settings with the selected value. Designed
to be used with settings which have a fixed amount of set values. Only works with
settings with number or string values. Prefer the Toggle input type for boolean
settings.
@param setting - The setting to be toggled
@param options - The options to be shown in the selector. Must be possible (valid)
values for the setting.
@param tooltip - Helpful tooltip for the setting
-->
<script lang="ts">
import { settingsStore } from '../../stores/settingsStore'
import type { SettingsStoreData } from '../../stores/settingsStore'
import { tooltip as tooltipAction } from '@svelte-plugins/tooltips'
export let setting: keyof SettingsStoreData
export let options: string[] | number[]
export let tooltip: string = ''
if (typeof setting !== 'string') {
throw new Error('Selector setting must be a string')
}
let selected: string | number = $settingsStore[setting] as string | number
// Setting is guaranteed to be string by guard clause above
// @ts-expect-error
$: selected && settingsStore.update(setting, selected)
</script>
<div class="flex gap-2 my-1">
<select bind:value={selected} class="w-min bg-slate-400 text-md">
{#each options as option}
<option value={option}>{option}</option>
{/each}
</select>
<label
class="text-xl font-medium text-slate-100"
for={setting}
use:tooltipAction={{ content: tooltip, action: 'hover', arrow: false }}
><slot /></label
>
</div>

View file

@ -4,6 +4,7 @@
@param accx - Acceleration in x
@param accy - Acceleration in y
@param orientation - Heading in degrees
@param placeholder - Whether or not to show the placeholder skeleton
Displays the heading direction and acceleration as human readable text
-->
@ -15,9 +16,9 @@
export let accx: number
export let accy: number
export let orientation: number
export let placeholder: boolean
$: accResolved = Math.hypot(accx, accy)
$: placeholder = accx === -999 && accy === -999
</script>
<div class="flex flex-col gap-2 text-center">

View file

@ -29,11 +29,18 @@
selectedGear={$telemetryReadonlyStore.gear}
selectedMode={$telemetryReadonlyStore['acc-profile']}
voltage={$telemetryReadonlyStore.voltage}
placeholder={!$telemetryReadonlyStore.connected}
/>
<div class="h-0.5 mt-1 w-full bg-slate-300 border-0"></div>
<div class="mt-8 flex justify-between">
<Speedometer speed={speedResolved} />
<SpeedLimit speedLimit={-999} />
<Speedometer
speed={speedResolved}
placeholder={!$telemetryReadonlyStore.connected}
/>
<SpeedLimit
speedLimit={5}
placeholder={!$telemetryReadonlyStore.connected}
/>
</div>
</div>
@ -47,6 +54,7 @@
accx={$telemetryReadonlyStore['accx']}
accy={$telemetryReadonlyStore['accy']}
orientation={$telemetryReadonlyStore['orientation']}
placeholder={!$telemetryReadonlyStore.connected}
/>
</div>
<MediaDisplay />

View file

@ -2,13 +2,13 @@
@component
@param speedLimit - Speed limit in Miles Per Hour (MPH)
@param placeholder - Whether or not to show the placeholder skeleton
Displays the speed limit
-->
<script lang="ts">
export let speedLimit: number = 5.0
$: placeholder = speedLimit === -999
export let speedLimit: number
export let placeholder: boolean
</script>
<div
@ -16,19 +16,17 @@
>
<div
class="px-3 py-1 border-black rounded-xl border-2 flex flex-col text-center gap-1 transition-all"
class:speed-limit-placeholder={placeholder}
>
<div class="text-lg font-medium">SPEED<br />LIMIT</div>
<div
class="text-2xl font-bold transition"
class:speed-limit-placeholder={placeholder}
>
{speedLimit}
<div class="text-2xl font-bold transition">
{speedLimit.toFixed(1)}
</div>
</div>
</div>
<style lang="postcss">
.speed-limit-placeholder {
@apply text-neutral-200 bg-neutral-200 animate-pulse rounded-lg;
@apply text-neutral-300 bg-neutral-300 animate-pulse rounded-lg;
}
</style>

View file

@ -2,6 +2,7 @@
@component
@param speed - Speed in meters per second
@param placeholder - Whether or not to show the placeholder skeleton
Displays the speed in miles per hour
-->
@ -10,10 +11,9 @@
import { mps2mph } from '../utils/unitConversions'
export let speed: number = 0.0
export let placeholder: boolean
$: formatted = mps2mph(speed).toFixed(1)
$: placeholder = speed === Math.hypot(-999, -999)
</script>
<div class="flex flex-col gap-4">
@ -24,10 +24,5 @@
>
{placeholder ? '-----' : formatted}
</div>
<div
class="text-2xl font-medium transition"
class:placeholder={speed === Math.hypot(-999, -999)}
>
MPH
</div>
<div class="text-2xl font-medium transition" class:placeholder>MPH</div>
</div>

View file

@ -1,9 +1,8 @@
<script lang="ts">
export let voltage: number
export let placeholder: boolean
$: formatted = voltage.toFixed(1)
$: placeholder = voltage === -999
</script>
<span class="flex gap-1">

View file

@ -1,18 +1,53 @@
<script lang="ts">
export let selectedGear: Gear | '-999'
import {
shiftedInAutoSequence,
shiftedInDriveSequence,
shiftedInLowSequence,
shiftedInNeutralSequence,
shiftedInParkSequence,
shiftedInReverseSequence,
} from '../../Sequences/sequences'
export let selectedGear: Gear
export let placeholder: boolean
const shift = (selectedGear: Gear) => {
switch (selectedGear) {
case 'park':
shiftedInParkSequence()
break
case 'reverse':
shiftedInReverseSequence()
break
case 'neutral':
shiftedInNeutralSequence()
break
case 'low':
shiftedInLowSequence()
break
case 'auto':
shiftedInAutoSequence()
break
case 'drive':
shiftedInDriveSequence()
break
}
}
$: shift(selectedGear)
</script>
<div class="flex justify-center w-full transition">
<div
class="flex flex-row gap-2 text-neutral-400 text-xl font-bold"
class:placeholder={selectedGear === '-999'}
class:placeholder
>
<div class:highlighted={selectedGear === 'park'}>P</div>
<div class:highlighted={selectedGear === 'reverse'}>R</div>
<div class:highlighted={selectedGear === 'neutral'}>N</div>
<div class:highlighted={selectedGear === 'low'}>L</div>
<div class:highlighted={selectedGear === 'auto'}>A</div>
<div class:highlighted={selectedGear === 'drive'}>D</div>
<div class:highlighted={selectedGear === 'park' && !placeholder}>P</div>
<div class:highlighted={selectedGear === 'reverse' && !placeholder}>R</div>
<div class:highlighted={selectedGear === 'neutral' && !placeholder}>N</div>
<div class:highlighted={selectedGear === 'low' && !placeholder}>L</div>
<div class:highlighted={selectedGear === 'auto' && !placeholder}>A</div>
<div class:highlighted={selectedGear === 'drive' && !placeholder}>D</div>
</div>
</div>

View file

@ -2,29 +2,45 @@
@component
@param selectedMode - Selected mode
@param placeholder - Whether or not to show the placeholder skeleton
Displays the drive mode
-->
<script lang="ts">
import { fade } from 'svelte/transition'
import {
modeChillSequence,
modeCruiseSequence,
modeLudicrousSequence,
} from '../../Sequences/sequences'
export let selectedMode: Mode | '-999'
export let selectedMode: Mode
export let placeholder: boolean
let modeText = ''
$: switch (selectedMode) {
case 'chill':
modeText = 'CHILL'
break
case 'cruise':
modeText = 'CRUISE'
break
case 'ludicrous':
modeText = 'LUDICROUS'
break
case '-999':
const setModeText = (selectedMode: Mode) => {
switch (selectedMode) {
case 'chill':
modeText = 'CHILL'
modeChillSequence()
break
case 'cruise':
modeText = 'CRUISE'
modeCruiseSequence()
break
case 'ludicrous':
modeText = 'LUDICROUS'
modeLudicrousSequence()
break
}
}
$: {
if (placeholder) {
modeText = 'DISCONNECTED'
break
} else {
setModeText(selectedMode)
}
}
</script>

View file

@ -4,6 +4,7 @@
@param selectedGear - Selected gear
@param selectedMode - Selected mode
@param voltage - Battery voltage
@param placeholder - Whether or not to show placeholder skeleton UIs
Displays the top bar of the dashboard
-->
@ -13,19 +14,20 @@
import GearSelector from './GearSelector.svelte'
import ModeSelector from './ModeSelector.svelte'
export let selectedGear: Gear | '-999'
export let selectedMode: Mode | '-999'
export let selectedGear: Gear
export let selectedMode: Mode
export let voltage: number
export let placeholder: boolean
</script>
<div class="flex flex-row w-full justify-between">
<div>
<GearSelector {selectedGear} />
<GearSelector {selectedGear} {placeholder} />
</div>
<div>
<ModeSelector {selectedMode} />
<ModeSelector {selectedMode} {placeholder} />
</div>
<div>
<BatteryDisplay {voltage} />
<BatteryDisplay {voltage} {placeholder} />
</div>
</div>

View file

@ -0,0 +1,103 @@
<script context="module" lang="ts">
let installed = false
</script>
<script lang="ts">
import {
T,
forwardEventHandlers,
useTask,
useParent,
useThrelte,
} from '@threlte/core'
import type {
CameraControlsEvents,
CameraControlsProps,
CameraControlsSlots,
} from './CameraControls.svelte'
type $$Props = CameraControlsProps
type $$Events = CameraControlsEvents
type $$Slots = CameraControlsSlots
import CameraControls from 'camera-controls'
import {
Box3,
Matrix4,
Quaternion,
Raycaster,
Sphere,
Spherical,
Vector2,
Vector3,
Vector4,
type PerspectiveCamera,
} from 'three'
// @ts-expect-error
import { DEG2RAD } from 'three/src/math/MathUtils'
import { cameraState } from './utils/cameraStore'
const subsetOfTHREE = {
Vector2,
Vector3,
Vector4,
Quaternion,
Matrix4,
Spherical,
Box3,
Sphere,
Raycaster,
}
if (!installed) {
CameraControls.install({ THREE: subsetOfTHREE })
installed = true
}
const parent = useParent()
if (!$parent) {
throw new Error('CameraControls must be a child of a ThreeJS camera')
}
const { renderer, invalidate } = useThrelte()
let autoRotate = true
export let autoRotateSpeed = 1
export const ref = new CameraControls(
$parent as PerspectiveCamera,
renderer?.domElement
)
const getControls = () => ref
useTask(
delta => {
if (autoRotate && $cameraState.mode === 'orbit') {
getControls().azimuthAngle += 4 * delta * DEG2RAD * autoRotateSpeed
}
const updated = getControls().update(delta)
if (updated) invalidate()
},
{
autoInvalidate: false,
}
)
const forwardingComponent = forwardEventHandlers()
</script>
<T
is={ref}
on:controlstart={e => {
autoRotate = false
}}
on:controlend={() => {
autoRotate = true
}}
{...$$restProps}
bind:this={$forwardingComponent}
>
<slot {ref} />
</T>

View file

@ -0,0 +1,16 @@
import type { Events, Props, Slots } from '@threlte/core'
import CC from 'camera-controls'
import type { SvelteComponent } from 'svelte'
export type CameraControlsProps = Props<CC> & {
autoRotate?: boolean
autoRotateSpeed?: number
}
export type CameraControlsEvents = Events<CC>
export type CameraControlsSlots = Slots<CC>
export default class CameraControls extends SvelteComponent<
CameraControlsProps,
CameraControlsEvents,
CameraControlsSlots
> {}

View file

@ -0,0 +1,33 @@
import type CameraControls from 'camera-controls'
import { writable } from 'svelte/store'
import type { Mesh } from 'three'
export const cameraControls = writable<CameraControls>()
export const mesh = writable<Mesh>()
type CameraMode =
| 'orbit'
| 'follow-facing'
| 'follow-direction'
| 'follow-position'
| 'showcase'
interface CameraState {
mode: CameraMode
}
const { set, update, subscribe } = writable<CameraState>({
mode: 'orbit',
})
const createCameraState = () => {
return {
update,
subscribe,
set: (prop: keyof CameraState, val: any) =>
update(state => ({ ...state, [prop]: val })),
reset: () => set({ mode: 'orbit' }),
}
}
export const cameraState = createCameraState()

View file

@ -0,0 +1,20 @@
import { useThrelteUserContext } from '@threlte/core'
import { writable, type Writable } from 'svelte/store'
import type { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
type ControlsContext = {
orbitControls: Writable<OrbitControls | undefined>
}
/**
* ### `useControlsContext`
*
* This hook is used to register the `OrbitControls` instance with the
* `ControlsContext`. We're using this context to enable and disable the
* controls when the user is interacting with the TransformControls.
*/
export const useControlsContext = (): ControlsContext => {
return useThrelteUserContext<ControlsContext>('threlte-controls', {
orbitControls: writable<OrbitControls | undefined>(undefined),
})
}

View file

@ -1,165 +0,0 @@
<script lang="ts">
import { createEventDispatcher, onDestroy } from 'svelte'
import {
Camera,
Vector2,
Vector3,
Quaternion,
Object3D,
type Object3DEventMap,
Group,
} from 'three'
import { useThrelte, useParent, useTask } from '@threlte/core'
export let object: Group<Object3DEventMap>
export let rotateSpeed = 1.0
export let shouldOrbit: boolean
$: if (object) {
// console.log(object)
// object.position.y = 10
// // Calculate the direction vector towards (0, 0, 0)
// const target = new Vector3(0, 0, 0)
// const direction = target.clone().sub(object.position).normalize()
// // Extract the forward direction from the object's current rotation matrix
// const currentDirection = new Vector3(0, 1, 0)
// currentDirection.applyQuaternion(object.quaternion)
// // Calculate the axis and angle to rotate the object
// const rotationAxis = currentDirection.clone().cross(direction).normalize()
// const rotationAngle = Math.acos(currentDirection.dot(direction))
// // Rotate the object using rotateOnAxis()
// object.rotateOnAxis(rotationAxis, rotationAngle)
}
export let idealOffset = { x: -0.5, y: 2, z: -3 }
export let idealLookAt = { x: 0, y: 1, z: 5 }
const currentPosition = new Vector3()
const currentLookAt = new Vector3()
let isOrbiting = false
let pointerDown = false
const rotateStart = new Vector2()
const rotateEnd = new Vector2()
const rotateDelta = new Vector2()
const axis = new Vector3(0, 1, 0)
const rotationQuat = new Quaternion()
const { renderer, invalidate } = useThrelte()
const domElement = renderer.domElement
const camera = useParent()
const dispatch = createEventDispatcher()
const isCamera = (p: any): p is Camera => {
return p.isCamera
}
if (!isCamera($camera)) {
throw new Error(
'Parent missing: <PointerLockControls> need to be a child of a <Camera>'
)
}
// This is basically your update function
useTask(delta => {
// the object's position is bound to the prop
if (!object) return
// then we calculate our ideal's
const offset = vectorFromObject(idealOffset)
const lookAt = vectorFromObject(idealLookAt)
// camera is based on character so we rotation character first
// rotationQuat.setFromAxisAngle(axis, rotateSpeed * delta)
// object.quaternion.multiply(rotationQuat)
// and how far we should move towards them
const t = 1.0 - Math.pow(0.001, delta)
currentPosition.lerp(offset, t)
currentLookAt.lerp(lookAt, t)
// typescript HACKS! never do this! How does this work? who knows!
const robotPosition = vectorFromObject(
object as unknown as { x: number; y: number; z: number }
)
const horizontalOffsetDistance = 15 // Distance behind the leading vector
const direction = new Vector3(0, 0, 1) // Default forward direction in Three.js is negative z-axis, so behind is positive z-axis
const verticalOffset = new Vector3(0, -5, 0)
// Calculate the offset vector
const offsetVector = direction
.normalize()
.multiplyScalar(horizontalOffsetDistance)
.add(verticalOffset)
// If the leading object is rotating, apply its rotation to the offset vector
const rotatedOffsetVector = offsetVector.applyQuaternion(object.quaternion)
const leftOffset = -1.5
function shiftVectorLeft(
vector: Vector3,
amount: number,
upDirection = axis
): void {
// Calculate the left direction. Assuming 'up' is the global up direction or a custom up vector.
// This creates a vector pointing to the "left" of the original vector, in a 3D context.
let leftDirection = new Vector3()
.crossVectors(upDirection, vector)
.normalize()
// Scale the left direction by the desired amount
leftDirection.multiplyScalar(amount)
// Add the scaled left direction to the original vector, mutating it
vector.add(leftDirection)
}
shiftVectorLeft(rotatedOffsetVector, leftOffset)
// Calculate the trailing vector's position
const trailingVector = robotPosition.clone().sub(rotatedOffsetVector)
function shiftVectorLeftNonMutate(
vector: Vector3,
amount: number,
upDirection = axis
): Vector3 {
// Create a new vector to avoid mutating the original vector
let shiftedVector = vector.clone()
// Calculate the left direction. Assuming 'up' is the global up direction or a custom up vector.
// This creates a vector pointing to the "left" of the original vector, in a 3D context.
let leftDirection = new Vector3()
.crossVectors(upDirection, vector)
.normalize()
// Scale the left direction by the desired amount and add it to the original vector
leftDirection.multiplyScalar(amount)
shiftedVector.add(leftDirection)
return shiftedVector
}
if (!shouldOrbit) {
// then finally set the camera, a bit behind the model
$camera!.position.copy(trailingVector)
// Rotate the offset around the Y-axis
$camera!.lookAt(shiftVectorLeftNonMutate(currentLookAt, -leftOffset))
}
})
function vectorFromObject(vec: { x: number; y: number; z: number }) {
const { x, y, z } = vec
const ideal = new Vector3(x, y, z)
ideal.applyQuaternion(object.quaternion)
ideal.add(
new Vector3(object.position.x, object.position.y, object.position.z)
)
return ideal
}
</script>

View file

@ -1,155 +1,154 @@
<script lang="ts">
import { T, useTask } from '@threlte/core'
import { ContactShadows, Float, Grid, OrbitControls } from '@threlte/extras'
import Robot from './models/RobotDecimated.svelte'
import Controls from './Controls.svelte'
import { Grid } from '@threlte/extras'
import CameraControls from './CameraControls/CameraControls.svelte'
import {
Vector3,
type Camera,
type Group,
type Object3D,
type Object3DEventMap,
} from 'three'
import {
telemetryReadonlyStore,
telemetryStore,
} from '../../stores/telemetryStore'
import { get } from 'svelte/store'
import { Vector2 } from 'three'
import { SmoothMotionController } from './smoothMotionController'
cameraControls,
mesh,
cameraState,
} from './CameraControls/utils/cameraStore'
import { Vector3 } from 'three'
import { onMount } from 'svelte'
import RobotDecimated from './models/RobotDecimated.svelte'
import { telemetryReadonlyStore } from '../../stores/telemetryStore'
import { DEG2RAD } from 'three/src/math/MathUtils.js'
/* This is the root scene where the robot visualization is built.
It renders an infinite grid (it's not actually infinite, but we shouldn't run out
of space in realistic use), and a 3D model of the robot. The camera is locked
to the model, and the model is rotated to match the robot's orientation.
A PID controller is used to smoothly rotate the model to match the robot's
orientation and dampen out jittering. How does it work? Who knows!
75% percent of this was created while reading
https://en.wikipedia.org/wiki/Quaternions_and_spatial_rotation,
and rest was generated by AI!
The rest of this codebase is remarkably jank-free, but this visualization module
is the most esoteric and jank code ever written.
*/
const SPEED_MULTIPLIER = 4
const axis = new Vector3(0, 1, 0)
let shouldOrbit = true
const follow = (delta: number) => {
// the object's position is bound to the prop
if (!$mesh || !$cameraControls) return
// CONSTANTS
const maxAngularVelocity = 2 // Max angular velocity, in radians per second
const stoppingThreshold = 0.005 // Threshold in radians for when to consider the rotation close enough to stop
const offsetPosition = new Vector3()
offsetPosition.copy($mesh.position)
const offsetVector = new Vector3(2.5, 0, -2)
offsetVector.applyAxisAngle(axis, $mesh.rotation.y)
offsetPosition.add(offsetVector)
// Proportional control factor
const kP = 2 // Adjust this value based on responsiveness and stability needs
const followDirection = () => {
const angle = Math.atan2(
$telemetryReadonlyStore['chassis-y-speed'],
$telemetryReadonlyStore['chassis-x-speed']
)
// Sync robot orientation with target rotation
let targetRot = 0
// Updates rotation to match target with PID controller (intended to be invoked in useTask)
let rot = 0 // (initial) rotation in radians
let angularVelocity = 0
const updateRotation = (delta: number) => {
let angleDifference = targetRot - rot
// Normalize angle difference to the range [-π, π]
angleDifference = ((angleDifference + Math.PI) % (2 * Math.PI)) - Math.PI
// Calculate the desired angular velocity based on the angle difference
let desiredVelocity =
Math.sign(angleDifference) *
Math.min(maxAngularVelocity, Math.abs(kP * angleDifference))
// If the object is very close to the target, adjust the desired velocity to zero to prevent overshooting
if (Math.abs(angleDifference) < stoppingThreshold) {
desiredVelocity = 0
$cameraControls.setLookAt(
offsetPosition.x - 13 * Math.sin(angle),
offsetPosition.y + 4,
offsetPosition.z - 13 * Math.cos(angle),
offsetPosition.x,
offsetPosition.y,
offsetPosition.z,
true
)
$cameraControls.zoomTo(1.1, true)
}
// Adjust angular velocity towards desired velocity
angularVelocity = desiredVelocity
const followFacing = () => {
if ($cameraState.mode === 'follow-facing') {
$cameraControls.setLookAt(
offsetPosition.x + 13 * Math.sin($mesh.rotation.y),
offsetPosition.y + 5,
offsetPosition.z + 13 * Math.cos($mesh.rotation.y),
offsetPosition.x,
offsetPosition.y + 2,
offsetPosition.z,
true
)
}
$cameraControls.zoomTo(1.2, true)
}
// Update rotation
rot += angularVelocity * delta
const orbit = () => {
$cameraControls.zoomTo(0.8, true)
$cameraControls.moveTo(
offsetPosition.x + 4 * Math.sin($mesh.rotation.y),
offsetPosition.y + 3,
offsetPosition.z * Math.cos($mesh.rotation.y),
true
)
}
// Normalize rot to the range [0, 2π]
if (rot < 0) rot += 2 * Math.PI
else if (rot > 2 * Math.PI) rot -= 2 * Math.PI
// Snap to the target rotation to prevent tiny oscillations if close enough
if (Math.abs(angleDifference) < stoppingThreshold) {
rot = targetRot
angularVelocity = 0
switch ($cameraState.mode) {
case 'follow-direction':
followDirection()
break
case 'follow-facing':
followFacing()
break
case 'orbit':
orbit()
break
default:
orbit()
break
}
}
let robotPos: Vector3 = new Vector3(0, 0, 0)
let gridFadeDistance = 30
const robotPosition = new Vector2(0, 0) // Initial position
const initialVelocity = { x: 0, y: 0 } // Initial velocity
// The smooth motion controller utilizes a cubic hermite spline to interpolate between
// the current simulation velocity and the robot's actual velocity
const controller = new SmoothMotionController(robotPosition, initialVelocity)
const fadeGridIn = (delta: number) => {
if (gridFadeDistance < 100) {
gridFadeDistance += delta * 40
}
}
onMount(() => {
telemetryReadonlyStore.subscribe(value => {
targetRot = (value['orientation'] * Math.PI) / 180 // convert deg to rad
controller.setTargetVelocity({
x: value['chassis-x-speed'],
y: value['chassis-y-speed'],
})
shouldOrbit = value.gear === 'park' || value.gear === '-999'
if (shouldOrbit) {
robotPos = new Vector3(0, 0, 0)
controller.reset()
}
})
})
const fadeGridOut = (delta: number) => {
if (gridFadeDistance > 30) {
gridFadeDistance -= delta * 40
}
}
useTask(delta => {
if (!shouldOrbit) {
updateRotation(delta)
/* TODO: standardize a scale (meters : grid lengths) so we can have
accurate positioning of sensor detected objects */
// update position data for robot model
$mesh.position.x +=
$telemetryReadonlyStore['chassis-y-speed'] * delta * SPEED_MULTIPLIER
$mesh.position.z +=
$telemetryReadonlyStore['chassis-x-speed'] * delta * SPEED_MULTIPLIER
$mesh.rotation.y = $telemetryReadonlyStore.orientation * DEG2RAD
controller.update(delta)
robotPos.x = controller.getPosition().x
robotPos.z = controller.getPosition().y
if ($cameraState.mode === 'orbit') {
fadeGridOut(delta)
} else {
fadeGridIn(delta)
}
// run the follow function
follow(delta)
})
let capsule: Group<Object3DEventMap>
let capRef: Group<Object3DEventMap>
$: if (capsule) {
capRef = capsule
}
onMount(() => {})
</script>
<T.PerspectiveCamera makeDefault position={[0, 8, -20]} fov={30} on:create>
<OrbitControls
autoRotateSpeed={1.5}
target.y={1.5}
autoRotate
enableDamping
<T.PerspectiveCamera makeDefault position={[12, 10, 12]}>
<CameraControls
on:create={({ ref }) => {
$cameraControls = ref
}}
autoRotateSpeed={3}
/>
<Controls {shouldOrbit} bind:object={capRef} rotateSpeed={angularVelocity} />
</T.PerspectiveCamera>
<T.DirectionalLight intensity={0.8} position.x={5} position.y={10} />
<T.AmbientLight intensity={0.2} />
<T.DirectionalLight position={[3, 10, 7]} />
<T.AmbientLight color={'#f0f0f0'} intensity={0.1} />
<RobotDecimated
scale={[10, 10, 10]}
position.y={0}
on:create={({ ref }) => {
// @ts-expect-error
$mesh = ref
}}
/>
<Grid
position.y={1}
cellColor="#ffffff"
sectionColor="#ffffff"
sectionThickness={0}
fadeDistance={100}
sectionColor={'#ff3e00'}
sectionThickness={1}
bind:fadeDistance={gridFadeDistance}
cellSize={6}
sectionSize={24}
cellColor={'#cccccc'}
infiniteGrid
/>
<ContactShadows scale={10} blur={2} far={2.5} opacity={0.5} />
<Robot
position.y={1}
position.z={robotPos.z}
position.x={robotPos.x}
scale={[5, 5, 5]}
bind:ref={capsule}
rotation.y={rot}
/>

View file

@ -1,54 +0,0 @@
import { Vector2 } from 'three'
interface Velocity {
x: number
y: number
}
export class SmoothMotionController {
private currentPosition: Vector2
private currentVelocity: Vector2
private targetVelocity: Velocity
private dampingFactor: number
constructor(
initialPosition: Vector2,
initialVelocity: Velocity,
dampingFactor: number = 0.1
) {
this.currentPosition = initialPosition
this.currentVelocity = new Vector2(initialVelocity.x, initialVelocity.y)
this.targetVelocity = { ...initialVelocity }
this.dampingFactor = dampingFactor
}
setTargetVelocity(velocity: Velocity) {
this.targetVelocity = velocity
}
update(delta: number) {
// Apply cubic interpolation to smoothly transition the current velocity towards the target velocity
this.currentVelocity.x +=
(this.targetVelocity.x - this.currentVelocity.x) *
this.dampingFactor *
delta
this.currentVelocity.y +=
(this.targetVelocity.y - this.currentVelocity.y) *
this.dampingFactor *
delta
// Update position based on the current velocity and the time delta
this.currentPosition.x += this.currentVelocity.x * delta * 3
this.currentPosition.y += this.currentVelocity.y * delta * 3
}
getPosition(): Vector2 {
return this.currentPosition
}
public reset() {
this.currentPosition = new Vector2(0, 0)
this.currentVelocity = new Vector2(0, 0)
this.targetVelocity = { x: 0, y: 0 }
}
}

View file

@ -1,113 +1,147 @@
import { toast } from 'svelte-french-toast'
import type { ToastOptions } from 'svelte-french-toast'
import InfoIcon from './InfoIcon.svelte'
import { Howl } from 'howler'
import WarnIcon from './WarnIcon.svelte'
import { toast } from "svelte-french-toast";
import type { ToastOptions } from "svelte-french-toast";
import InfoIcon from "./InfoIcon.svelte";
import { Howl } from "howler";
import WarnIcon from "./WarnIcon.svelte";
interface NotificationOptions extends ToastOptions {
withAudio?: boolean
src?: string
onComplete?: () => void
withAudio?: boolean;
src?: string;
onComplete?: () => void;
}
// get colors from https://tailwindcss.com/docs/customizing-colors
export class Notifications {
private static readonly defaultDuration = 3000
private static readonly defaultDuration = 3000;
public static success(message: string, options?: NotificationOptions) {
if (options?.withAudio && !options.src)
throw new Error('No audio source provided')
throw new Error("No audio source provided");
const onComplete = () => {
if (options?.onComplete) options.onComplete()
}
if (options?.onComplete) options.onComplete();
};
const sendToast = (duration: number) => {
toast.success(message, {
style:
'padding: 25px; font-size: 1.5rem; background-color: #15803d; color: #fafafa; gap: 0.5rem; user-select: none; max-width: 70vw;',
"padding: 25px; font-size: 1.5rem; background-color: #15803d; color: #fafafa; gap: 0.5rem; user-select: none; max-width: 70vw;",
duration,
...options,
})
}
});
};
if (options?.withAudio && options?.src) {
let sound: Howl
let sound: Howl;
sound = new Howl({
src: [options.src],
preload: true,
autoplay: true,
onload: () => {
let duration = sound.duration() * 1000
sendToast(duration)
setTimeout(onComplete, duration)
let duration = sound.duration() * 1000;
sendToast(duration);
setTimeout(onComplete, duration);
},
})
});
} else {
sendToast(this.defaultDuration)
setTimeout(onComplete, this.defaultDuration)
sendToast(this.defaultDuration);
setTimeout(onComplete, this.defaultDuration);
}
}
public static error(message: string, options?: NotificationOptions) {
if (options?.withAudio && !options.src)
throw new Error("No audio source provided");
const onComplete = () => {
if (options?.onComplete) options.onComplete();
};
const sendToast = (duration: number) => {
toast.error(message, {
style:
"padding: 25px; font-size: 1.5rem; background-color: #dc2626; color: #fafafa; gap: 0.5rem; user-select: none; max-width: 70vw;",
duration,
...options,
});
};
if (options?.withAudio && options?.src) {
let sound: Howl;
sound = new Howl({
src: [options.src],
preload: true,
autoplay: true,
onload: () => {
let duration = sound.duration() * 1000;
sendToast(duration);
setTimeout(onComplete, duration);
},
});
} else {
sendToast(this.defaultDuration);
setTimeout(onComplete, this.defaultDuration);
}
}
public static info(message: string, options?: NotificationOptions) {
const onComplete = () => {
if (options?.onComplete) options.onComplete()
}
if (options?.onComplete) options.onComplete();
};
const sendToast = (duration: number) => {
toast(message, {
style:
'padding: 25px; font-size: 1.5rem; gap: 0.5rem; user-select: none; max-width-600px; max-width: 70vw;',
"padding: 25px; font-size: 1.5rem; gap: 0.5rem; user-select: none; max-width-600px; max-width: 70vw;",
icon: InfoIcon,
duration,
...options,
})
}
});
};
if (options?.withAudio && options?.src) {
let sound: Howl
let sound: Howl;
sound = new Howl({
src: [options.src],
preload: true,
autoplay: true,
onload: () => {
let duration = sound.duration() * 1000
sendToast(duration)
setTimeout(onComplete, duration)
let duration = sound.duration() * 1000;
sendToast(duration);
setTimeout(onComplete, duration);
},
})
});
} else {
sendToast(this.defaultDuration)
setTimeout(onComplete, this.defaultDuration)
sendToast(this.defaultDuration);
setTimeout(onComplete, this.defaultDuration);
}
}
public static warn(message: string, options?: NotificationOptions) {
const onComplete = () => {
if (options?.onComplete) options.onComplete()
}
if (options?.onComplete) options.onComplete();
};
const sendToast = (duration: number) => {
toast(message, {
style:
'padding: 25px; font-size: 1.5rem; background-color: #f59e0b; color: #fafafa; gap: 0.5rem; user-select: none; max-width: 70vw;',
"padding: 25px; font-size: 1.5rem; background-color: #f59e0b; color: #fafafa; gap: 0.5rem; user-select: none; max-width: 70vw;",
icon: WarnIcon,
duration,
...options,
})
}
});
};
if (options?.withAudio && options?.src) {
let sound: Howl
let sound: Howl;
sound = new Howl({
src: [options.src],
preload: true,
autoplay: true,
onload: () => {
let duration = sound.duration() * 1000
sendToast(duration)
setTimeout(onComplete, duration)
let duration = sound.duration() * 1000;
sendToast(duration);
setTimeout(onComplete, duration);
},
})
});
} else {
sendToast(this.defaultDuration)
setTimeout(onComplete, this.defaultDuration)
sendToast(this.defaultDuration);
setTimeout(onComplete, this.defaultDuration);
}
}
public static playAudio(src: string, onComplete: () => void = () => {}) {
@ -116,8 +150,8 @@ export class Notifications {
preload: true,
autoplay: true,
onload: () => {
setTimeout(onComplete, 1000 * sound.duration())
setTimeout(onComplete, 1000 * sound.duration());
},
})
});
}
}

View file

@ -20,6 +20,7 @@ import { settingsStore } from '../stores/settingsStore'
import { get } from 'svelte/store'
import getVoicePath from '../utils/getVoicePath'
import { tick } from 'svelte'
import { cameraState } from '../Dashboard/Visualization/CameraControls/utils/cameraStore'
// await a "tick" (a svelte update frame) at the start of every sequence so that
// state is synced and no weird side effects occur
@ -28,30 +29,34 @@ export const initializationSequence = async () => {
await tick()
Notifications.info('Jankboard initialized!', {
withAudio: true,
src: getVoicePath('jankboard-initialized', 'en'),
})
setTimeout(() => {
if (get(settingsStore).goWoke) return
Notifications.success('LittenOS is online', {
withAudio: true,
src: getVoicePath('littenos-is-online', 'en'),
})
setTimeout(() => {
Notifications.warn('Breaching Monte Vista codebase', {
src: getVoicePath('jankboard-initialized'),
onComplete: () => {
if (get(settingsStore).goWoke) {
sequenceStore.update('initializationComplete', true)
periodicSequence()
return
}
Notifications.success('LittenOS is online', {
withAudio: true,
src: getVoicePath('breaching-monte-vista', 'en'),
src: getVoicePath('littenos-is-online'),
onComplete: () => {
Notifications.warn('Breaching Monte Vista codebase', {
withAudio: true,
src: getVoicePath('breaching-monte-vista'),
onComplete: () => {
Notifications.playAudio(
getVoicePath('hello-virtual-assistant'),
() => {
sequenceStore.update('initializationComplete', true)
periodicSequence()
}
)
},
})
},
})
setTimeout(() => {
Notifications.playAudio(
getVoicePath('hello-virtual-assistant', 'en'),
() => {
sequenceStore.update('initializationComplete', true)
periodicSequence()
}
)
}, 3000)
}, 3000)
}, 3000)
},
})
}
let counter = 1
@ -108,7 +113,7 @@ export const criticalFailureIminentSequence = async () => {
await tick()
Notifications.error('Critical robot failure imminent', {
withAudio: true,
src: getVoicePath('critical-robot-failure', 'en'),
src: getVoicePath('critical-robot-failure'),
})
}
@ -116,7 +121,7 @@ export const collisionDetectedSequence = async () => {
await tick()
Notifications.error('Collision detected', {
withAudio: true,
src: getVoicePath('collision-detected', 'en'),
src: getVoicePath('collision-detected'),
})
}
@ -124,7 +129,7 @@ export const collisionImminentSequence = async () => {
await tick()
Notifications.error('Collision imminent', {
withAudio: true,
src: getVoicePath('collision-imminent', 'en'),
src: getVoicePath('collision-imminent'),
})
}
@ -133,7 +138,7 @@ export const cruiseControlEngagedSequence = async () => {
await tick()
Notifications.success('Cruise control engaged', {
withAudio: true,
src: getVoicePath('cruise-control-engaged', 'en'),
src: getVoicePath('cruise-control-engaged'),
})
}
@ -142,7 +147,7 @@ export const retardSequence = async () => {
await tick()
Notifications.warn('Retard', {
withAudio: true,
src: getVoicePath('retard', 'en'),
src: getVoicePath('retard'),
})
}
@ -151,7 +156,7 @@ const breaching254Sequence = async () => {
await tick()
Notifications.warn('Breaching 254 mainframe', {
withAudio: true,
src: getVoicePath('breaching-254-mainframe', 'en'),
src: getVoicePath('breaching-254-mainframe'),
})
}
@ -160,7 +165,7 @@ const breaching1323Sequence = async () => {
await tick()
Notifications.warn('Breaching 1323 mainframe', {
withAudio: true,
src: getVoicePath('breaching-1323-mainframe', 'en'),
src: getVoicePath('breaching-1323-mainframe'),
})
}
@ -169,7 +174,7 @@ const bullyingRohanSequence = async () => {
await tick()
Notifications.info('Bullying Rohan', {
withAudio: true,
src: getVoicePath('bullying-rohan', 'en'),
src: getVoicePath('bullying-rohan'),
})
}
@ -177,7 +182,7 @@ export const userErrorDetectedSequence = async () => {
await tick()
Notifications.error('User error detected', {
withAudio: true,
src: getVoicePath('user-error-detected', 'en'),
src: getVoicePath('user-error-detected'),
})
}
@ -188,8 +193,9 @@ export const infotainmentBootupSequence = async () => {
get(sequenceStore).infotainmentStartedFirstTime ||
get(settingsStore).disableAnnoyances ||
infotainmentStarted
)
) {
return
}
infotainmentStarted = true
await tick()
@ -197,17 +203,17 @@ export const infotainmentBootupSequence = async () => {
const sequence = () => {
Notifications.info('Infotainment system buffering', {
withAudio: true,
src: getVoicePath('infotainment-system-buffering', 'en'),
src: getVoicePath('infotainment-system-buffering'),
onComplete: () => {
Notifications.success('Infotainment system online', {
withAudio: true,
src: getVoicePath('infotainment-system-online'),
onComplete: () => {
sequenceStore.update('infotainmentStartedFirstTime', true)
},
})
},
})
setTimeout(() => {
Notifications.success('Infotainment system online', {
withAudio: true,
src: getVoicePath('infotainment-system-online', 'en'),
onComplete: () => {
sequenceStore.update('infotainmentStartedFirstTime', true)
},
})
}, 3000)
}
if (!get(sequenceStore).initializationComplete) {
@ -256,7 +262,7 @@ export const musicPlayerBootupSequence = async () => {
waitForInfotainmentBootup(() => {
Notifications.info('Downloading copyrighted music...', {
withAudio: true,
src: getVoicePath('downloading-copyrighted-music', 'en'),
src: getVoicePath('downloading-copyrighted-music'),
})
})
}
@ -274,7 +280,7 @@ export const gbaEmulatorBootupSequence = async () => {
waitForInfotainmentBootup(() => {
Notifications.info('Loading pirated Nintendo ROMs', {
withAudio: true,
src: getVoicePath('loading-pirated-nintendo', 'en'),
src: getVoicePath('loading-pirated-nintendo'),
})
})
}
@ -292,16 +298,135 @@ export const doomBootupSequence = async () => {
waitForInfotainmentBootup(() => {
Notifications.success('Doom Engaged', {
withAudio: true,
src: getVoicePath('doom-engaged', 'en'),
src: getVoicePath('doom-engaged'),
})
})
}
const bypassCoprocessorRestrictionsSequence = async () => {
if (get(settingsStore).disableAnnoyances) return
if (
get(settingsStore).disableAnnoyances ||
get(sequenceStore).initializationComplete
)
return
await tick()
Notifications.warn('Bypassing coprocessor restrictions', {
withAudio: true,
src: getVoicePath('bypassing-coprocessor-restrictions', 'en'),
src: getVoicePath('bypassing-coprocessor-restrictions'),
})
}
export const shiftedInParkSequence = async () => {
await tick()
cameraState.set('mode', 'orbit')
if (
get(settingsStore).disableAnnoyances ||
!get(sequenceStore).initializationComplete
)
return
Notifications.playAudio(getVoicePath('parked-brakes-engaged'), () => {
if (!get(settingsStore).sentry) return
Notifications.playAudio(getVoicePath('sentry-mode-engaged'))
Notifications.warn('Sentry mode engaged. Threats will be neutralized')
})
}
export const shiftedInReverseSequence = async () => {
await tick()
cameraState.set('mode', 'follow-direction')
if (
get(settingsStore).disableAnnoyances ||
!get(sequenceStore).initializationComplete
)
return
Notifications.playAudio(getVoicePath('reverse'))
}
export const shiftedInNeutralSequence = async () => {
await tick()
cameraState.set('mode', 'orbit')
if (
get(settingsStore).disableAnnoyances ||
!get(sequenceStore).initializationComplete
)
return
Notifications.playAudio(getVoicePath('neutral-brakes-engaged'))
}
export const shiftedInLowSequence = async () => {
await tick()
cameraState.set('mode', 'follow-facing')
if (
get(settingsStore).disableAnnoyances ||
!get(sequenceStore).initializationComplete
)
return
Notifications.playAudio(getVoicePath('shifted-into-low'))
}
export const shiftedInAutoSequence = async () => {
await tick()
cameraState.set('mode', 'follow-direction')
if (
get(settingsStore).disableAnnoyances ||
!get(sequenceStore).initializationComplete
)
return
Notifications.playAudio(getVoicePath('shifted-into-automatic'))
}
export const shiftedInDriveSequence = async () => {
await tick()
cameraState.set('mode', 'follow-facing')
if (
get(settingsStore).disableAnnoyances ||
!get(sequenceStore).initializationComplete
)
return
Notifications.playAudio(getVoicePath('shifted-into-drive'))
}
export const modeChillSequence = async () => {
if (
get(settingsStore).disableAnnoyances ||
!get(sequenceStore).initializationComplete
)
return
await tick()
Notifications.playAudio(getVoicePath('set-acceleration-profile-chill'))
}
export const modeCruiseSequence = async () => {
if (
get(settingsStore).disableAnnoyances ||
!get(sequenceStore).initializationComplete
)
return
await tick()
Notifications.playAudio(getVoicePath('cruise-control-engaged'))
}
export const modeLudicrousSequence = async () => {
if (
get(settingsStore).disableAnnoyances ||
!get(sequenceStore).initializationComplete
)
return
await tick()
Notifications.playAudio(getVoicePath('set-acceleration-profile-ludicrous'))
}

View file

@ -2,11 +2,15 @@
import { writable } from 'svelte/store'
type SupportedLanguage = 'en-US' | 'en-RU'
export interface SettingsStoreData {
disableAnnoyances: boolean
goWoke: boolean
fastStartup: boolean
randomWeight: number
voiceLang: SupportedLanguage
sentry: boolean
}
export const defaults: SettingsStoreData = {
@ -14,6 +18,8 @@ export const defaults: SettingsStoreData = {
goWoke: false, // go woke (for showing parents or other officials where DEI has taken over), disables "offensive" sequences
fastStartup: false, // skip the loading splash screen (for development purposes. Setting this from within the app has no effect.)
randomWeight: 1, // the weight of random events (multiplied by the original probability)
voiceLang: 'en-US',
sentry: true,
}
const createSequenceStore = () => {

View file

@ -1,20 +1,21 @@
import { writable, readonly } from 'svelte/store'
import { writable, readonly, get } from 'svelte/store'
let defaults: TelemetryData = {
'orientation': -999,
'chassis-x-speed': -999,
'chassis-y-speed': -999,
'accx': -999,
'accy': -999,
'accz': -999,
'jerk-x': -999,
'jerk-y': -999,
'voltage': -999,
'acc-profile': '-999',
'gear': '-999',
'orientation': 0,
'chassis-x-speed': 0,
'chassis-y-speed': 0,
'accx': 0,
'accy': 0,
'accz': 0,
'jerk-x': 0,
'jerk-y': 0,
'voltage': 0,
'acc-profile': 'chill',
'gear': 'park',
'ebrake': false,
'reorient': false,
'gpws': false,
'connected': false,
}
const createTelemetryStore = () => {
@ -27,6 +28,16 @@ const createTelemetryStore = () => {
return store
})
},
set: (key: keyof TelemetryData, value: any) => {
let newObj = {
...get(telemetryStore),
}
newObj = {
...newObj,
[key]: value,
}
set(newObj)
},
reset: () => set(defaults),
}
}

View file

@ -1,3 +1,6 @@
import { get } from 'svelte/store'
import { settingsStore } from '../stores/settingsStore'
/**
* Retrieves the voice audio path for the given audio file.
*
@ -5,7 +8,13 @@
* @param lang - the language of the audio
* @return the path of the audio file
*/
type SupportedLanguage = 'en' | 'rus'
export default function getVoicePath(audio: string, lang: SupportedLanguage) {
type SupportedLanguage = 'en-US' | 'en-RU'
export default function getVoicePath(audio: string, lang?: SupportedLanguage) {
console.log(get(settingsStore).voiceLang)
if (!lang) {
return `/static/voices/${get(settingsStore).voiceLang}/${audio}.wav`
}
return `/static/voices/${lang}/${audio}.wav`
}

View file

@ -1,5 +1,6 @@
import { io } from 'socket.io-client'
import { get } from 'svelte/store'
import { telemetryStore } from '../stores/telemetryStore'
import { emit, listen } from '@tauri-apps/api/event'
/**
* Connects to sockets and subscribes to specified topics to receive telemetry data.
@ -14,7 +15,7 @@ const onUpdate = (data: TelemetryData) => {
// console.log(data)
}
export const initializeTelemetry = (
export const initializeTelemetry = async (
topics: TelemetryTopics,
refreshRate: number
) => {
@ -25,20 +26,24 @@ export const initializeTelemetry = (
)
}
const socket = io('localhost:1280')
socket.on('connect', () => {
console.log('Socket-IO connected!')
socket.emit('subscribe', topics)
console.log(`Subscribing to topics: ${JSON.stringify(topics)}`)
const unlistenStatus = await listen('telemetry_status', event => {
if (event.payload === 'connected') {
telemetryStore.set('connected', false)
} else if (event.payload === 'disconnected') {
telemetryStore.set('connected', false)
}
})
socket.on('subscribed', () => {
console.log('Successfully subscribed to requested topics!')
socket.emit('request_data', { refresh_rate: refreshRate })
console.log(`Refreshing at ${refreshRate} Hz`)
const unlistenTelemetry = await listen('telemetry_data', event => {
const data = JSON.parse(event.payload as string)
// console.log(JSON.parse)
telemetryStore.set(data['topic_name'], data['data'])
})
socket.on('telemetry_data', (data: string) => {
onUpdate(JSON.parse(data))
})
const unlistenAll = () => {
unlistenStatus()
unlistenTelemetry()
}
return unlistenAll
}