2023-10-14 03:47:46 +11:00
|
|
|
import { SourceRange } from 'lang/wasm'
|
2023-09-10 19:04:46 -04:00
|
|
|
import { VITE_KC_API_WS_MODELING_URL, VITE_KC_CONNECTION_TIMEOUT_MS } from 'env'
|
2023-08-02 15:41:59 +10:00
|
|
|
import { Models } from '@kittycad/lib'
|
2023-08-24 15:34:51 -07:00
|
|
|
import { exportSave } from 'lib/exportSave'
|
2023-08-09 20:49:10 +10:00
|
|
|
import { v4 as uuidv4 } from 'uuid'
|
2023-08-30 10:34:14 -04:00
|
|
|
import * as Sentry from '@sentry/react'
|
2023-10-11 13:36:54 +11:00
|
|
|
import { DefaultPlanes } from 'wasm-lib/kcl/bindings/DefaultPlanes'
|
2023-06-22 16:43:33 +10:00
|
|
|
|
2023-09-13 08:36:47 +10:00
|
|
|
let lastMessage = ''
|
|
|
|
|
2023-09-08 17:50:37 +10:00
|
|
|
interface CommandInfo {
|
|
|
|
commandType: CommandTypes
|
|
|
|
range: SourceRange
|
|
|
|
parentId?: string
|
|
|
|
}
|
2023-09-17 21:57:43 -07:00
|
|
|
|
|
|
|
type WebSocketResponse = Models['OkWebSocketResponseData_type']
|
|
|
|
|
2023-09-08 17:50:37 +10:00
|
|
|
interface ResultCommand extends CommandInfo {
|
2023-06-22 16:43:33 +10:00
|
|
|
type: 'result'
|
|
|
|
data: any
|
2023-09-17 21:57:43 -07:00
|
|
|
raw: WebSocketResponse
|
2023-10-16 15:29:02 +11:00
|
|
|
headVertexId?: string
|
2023-06-22 16:43:33 +10:00
|
|
|
}
|
2023-09-21 14:32:47 +10:00
|
|
|
interface FailedCommand extends CommandInfo {
|
|
|
|
type: 'failed'
|
|
|
|
errors: Models['FailureWebSocketResponse_type']['errors']
|
|
|
|
}
|
2023-09-08 17:50:37 +10:00
|
|
|
interface PendingCommand extends CommandInfo {
|
2023-06-22 16:43:33 +10:00
|
|
|
type: 'pending'
|
|
|
|
promise: Promise<any>
|
|
|
|
resolve: (val: any) => void
|
|
|
|
}
|
|
|
|
|
|
|
|
export interface ArtifactMap {
|
2023-09-21 14:32:47 +10:00
|
|
|
[key: string]: ResultCommand | PendingCommand | FailedCommand
|
2023-06-22 16:43:33 +10:00
|
|
|
}
|
|
|
|
|
2023-08-21 16:53:31 -04:00
|
|
|
interface NewTrackArgs {
|
|
|
|
conn: EngineConnection
|
|
|
|
mediaStream: MediaStream
|
|
|
|
}
|
|
|
|
|
2023-09-21 12:07:47 -04:00
|
|
|
// This looks funny, I know. This is needed because node and the browser
|
|
|
|
// disagree as to the type. In a browser it's a number, but in node it's a
|
|
|
|
// "Timeout".
|
|
|
|
type Timeout = ReturnType<typeof setTimeout>
|
|
|
|
|
2023-09-10 19:04:46 -04:00
|
|
|
type ClientMetrics = Models['ClientMetrics_type']
|
|
|
|
|
2023-08-18 16:16:16 -04:00
|
|
|
// EngineConnection encapsulates the connection(s) to the Engine
|
|
|
|
// for the EngineCommandManager; namely, the underlying WebSocket
|
|
|
|
// and WebRTC connections.
|
2023-11-28 21:23:20 +11:00
|
|
|
class EngineConnection {
|
2023-08-18 16:16:16 -04:00
|
|
|
websocket?: WebSocket
|
2023-06-22 16:43:33 +10:00
|
|
|
pc?: RTCPeerConnection
|
2023-08-31 07:39:03 +10:00
|
|
|
unreliableDataChannel?: RTCDataChannel
|
2023-08-18 16:16:16 -04:00
|
|
|
|
2023-08-21 16:53:31 -04:00
|
|
|
private ready: boolean
|
2023-09-21 12:07:47 -04:00
|
|
|
private connecting: boolean
|
|
|
|
private dead: boolean
|
|
|
|
private failedConnTimeout: Timeout | null
|
2023-08-18 16:16:16 -04:00
|
|
|
|
|
|
|
readonly url: string
|
|
|
|
private readonly token?: string
|
2023-08-24 23:46:45 +10:00
|
|
|
private onWebsocketOpen: (engineConnection: EngineConnection) => void
|
|
|
|
private onDataChannelOpen: (engineConnection: EngineConnection) => void
|
|
|
|
private onEngineConnectionOpen: (engineConnection: EngineConnection) => void
|
|
|
|
private onConnectionStarted: (engineConnection: EngineConnection) => void
|
|
|
|
private onClose: (engineConnection: EngineConnection) => void
|
|
|
|
private onNewTrack: (track: NewTrackArgs) => void
|
2023-08-18 16:16:16 -04:00
|
|
|
|
2023-09-10 19:04:46 -04:00
|
|
|
// TODO: actual type is ClientMetrics
|
|
|
|
private webrtcStatsCollector?: () => Promise<ClientMetrics>
|
|
|
|
|
2023-08-24 23:46:45 +10:00
|
|
|
constructor({
|
|
|
|
url,
|
|
|
|
token,
|
|
|
|
onWebsocketOpen = () => {},
|
|
|
|
onNewTrack = () => {},
|
|
|
|
onEngineConnectionOpen = () => {},
|
|
|
|
onConnectionStarted = () => {},
|
|
|
|
onClose = () => {},
|
|
|
|
onDataChannelOpen = () => {},
|
|
|
|
}: {
|
|
|
|
url: string
|
|
|
|
token?: string
|
|
|
|
onWebsocketOpen?: (engineConnection: EngineConnection) => void
|
|
|
|
onDataChannelOpen?: (engineConnection: EngineConnection) => void
|
|
|
|
onEngineConnectionOpen?: (engineConnection: EngineConnection) => void
|
|
|
|
onConnectionStarted?: (engineConnection: EngineConnection) => void
|
|
|
|
onClose?: (engineConnection: EngineConnection) => void
|
|
|
|
onNewTrack?: (track: NewTrackArgs) => void
|
|
|
|
}) {
|
2023-08-18 16:16:16 -04:00
|
|
|
this.url = url
|
|
|
|
this.token = token
|
2023-08-21 16:53:31 -04:00
|
|
|
this.ready = false
|
2023-09-21 12:07:47 -04:00
|
|
|
this.connecting = false
|
|
|
|
this.dead = false
|
|
|
|
this.failedConnTimeout = null
|
2023-08-24 23:46:45 +10:00
|
|
|
this.onWebsocketOpen = onWebsocketOpen
|
|
|
|
this.onDataChannelOpen = onDataChannelOpen
|
|
|
|
this.onEngineConnectionOpen = onEngineConnectionOpen
|
|
|
|
this.onConnectionStarted = onConnectionStarted
|
|
|
|
this.onClose = onClose
|
|
|
|
this.onNewTrack = onNewTrack
|
2023-08-21 16:53:31 -04:00
|
|
|
|
|
|
|
// TODO(paultag): This ought to be tweakable.
|
|
|
|
const pingIntervalMs = 10000
|
2023-08-02 16:23:17 -07:00
|
|
|
|
2023-09-21 12:07:47 -04:00
|
|
|
let pingInterval = setInterval(() => {
|
|
|
|
if (this.dead) {
|
|
|
|
clearInterval(pingInterval)
|
|
|
|
}
|
2023-08-21 16:53:31 -04:00
|
|
|
if (this.isReady()) {
|
|
|
|
// When we're online, every 10 seconds, we'll attempt to put a 'ping'
|
|
|
|
// command through the WebSocket connection. This will help both ends
|
|
|
|
// of the connection maintain the TCP connection without hitting a
|
|
|
|
// timeout condition.
|
|
|
|
this.send({ type: 'ping' })
|
|
|
|
}
|
|
|
|
}, pingIntervalMs)
|
2023-09-21 12:07:47 -04:00
|
|
|
|
|
|
|
const connectionTimeoutMs = VITE_KC_CONNECTION_TIMEOUT_MS
|
|
|
|
let connectInterval = setInterval(() => {
|
|
|
|
if (this.dead) {
|
|
|
|
clearInterval(connectInterval)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
if (this.isReady()) {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
console.log('connecting via retry')
|
|
|
|
this.connect()
|
|
|
|
}, connectionTimeoutMs)
|
|
|
|
}
|
|
|
|
// isConnecting will return true when connect has been called, but the full
|
|
|
|
// WebRTC is not online.
|
|
|
|
isConnecting() {
|
|
|
|
return this.connecting
|
2023-08-21 16:53:31 -04:00
|
|
|
}
|
|
|
|
// isReady will return true only when the WebRTC *and* WebSocket connection
|
|
|
|
// are connected. During setup, the WebSocket connection comes online first,
|
|
|
|
// which is used to establish the WebRTC connection. The EngineConnection
|
|
|
|
// is not "Ready" until both are connected.
|
|
|
|
isReady() {
|
|
|
|
return this.ready
|
|
|
|
}
|
2023-09-21 12:07:47 -04:00
|
|
|
tearDown() {
|
|
|
|
this.dead = true
|
|
|
|
this.close()
|
|
|
|
}
|
2023-08-30 13:14:52 -04:00
|
|
|
// shouldTrace will return true when Sentry should be used to instrument
|
|
|
|
// the Engine.
|
|
|
|
shouldTrace() {
|
|
|
|
return Sentry.getCurrentHub()?.getClient()?.getOptions()?.sendClientReports
|
|
|
|
}
|
2023-08-21 16:53:31 -04:00
|
|
|
// connect will attempt to connect to the Engine over a WebSocket, and
|
|
|
|
// establish the WebRTC connections.
|
|
|
|
//
|
|
|
|
// This will attempt the full handshake, and retry if the connection
|
|
|
|
// did not establish.
|
2023-08-18 16:16:16 -04:00
|
|
|
connect() {
|
2023-09-21 12:07:47 -04:00
|
|
|
console.log('connect was called')
|
|
|
|
if (this.isConnecting() || this.isReady()) {
|
|
|
|
return
|
|
|
|
}
|
2023-08-18 16:16:16 -04:00
|
|
|
|
2023-08-30 10:34:14 -04:00
|
|
|
// Information on the connect transaction
|
|
|
|
|
2023-08-31 12:59:46 -04:00
|
|
|
class SpanPromise {
|
|
|
|
span: Sentry.Span
|
|
|
|
promise: Promise<void>
|
|
|
|
resolve?: (v: void) => void
|
|
|
|
|
|
|
|
constructor(span: Sentry.Span) {
|
|
|
|
this.span = span
|
|
|
|
this.promise = new Promise((resolve) => {
|
|
|
|
this.resolve = (v: void) => {
|
|
|
|
// here we're going to invoke finish before resolving the
|
|
|
|
// promise so that a `.then()` will order strictly after
|
|
|
|
// all spans have -- for sure -- been resolved, rather than
|
|
|
|
// doing a `then` on this promise.
|
|
|
|
this.span.finish()
|
|
|
|
resolve(v)
|
|
|
|
}
|
|
|
|
})
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-08-30 13:14:52 -04:00
|
|
|
let webrtcMediaTransaction: Sentry.Transaction
|
2023-08-31 12:59:46 -04:00
|
|
|
let websocketSpan: SpanPromise
|
|
|
|
let mediaTrackSpan: SpanPromise
|
|
|
|
let dataChannelSpan: SpanPromise
|
|
|
|
let handshakeSpan: SpanPromise
|
|
|
|
let iceSpan: SpanPromise
|
2023-08-30 10:34:14 -04:00
|
|
|
|
2023-08-30 13:14:52 -04:00
|
|
|
if (this.shouldTrace()) {
|
|
|
|
webrtcMediaTransaction = Sentry.startTransaction({
|
|
|
|
name: 'webrtc-media',
|
|
|
|
})
|
2023-08-31 12:59:46 -04:00
|
|
|
websocketSpan = new SpanPromise(
|
|
|
|
webrtcMediaTransaction.startChild({ op: 'websocket' })
|
|
|
|
)
|
2023-08-30 13:14:52 -04:00
|
|
|
}
|
|
|
|
|
2023-08-21 16:53:31 -04:00
|
|
|
this.websocket = new WebSocket(this.url, [])
|
2023-08-18 16:16:16 -04:00
|
|
|
this.websocket.binaryType = 'arraybuffer'
|
2023-08-02 16:23:17 -07:00
|
|
|
|
2023-06-22 16:43:33 +10:00
|
|
|
this.pc = new RTCPeerConnection()
|
2023-06-23 10:54:19 +10:00
|
|
|
this.pc.createDataChannel('unreliable_modeling_cmds')
|
2023-08-18 16:16:16 -04:00
|
|
|
this.websocket.addEventListener('open', (event) => {
|
2023-06-22 16:43:33 +10:00
|
|
|
console.log('Connected to websocket, waiting for ICE servers')
|
2023-08-18 16:16:16 -04:00
|
|
|
if (this.token) {
|
2023-08-21 16:53:31 -04:00
|
|
|
this.send({ headers: { Authorization: `Bearer ${this.token}` } })
|
2023-07-11 20:34:09 +10:00
|
|
|
}
|
2023-06-22 16:43:33 +10:00
|
|
|
})
|
|
|
|
|
2023-11-13 11:13:15 -08:00
|
|
|
this.pc.addEventListener('icecandidateerror', (_event) => {
|
|
|
|
const event = _event as RTCPeerConnectionIceErrorEvent
|
|
|
|
console.error(
|
|
|
|
`ICE candidate returned an error: ${event.errorCode}: ${event.errorText} for ${event.url}`
|
|
|
|
)
|
|
|
|
})
|
|
|
|
|
|
|
|
this.pc.addEventListener('connectionstatechange', (event) => {
|
|
|
|
if (this.pc?.iceConnectionState === 'connected') {
|
|
|
|
if (this.shouldTrace()) {
|
|
|
|
iceSpan.resolve?.()
|
|
|
|
}
|
|
|
|
} else if (this.pc?.iceConnectionState === 'failed') {
|
|
|
|
// failed is a terminal state; let's explicitly kill the
|
|
|
|
// connection to the server at this point.
|
|
|
|
console.log('failed to negotiate ice connection; restarting')
|
|
|
|
this.close()
|
|
|
|
}
|
|
|
|
})
|
|
|
|
|
2023-08-21 16:53:31 -04:00
|
|
|
this.websocket.addEventListener('open', (event) => {
|
2023-08-30 13:14:52 -04:00
|
|
|
if (this.shouldTrace()) {
|
2023-08-31 12:59:46 -04:00
|
|
|
websocketSpan.resolve?.()
|
|
|
|
|
|
|
|
handshakeSpan = new SpanPromise(
|
|
|
|
webrtcMediaTransaction.startChild({ op: 'handshake' })
|
|
|
|
)
|
|
|
|
iceSpan = new SpanPromise(
|
|
|
|
webrtcMediaTransaction.startChild({ op: 'ice' })
|
|
|
|
)
|
|
|
|
dataChannelSpan = new SpanPromise(
|
|
|
|
webrtcMediaTransaction.startChild({
|
|
|
|
op: 'data-channel',
|
|
|
|
})
|
|
|
|
)
|
|
|
|
mediaTrackSpan = new SpanPromise(
|
|
|
|
webrtcMediaTransaction.startChild({
|
|
|
|
op: 'media-track',
|
|
|
|
})
|
|
|
|
)
|
2023-08-30 13:14:52 -04:00
|
|
|
}
|
2023-08-31 12:59:46 -04:00
|
|
|
|
2023-09-08 16:40:08 -04:00
|
|
|
if (this.shouldTrace()) {
|
|
|
|
Promise.all([
|
|
|
|
handshakeSpan.promise,
|
|
|
|
iceSpan.promise,
|
|
|
|
dataChannelSpan.promise,
|
|
|
|
mediaTrackSpan.promise,
|
|
|
|
]).then(() => {
|
|
|
|
console.log('All spans finished, reporting')
|
|
|
|
webrtcMediaTransaction?.finish()
|
|
|
|
})
|
|
|
|
}
|
2023-08-31 12:59:46 -04:00
|
|
|
|
2023-08-24 23:46:45 +10:00
|
|
|
this.onWebsocketOpen(this)
|
2023-08-21 16:53:31 -04:00
|
|
|
})
|
|
|
|
|
2023-08-18 16:16:16 -04:00
|
|
|
this.websocket.addEventListener('close', (event) => {
|
2023-08-02 13:51:05 -05:00
|
|
|
console.log('websocket connection closed', event)
|
2023-08-21 16:53:31 -04:00
|
|
|
this.close()
|
2023-06-22 16:43:33 +10:00
|
|
|
})
|
|
|
|
|
2023-08-18 16:16:16 -04:00
|
|
|
this.websocket.addEventListener('error', (event) => {
|
2023-08-02 13:51:05 -05:00
|
|
|
console.log('websocket connection error', event)
|
2023-08-21 16:53:31 -04:00
|
|
|
this.close()
|
2023-06-22 16:43:33 +10:00
|
|
|
})
|
|
|
|
|
2023-08-18 16:16:16 -04:00
|
|
|
this.websocket.addEventListener('message', (event) => {
|
|
|
|
// In the EngineConnection, we're looking for messages to/from
|
|
|
|
// the server that relate to the ICE handshake, or WebRTC
|
|
|
|
// negotiation. There may be other messages (including ArrayBuffer
|
|
|
|
// messages) that are intended for the GUI itself, so be careful
|
|
|
|
// when assuming we're the only consumer or that all messages will
|
|
|
|
// be carefully formatted here.
|
|
|
|
|
|
|
|
if (typeof event.data !== 'string') {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2023-08-25 00:16:37 -04:00
|
|
|
const message: Models['WebSocketResponse_type'] = JSON.parse(event.data)
|
|
|
|
|
|
|
|
if (!message.success) {
|
2023-10-11 13:36:54 +11:00
|
|
|
const errorsString = message?.errors
|
|
|
|
?.map((error) => {
|
|
|
|
return ` - ${error.error_code}: ${error.message}`
|
|
|
|
})
|
|
|
|
.join('\n')
|
2023-08-25 00:16:37 -04:00
|
|
|
if (message.request_id) {
|
2023-10-11 13:36:54 +11:00
|
|
|
console.error(
|
|
|
|
`Error in response to request ${message.request_id}:\n${errorsString}`
|
|
|
|
)
|
2023-08-25 00:16:37 -04:00
|
|
|
} else {
|
2023-10-11 13:36:54 +11:00
|
|
|
console.error(`Error from server:\n${errorsString}`)
|
2023-08-25 00:16:37 -04:00
|
|
|
}
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
let resp = message.resp
|
|
|
|
if (!resp) {
|
|
|
|
// If there's no body to the response, we can bail here.
|
2023-08-21 16:53:31 -04:00
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2023-08-25 00:16:37 -04:00
|
|
|
if (resp.type === 'sdp_answer') {
|
|
|
|
let answer = resp.data?.answer
|
|
|
|
if (!answer || answer.type === 'unspecified') {
|
|
|
|
return
|
|
|
|
}
|
2023-08-18 16:16:16 -04:00
|
|
|
|
2023-08-21 16:53:31 -04:00
|
|
|
if (this.pc?.signalingState !== 'stable') {
|
|
|
|
// If the connection is stable, we shouldn't bother updating the
|
|
|
|
// SDP, since we have a stable connection to the backend. If we
|
|
|
|
// need to renegotiate, the whole PeerConnection needs to get
|
|
|
|
// tore down.
|
|
|
|
this.pc?.setRemoteDescription(
|
|
|
|
new RTCSessionDescription({
|
2023-08-25 00:16:37 -04:00
|
|
|
type: answer.type,
|
|
|
|
sdp: answer.sdp,
|
2023-08-21 16:53:31 -04:00
|
|
|
})
|
|
|
|
)
|
2023-08-30 10:34:14 -04:00
|
|
|
|
2023-08-30 13:14:52 -04:00
|
|
|
if (this.shouldTrace()) {
|
|
|
|
// When both ends have a local and remote SDP, we've been able to
|
|
|
|
// set up successfully. We'll still need to find the right ICE
|
|
|
|
// servers, but this is hand-shook.
|
2023-08-31 12:59:46 -04:00
|
|
|
handshakeSpan.resolve?.()
|
2023-08-30 13:14:52 -04:00
|
|
|
}
|
2023-08-21 16:53:31 -04:00
|
|
|
}
|
2023-08-25 00:16:37 -04:00
|
|
|
} else if (resp.type === 'trickle_ice') {
|
|
|
|
let candidate = resp.data?.candidate
|
|
|
|
this.pc?.addIceCandidate(candidate as RTCIceCandidateInit)
|
|
|
|
} else if (resp.type === 'ice_server_info' && this.pc) {
|
2023-08-18 16:16:16 -04:00
|
|
|
console.log('received ice_server_info')
|
2023-08-25 00:16:37 -04:00
|
|
|
let ice_servers = resp.data?.ice_servers
|
2023-06-22 16:43:33 +10:00
|
|
|
|
2023-08-25 00:16:37 -04:00
|
|
|
if (ice_servers?.length > 0) {
|
2023-08-18 16:16:16 -04:00
|
|
|
// When we set the Configuration, we want to always force
|
|
|
|
// iceTransportPolicy to 'relay', since we know the topology
|
|
|
|
// of the ICE/STUN/TUN server and the engine. We don't wish to
|
|
|
|
// talk to the engine in any configuration /other/ than relay
|
|
|
|
// from a infra POV.
|
|
|
|
this.pc.setConfiguration({
|
2023-08-25 00:16:37 -04:00
|
|
|
iceServers: ice_servers,
|
2023-08-18 16:16:16 -04:00
|
|
|
iceTransportPolicy: 'relay',
|
2023-06-22 16:43:33 +10:00
|
|
|
})
|
2023-08-18 16:16:16 -04:00
|
|
|
} else {
|
|
|
|
this.pc?.setConfiguration({})
|
|
|
|
}
|
2023-06-22 16:43:33 +10:00
|
|
|
|
2023-08-18 16:16:16 -04:00
|
|
|
// We have an ICE Servers set now. We just setConfiguration, so let's
|
|
|
|
// start adding things we care about to the PeerConnection and let
|
|
|
|
// ICE negotiation happen in the background. Everything from here
|
|
|
|
// until the end of this function is setup of our end of the
|
|
|
|
// PeerConnection and waiting for events to fire our callbacks.
|
|
|
|
|
|
|
|
this.pc.addEventListener('icecandidate', (event) => {
|
|
|
|
if (!this.pc || !this.websocket) return
|
2023-08-25 00:16:37 -04:00
|
|
|
if (event.candidate !== null) {
|
2023-08-18 16:16:16 -04:00
|
|
|
console.log('sending trickle ice candidate')
|
|
|
|
const { candidate } = event
|
2023-08-21 16:53:31 -04:00
|
|
|
this.send({
|
|
|
|
type: 'trickle_ice',
|
|
|
|
candidate: candidate.toJSON(),
|
|
|
|
})
|
2023-08-18 16:16:16 -04:00
|
|
|
}
|
|
|
|
})
|
|
|
|
|
|
|
|
// Offer to receive 1 video track
|
|
|
|
this.pc.addTransceiver('video', {})
|
|
|
|
|
|
|
|
// Finally (but actually firstly!), to kick things off, we're going to
|
|
|
|
// generate our SDP, set it on our PeerConnection, and let the server
|
|
|
|
// know about our capabilities.
|
|
|
|
this.pc
|
|
|
|
.createOffer()
|
|
|
|
.then(async (descriptionInit) => {
|
|
|
|
await this?.pc?.setLocalDescription(descriptionInit)
|
|
|
|
console.log('sent sdp_offer begin')
|
2023-08-21 16:53:31 -04:00
|
|
|
this.send({
|
2023-08-18 16:16:16 -04:00
|
|
|
type: 'sdp_offer',
|
|
|
|
offer: this.pc?.localDescription,
|
2023-06-23 09:56:37 +10:00
|
|
|
})
|
|
|
|
})
|
2023-08-18 16:16:16 -04:00
|
|
|
.catch(console.log)
|
2023-09-10 19:04:46 -04:00
|
|
|
} else if (resp.type === 'metrics_request') {
|
|
|
|
if (this.webrtcStatsCollector === undefined) {
|
|
|
|
// TODO: Error message here?
|
|
|
|
return
|
|
|
|
}
|
|
|
|
this.webrtcStatsCollector().then((client_metrics) => {
|
|
|
|
this.send({
|
|
|
|
type: 'metrics_response',
|
|
|
|
metrics: client_metrics,
|
|
|
|
})
|
|
|
|
})
|
2023-08-18 16:16:16 -04:00
|
|
|
}
|
2023-08-21 16:53:31 -04:00
|
|
|
})
|
|
|
|
|
|
|
|
this.pc.addEventListener('track', (event) => {
|
|
|
|
const mediaStream = event.streams[0]
|
2023-08-30 10:34:14 -04:00
|
|
|
|
2023-08-30 13:14:52 -04:00
|
|
|
if (this.shouldTrace()) {
|
2023-08-31 12:59:46 -04:00
|
|
|
let mediaStreamTrack = mediaStream.getVideoTracks()[0]
|
|
|
|
mediaStreamTrack.addEventListener('unmute', () => {
|
|
|
|
// let settings = mediaStreamTrack.getSettings()
|
|
|
|
// mediaTrackSpan.span.setTag("fps", settings.frameRate)
|
|
|
|
// mediaTrackSpan.span.setTag("width", settings.width)
|
|
|
|
// mediaTrackSpan.span.setTag("height", settings.height)
|
|
|
|
mediaTrackSpan.resolve?.()
|
2023-08-30 13:14:52 -04:00
|
|
|
})
|
|
|
|
}
|
2023-08-30 10:34:14 -04:00
|
|
|
|
2023-09-10 19:04:46 -04:00
|
|
|
this.webrtcStatsCollector = (): Promise<ClientMetrics> => {
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
if (mediaStream.getVideoTracks().length !== 1) {
|
|
|
|
reject(new Error('too many video tracks to report'))
|
2023-08-30 13:14:52 -04:00
|
|
|
return
|
|
|
|
}
|
2023-08-30 10:34:14 -04:00
|
|
|
|
2023-09-10 19:04:46 -04:00
|
|
|
let videoTrack = mediaStream.getVideoTracks()[0]
|
|
|
|
this.pc?.getStats(videoTrack).then((videoTrackStats) => {
|
|
|
|
let client_metrics: ClientMetrics = {
|
|
|
|
rtc_frames_decoded: 0,
|
|
|
|
rtc_frames_dropped: 0,
|
|
|
|
rtc_frames_received: 0,
|
|
|
|
rtc_frames_per_second: 0,
|
|
|
|
rtc_freeze_count: 0,
|
|
|
|
rtc_jitter_sec: 0.0,
|
|
|
|
rtc_keyframes_decoded: 0,
|
|
|
|
rtc_total_freezes_duration_sec: 0.0,
|
|
|
|
}
|
|
|
|
|
|
|
|
// TODO(paultag): Since we can technically have multiple WebRTC
|
|
|
|
// video tracks (even if the Server doesn't at the moment), we
|
|
|
|
// ought to send stats for every video track(?), and add the stream
|
|
|
|
// ID into it. This raises the cardinality of collected metrics
|
|
|
|
// when/if we do, but for now, just report the one stream.
|
|
|
|
|
|
|
|
videoTrackStats.forEach((videoTrackReport) => {
|
|
|
|
if (videoTrackReport.type === 'inbound-rtp') {
|
|
|
|
client_metrics.rtc_frames_decoded =
|
2023-10-17 12:55:45 -04:00
|
|
|
videoTrackReport.framesDecoded || 0
|
2023-09-10 19:04:46 -04:00
|
|
|
client_metrics.rtc_frames_dropped =
|
2023-10-17 12:55:45 -04:00
|
|
|
videoTrackReport.framesDropped || 0
|
2023-09-10 19:04:46 -04:00
|
|
|
client_metrics.rtc_frames_received =
|
2023-10-17 12:55:45 -04:00
|
|
|
videoTrackReport.framesReceived || 0
|
2023-09-10 19:04:46 -04:00
|
|
|
client_metrics.rtc_frames_per_second =
|
|
|
|
videoTrackReport.framesPerSecond || 0
|
2023-09-15 12:05:46 -04:00
|
|
|
client_metrics.rtc_freeze_count =
|
|
|
|
videoTrackReport.freezeCount || 0
|
2023-10-17 12:55:45 -04:00
|
|
|
client_metrics.rtc_jitter_sec = videoTrackReport.jitter || 0.0
|
2023-09-10 19:04:46 -04:00
|
|
|
client_metrics.rtc_keyframes_decoded =
|
2023-10-17 12:55:45 -04:00
|
|
|
videoTrackReport.keyFramesDecoded || 0
|
2023-09-10 19:04:46 -04:00
|
|
|
client_metrics.rtc_total_freezes_duration_sec =
|
2023-09-15 12:05:46 -04:00
|
|
|
videoTrackReport.totalFreezesDuration || 0
|
2023-09-10 19:04:46 -04:00
|
|
|
} else if (videoTrackReport.type === 'transport') {
|
|
|
|
// videoTrackReport.bytesReceived,
|
|
|
|
// videoTrackReport.bytesSent,
|
|
|
|
}
|
2023-08-30 10:34:14 -04:00
|
|
|
})
|
2023-09-10 19:04:46 -04:00
|
|
|
resolve(client_metrics)
|
2023-08-30 10:34:14 -04:00
|
|
|
})
|
2023-09-10 19:04:46 -04:00
|
|
|
})
|
2023-08-30 10:34:14 -04:00
|
|
|
}
|
|
|
|
|
2023-08-24 23:46:45 +10:00
|
|
|
this.onNewTrack({
|
|
|
|
conn: this,
|
|
|
|
mediaStream: mediaStream,
|
|
|
|
})
|
2023-08-18 16:16:16 -04:00
|
|
|
})
|
|
|
|
|
|
|
|
this.pc.addEventListener('datachannel', (event) => {
|
2023-08-31 07:39:03 +10:00
|
|
|
this.unreliableDataChannel = event.channel
|
2023-08-18 16:16:16 -04:00
|
|
|
|
2023-08-31 07:39:03 +10:00
|
|
|
console.log('accepted unreliable data channel', event.channel.label)
|
|
|
|
this.unreliableDataChannel.addEventListener('open', (event) => {
|
|
|
|
console.log('unreliable data channel opened', event)
|
2023-08-30 13:14:52 -04:00
|
|
|
if (this.shouldTrace()) {
|
2023-08-31 12:59:46 -04:00
|
|
|
dataChannelSpan.resolve?.()
|
2023-08-30 13:14:52 -04:00
|
|
|
}
|
2023-08-21 16:53:31 -04:00
|
|
|
|
2023-08-24 23:46:45 +10:00
|
|
|
this.onDataChannelOpen(this)
|
2023-08-21 16:53:31 -04:00
|
|
|
|
2023-08-24 23:46:45 +10:00
|
|
|
this.ready = true
|
2023-09-21 12:07:47 -04:00
|
|
|
this.connecting = false
|
2023-09-29 12:41:58 -07:00
|
|
|
// Do this after we set the connection is ready to avoid errors when
|
|
|
|
// we try to send messages before the connection is ready.
|
|
|
|
this.onEngineConnectionOpen(this)
|
2023-08-18 16:16:16 -04:00
|
|
|
})
|
|
|
|
|
2023-08-31 07:39:03 +10:00
|
|
|
this.unreliableDataChannel.addEventListener('close', (event) => {
|
|
|
|
console.log('unreliable data channel closed')
|
2023-08-21 16:53:31 -04:00
|
|
|
this.close()
|
2023-08-18 16:16:16 -04:00
|
|
|
})
|
|
|
|
|
2023-08-31 07:39:03 +10:00
|
|
|
this.unreliableDataChannel.addEventListener('error', (event) => {
|
|
|
|
console.log('unreliable data channel error')
|
2023-08-21 16:53:31 -04:00
|
|
|
this.close()
|
2023-08-18 16:16:16 -04:00
|
|
|
})
|
|
|
|
})
|
|
|
|
|
2023-09-21 12:07:47 -04:00
|
|
|
const connectionTimeoutMs = VITE_KC_CONNECTION_TIMEOUT_MS
|
|
|
|
|
|
|
|
if (this.failedConnTimeout) {
|
|
|
|
console.log('clearing timeout before set')
|
|
|
|
clearTimeout(this.failedConnTimeout)
|
|
|
|
this.failedConnTimeout = null
|
|
|
|
}
|
|
|
|
console.log('timeout set')
|
|
|
|
this.failedConnTimeout = setTimeout(() => {
|
|
|
|
if (this.isReady()) {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
console.log('engine connection timeout on connection, closing')
|
|
|
|
this.close()
|
|
|
|
}, connectionTimeoutMs)
|
|
|
|
|
2023-08-24 23:46:45 +10:00
|
|
|
this.onConnectionStarted(this)
|
2023-08-21 16:53:31 -04:00
|
|
|
}
|
2023-09-15 17:14:58 -04:00
|
|
|
unreliableSend(message: object | string) {
|
|
|
|
// TODO(paultag): Add in logic to determine the connection state and
|
|
|
|
// take actions if needed?
|
|
|
|
this.unreliableDataChannel?.send(
|
|
|
|
typeof message === 'string' ? message : JSON.stringify(message)
|
|
|
|
)
|
|
|
|
}
|
2023-08-31 14:44:22 +10:00
|
|
|
send(message: object | string) {
|
2023-08-21 16:53:31 -04:00
|
|
|
// TODO(paultag): Add in logic to determine the connection state and
|
|
|
|
// take actions if needed?
|
2023-08-31 14:44:22 +10:00
|
|
|
this.websocket?.send(
|
|
|
|
typeof message === 'string' ? message : JSON.stringify(message)
|
|
|
|
)
|
2023-08-18 16:16:16 -04:00
|
|
|
}
|
|
|
|
close() {
|
|
|
|
this.websocket?.close()
|
|
|
|
this.pc?.close()
|
2023-08-31 07:39:03 +10:00
|
|
|
this.unreliableDataChannel?.close()
|
2023-08-22 18:18:22 -04:00
|
|
|
this.websocket = undefined
|
|
|
|
this.pc = undefined
|
2023-08-31 07:39:03 +10:00
|
|
|
this.unreliableDataChannel = undefined
|
2023-09-10 19:04:46 -04:00
|
|
|
this.webrtcStatsCollector = undefined
|
2023-09-21 12:07:47 -04:00
|
|
|
if (this.failedConnTimeout) {
|
|
|
|
console.log('closed timeout in close')
|
|
|
|
clearTimeout(this.failedConnTimeout)
|
|
|
|
this.failedConnTimeout = null
|
|
|
|
}
|
2023-08-21 16:53:31 -04:00
|
|
|
|
2023-08-24 23:46:45 +10:00
|
|
|
this.onClose(this)
|
|
|
|
this.ready = false
|
2023-09-21 12:07:47 -04:00
|
|
|
this.connecting = false
|
2023-08-18 16:16:16 -04:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-08-25 00:16:37 -04:00
|
|
|
export type EngineCommand = Models['WebSocketRequest_type']
|
2023-08-31 05:19:37 +10:00
|
|
|
type ModelTypes = Models['OkModelingCmdResponse_type']['type']
|
|
|
|
|
2023-09-08 17:50:37 +10:00
|
|
|
type CommandTypes = Models['ModelingCmd_type']['type']
|
|
|
|
|
2023-08-31 07:39:03 +10:00
|
|
|
type UnreliableResponses = Extract<
|
2023-08-31 05:19:37 +10:00
|
|
|
Models['OkModelingCmdResponse_type'],
|
|
|
|
{ type: 'highlight_set_entity' }
|
|
|
|
>
|
2023-08-31 07:39:03 +10:00
|
|
|
interface UnreliableSubscription<T extends UnreliableResponses['type']> {
|
2023-08-31 05:19:37 +10:00
|
|
|
event: T
|
2023-08-31 07:39:03 +10:00
|
|
|
callback: (data: Extract<UnreliableResponses, { type: T }>) => void
|
2023-08-31 05:19:37 +10:00
|
|
|
}
|
|
|
|
|
|
|
|
interface Subscription<T extends ModelTypes> {
|
|
|
|
event: T
|
|
|
|
callback: (
|
|
|
|
data: Extract<Models['OkModelingCmdResponse_type'], { type: T }>
|
|
|
|
) => void
|
|
|
|
}
|
2023-08-25 00:16:37 -04:00
|
|
|
|
2023-11-24 08:59:24 +11:00
|
|
|
export type CommandLog =
|
|
|
|
| {
|
|
|
|
type: 'send-modeling'
|
|
|
|
data: EngineCommand
|
|
|
|
}
|
|
|
|
| {
|
|
|
|
type: 'send-scene'
|
|
|
|
data: EngineCommand
|
|
|
|
}
|
|
|
|
| {
|
|
|
|
type: 'receive-reliable'
|
|
|
|
data: WebSocketResponse
|
|
|
|
id: string
|
|
|
|
cmd_type?: string
|
|
|
|
}
|
|
|
|
| {
|
|
|
|
type: 'execution-done'
|
|
|
|
data: null
|
|
|
|
}
|
|
|
|
|
2023-08-18 16:16:16 -04:00
|
|
|
export class EngineCommandManager {
|
|
|
|
artifactMap: ArtifactMap = {}
|
2023-11-28 21:23:20 +11:00
|
|
|
lastArtifactMap: ArtifactMap = {}
|
2023-08-18 16:16:16 -04:00
|
|
|
outSequence = 1
|
|
|
|
inSequence = 1
|
|
|
|
engineConnection?: EngineConnection
|
2023-10-11 13:36:54 +11:00
|
|
|
defaultPlanes: DefaultPlanes = { xy: '', yz: '', xz: '' }
|
2023-11-24 08:59:24 +11:00
|
|
|
_commandLogs: CommandLog[] = []
|
|
|
|
_commandLogCallBack: (command: CommandLog[]) => void = () => {}
|
2023-09-29 12:41:58 -07:00
|
|
|
// Folks should realize that wait for ready does not get called _everytime_
|
|
|
|
// the connection resets and restarts, it only gets called the first time.
|
|
|
|
// Be careful what you put here.
|
2023-08-18 16:16:16 -04:00
|
|
|
private resolveReady = () => {}
|
2023-10-11 13:36:54 +11:00
|
|
|
waitForReady: Promise<void> = new Promise((resolve) => {
|
|
|
|
this.resolveReady = resolve
|
|
|
|
})
|
2023-08-31 05:19:37 +10:00
|
|
|
|
|
|
|
subscriptions: {
|
|
|
|
[event: string]: {
|
|
|
|
[localUnsubscribeId: string]: (a: any) => void
|
|
|
|
}
|
|
|
|
} = {} as any
|
2023-08-31 07:39:03 +10:00
|
|
|
unreliableSubscriptions: {
|
2023-08-31 05:19:37 +10:00
|
|
|
[event: string]: {
|
|
|
|
[localUnsubscribeId: string]: (a: any) => void
|
|
|
|
}
|
|
|
|
} = {} as any
|
2023-09-25 19:49:53 -07:00
|
|
|
|
|
|
|
constructor() {
|
|
|
|
this.engineConnection = undefined
|
|
|
|
}
|
|
|
|
|
|
|
|
start({
|
2023-08-18 16:16:16 -04:00
|
|
|
setMediaStream,
|
|
|
|
setIsStreamReady,
|
|
|
|
width,
|
|
|
|
height,
|
2023-09-29 12:41:58 -07:00
|
|
|
executeCode,
|
2023-08-18 16:16:16 -04:00
|
|
|
token,
|
|
|
|
}: {
|
|
|
|
setMediaStream: (stream: MediaStream) => void
|
|
|
|
setIsStreamReady: (isStreamReady: boolean) => void
|
|
|
|
width: number
|
|
|
|
height: number
|
2023-09-29 12:41:58 -07:00
|
|
|
executeCode: (code?: string, force?: boolean) => void
|
2023-08-18 16:16:16 -04:00
|
|
|
token?: string
|
|
|
|
}) {
|
2023-09-25 19:49:53 -07:00
|
|
|
if (width === 0 || height === 0) {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
// If we already have an engine connection, just need to resize the stream.
|
|
|
|
if (this.engineConnection) {
|
2023-11-13 19:32:07 -05:00
|
|
|
this.handleResize({
|
|
|
|
streamWidth: width,
|
|
|
|
streamHeight: height,
|
|
|
|
})
|
2023-09-25 19:49:53 -07:00
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2023-08-18 16:16:16 -04:00
|
|
|
const url = `${VITE_KC_API_WS_MODELING_URL}?video_res_width=${width}&video_res_height=${height}`
|
|
|
|
this.engineConnection = new EngineConnection({
|
|
|
|
url,
|
|
|
|
token,
|
2023-08-24 23:46:45 +10:00
|
|
|
onEngineConnectionOpen: () => {
|
2023-08-21 16:53:31 -04:00
|
|
|
this.resolveReady()
|
|
|
|
setIsStreamReady(true)
|
2023-09-29 12:41:58 -07:00
|
|
|
|
|
|
|
// Make the axis gizmo.
|
|
|
|
// We do this after the connection opened to avoid a race condition.
|
|
|
|
// Connected opened is the last thing that happens when the stream
|
|
|
|
// is ready.
|
|
|
|
// We also do this here because we want to ensure we create the gizmo
|
|
|
|
// and execute the code everytime the stream is restarted.
|
|
|
|
const gizmoId = uuidv4()
|
|
|
|
this.sendSceneCommand({
|
|
|
|
type: 'modeling_cmd_req',
|
|
|
|
cmd_id: gizmoId,
|
|
|
|
cmd: {
|
|
|
|
type: 'make_axes_gizmo',
|
|
|
|
clobber: false,
|
|
|
|
// If true, axes gizmo will be placed in the corner of the screen.
|
|
|
|
// If false, it will be placed at the origin of the scene.
|
|
|
|
gizmo_mode: true,
|
|
|
|
},
|
|
|
|
})
|
|
|
|
|
2023-11-01 17:34:54 -05:00
|
|
|
// Initialize the planes.
|
2023-10-11 13:36:54 +11:00
|
|
|
this.initPlanes().then(() => {
|
|
|
|
// We execute the code here to make sure if the stream was to
|
|
|
|
// restart in a session, we want to make sure to execute the code.
|
|
|
|
// We force it to re-execute the code because we want to make sure
|
|
|
|
// the code is executed everytime the stream is restarted.
|
|
|
|
// We pass undefined for the code so it reads from the current state.
|
|
|
|
executeCode(undefined, true)
|
|
|
|
})
|
2023-08-24 23:46:45 +10:00
|
|
|
},
|
|
|
|
onClose: () => {
|
2023-08-21 16:53:31 -04:00
|
|
|
setIsStreamReady(false)
|
2023-08-24 23:46:45 +10:00
|
|
|
},
|
|
|
|
onConnectionStarted: (engineConnection) => {
|
|
|
|
engineConnection?.pc?.addEventListener('datachannel', (event) => {
|
2023-08-31 07:39:03 +10:00
|
|
|
let unreliableDataChannel = event.channel
|
|
|
|
|
|
|
|
unreliableDataChannel.addEventListener('message', (event) => {
|
|
|
|
const result: UnreliableResponses = JSON.parse(event.data)
|
|
|
|
Object.values(
|
|
|
|
this.unreliableSubscriptions[result.type] || {}
|
|
|
|
).forEach(
|
|
|
|
// TODO: There is only one response that uses the unreliable channel atm,
|
2023-08-31 05:19:37 +10:00
|
|
|
// highlight_set_entity, if there are more it's likely they will all have the same
|
|
|
|
// sequence logic, but I'm not sure if we use a single global sequence or a sequence
|
2023-08-31 07:39:03 +10:00
|
|
|
// per unreliable subscription.
|
2023-08-31 05:19:37 +10:00
|
|
|
(callback) => {
|
|
|
|
if (
|
|
|
|
result?.data?.sequence &&
|
|
|
|
result?.data.sequence > this.inSequence &&
|
|
|
|
result.type === 'highlight_set_entity'
|
|
|
|
) {
|
|
|
|
this.inSequence = result.data.sequence
|
|
|
|
callback(result)
|
|
|
|
}
|
|
|
|
}
|
2023-08-25 00:16:37 -04:00
|
|
|
)
|
2023-08-18 16:16:16 -04:00
|
|
|
})
|
|
|
|
})
|
|
|
|
|
|
|
|
// When the EngineConnection starts a connection, we want to register
|
|
|
|
// callbacks into the WebSocket/PeerConnection.
|
2023-08-24 23:46:45 +10:00
|
|
|
engineConnection.websocket?.addEventListener('message', (event) => {
|
2023-08-18 16:16:16 -04:00
|
|
|
if (event.data instanceof ArrayBuffer) {
|
|
|
|
// If the data is an ArrayBuffer, it's the result of an export command,
|
|
|
|
// because in all other cases we send JSON strings. But in the case of
|
|
|
|
// export we send a binary blob.
|
|
|
|
// Pass this to our export function.
|
|
|
|
exportSave(event.data)
|
|
|
|
} else {
|
2023-08-25 00:16:37 -04:00
|
|
|
const message: Models['WebSocketResponse_type'] = JSON.parse(
|
|
|
|
event.data
|
|
|
|
)
|
|
|
|
if (
|
|
|
|
message.success &&
|
|
|
|
message.resp.type === 'modeling' &&
|
|
|
|
message.request_id
|
|
|
|
) {
|
|
|
|
this.handleModelingCommand(message.resp, message.request_id)
|
2023-10-05 00:18:50 -05:00
|
|
|
} else if (
|
|
|
|
!message.success &&
|
|
|
|
message.request_id &&
|
|
|
|
this.artifactMap[message.request_id]
|
|
|
|
) {
|
2023-09-21 14:32:47 +10:00
|
|
|
this.handleFailedModelingCommand(message)
|
2023-08-09 20:49:10 +10:00
|
|
|
}
|
|
|
|
}
|
2023-08-18 16:16:16 -04:00
|
|
|
})
|
2023-08-24 23:46:45 +10:00
|
|
|
},
|
|
|
|
onNewTrack: ({ mediaStream }) => {
|
2023-08-21 16:53:31 -04:00
|
|
|
console.log('received track', mediaStream)
|
2023-08-22 20:07:39 -04:00
|
|
|
|
|
|
|
mediaStream.getVideoTracks()[0].addEventListener('mute', () => {
|
|
|
|
console.log('peer is not sending video to us')
|
2023-08-31 22:57:58 -04:00
|
|
|
// this.engineConnection?.close()
|
|
|
|
// this.engineConnection?.connect()
|
2023-08-22 20:07:39 -04:00
|
|
|
})
|
|
|
|
|
2023-08-21 16:53:31 -04:00
|
|
|
setMediaStream(mediaStream)
|
2023-08-24 23:46:45 +10:00
|
|
|
},
|
|
|
|
})
|
|
|
|
|
2023-08-18 16:16:16 -04:00
|
|
|
this.engineConnection?.connect()
|
2023-06-22 16:43:33 +10:00
|
|
|
}
|
2023-09-25 19:49:53 -07:00
|
|
|
handleResize({
|
|
|
|
streamWidth,
|
|
|
|
streamHeight,
|
|
|
|
}: {
|
|
|
|
streamWidth: number
|
|
|
|
streamHeight: number
|
|
|
|
}) {
|
|
|
|
if (!this.engineConnection?.isReady()) {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
const resizeCmd: EngineCommand = {
|
|
|
|
type: 'modeling_cmd_req',
|
|
|
|
cmd_id: uuidv4(),
|
|
|
|
cmd: {
|
|
|
|
type: 'reconfigure_stream',
|
|
|
|
width: streamWidth,
|
|
|
|
height: streamHeight,
|
|
|
|
fps: 60,
|
|
|
|
},
|
|
|
|
}
|
|
|
|
this.engineConnection?.send(resizeCmd)
|
|
|
|
}
|
2023-08-25 00:16:37 -04:00
|
|
|
handleModelingCommand(message: WebSocketResponse, id: string) {
|
2023-08-21 16:53:31 -04:00
|
|
|
if (message.type !== 'modeling') {
|
|
|
|
return
|
|
|
|
}
|
2023-08-25 00:16:37 -04:00
|
|
|
const modelingResponse = message.data.modeling_response
|
2023-11-28 21:23:20 +11:00
|
|
|
const command = this.artifactMap[id]
|
2023-11-24 08:59:24 +11:00
|
|
|
this.addCommandLog({
|
|
|
|
type: 'receive-reliable',
|
|
|
|
data: message,
|
|
|
|
id,
|
2023-11-28 21:23:20 +11:00
|
|
|
cmd_type: command?.commandType || this.lastArtifactMap[id]?.commandType,
|
2023-11-24 08:59:24 +11:00
|
|
|
})
|
2023-08-31 05:19:37 +10:00
|
|
|
Object.values(this.subscriptions[modelingResponse.type] || {}).forEach(
|
|
|
|
(callback) => callback(modelingResponse)
|
|
|
|
)
|
2023-08-21 16:53:31 -04:00
|
|
|
|
|
|
|
if (command && command.type === 'pending') {
|
|
|
|
const resolve = command.resolve
|
|
|
|
this.artifactMap[id] = {
|
|
|
|
type: 'result',
|
2023-09-08 17:50:37 +10:00
|
|
|
range: command.range,
|
|
|
|
commandType: command.commandType,
|
|
|
|
parentId: command.parentId ? command.parentId : undefined,
|
2023-08-25 00:16:37 -04:00
|
|
|
data: modelingResponse,
|
2023-09-17 21:57:43 -07:00
|
|
|
raw: message,
|
2023-08-21 16:53:31 -04:00
|
|
|
}
|
|
|
|
resolve({
|
|
|
|
id,
|
2023-09-08 17:50:37 +10:00
|
|
|
commandType: command.commandType,
|
|
|
|
range: command.range,
|
2023-08-31 05:19:37 +10:00
|
|
|
data: modelingResponse,
|
2023-09-17 21:57:43 -07:00
|
|
|
raw: message,
|
2023-08-21 16:53:31 -04:00
|
|
|
})
|
|
|
|
} else {
|
|
|
|
this.artifactMap[id] = {
|
|
|
|
type: 'result',
|
2023-09-08 17:50:37 +10:00
|
|
|
commandType: command?.commandType,
|
|
|
|
range: command?.range,
|
2023-08-25 00:16:37 -04:00
|
|
|
data: modelingResponse,
|
2023-09-17 21:57:43 -07:00
|
|
|
raw: message,
|
2023-08-21 16:53:31 -04:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
2023-09-21 14:32:47 +10:00
|
|
|
handleFailedModelingCommand({
|
|
|
|
request_id,
|
|
|
|
errors,
|
|
|
|
}: Models['FailureWebSocketResponse_type']) {
|
|
|
|
const id = request_id
|
|
|
|
if (!id) return
|
|
|
|
const command = this.artifactMap[id]
|
|
|
|
if (command && command.type === 'pending') {
|
|
|
|
const resolve = command.resolve
|
|
|
|
this.artifactMap[id] = {
|
|
|
|
type: 'failed',
|
|
|
|
range: command.range,
|
|
|
|
commandType: command.commandType,
|
|
|
|
parentId: command.parentId ? command.parentId : undefined,
|
|
|
|
errors,
|
|
|
|
}
|
|
|
|
resolve({
|
|
|
|
id,
|
|
|
|
commandType: command.commandType,
|
|
|
|
range: command.range,
|
|
|
|
errors,
|
|
|
|
})
|
|
|
|
} else {
|
|
|
|
this.artifactMap[id] = {
|
|
|
|
type: 'failed',
|
|
|
|
range: command.range,
|
|
|
|
commandType: command.commandType,
|
|
|
|
parentId: command.parentId ? command.parentId : undefined,
|
|
|
|
errors,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
2023-06-23 09:56:37 +10:00
|
|
|
tearDown() {
|
2023-09-21 12:07:47 -04:00
|
|
|
this.engineConnection?.tearDown()
|
2023-06-23 09:56:37 +10:00
|
|
|
}
|
2023-06-22 16:43:33 +10:00
|
|
|
startNewSession() {
|
2023-11-28 21:23:20 +11:00
|
|
|
this.lastArtifactMap = this.artifactMap
|
2023-06-22 16:43:33 +10:00
|
|
|
this.artifactMap = {}
|
|
|
|
}
|
2023-08-31 05:19:37 +10:00
|
|
|
subscribeTo<T extends ModelTypes>({
|
|
|
|
event,
|
|
|
|
callback,
|
|
|
|
}: Subscription<T>): () => void {
|
|
|
|
const localUnsubscribeId = uuidv4()
|
|
|
|
const otherEventCallbacks = this.subscriptions[event]
|
|
|
|
if (otherEventCallbacks) {
|
|
|
|
otherEventCallbacks[localUnsubscribeId] = callback
|
|
|
|
} else {
|
|
|
|
this.subscriptions[event] = {
|
|
|
|
[localUnsubscribeId]: callback,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return () => this.unSubscribeTo(event, localUnsubscribeId)
|
|
|
|
}
|
|
|
|
private unSubscribeTo(event: ModelTypes, id: string) {
|
|
|
|
delete this.subscriptions[event][id]
|
|
|
|
}
|
2023-08-31 07:39:03 +10:00
|
|
|
subscribeToUnreliable<T extends UnreliableResponses['type']>({
|
2023-08-31 05:19:37 +10:00
|
|
|
event,
|
|
|
|
callback,
|
2023-08-31 07:39:03 +10:00
|
|
|
}: UnreliableSubscription<T>): () => void {
|
2023-08-31 05:19:37 +10:00
|
|
|
const localUnsubscribeId = uuidv4()
|
2023-08-31 07:39:03 +10:00
|
|
|
const otherEventCallbacks = this.unreliableSubscriptions[event]
|
2023-08-31 05:19:37 +10:00
|
|
|
if (otherEventCallbacks) {
|
|
|
|
otherEventCallbacks[localUnsubscribeId] = callback
|
|
|
|
} else {
|
2023-08-31 07:39:03 +10:00
|
|
|
this.unreliableSubscriptions[event] = {
|
2023-08-31 05:19:37 +10:00
|
|
|
[localUnsubscribeId]: callback,
|
|
|
|
}
|
|
|
|
}
|
2023-08-31 07:39:03 +10:00
|
|
|
return () => this.unSubscribeToUnreliable(event, localUnsubscribeId)
|
2023-08-31 05:19:37 +10:00
|
|
|
}
|
2023-08-31 07:39:03 +10:00
|
|
|
private unSubscribeToUnreliable(
|
|
|
|
event: UnreliableResponses['type'],
|
|
|
|
id: string
|
|
|
|
) {
|
|
|
|
delete this.unreliableSubscriptions[event][id]
|
2023-08-31 05:19:37 +10:00
|
|
|
}
|
2023-06-22 16:43:33 +10:00
|
|
|
endSession() {
|
2023-09-08 17:50:37 +10:00
|
|
|
// TODO: instead of sending a single command with `object_ids: Object.keys(this.artifactMap)`
|
2023-11-01 17:34:54 -05:00
|
|
|
// we need to loop over them each individually because if the engine doesn't recognise a single
|
2023-09-08 17:50:37 +10:00
|
|
|
// id the whole command fails.
|
2023-11-06 11:49:13 +11:00
|
|
|
const artifactsToDelete: any = {}
|
2023-09-08 17:50:37 +10:00
|
|
|
Object.entries(this.artifactMap).forEach(([id, artifact]) => {
|
|
|
|
const artifactTypesToDelete: ArtifactMap[string]['commandType'][] = [
|
|
|
|
// 'start_path' creates a new scene object for the path, which is why it needs to be deleted,
|
|
|
|
// however all of the segments in the path are its children so there don't need to be deleted.
|
|
|
|
// this fact is very opaque in the api and docs (as to what should can be deleted).
|
|
|
|
// Using an array is the list is likely to grow.
|
|
|
|
'start_path',
|
|
|
|
]
|
|
|
|
if (!artifactTypesToDelete.includes(artifact.commandType)) return
|
2023-11-06 11:49:13 +11:00
|
|
|
artifactsToDelete[id] = artifact
|
|
|
|
})
|
|
|
|
Object.keys(artifactsToDelete).forEach((id) => {
|
2023-09-08 17:50:37 +10:00
|
|
|
const deletCmd: EngineCommand = {
|
|
|
|
type: 'modeling_cmd_req',
|
|
|
|
cmd_id: uuidv4(),
|
|
|
|
cmd: {
|
|
|
|
type: 'remove_scene_objects',
|
|
|
|
object_ids: [id],
|
|
|
|
},
|
|
|
|
}
|
|
|
|
this.engineConnection?.send(deletCmd)
|
|
|
|
})
|
2023-06-22 16:43:33 +10:00
|
|
|
}
|
2023-11-24 08:59:24 +11:00
|
|
|
addCommandLog(message: CommandLog) {
|
|
|
|
if (this._commandLogs.length > 500) {
|
|
|
|
this._commandLogs.shift()
|
|
|
|
}
|
|
|
|
this._commandLogs.push(message)
|
|
|
|
|
|
|
|
this._commandLogCallBack([...this._commandLogs])
|
|
|
|
}
|
|
|
|
clearCommandLogs() {
|
|
|
|
this._commandLogs = []
|
|
|
|
this._commandLogCallBack(this._commandLogs)
|
|
|
|
}
|
|
|
|
registerCommandLogCallback(callback: (command: CommandLog[]) => void) {
|
|
|
|
this._commandLogCallBack = callback
|
|
|
|
}
|
2023-08-31 05:19:37 +10:00
|
|
|
sendSceneCommand(command: EngineCommand): Promise<any> {
|
2023-09-25 19:49:53 -07:00
|
|
|
if (this.engineConnection === undefined) {
|
|
|
|
return Promise.resolve()
|
|
|
|
}
|
2023-09-29 12:41:58 -07:00
|
|
|
|
|
|
|
if (!this.engineConnection?.isReady()) {
|
|
|
|
return Promise.resolve()
|
|
|
|
}
|
|
|
|
|
2023-11-24 08:59:24 +11:00
|
|
|
if (
|
|
|
|
!(
|
|
|
|
command.type === 'modeling_cmd_req' &&
|
|
|
|
(command.cmd.type === 'highlight_set_entity' ||
|
2023-12-18 05:10:31 +11:00
|
|
|
command.cmd.type === 'mouse_move' ||
|
|
|
|
command.cmd.type === 'camera_drag_move')
|
2023-11-24 08:59:24 +11:00
|
|
|
)
|
|
|
|
) {
|
2023-12-18 05:10:31 +11:00
|
|
|
// highlight_set_entity, mouse_move and camera_drag_move are sent over the unreliable channel and are too noisy
|
2023-11-24 08:59:24 +11:00
|
|
|
this.addCommandLog({
|
|
|
|
type: 'send-scene',
|
|
|
|
data: command,
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
2023-09-13 08:36:47 +10:00
|
|
|
if (
|
|
|
|
command.type === 'modeling_cmd_req' &&
|
|
|
|
command.cmd.type !== lastMessage
|
|
|
|
) {
|
|
|
|
console.log('sending command', command.cmd.type)
|
|
|
|
lastMessage = command.cmd.type
|
|
|
|
}
|
2023-08-31 05:19:37 +10:00
|
|
|
if (command.type !== 'modeling_cmd_req') return Promise.resolve()
|
2023-08-02 15:41:59 +10:00
|
|
|
const cmd = command.cmd
|
2023-08-18 16:16:16 -04:00
|
|
|
if (
|
2023-09-13 17:42:42 +10:00
|
|
|
(cmd.type === 'camera_drag_move' ||
|
|
|
|
cmd.type === 'handle_mouse_drag_move') &&
|
2023-08-31 07:39:03 +10:00
|
|
|
this.engineConnection?.unreliableDataChannel
|
2023-08-18 16:16:16 -04:00
|
|
|
) {
|
2023-08-09 20:49:10 +10:00
|
|
|
cmd.sequence = this.outSequence
|
|
|
|
this.outSequence++
|
2023-09-15 17:14:58 -04:00
|
|
|
this.engineConnection?.unreliableSend(command)
|
2023-08-31 05:19:37 +10:00
|
|
|
return Promise.resolve()
|
2023-08-18 16:16:16 -04:00
|
|
|
} else if (
|
|
|
|
cmd.type === 'highlight_set_entity' &&
|
2023-08-31 07:39:03 +10:00
|
|
|
this.engineConnection?.unreliableDataChannel
|
2023-08-18 16:16:16 -04:00
|
|
|
) {
|
2023-08-09 20:49:10 +10:00
|
|
|
cmd.sequence = this.outSequence
|
|
|
|
this.outSequence++
|
2023-09-15 17:14:58 -04:00
|
|
|
this.engineConnection?.unreliableSend(command)
|
2023-08-31 05:19:37 +10:00
|
|
|
return Promise.resolve()
|
2023-09-08 17:50:37 +10:00
|
|
|
} else if (
|
|
|
|
cmd.type === 'mouse_move' &&
|
|
|
|
this.engineConnection.unreliableDataChannel
|
|
|
|
) {
|
|
|
|
cmd.sequence = this.outSequence
|
|
|
|
this.outSequence++
|
2023-09-15 17:14:58 -04:00
|
|
|
this.engineConnection?.unreliableSend(command)
|
2023-09-08 17:50:37 +10:00
|
|
|
return Promise.resolve()
|
2023-06-23 09:56:37 +10:00
|
|
|
}
|
2023-08-31 05:19:37 +10:00
|
|
|
// since it's not mouse drag or highlighting send over TCP and keep track of the command
|
2023-08-21 16:53:31 -04:00
|
|
|
this.engineConnection?.send(command)
|
2023-09-08 17:50:37 +10:00
|
|
|
return this.handlePendingCommand(command.cmd_id, command.cmd)
|
2023-06-22 16:43:33 +10:00
|
|
|
}
|
2023-08-24 11:41:38 -07:00
|
|
|
sendModelingCommand({
|
2023-06-22 16:43:33 +10:00
|
|
|
id,
|
|
|
|
range,
|
|
|
|
command,
|
|
|
|
}: {
|
|
|
|
id: string
|
|
|
|
range: SourceRange
|
2023-08-31 14:44:22 +10:00
|
|
|
command: EngineCommand | string
|
2023-06-22 16:43:33 +10:00
|
|
|
}): Promise<any> {
|
2023-09-25 19:49:53 -07:00
|
|
|
if (this.engineConnection === undefined) {
|
|
|
|
return Promise.resolve()
|
|
|
|
}
|
2023-06-22 16:43:33 +10:00
|
|
|
|
2023-08-21 16:53:31 -04:00
|
|
|
if (!this.engineConnection?.isReady()) {
|
2023-08-31 05:19:37 +10:00
|
|
|
return Promise.resolve()
|
2023-06-22 16:43:33 +10:00
|
|
|
}
|
2023-11-24 08:59:24 +11:00
|
|
|
if (typeof command !== 'string') {
|
|
|
|
this.addCommandLog({
|
|
|
|
type: 'send-modeling',
|
|
|
|
data: command,
|
|
|
|
})
|
|
|
|
} else {
|
|
|
|
this.addCommandLog({
|
|
|
|
type: 'send-modeling',
|
|
|
|
data: JSON.parse(command),
|
|
|
|
})
|
|
|
|
}
|
2023-08-21 16:53:31 -04:00
|
|
|
this.engineConnection?.send(command)
|
2023-09-08 17:50:37 +10:00
|
|
|
if (typeof command !== 'string' && command.type === 'modeling_cmd_req') {
|
|
|
|
return this.handlePendingCommand(id, command?.cmd, range)
|
|
|
|
} else if (typeof command === 'string') {
|
|
|
|
const parseCommand: EngineCommand = JSON.parse(command)
|
|
|
|
if (parseCommand.type === 'modeling_cmd_req')
|
|
|
|
return this.handlePendingCommand(id, parseCommand?.cmd, range)
|
|
|
|
}
|
2023-11-01 07:39:31 -04:00
|
|
|
throw Error('shouldnt reach here')
|
2023-08-31 05:19:37 +10:00
|
|
|
}
|
2023-09-08 17:50:37 +10:00
|
|
|
handlePendingCommand(
|
|
|
|
id: string,
|
|
|
|
command: Models['ModelingCmd_type'],
|
|
|
|
range?: SourceRange
|
|
|
|
) {
|
2023-06-22 16:43:33 +10:00
|
|
|
let resolve: (val: any) => void = () => {}
|
|
|
|
const promise = new Promise((_resolve, reject) => {
|
|
|
|
resolve = _resolve
|
|
|
|
})
|
2023-09-08 17:50:37 +10:00
|
|
|
const getParentId = (): string | undefined => {
|
|
|
|
if (command.type === 'extend_path') {
|
|
|
|
return command.path
|
|
|
|
}
|
|
|
|
// TODO handle other commands that have a parent
|
|
|
|
}
|
2023-06-22 16:43:33 +10:00
|
|
|
this.artifactMap[id] = {
|
2023-09-08 17:50:37 +10:00
|
|
|
range: range || [0, 0],
|
2023-06-22 16:43:33 +10:00
|
|
|
type: 'pending',
|
2023-09-08 17:50:37 +10:00
|
|
|
commandType: command.type,
|
|
|
|
parentId: getParentId(),
|
2023-06-22 16:43:33 +10:00
|
|
|
promise,
|
|
|
|
resolve,
|
|
|
|
}
|
|
|
|
return promise
|
|
|
|
}
|
2023-08-24 15:34:51 -07:00
|
|
|
sendModelingCommandFromWasm(
|
|
|
|
id: string,
|
|
|
|
rangeStr: string,
|
|
|
|
commandStr: string
|
|
|
|
): Promise<any> {
|
2023-09-25 19:49:53 -07:00
|
|
|
if (this.engineConnection === undefined) {
|
|
|
|
return Promise.resolve()
|
|
|
|
}
|
2023-08-24 15:34:51 -07:00
|
|
|
if (id === undefined) {
|
|
|
|
throw new Error('id is undefined')
|
|
|
|
}
|
|
|
|
if (rangeStr === undefined) {
|
|
|
|
throw new Error('rangeStr is undefined')
|
|
|
|
}
|
|
|
|
if (commandStr === undefined) {
|
|
|
|
throw new Error('commandStr is undefined')
|
|
|
|
}
|
|
|
|
const range: SourceRange = JSON.parse(rangeStr)
|
|
|
|
|
2023-09-17 21:57:43 -07:00
|
|
|
// We only care about the modeling command response.
|
|
|
|
return this.sendModelingCommand({ id, range, command: commandStr }).then(
|
|
|
|
({ raw }) => JSON.stringify(raw)
|
|
|
|
)
|
2023-08-24 15:34:51 -07:00
|
|
|
}
|
2023-06-22 16:43:33 +10:00
|
|
|
commandResult(id: string): Promise<any> {
|
|
|
|
const command = this.artifactMap[id]
|
|
|
|
if (!command) {
|
|
|
|
throw new Error('No command found')
|
|
|
|
}
|
|
|
|
if (command.type === 'result') {
|
|
|
|
return command.data
|
2023-09-21 14:32:47 +10:00
|
|
|
} else if (command.type === 'failed') {
|
|
|
|
return Promise.resolve(command.errors)
|
2023-06-22 16:43:33 +10:00
|
|
|
}
|
|
|
|
return command.promise
|
|
|
|
}
|
2023-10-14 03:47:46 +11:00
|
|
|
async waitForAllCommands(): Promise<{
|
2023-06-22 16:43:33 +10:00
|
|
|
artifactMap: ArtifactMap
|
|
|
|
}> {
|
|
|
|
const pendingCommands = Object.values(this.artifactMap).filter(
|
|
|
|
({ type }) => type === 'pending'
|
|
|
|
) as PendingCommand[]
|
|
|
|
const proms = pendingCommands.map(({ promise }) => promise)
|
|
|
|
await Promise.all(proms)
|
2023-09-14 13:49:59 +10:00
|
|
|
|
2023-06-22 16:43:33 +10:00
|
|
|
return {
|
|
|
|
artifactMap: this.artifactMap,
|
|
|
|
}
|
|
|
|
}
|
2023-10-11 13:36:54 +11:00
|
|
|
private async initPlanes() {
|
|
|
|
const [xy, yz, xz] = [
|
|
|
|
await this.createPlane({
|
|
|
|
x_axis: { x: 1, y: 0, z: 0 },
|
|
|
|
y_axis: { x: 0, y: 1, z: 0 },
|
|
|
|
color: { r: 0.7, g: 0.28, b: 0.28, a: 0.4 },
|
|
|
|
}),
|
|
|
|
await this.createPlane({
|
|
|
|
x_axis: { x: 0, y: 1, z: 0 },
|
|
|
|
y_axis: { x: 0, y: 0, z: 1 },
|
|
|
|
color: { r: 0.28, g: 0.7, b: 0.28, a: 0.4 },
|
|
|
|
}),
|
|
|
|
await this.createPlane({
|
|
|
|
x_axis: { x: 1, y: 0, z: 0 },
|
|
|
|
y_axis: { x: 0, y: 0, z: 1 },
|
|
|
|
color: { r: 0.28, g: 0.28, b: 0.7, a: 0.4 },
|
|
|
|
}),
|
|
|
|
]
|
|
|
|
this.defaultPlanes = { xy, yz, xz }
|
|
|
|
|
|
|
|
this.subscribeTo({
|
|
|
|
event: 'select_with_point',
|
|
|
|
callback: ({ data }) => {
|
|
|
|
if (!data?.entity_id) return
|
|
|
|
if (
|
|
|
|
![
|
|
|
|
this.defaultPlanes.xy,
|
|
|
|
this.defaultPlanes.yz,
|
|
|
|
this.defaultPlanes.xz,
|
|
|
|
].includes(data.entity_id)
|
|
|
|
)
|
|
|
|
return
|
|
|
|
this.onPlaneSelectCallback(data.entity_id)
|
|
|
|
},
|
|
|
|
})
|
|
|
|
}
|
2023-10-11 10:40:54 -07:00
|
|
|
planesInitialized(): boolean {
|
|
|
|
return (
|
|
|
|
this.defaultPlanes.xy !== '' &&
|
|
|
|
this.defaultPlanes.yz !== '' &&
|
|
|
|
this.defaultPlanes.xz !== ''
|
|
|
|
)
|
|
|
|
}
|
2023-10-11 13:36:54 +11:00
|
|
|
|
|
|
|
onPlaneSelectCallback = (id: string) => {}
|
|
|
|
onPlaneSelected(callback: (id: string) => void) {
|
|
|
|
this.onPlaneSelectCallback = callback
|
|
|
|
}
|
|
|
|
|
|
|
|
async setPlaneHidden(id: string, hidden: boolean): Promise<string> {
|
|
|
|
return await this.sendSceneCommand({
|
|
|
|
type: 'modeling_cmd_req',
|
|
|
|
cmd_id: uuidv4(),
|
|
|
|
cmd: {
|
|
|
|
type: 'object_visible',
|
|
|
|
object_id: id,
|
|
|
|
hidden: hidden,
|
|
|
|
},
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
private async createPlane({
|
|
|
|
x_axis,
|
|
|
|
y_axis,
|
|
|
|
color,
|
|
|
|
}: {
|
|
|
|
x_axis: Models['Point3d_type']
|
|
|
|
y_axis: Models['Point3d_type']
|
|
|
|
color: Models['Color_type']
|
|
|
|
}): Promise<string> {
|
|
|
|
const planeId = uuidv4()
|
|
|
|
await this.sendSceneCommand({
|
|
|
|
type: 'modeling_cmd_req',
|
|
|
|
cmd: {
|
|
|
|
type: 'make_plane',
|
2023-12-05 14:59:50 +11:00
|
|
|
size: 100,
|
2023-10-11 13:36:54 +11:00
|
|
|
origin: { x: 0, y: 0, z: 0 },
|
|
|
|
x_axis,
|
|
|
|
y_axis,
|
|
|
|
clobber: false,
|
|
|
|
hide: true,
|
|
|
|
},
|
|
|
|
cmd_id: planeId,
|
|
|
|
})
|
|
|
|
await this.sendSceneCommand({
|
|
|
|
type: 'modeling_cmd_req',
|
|
|
|
cmd: {
|
|
|
|
type: 'plane_set_color',
|
|
|
|
plane_id: planeId,
|
|
|
|
color,
|
|
|
|
},
|
|
|
|
cmd_id: uuidv4(),
|
|
|
|
})
|
|
|
|
await this.setPlaneHidden(planeId, true)
|
|
|
|
return planeId
|
|
|
|
}
|
2023-06-22 16:43:33 +10:00
|
|
|
}
|
2023-09-25 19:49:53 -07:00
|
|
|
|
|
|
|
export const engineCommandManager = new EngineCommandManager()
|