2024-02-11 12:59:00 +11:00
|
|
|
import { PathToNode, Program, SourceRange } from 'lang/wasm'
|
2024-06-04 08:32:24 -04:00
|
|
|
import { VITE_KC_API_WS_MODELING_URL } 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'
|
2024-04-03 19:38:16 +11:00
|
|
|
import { uuidv4 } from 'lib/utils'
|
2024-02-11 12:59:00 +11:00
|
|
|
import { getNodePathFromSourceRange } from 'lang/queryAst'
|
2024-04-24 13:59:25 -07:00
|
|
|
import { Themes, getThemeColorForEngine, getOppositeTheme } from 'lib/theme'
|
2024-04-15 17:18:32 -07: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 = ''
|
|
|
|
|
2024-06-04 08:32:24 -04:00
|
|
|
// TODO(paultag): This ought to be tweakable.
|
|
|
|
const pingIntervalMs = 10000
|
|
|
|
|
2024-06-04 13:57:01 -04:00
|
|
|
type CommandTypes = Models['ModelingCmd_type']['type'] | 'batch'
|
|
|
|
|
|
|
|
type CommandInfo =
|
|
|
|
| {
|
|
|
|
commandType: 'extrude'
|
|
|
|
// commandType: CommandTypes
|
|
|
|
range: SourceRange
|
|
|
|
pathToNode: PathToNode
|
|
|
|
/// uuid of the entity to extrude
|
|
|
|
target: string
|
|
|
|
parentId?: string
|
|
|
|
}
|
|
|
|
| {
|
|
|
|
commandType: 'start_path'
|
|
|
|
// commandType: CommandTypes
|
|
|
|
range: SourceRange
|
|
|
|
pathToNode: PathToNode
|
|
|
|
/// uuid of the entity that have been extruded
|
|
|
|
extrusions: string[]
|
|
|
|
parentId?: string
|
|
|
|
}
|
|
|
|
| {
|
|
|
|
commandType: CommandTypes
|
|
|
|
range: SourceRange
|
|
|
|
pathToNode: PathToNode
|
|
|
|
parentId?: string
|
|
|
|
additionalData?:
|
|
|
|
| {
|
|
|
|
type: 'cap'
|
|
|
|
info: 'start' | 'end'
|
|
|
|
}
|
|
|
|
| {
|
|
|
|
type: 'batch-ids'
|
|
|
|
ids: string[]
|
|
|
|
info?: null
|
|
|
|
}
|
|
|
|
}
|
2023-09-17 21:57:43 -07:00
|
|
|
|
2024-06-04 08:32:24 -04:00
|
|
|
function isHighlightSetEntity_type(
|
|
|
|
data: any
|
|
|
|
): data is Models['HighlightSetEntity_type'] {
|
|
|
|
return data.entity_id && data.sequence
|
|
|
|
}
|
|
|
|
|
2024-04-09 11:51:41 -07:00
|
|
|
type WebSocketResponse = Models['WebSocketResponse_type']
|
|
|
|
type OkWebSocketResponseData = Models['OkWebSocketResponseData_type']
|
2024-06-19 13:57:50 -07:00
|
|
|
type BatchResponseMap = {
|
|
|
|
[key: string]: Models['BatchResponse_type']
|
|
|
|
}
|
2023-09-17 21:57:43 -07:00
|
|
|
|
2024-06-04 13:57:01 -04:00
|
|
|
type ResultCommand = 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
|
|
|
}
|
2024-06-04 13:57:01 -04:00
|
|
|
type FailedCommand = CommandInfo & {
|
2023-09-21 14:32:47 +10:00
|
|
|
type: 'failed'
|
|
|
|
errors: Models['FailureWebSocketResponse_type']['errors']
|
|
|
|
}
|
2024-04-09 11:51:41 -07:00
|
|
|
interface ResolveCommand {
|
|
|
|
id: string
|
|
|
|
commandType: CommandTypes
|
|
|
|
range: SourceRange
|
|
|
|
// We ALWAYS need the raw response because we pass it back to the rust side.
|
|
|
|
raw: WebSocketResponse
|
|
|
|
data?: Models['OkModelingCmdResponse_type']
|
|
|
|
errors?: Models['FailureWebSocketResponse_type']['errors']
|
|
|
|
}
|
2024-06-04 13:57:01 -04:00
|
|
|
type PendingCommand = CommandInfo & {
|
2023-06-22 16:43:33 +10:00
|
|
|
type: 'pending'
|
|
|
|
promise: Promise<any>
|
2024-04-09 11:51:41 -07:00
|
|
|
resolve: (val: ResolveCommand) => void
|
2023-06-22 16:43:33 +10:00
|
|
|
}
|
|
|
|
|
2024-06-04 13:57:01 -04:00
|
|
|
export type ArtifactMapCommand = ResultCommand | PendingCommand | FailedCommand
|
|
|
|
|
2024-05-20 13:38:51 -04:00
|
|
|
/**
|
|
|
|
* The ArtifactMap is a client-side representation of the artifacts that
|
|
|
|
* have been sent to the server-side engine. It is used to keep track of
|
|
|
|
* the state of each command, and to resolve the promise that was returned.
|
|
|
|
* It is also used to keep track of what entities are in the engine scene,
|
|
|
|
* so that we can associate IDs returned from the engine with the
|
|
|
|
* lines of KCL code that generated them.
|
|
|
|
*/
|
2023-06-22 16:43:33 +10:00
|
|
|
export interface ArtifactMap {
|
2024-06-04 13:57:01 -04:00
|
|
|
[commandId: string]: ArtifactMapCommand
|
2023-06-22 16:43:33 +10:00
|
|
|
}
|
|
|
|
|
2024-03-19 20:39:49 -04:00
|
|
|
interface NewTrackArgs {
|
|
|
|
conn: EngineConnection
|
|
|
|
mediaStream: MediaStream
|
|
|
|
}
|
|
|
|
|
2024-05-20 13:38:51 -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 IsomorphicTimeout = ReturnType<typeof setTimeout>
|
2023-09-21 12:07:47 -04:00
|
|
|
|
2023-09-10 19:04:46 -04:00
|
|
|
type ClientMetrics = Models['ClientMetrics_type']
|
|
|
|
|
2024-04-09 18:05:36 -07:00
|
|
|
interface WebRTCClientMetrics extends ClientMetrics {
|
|
|
|
rtc_frame_height: number
|
|
|
|
rtc_frame_width: number
|
|
|
|
rtc_packets_lost: number
|
|
|
|
rtc_pli_count: number
|
|
|
|
rtc_pause_count: number
|
|
|
|
rtc_total_pauses_duration_sec: number
|
|
|
|
}
|
|
|
|
|
2024-01-23 13:13:43 -05:00
|
|
|
type Value<T, U> = U extends undefined
|
|
|
|
? { type: T; value: U }
|
|
|
|
: U extends void
|
|
|
|
? { type: T }
|
|
|
|
: { type: T; value: U }
|
|
|
|
|
|
|
|
type State<T, U> = Value<T, U>
|
|
|
|
|
2024-02-12 16:00:31 -05:00
|
|
|
export enum EngineConnectionStateType {
|
2024-01-23 13:13:43 -05:00
|
|
|
Fresh = 'fresh',
|
|
|
|
Connecting = 'connecting',
|
|
|
|
ConnectionEstablished = 'connection-established',
|
2024-02-12 16:00:31 -05:00
|
|
|
Disconnecting = 'disconnecting',
|
2024-01-23 13:13:43 -05:00
|
|
|
Disconnected = 'disconnected',
|
|
|
|
}
|
|
|
|
|
2024-02-12 16:00:31 -05:00
|
|
|
export enum DisconnectingType {
|
2024-01-23 13:13:43 -05:00
|
|
|
Error = 'error',
|
|
|
|
Timeout = 'timeout',
|
|
|
|
Quit = 'quit',
|
|
|
|
}
|
|
|
|
|
2024-06-04 08:32:24 -04:00
|
|
|
// Sorted by severity
|
|
|
|
export enum ConnectionError {
|
|
|
|
Unset = 0,
|
|
|
|
LongLoadingTime,
|
|
|
|
|
|
|
|
ICENegotiate,
|
|
|
|
DataChannelError,
|
|
|
|
WebSocketError,
|
|
|
|
LocalDescriptionInvalid,
|
|
|
|
|
|
|
|
// These are more severe than protocol errors because they don't even allow
|
|
|
|
// the program to do any protocol messages in the first place if they occur.
|
|
|
|
MissingAuthToken,
|
|
|
|
BadAuthToken,
|
|
|
|
TooManyConnections,
|
|
|
|
|
|
|
|
// An unknown error is the most severe because it has not been classified
|
|
|
|
// or encountered before.
|
|
|
|
Unknown,
|
|
|
|
}
|
|
|
|
|
|
|
|
export const CONNECTION_ERROR_TEXT: Record<ConnectionError, string> = {
|
|
|
|
[ConnectionError.Unset]: '',
|
|
|
|
[ConnectionError.LongLoadingTime]:
|
|
|
|
'Loading is taking longer than expected...',
|
|
|
|
[ConnectionError.ICENegotiate]: 'ICE negotiation failed.',
|
|
|
|
[ConnectionError.DataChannelError]: 'The data channel signaled an error.',
|
|
|
|
[ConnectionError.WebSocketError]: 'The websocket signaled an error.',
|
|
|
|
[ConnectionError.LocalDescriptionInvalid]:
|
|
|
|
'The local description is invalid.',
|
|
|
|
[ConnectionError.MissingAuthToken]:
|
|
|
|
'Your authorization token is missing; please login again.',
|
|
|
|
[ConnectionError.BadAuthToken]:
|
|
|
|
'Your authorization token is invalid; please login again.',
|
|
|
|
[ConnectionError.TooManyConnections]: 'There are too many connections.',
|
|
|
|
[ConnectionError.Unknown]:
|
|
|
|
'An unexpected error occurred. Please report this to us.',
|
|
|
|
}
|
|
|
|
|
2024-02-12 16:00:31 -05:00
|
|
|
export interface ErrorType {
|
2024-06-04 08:32:24 -04:00
|
|
|
// The error we've encountered.
|
|
|
|
error: ConnectionError
|
|
|
|
|
|
|
|
// Additional context.
|
|
|
|
context?: any
|
2024-02-12 16:00:31 -05:00
|
|
|
|
|
|
|
// We assign this in the state setter because we may have not failed at
|
|
|
|
// a Connecting state, which we check for there.
|
|
|
|
lastConnectingValue?: ConnectingValue
|
|
|
|
}
|
|
|
|
|
|
|
|
export type DisconnectingValue =
|
|
|
|
| State<DisconnectingType.Error, ErrorType>
|
|
|
|
| State<DisconnectingType.Timeout, void>
|
|
|
|
| State<DisconnectingType.Quit, void>
|
2024-01-23 13:13:43 -05:00
|
|
|
|
|
|
|
// These are ordered by the expected sequence.
|
2024-02-12 16:00:31 -05:00
|
|
|
export enum ConnectingType {
|
2024-01-23 13:13:43 -05:00
|
|
|
WebSocketConnecting = 'websocket-connecting',
|
2024-06-04 08:32:24 -04:00
|
|
|
WebSocketOpen = 'websocket-open',
|
2024-01-23 13:13:43 -05:00
|
|
|
PeerConnectionCreated = 'peer-connection-created',
|
|
|
|
ICEServersSet = 'ice-servers-set',
|
|
|
|
SetLocalDescription = 'set-local-description',
|
|
|
|
OfferedSdp = 'offered-sdp',
|
|
|
|
ReceivedSdp = 'received-sdp',
|
|
|
|
SetRemoteDescription = 'set-remote-description',
|
|
|
|
WebRTCConnecting = 'webrtc-connecting',
|
|
|
|
ICECandidateReceived = 'ice-candidate-received',
|
|
|
|
TrackReceived = 'track-received',
|
|
|
|
DataChannelRequested = 'data-channel-requested',
|
|
|
|
DataChannelConnecting = 'data-channel-connecting',
|
|
|
|
DataChannelEstablished = 'data-channel-established',
|
|
|
|
}
|
|
|
|
|
2024-02-12 16:00:31 -05:00
|
|
|
export enum ConnectingTypeGroup {
|
|
|
|
WebSocket = 'WebSocket',
|
|
|
|
ICE = 'ICE',
|
|
|
|
WebRTC = 'WebRTC',
|
|
|
|
}
|
|
|
|
|
|
|
|
export const initialConnectingTypeGroupState: Record<
|
|
|
|
ConnectingTypeGroup,
|
|
|
|
[ConnectingType, boolean | undefined][]
|
|
|
|
> = {
|
|
|
|
[ConnectingTypeGroup.WebSocket]: [
|
|
|
|
[ConnectingType.WebSocketConnecting, undefined],
|
2024-06-04 08:32:24 -04:00
|
|
|
[ConnectingType.WebSocketOpen, undefined],
|
2024-02-12 16:00:31 -05:00
|
|
|
],
|
|
|
|
[ConnectingTypeGroup.ICE]: [
|
|
|
|
[ConnectingType.PeerConnectionCreated, undefined],
|
|
|
|
[ConnectingType.ICEServersSet, undefined],
|
|
|
|
[ConnectingType.SetLocalDescription, undefined],
|
|
|
|
[ConnectingType.OfferedSdp, undefined],
|
|
|
|
[ConnectingType.ReceivedSdp, undefined],
|
|
|
|
[ConnectingType.SetRemoteDescription, undefined],
|
|
|
|
[ConnectingType.WebRTCConnecting, undefined],
|
|
|
|
[ConnectingType.ICECandidateReceived, undefined],
|
|
|
|
],
|
|
|
|
[ConnectingTypeGroup.WebRTC]: [
|
|
|
|
[ConnectingType.TrackReceived, undefined],
|
|
|
|
[ConnectingType.DataChannelRequested, undefined],
|
|
|
|
[ConnectingType.DataChannelConnecting, undefined],
|
|
|
|
[ConnectingType.DataChannelEstablished, undefined],
|
|
|
|
],
|
|
|
|
}
|
|
|
|
|
|
|
|
export type ConnectingValue =
|
2024-01-23 13:13:43 -05:00
|
|
|
| State<ConnectingType.WebSocketConnecting, void>
|
2024-06-04 08:32:24 -04:00
|
|
|
| State<ConnectingType.WebSocketOpen, void>
|
2024-01-23 13:13:43 -05:00
|
|
|
| State<ConnectingType.PeerConnectionCreated, void>
|
|
|
|
| State<ConnectingType.ICEServersSet, void>
|
|
|
|
| State<ConnectingType.SetLocalDescription, void>
|
|
|
|
| State<ConnectingType.OfferedSdp, void>
|
|
|
|
| State<ConnectingType.ReceivedSdp, void>
|
|
|
|
| State<ConnectingType.SetRemoteDescription, void>
|
|
|
|
| State<ConnectingType.WebRTCConnecting, void>
|
|
|
|
| State<ConnectingType.TrackReceived, void>
|
|
|
|
| State<ConnectingType.ICECandidateReceived, void>
|
|
|
|
| State<ConnectingType.DataChannelRequested, string>
|
|
|
|
| State<ConnectingType.DataChannelConnecting, string>
|
|
|
|
| State<ConnectingType.DataChannelEstablished, void>
|
|
|
|
|
2024-02-12 16:00:31 -05:00
|
|
|
export type EngineConnectionState =
|
2024-01-23 13:13:43 -05:00
|
|
|
| State<EngineConnectionStateType.Fresh, void>
|
|
|
|
| State<EngineConnectionStateType.Connecting, ConnectingValue>
|
|
|
|
| State<EngineConnectionStateType.ConnectionEstablished, void>
|
2024-02-12 16:00:31 -05:00
|
|
|
| State<EngineConnectionStateType.Disconnecting, DisconnectingValue>
|
|
|
|
| State<EngineConnectionStateType.Disconnected, void>
|
2024-01-23 13:13:43 -05:00
|
|
|
|
2024-06-04 08:32:24 -04:00
|
|
|
export type PingPongState = 'OK' | 'TIMEOUT'
|
|
|
|
|
|
|
|
export enum EngineConnectionEvents {
|
|
|
|
// Fires for each ping-pong success or failure.
|
|
|
|
PingPongChanged = 'ping-pong-changed', // (state: PingPongState) => void
|
|
|
|
|
|
|
|
// For now, this is only used by the NetworkHealthIndicator.
|
|
|
|
// We can eventually use it for more, but one step at a time.
|
|
|
|
ConnectionStateChanged = 'connection-state-changed', // (state: EngineConnectionState) => void
|
|
|
|
|
|
|
|
// These are used for the EngineCommandManager and were created
|
|
|
|
// before onConnectionStateChange existed.
|
|
|
|
ConnectionStarted = 'connection-started', // (engineConnection: EngineConnection) => void
|
|
|
|
Opened = 'opened', // (engineConnection: EngineConnection) => void
|
|
|
|
Closed = 'closed', // (engineConnection: EngineConnection) => void
|
|
|
|
NewTrack = 'new-track', // (track: NewTrackArgs) => void
|
|
|
|
}
|
|
|
|
|
|
|
|
// EngineConnection encapsulates the connection(s) to the Engine
|
|
|
|
// for the EngineCommandManager; namely, the underlying WebSocket
|
|
|
|
// and WebRTC connections.
|
|
|
|
class EngineConnection extends EventTarget {
|
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
|
2024-01-23 13:13:43 -05:00
|
|
|
mediaStream?: MediaStream
|
|
|
|
|
|
|
|
private _state: EngineConnectionState = {
|
|
|
|
type: EngineConnectionStateType.Fresh,
|
|
|
|
}
|
|
|
|
|
|
|
|
get state(): EngineConnectionState {
|
|
|
|
return this._state
|
|
|
|
}
|
|
|
|
|
|
|
|
set state(next: EngineConnectionState) {
|
|
|
|
console.log(`${JSON.stringify(this.state)} → ${JSON.stringify(next)}`)
|
2024-02-12 16:00:31 -05:00
|
|
|
|
|
|
|
if (next.type === EngineConnectionStateType.Disconnecting) {
|
2024-01-23 13:13:43 -05:00
|
|
|
const sub = next.value
|
2024-02-12 16:00:31 -05:00
|
|
|
if (sub.type === DisconnectingType.Error) {
|
|
|
|
// Record the last step we failed at.
|
|
|
|
// (Check the current state that we're about to override that
|
|
|
|
// it was a Connecting state.)
|
|
|
|
if (this._state.type === EngineConnectionStateType.Connecting) {
|
2024-06-04 08:32:24 -04:00
|
|
|
if (!sub.value) sub.value = { error: ConnectionError.Unknown }
|
2024-02-12 16:00:31 -05:00
|
|
|
sub.value.lastConnectingValue = this._state.value
|
|
|
|
}
|
|
|
|
|
2024-01-23 13:13:43 -05:00
|
|
|
console.error(sub.value)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
this._state = next
|
2024-06-04 08:32:24 -04:00
|
|
|
|
|
|
|
this.dispatchEvent(
|
|
|
|
new CustomEvent(EngineConnectionEvents.ConnectionStateChanged, {
|
|
|
|
detail: this._state,
|
|
|
|
})
|
|
|
|
)
|
2024-01-23 13:13:43 -05:00
|
|
|
}
|
2023-08-18 16:16:16 -04:00
|
|
|
|
2024-05-20 13:38:51 -04:00
|
|
|
private failedConnTimeout: IsomorphicTimeout | null
|
2023-08-18 16:16:16 -04:00
|
|
|
|
|
|
|
readonly url: string
|
|
|
|
private readonly token?: string
|
2024-02-12 16:00:31 -05:00
|
|
|
|
2024-06-04 08:32:24 -04:00
|
|
|
// TODO: actual type is ClientMetrics
|
2024-04-09 18:05:36 -07:00
|
|
|
public webrtcStatsCollector?: () => Promise<WebRTCClientMetrics>
|
2024-03-22 16:55:30 +11:00
|
|
|
private engineCommandManager: EngineCommandManager
|
2024-03-14 12:18:06 -04:00
|
|
|
|
2024-06-04 08:32:24 -04:00
|
|
|
private pingPongSpan: { ping?: Date; pong?: Date }
|
|
|
|
|
2024-03-19 20:39:49 -04:00
|
|
|
constructor({
|
2024-03-22 16:55:30 +11:00
|
|
|
engineCommandManager,
|
2024-03-19 20:39:49 -04:00
|
|
|
url,
|
|
|
|
token,
|
|
|
|
}: {
|
2024-03-22 16:55:30 +11:00
|
|
|
engineCommandManager: EngineCommandManager
|
2024-03-19 20:39:49 -04:00
|
|
|
url: string
|
|
|
|
token?: string
|
|
|
|
}) {
|
2024-06-04 08:32:24 -04:00
|
|
|
super()
|
|
|
|
|
2024-03-22 16:55:30 +11:00
|
|
|
this.engineCommandManager = engineCommandManager
|
2023-08-18 16:16:16 -04:00
|
|
|
this.url = url
|
|
|
|
this.token = token
|
2023-09-21 12:07:47 -04:00
|
|
|
this.failedConnTimeout = null
|
2024-03-19 20:39:49 -04:00
|
|
|
|
2024-06-04 08:32:24 -04:00
|
|
|
this.pingPongSpan = { ping: undefined, pong: undefined }
|
2023-08-02 16:23:17 -07:00
|
|
|
|
2024-01-23 13:13:43 -05:00
|
|
|
// Without an interval ping, our connection will timeout.
|
2024-06-04 08:32:24 -04:00
|
|
|
setInterval(() => {
|
2024-01-23 13:13:43 -05:00
|
|
|
switch (this.state.type as EngineConnectionStateType) {
|
|
|
|
case EngineConnectionStateType.ConnectionEstablished:
|
2024-06-04 08:32:24 -04:00
|
|
|
// If there was no reply to the last ping, report a timeout.
|
|
|
|
if (this.pingPongSpan.ping && !this.pingPongSpan.pong) {
|
|
|
|
this.dispatchEvent(
|
|
|
|
new CustomEvent(EngineConnectionEvents.PingPongChanged, {
|
|
|
|
detail: 'TIMEOUT',
|
|
|
|
})
|
|
|
|
)
|
|
|
|
// Otherwise check the time between was >= pingIntervalMs,
|
|
|
|
// and if it was, then it's bad network health.
|
|
|
|
} else if (this.pingPongSpan.ping && this.pingPongSpan.pong) {
|
|
|
|
if (
|
|
|
|
Math.abs(
|
|
|
|
this.pingPongSpan.pong.valueOf() -
|
|
|
|
this.pingPongSpan.ping.valueOf()
|
|
|
|
) >= pingIntervalMs
|
|
|
|
) {
|
|
|
|
this.dispatchEvent(
|
|
|
|
new CustomEvent(EngineConnectionEvents.PingPongChanged, {
|
|
|
|
detail: 'TIMEOUT',
|
|
|
|
})
|
|
|
|
)
|
|
|
|
} else {
|
|
|
|
this.dispatchEvent(
|
|
|
|
new CustomEvent(EngineConnectionEvents.PingPongChanged, {
|
|
|
|
detail: 'OK',
|
|
|
|
})
|
|
|
|
)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-01-23 13:13:43 -05:00
|
|
|
this.send({ type: 'ping' })
|
2024-06-04 08:32:24 -04:00
|
|
|
this.pingPongSpan.ping = new Date()
|
|
|
|
this.pingPongSpan.pong = undefined
|
2024-01-23 13:13:43 -05:00
|
|
|
break
|
2024-02-12 16:00:31 -05:00
|
|
|
case EngineConnectionStateType.Disconnecting:
|
2024-01-23 13:13:43 -05:00
|
|
|
case EngineConnectionStateType.Disconnected:
|
2024-06-04 08:32:24 -04:00
|
|
|
// Reconnect if we have disconnected.
|
|
|
|
if (!this.isConnecting()) this.connect(true)
|
2024-01-23 13:13:43 -05:00
|
|
|
break
|
|
|
|
default:
|
2024-06-04 08:32:24 -04:00
|
|
|
if (this.isConnecting()) break
|
|
|
|
// Means we never could do an initial connection. Reconnect everything.
|
|
|
|
if (!this.pingPongSpan.ping) this.connect(true)
|
2024-01-23 13:13:43 -05:00
|
|
|
break
|
2023-08-21 16:53:31 -04:00
|
|
|
}
|
|
|
|
}, pingIntervalMs)
|
2024-03-19 20:39:49 -04:00
|
|
|
|
2024-06-04 08:32:24 -04:00
|
|
|
this.connect()
|
2023-09-21 12:07:47 -04:00
|
|
|
}
|
2024-01-23 13:13:43 -05:00
|
|
|
|
2023-09-21 12:07:47 -04:00
|
|
|
isConnecting() {
|
2024-01-23 13:13:43 -05:00
|
|
|
return this.state.type === EngineConnectionStateType.Connecting
|
2023-08-21 16:53:31 -04:00
|
|
|
}
|
2024-01-23 13:13:43 -05:00
|
|
|
|
2023-08-21 16:53:31 -04:00
|
|
|
isReady() {
|
2024-01-23 13:13:43 -05:00
|
|
|
return this.state.type === EngineConnectionStateType.ConnectionEstablished
|
2023-08-21 16:53:31 -04:00
|
|
|
}
|
2024-01-23 13:13:43 -05:00
|
|
|
|
2023-09-21 12:07:47 -04:00
|
|
|
tearDown() {
|
2024-01-23 13:13:43 -05:00
|
|
|
this.disconnectAll()
|
|
|
|
this.state = {
|
2024-02-12 16:00:31 -05:00
|
|
|
type: EngineConnectionStateType.Disconnecting,
|
|
|
|
value: { type: DisconnectingType.Quit },
|
2024-01-23 13:13:43 -05:00
|
|
|
}
|
2023-09-21 12:07:47 -04:00
|
|
|
}
|
2024-01-23 13:13:43 -05:00
|
|
|
|
2024-05-20 13:38:51 -04:00
|
|
|
/**
|
|
|
|
* Attempts 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.
|
|
|
|
*/
|
2024-06-04 08:32:24 -04:00
|
|
|
connect(reconnecting?: boolean) {
|
2023-09-21 12:07:47 -04:00
|
|
|
if (this.isConnecting() || this.isReady()) {
|
|
|
|
return
|
|
|
|
}
|
2023-08-18 16:16:16 -04:00
|
|
|
|
2024-01-23 13:13:43 -05:00
|
|
|
const createPeerConnection = () => {
|
2024-04-29 10:01:37 -04:00
|
|
|
this.pc = new RTCPeerConnection({
|
|
|
|
bundlePolicy: 'max-bundle',
|
|
|
|
})
|
2024-01-23 13:13:43 -05:00
|
|
|
|
2024-06-04 08:32:24 -04:00
|
|
|
// Other parts of the application expect pc to be initialized when firing.
|
|
|
|
this.dispatchEvent(
|
|
|
|
new CustomEvent(EngineConnectionEvents.ConnectionStarted, {
|
|
|
|
detail: this,
|
|
|
|
})
|
|
|
|
)
|
|
|
|
|
2024-01-23 13:13:43 -05:00
|
|
|
// Data channels MUST BE specified before SDP offers because requesting
|
|
|
|
// them affects what our needs are!
|
|
|
|
const DATACHANNEL_NAME_UMC = 'unreliable_modeling_cmds'
|
|
|
|
this.pc.createDataChannel(DATACHANNEL_NAME_UMC)
|
|
|
|
|
|
|
|
this.state = {
|
|
|
|
type: EngineConnectionStateType.Connecting,
|
|
|
|
value: {
|
|
|
|
type: ConnectingType.DataChannelRequested,
|
|
|
|
value: DATACHANNEL_NAME_UMC,
|
|
|
|
},
|
|
|
|
}
|
|
|
|
|
|
|
|
this.pc.addEventListener('icecandidate', (event) => {
|
|
|
|
if (event.candidate === null) {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
this.state = {
|
|
|
|
type: EngineConnectionStateType.Connecting,
|
|
|
|
value: {
|
|
|
|
type: ConnectingType.ICECandidateReceived,
|
|
|
|
},
|
|
|
|
}
|
|
|
|
|
|
|
|
// Request a candidate to use
|
|
|
|
this.send({
|
|
|
|
type: 'trickle_ice',
|
2024-04-19 13:30:59 -07:00
|
|
|
candidate: {
|
|
|
|
candidate: event.candidate.candidate,
|
|
|
|
sdpMid: event.candidate.sdpMid || undefined,
|
|
|
|
sdpMLineIndex: event.candidate.sdpMLineIndex || undefined,
|
|
|
|
usernameFragment: event.candidate.usernameFragment || undefined,
|
|
|
|
},
|
2024-01-23 13:13:43 -05:00
|
|
|
})
|
|
|
|
})
|
|
|
|
|
|
|
|
this.pc.addEventListener('icecandidateerror', (_event: Event) => {
|
|
|
|
const event = _event as RTCPeerConnectionIceErrorEvent
|
|
|
|
console.warn(
|
|
|
|
`ICE candidate returned an error: ${event.errorCode}: ${event.errorText} for ${event.url}`
|
|
|
|
)
|
2023-08-30 13:14:52 -04:00
|
|
|
})
|
2024-01-23 13:13:43 -05:00
|
|
|
|
|
|
|
// https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/connectionstatechange_event
|
|
|
|
// Event type: generic Event type...
|
|
|
|
this.pc.addEventListener('connectionstatechange', (event: any) => {
|
|
|
|
console.log('connectionstatechange: ' + event.target?.connectionState)
|
|
|
|
switch (event.target?.connectionState) {
|
|
|
|
// From what I understand, only after have we done the ICE song and
|
|
|
|
// dance is it safest to connect the video tracks / stream
|
|
|
|
case 'connected':
|
|
|
|
// Let the browser attach to the video stream now
|
2024-06-04 08:32:24 -04:00
|
|
|
this.dispatchEvent(
|
|
|
|
new CustomEvent(EngineConnectionEvents.NewTrack, {
|
|
|
|
detail: { conn: this, mediaStream: this.mediaStream! },
|
|
|
|
})
|
|
|
|
)
|
2024-01-23 13:13:43 -05:00
|
|
|
break
|
|
|
|
case 'failed':
|
|
|
|
this.disconnectAll()
|
|
|
|
this.state = {
|
2024-02-12 16:00:31 -05:00
|
|
|
type: EngineConnectionStateType.Disconnecting,
|
2024-01-23 13:13:43 -05:00
|
|
|
value: {
|
2024-02-12 16:00:31 -05:00
|
|
|
type: DisconnectingType.Error,
|
|
|
|
value: {
|
2024-06-04 08:32:24 -04:00
|
|
|
error: ConnectionError.ICENegotiate,
|
|
|
|
context: event,
|
2024-02-12 16:00:31 -05:00
|
|
|
},
|
2024-01-23 13:13:43 -05:00
|
|
|
},
|
|
|
|
}
|
|
|
|
break
|
|
|
|
default:
|
|
|
|
break
|
|
|
|
}
|
|
|
|
})
|
|
|
|
|
|
|
|
this.pc.addEventListener('track', (event) => {
|
|
|
|
const mediaStream = event.streams[0]
|
|
|
|
|
|
|
|
this.state = {
|
|
|
|
type: EngineConnectionStateType.Connecting,
|
|
|
|
value: {
|
|
|
|
type: ConnectingType.TrackReceived,
|
|
|
|
},
|
|
|
|
}
|
|
|
|
|
2024-04-09 18:05:36 -07:00
|
|
|
this.webrtcStatsCollector = (): Promise<WebRTCClientMetrics> => {
|
2024-01-23 13:13:43 -05:00
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
if (mediaStream.getVideoTracks().length !== 1) {
|
|
|
|
reject(new Error('too many video tracks to report'))
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
let videoTrack = mediaStream.getVideoTracks()[0]
|
|
|
|
void this.pc?.getStats(videoTrack).then((videoTrackStats) => {
|
2024-04-09 18:05:36 -07:00
|
|
|
let client_metrics: WebRTCClientMetrics = {
|
2024-01-23 13:13:43 -05:00
|
|
|
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,
|
2024-04-09 18:05:36 -07:00
|
|
|
rtc_frame_height: 0,
|
|
|
|
rtc_frame_width: 0,
|
|
|
|
rtc_packets_lost: 0,
|
|
|
|
rtc_pli_count: 0,
|
|
|
|
rtc_pause_count: 0,
|
|
|
|
rtc_total_pauses_duration_sec: 0.0,
|
2024-01-23 13:13:43 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
// 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 =
|
|
|
|
videoTrackReport.framesDecoded || 0
|
|
|
|
client_metrics.rtc_frames_dropped =
|
|
|
|
videoTrackReport.framesDropped || 0
|
|
|
|
client_metrics.rtc_frames_received =
|
|
|
|
videoTrackReport.framesReceived || 0
|
|
|
|
client_metrics.rtc_frames_per_second =
|
|
|
|
videoTrackReport.framesPerSecond || 0
|
|
|
|
client_metrics.rtc_freeze_count =
|
|
|
|
videoTrackReport.freezeCount || 0
|
|
|
|
client_metrics.rtc_jitter_sec = videoTrackReport.jitter || 0.0
|
|
|
|
client_metrics.rtc_keyframes_decoded =
|
|
|
|
videoTrackReport.keyFramesDecoded || 0
|
|
|
|
client_metrics.rtc_total_freezes_duration_sec =
|
|
|
|
videoTrackReport.totalFreezesDuration || 0
|
2024-04-09 18:05:36 -07:00
|
|
|
client_metrics.rtc_frame_height =
|
|
|
|
videoTrackReport.frameHeight || 0
|
|
|
|
client_metrics.rtc_frame_width =
|
|
|
|
videoTrackReport.frameWidth || 0
|
|
|
|
client_metrics.rtc_packets_lost =
|
|
|
|
videoTrackReport.packetsLost || 0
|
|
|
|
client_metrics.rtc_pli_count = videoTrackReport.pliCount || 0
|
2024-01-23 13:13:43 -05:00
|
|
|
} else if (videoTrackReport.type === 'transport') {
|
|
|
|
// videoTrackReport.bytesReceived,
|
|
|
|
// videoTrackReport.bytesSent,
|
|
|
|
}
|
|
|
|
})
|
|
|
|
resolve(client_metrics)
|
|
|
|
})
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
// The app is eager to use the MediaStream; as soon as onNewTrack is
|
|
|
|
// called, the following sequence happens:
|
|
|
|
// EngineConnection.onNewTrack -> StoreState.setMediaStream ->
|
|
|
|
// Stream.tsx reacts to mediaStream change, setting a video element.
|
|
|
|
// We wait until connectionstatechange changes to "connected"
|
|
|
|
// to pass it to the rest of the application.
|
|
|
|
|
|
|
|
this.mediaStream = mediaStream
|
|
|
|
})
|
|
|
|
|
|
|
|
this.pc.addEventListener('datachannel', (event) => {
|
|
|
|
this.unreliableDataChannel = event.channel
|
|
|
|
|
|
|
|
this.state = {
|
|
|
|
type: EngineConnectionStateType.Connecting,
|
|
|
|
value: {
|
|
|
|
type: ConnectingType.DataChannelConnecting,
|
|
|
|
value: event.channel.label,
|
|
|
|
},
|
|
|
|
}
|
|
|
|
|
|
|
|
this.unreliableDataChannel.addEventListener('open', (event) => {
|
|
|
|
this.state = {
|
|
|
|
type: EngineConnectionStateType.Connecting,
|
|
|
|
value: {
|
|
|
|
type: ConnectingType.DataChannelEstablished,
|
|
|
|
},
|
|
|
|
}
|
|
|
|
|
|
|
|
// Everything is now connected.
|
|
|
|
this.state = { type: EngineConnectionStateType.ConnectionEstablished }
|
|
|
|
|
2024-05-21 14:52:14 -04:00
|
|
|
this.engineCommandManager.inSequence = 1
|
2024-06-04 08:32:24 -04:00
|
|
|
|
|
|
|
this.dispatchEvent(
|
|
|
|
new CustomEvent(EngineConnectionEvents.Opened, { detail: this })
|
|
|
|
)
|
2024-01-23 13:13:43 -05:00
|
|
|
})
|
|
|
|
|
|
|
|
this.unreliableDataChannel.addEventListener('close', (event) => {
|
|
|
|
this.disconnectAll()
|
2024-02-12 16:00:31 -05:00
|
|
|
this.finalizeIfAllConnectionsClosed()
|
2024-01-23 13:13:43 -05:00
|
|
|
})
|
|
|
|
|
|
|
|
this.unreliableDataChannel.addEventListener('error', (event) => {
|
|
|
|
this.disconnectAll()
|
|
|
|
|
|
|
|
this.state = {
|
2024-02-12 16:00:31 -05:00
|
|
|
type: EngineConnectionStateType.Disconnecting,
|
2024-01-23 13:13:43 -05:00
|
|
|
value: {
|
2024-02-12 16:00:31 -05:00
|
|
|
type: DisconnectingType.Error,
|
|
|
|
value: {
|
2024-06-04 08:32:24 -04:00
|
|
|
error: ConnectionError.DataChannelError,
|
|
|
|
context: event,
|
2024-02-12 16:00:31 -05:00
|
|
|
},
|
2024-01-23 13:13:43 -05:00
|
|
|
},
|
|
|
|
}
|
|
|
|
})
|
2024-05-09 15:04:33 +10:00
|
|
|
this.unreliableDataChannel.addEventListener('message', (event) => {
|
|
|
|
const result: UnreliableResponses = JSON.parse(event.data)
|
|
|
|
Object.values(
|
|
|
|
this.engineCommandManager.unreliableSubscriptions[result.type] || {}
|
|
|
|
).forEach(
|
|
|
|
// TODO: There is only one response that uses the unreliable channel atm,
|
|
|
|
// 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
|
|
|
|
// per unreliable subscription.
|
|
|
|
(callback) => {
|
|
|
|
if (
|
|
|
|
result.type === 'highlight_set_entity' &&
|
|
|
|
result?.data?.sequence &&
|
|
|
|
result?.data.sequence > this.engineCommandManager.inSequence
|
|
|
|
) {
|
|
|
|
this.engineCommandManager.inSequence = result.data.sequence
|
|
|
|
callback(result)
|
2024-05-23 22:02:25 +02:00
|
|
|
} else if (result.type !== 'highlight_set_entity') {
|
|
|
|
callback(result)
|
2024-05-09 15:04:33 +10:00
|
|
|
}
|
|
|
|
}
|
|
|
|
)
|
|
|
|
})
|
2024-01-23 13:13:43 -05:00
|
|
|
})
|
|
|
|
}
|
|
|
|
|
2024-06-04 08:32:24 -04:00
|
|
|
const createWebSocketConnection = () => {
|
2024-01-23 13:13:43 -05:00
|
|
|
this.state = {
|
|
|
|
type: EngineConnectionStateType.Connecting,
|
|
|
|
value: {
|
2024-06-04 08:32:24 -04:00
|
|
|
type: ConnectingType.WebSocketConnecting,
|
2024-01-23 13:13:43 -05:00
|
|
|
},
|
2023-07-11 20:34:09 +10:00
|
|
|
}
|
2023-06-22 16:43:33 +10:00
|
|
|
|
2024-06-04 08:32:24 -04:00
|
|
|
this.websocket = new WebSocket(this.url, [])
|
|
|
|
this.websocket.binaryType = 'arraybuffer'
|
2023-06-22 16:43:33 +10:00
|
|
|
|
2024-06-04 08:32:24 -04:00
|
|
|
this.websocket.addEventListener('open', (event) => {
|
|
|
|
this.state = {
|
|
|
|
type: EngineConnectionStateType.Connecting,
|
2024-02-12 16:00:31 -05:00
|
|
|
value: {
|
2024-06-04 08:32:24 -04:00
|
|
|
type: ConnectingType.WebSocketOpen,
|
2024-02-12 16:00:31 -05:00
|
|
|
},
|
2024-06-04 08:32:24 -04:00
|
|
|
}
|
2023-08-25 00:16:37 -04:00
|
|
|
|
2024-06-04 08:32:24 -04:00
|
|
|
// This is required for when KCMA is running stand-alone / within Tauri.
|
|
|
|
// Otherwise when run in a browser, the token is sent implicitly via
|
|
|
|
// the Cookie header.
|
|
|
|
if (this.token) {
|
|
|
|
this.send({
|
|
|
|
type: 'headers',
|
|
|
|
headers: { Authorization: `Bearer ${this.token}` },
|
2023-10-11 13:36:54 +11:00
|
|
|
})
|
2023-08-25 00:16:37 -04:00
|
|
|
}
|
2024-01-23 13:13:43 -05:00
|
|
|
|
2024-06-04 08:32:24 -04:00
|
|
|
// Send an initial ping
|
|
|
|
this.send({ type: 'ping' })
|
|
|
|
this.pingPongSpan.ping = new Date()
|
|
|
|
})
|
2023-08-21 16:53:31 -04:00
|
|
|
|
2024-06-04 08:32:24 -04:00
|
|
|
this.websocket.addEventListener('close', (event) => {
|
|
|
|
this.disconnectAll()
|
|
|
|
this.finalizeIfAllConnectionsClosed()
|
|
|
|
})
|
2023-08-30 10:34:14 -04:00
|
|
|
|
2024-06-04 08:32:24 -04:00
|
|
|
this.websocket.addEventListener('error', (event) => {
|
|
|
|
this.disconnectAll()
|
2023-06-22 16:43:33 +10:00
|
|
|
|
2024-06-04 08:32:24 -04:00
|
|
|
this.state = {
|
|
|
|
type: EngineConnectionStateType.Disconnecting,
|
|
|
|
value: {
|
|
|
|
type: DisconnectingType.Error,
|
2024-01-23 13:13:43 -05:00
|
|
|
value: {
|
2024-06-04 08:32:24 -04:00
|
|
|
error: ConnectionError.WebSocketError,
|
|
|
|
context: event,
|
2024-01-23 13:13:43 -05:00
|
|
|
},
|
2024-06-04 08:32:24 -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.
|
2023-08-18 16:16:16 -04:00
|
|
|
|
2024-06-04 08:32:24 -04:00
|
|
|
if (typeof event.data !== 'string') {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
const message: Models['WebSocketResponse_type'] = JSON.parse(event.data)
|
|
|
|
|
|
|
|
if (!message.success) {
|
|
|
|
const errorsString = message?.errors
|
|
|
|
?.map((error) => {
|
|
|
|
return ` - ${error.error_code}: ${error.message}`
|
2024-04-30 12:39:06 -04:00
|
|
|
})
|
2024-06-04 08:32:24 -04:00
|
|
|
.join('\n')
|
|
|
|
if (message.request_id) {
|
|
|
|
const artifactThatFailed =
|
|
|
|
this.engineCommandManager.artifactMap[message.request_id] ||
|
|
|
|
this.engineCommandManager.lastArtifactMap[message.request_id]
|
|
|
|
console.error(
|
|
|
|
`Error in response to request ${message.request_id}:\n${errorsString}
|
|
|
|
failed cmd type was ${artifactThatFailed?.commandType}`
|
|
|
|
)
|
2024-01-23 13:13:43 -05:00
|
|
|
} else {
|
2024-06-04 08:32:24 -04:00
|
|
|
console.error(`Error from server:\n${errorsString}`)
|
2024-01-23 13:13:43 -05:00
|
|
|
}
|
2023-08-30 10:34:14 -04:00
|
|
|
|
2024-06-04 08:32:24 -04:00
|
|
|
const firstError = message?.errors[0]
|
|
|
|
if (firstError.error_code === 'auth_token_invalid') {
|
|
|
|
this.state = {
|
|
|
|
type: EngineConnectionStateType.Disconnecting,
|
|
|
|
value: {
|
|
|
|
type: DisconnectingType.Error,
|
|
|
|
value: {
|
|
|
|
error: ConnectionError.BadAuthToken,
|
|
|
|
context: firstError.message,
|
|
|
|
},
|
|
|
|
},
|
|
|
|
}
|
|
|
|
this.disconnectAll()
|
2023-08-30 13:14:52 -04:00
|
|
|
}
|
2024-06-04 08:32:24 -04:00
|
|
|
return
|
|
|
|
}
|
2023-08-30 10:34:14 -04:00
|
|
|
|
2024-06-04 08:32:24 -04:00
|
|
|
let resp = message.resp
|
2023-09-10 19:04:46 -04:00
|
|
|
|
2024-06-04 08:32:24 -04:00
|
|
|
// If there's no body to the response, we can bail here.
|
|
|
|
if (!resp || !resp.type) {
|
|
|
|
return
|
|
|
|
}
|
2024-01-23 13:13:43 -05:00
|
|
|
|
2024-06-04 08:32:24 -04:00
|
|
|
switch (resp.type) {
|
|
|
|
case 'pong':
|
|
|
|
this.pingPongSpan.pong = new Date()
|
|
|
|
break
|
|
|
|
case 'ice_server_info':
|
|
|
|
let ice_servers = resp.data?.ice_servers
|
|
|
|
|
|
|
|
// Now that we have some ICE servers it makes sense
|
|
|
|
// to start initializing the RTCPeerConnection. RTCPeerConnection
|
|
|
|
// will begin the ICE process.
|
|
|
|
createPeerConnection()
|
|
|
|
|
|
|
|
this.state = {
|
|
|
|
type: EngineConnectionStateType.Connecting,
|
|
|
|
value: {
|
|
|
|
type: ConnectingType.PeerConnectionCreated,
|
|
|
|
},
|
|
|
|
}
|
|
|
|
|
|
|
|
// No ICE servers can be valid in a local dev. env.
|
|
|
|
if (ice_servers?.length === 0) {
|
|
|
|
console.warn('No ICE servers')
|
|
|
|
this.pc?.setConfiguration({
|
|
|
|
bundlePolicy: 'max-bundle',
|
|
|
|
})
|
|
|
|
} else {
|
|
|
|
// 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({
|
|
|
|
bundlePolicy: 'max-bundle',
|
|
|
|
iceServers: ice_servers,
|
|
|
|
iceTransportPolicy: 'relay',
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
this.state = {
|
|
|
|
type: EngineConnectionStateType.Connecting,
|
|
|
|
value: {
|
|
|
|
type: ConnectingType.ICEServersSet,
|
|
|
|
},
|
|
|
|
}
|
|
|
|
|
|
|
|
// 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.
|
|
|
|
|
|
|
|
// Add a transceiver to our SDP offer
|
|
|
|
this.pc?.addTransceiver('video', {
|
|
|
|
direction: 'recvonly',
|
|
|
|
})
|
|
|
|
|
|
|
|
// Create a session description offer based on our local environment
|
|
|
|
// that we will send to the remote end. The remote will send back
|
|
|
|
// what it supports via sdp_answer.
|
|
|
|
this.pc
|
|
|
|
?.createOffer()
|
|
|
|
.then((offer: RTCSessionDescriptionInit) => {
|
2024-01-23 13:13:43 -05:00
|
|
|
this.state = {
|
|
|
|
type: EngineConnectionStateType.Connecting,
|
|
|
|
value: {
|
2024-06-04 08:32:24 -04:00
|
|
|
type: ConnectingType.SetLocalDescription,
|
2024-01-23 13:13:43 -05:00
|
|
|
},
|
|
|
|
}
|
2024-06-04 08:32:24 -04:00
|
|
|
return this.pc?.setLocalDescription(offer).then(() => {
|
|
|
|
this.send({
|
|
|
|
type: 'sdp_offer',
|
|
|
|
offer: offer as Models['RtcSessionDescription_type'],
|
|
|
|
})
|
|
|
|
this.state = {
|
|
|
|
type: EngineConnectionStateType.Connecting,
|
|
|
|
value: {
|
|
|
|
type: ConnectingType.OfferedSdp,
|
|
|
|
},
|
|
|
|
}
|
|
|
|
})
|
2024-01-23 13:13:43 -05:00
|
|
|
})
|
2024-06-04 08:32:24 -04:00
|
|
|
.catch((err: Error) => {
|
|
|
|
// The local description is invalid, so there's no point continuing.
|
|
|
|
this.disconnectAll()
|
|
|
|
this.state = {
|
|
|
|
type: EngineConnectionStateType.Disconnecting,
|
2024-02-12 16:00:31 -05:00
|
|
|
value: {
|
2024-06-04 08:32:24 -04:00
|
|
|
type: DisconnectingType.Error,
|
|
|
|
value: {
|
|
|
|
error: ConnectionError.LocalDescriptionInvalid,
|
|
|
|
context: err,
|
|
|
|
},
|
2024-02-12 16:00:31 -05:00
|
|
|
},
|
2024-06-04 08:32:24 -04:00
|
|
|
}
|
|
|
|
})
|
|
|
|
break
|
2023-08-30 10:34:14 -04:00
|
|
|
|
2024-06-04 08:32:24 -04:00
|
|
|
case 'sdp_answer':
|
|
|
|
let answer = resp.data?.answer
|
|
|
|
if (!answer || answer.type === 'unspecified') {
|
|
|
|
return
|
|
|
|
}
|
2023-08-18 16:16:16 -04:00
|
|
|
|
2024-06-04 08:32:24 -04:00
|
|
|
this.state = {
|
|
|
|
type: EngineConnectionStateType.Connecting,
|
|
|
|
value: {
|
|
|
|
type: ConnectingType.ReceivedSdp,
|
|
|
|
},
|
|
|
|
}
|
2023-08-18 16:16:16 -04:00
|
|
|
|
2024-06-04 08:32:24 -04:00
|
|
|
// As soon as this is set, RTCPeerConnection tries to
|
|
|
|
// establish a connection.
|
|
|
|
// @ts-ignore
|
|
|
|
// Have to ignore because dom.ts doesn't have the right type
|
|
|
|
void this.pc?.setRemoteDescription(answer)
|
2024-01-23 13:13:43 -05:00
|
|
|
|
2024-06-04 08:32:24 -04:00
|
|
|
this.state = {
|
|
|
|
type: EngineConnectionStateType.Connecting,
|
|
|
|
value: {
|
|
|
|
type: ConnectingType.SetRemoteDescription,
|
|
|
|
},
|
|
|
|
}
|
2023-08-21 16:53:31 -04:00
|
|
|
|
2024-06-04 08:32:24 -04:00
|
|
|
this.state = {
|
|
|
|
type: EngineConnectionStateType.Connecting,
|
|
|
|
value: {
|
|
|
|
type: ConnectingType.WebRTCConnecting,
|
|
|
|
},
|
|
|
|
}
|
|
|
|
break
|
2023-08-18 16:16:16 -04:00
|
|
|
|
2024-06-04 08:32:24 -04:00
|
|
|
case 'trickle_ice':
|
|
|
|
let candidate = resp.data?.candidate
|
|
|
|
void this.pc?.addIceCandidate(candidate as RTCIceCandidateInit)
|
|
|
|
break
|
2023-08-18 16:16:16 -04:00
|
|
|
|
2024-06-04 08:32:24 -04:00
|
|
|
case 'metrics_request':
|
|
|
|
if (this.webrtcStatsCollector === undefined) {
|
|
|
|
// TODO: Error message here?
|
|
|
|
return
|
|
|
|
}
|
|
|
|
void this.webrtcStatsCollector().then((client_metrics) => {
|
|
|
|
this.send({
|
|
|
|
type: 'metrics_response',
|
|
|
|
metrics: client_metrics,
|
|
|
|
})
|
2024-01-23 13:13:43 -05:00
|
|
|
})
|
2024-06-04 08:32:24 -04:00
|
|
|
break
|
|
|
|
}
|
|
|
|
})
|
2024-03-19 20:39:49 -04:00
|
|
|
}
|
|
|
|
|
2024-06-04 08:32:24 -04:00
|
|
|
if (reconnecting) {
|
|
|
|
createWebSocketConnection()
|
|
|
|
} else {
|
|
|
|
window.addEventListener('use-network-status-ready', () => {
|
|
|
|
createWebSocketConnection()
|
|
|
|
})
|
|
|
|
}
|
2023-08-21 16:53:31 -04:00
|
|
|
}
|
2024-04-19 13:30:59 -07:00
|
|
|
// Do not change this back to an object or any, we should only be sending the
|
|
|
|
// WebSocketRequest type!
|
|
|
|
unreliableSend(message: Models['WebSocketRequest_type']) {
|
2023-09-15 17:14:58 -04:00
|
|
|
// 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)
|
|
|
|
)
|
|
|
|
}
|
2024-04-19 13:30:59 -07:00
|
|
|
// Do not change this back to an object or any, we should only be sending the
|
|
|
|
// WebSocketRequest type!
|
|
|
|
send(message: Models['WebSocketRequest_type']) {
|
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
|
|
|
}
|
2024-01-23 13:13:43 -05:00
|
|
|
disconnectAll() {
|
2023-08-18 16:16:16 -04:00
|
|
|
this.websocket?.close()
|
2023-08-31 07:39:03 +10:00
|
|
|
this.unreliableDataChannel?.close()
|
2024-01-23 13:13:43 -05:00
|
|
|
this.pc?.close()
|
2024-02-12 16:00:31 -05:00
|
|
|
|
2023-09-10 19:04:46 -04:00
|
|
|
this.webrtcStatsCollector = undefined
|
2024-01-23 13:13:43 -05:00
|
|
|
}
|
2024-02-12 16:00:31 -05:00
|
|
|
finalizeIfAllConnectionsClosed() {
|
|
|
|
const allClosed =
|
|
|
|
this.websocket?.readyState === 3 &&
|
|
|
|
this.pc?.connectionState === 'closed' &&
|
|
|
|
this.unreliableDataChannel?.readyState === 'closed'
|
|
|
|
if (allClosed) {
|
|
|
|
this.state = { type: EngineConnectionStateType.Disconnected }
|
|
|
|
}
|
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-08-31 07:39:03 +10:00
|
|
|
type UnreliableResponses = Extract<
|
2023-08-31 05:19:37 +10:00
|
|
|
Models['OkModelingCmdResponse_type'],
|
2024-05-09 15:04:33 +10:00
|
|
|
{ type: 'highlight_set_entity' | 'camera_drag_move' }
|
2023-08-31 05:19:37 +10:00
|
|
|
>
|
2024-05-23 22:02:25 +02:00
|
|
|
export 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
|
|
|
}
|
|
|
|
|
2024-06-04 08:32:24 -04:00
|
|
|
// TODO: Should eventually be replaced with native EventTarget event system,
|
|
|
|
// as it manages events in a more familiar way to other developers.
|
2024-03-02 08:20:50 +11:00
|
|
|
export interface Subscription<T extends ModelTypes> {
|
2023-08-31 05:19:37 +10:00
|
|
|
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'
|
2024-04-09 11:51:41 -07:00
|
|
|
data: OkWebSocketResponseData
|
2023-11-24 08:59:24 +11:00
|
|
|
id: string
|
|
|
|
cmd_type?: string
|
|
|
|
}
|
|
|
|
| {
|
|
|
|
type: 'execution-done'
|
|
|
|
data: null
|
|
|
|
}
|
|
|
|
|
2024-06-04 08:32:24 -04:00
|
|
|
export enum EngineCommandManagerEvents {
|
|
|
|
EngineAvailable = 'engine-available',
|
|
|
|
}
|
|
|
|
|
2024-05-20 13:38:51 -04:00
|
|
|
/**
|
|
|
|
* The EngineCommandManager is the main interface to the Engine for Modeling App.
|
|
|
|
*
|
|
|
|
* It is responsible for sending commands to the Engine, and managing the state
|
|
|
|
* of those commands. It also sets up and tears down the connection to the Engine
|
|
|
|
* through the {@link EngineConnection} class.
|
|
|
|
*
|
|
|
|
* It also maintains an {@link artifactMap} that keeps track of the state of each
|
|
|
|
* command, and the artifacts that have been generated by those commands.
|
|
|
|
*/
|
2024-06-04 08:32:24 -04:00
|
|
|
export class EngineCommandManager extends EventTarget {
|
2024-05-20 13:38:51 -04:00
|
|
|
/**
|
|
|
|
* The artifactMap is a client-side representation of the commands that have been sent
|
|
|
|
* to the server-side geometry engine, and the state of their resulting artifacts.
|
|
|
|
*
|
|
|
|
* It is used to keep track of the state of each command, which can fail, succeed, or be
|
|
|
|
* pending.
|
|
|
|
*
|
|
|
|
* It is also used to keep track of our client's understanding of what is in the engine scene
|
|
|
|
* so that we can map to and from KCL code. Each artifact maintains a source range to the part
|
|
|
|
* of the KCL code that generated it.
|
|
|
|
*/
|
2023-08-18 16:16:16 -04:00
|
|
|
artifactMap: ArtifactMap = {}
|
2024-05-20 13:38:51 -04:00
|
|
|
/**
|
|
|
|
* The {@link ArtifactMap} from the previous engine connection. This is used as a fallback
|
|
|
|
* when the engine connection is reset without a full client-side refresh.
|
|
|
|
*
|
|
|
|
* @deprecated This was used during a short time when we were choosing to not execute the engine in certain cases.
|
|
|
|
*/
|
2023-11-28 21:23:20 +11:00
|
|
|
lastArtifactMap: ArtifactMap = {}
|
2024-05-20 13:38:51 -04:00
|
|
|
/**
|
|
|
|
* The client-side representation of the scene command artifacts that have been sent to the server;
|
|
|
|
* that is, the *non-modeling* commands and corresponding artifacts.
|
|
|
|
*
|
|
|
|
* For modeling commands, see {@link artifactMap}.
|
|
|
|
*/
|
2024-02-13 18:47:37 +11:00
|
|
|
sceneCommandArtifacts: ArtifactMap = {}
|
2024-05-20 13:38:51 -04:00
|
|
|
/**
|
|
|
|
* A counter that is incremented with each command sent over the *unreliable* channel to the engine.
|
|
|
|
* This is compared to the latest received {@link inSequence} number to determine if we should ignore
|
|
|
|
* any out-of-order late responses in the unreliable channel.
|
|
|
|
*/
|
2023-08-18 16:16:16 -04:00
|
|
|
outSequence = 1
|
2024-05-20 13:38:51 -04:00
|
|
|
/**
|
|
|
|
* The latest sequence number received from the engine over the *unreliable* channel.
|
|
|
|
* This is compared to the {@link outSequence} number to determine if we should ignore
|
|
|
|
* any out-of-order late responses in the unreliable channel.
|
|
|
|
*/
|
2023-08-18 16:16:16 -04:00
|
|
|
inSequence = 1
|
2024-04-25 15:51:33 -04:00
|
|
|
pool?: string
|
2023-08-18 16:16:16 -04:00
|
|
|
engineConnection?: EngineConnection
|
2024-04-15 17:18:32 -07:00
|
|
|
defaultPlanes: DefaultPlanes | null = null
|
|
|
|
commandLogs: CommandLog[] = []
|
2024-05-29 18:04:27 -04:00
|
|
|
pendingExport?: {
|
|
|
|
resolve: (filename?: string) => void
|
|
|
|
reject: (reason: any) => void
|
|
|
|
}
|
2023-11-24 08:59:24 +11:00
|
|
|
_commandLogCallBack: (command: CommandLog[]) => void = () => {}
|
2023-08-18 16:16:16 -04:00
|
|
|
private resolveReady = () => {}
|
2024-05-20 13:38:51 -04: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-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
|
|
|
|
2024-04-25 15:51:33 -04:00
|
|
|
constructor(pool?: string) {
|
2024-06-04 08:32:24 -04:00
|
|
|
super()
|
|
|
|
|
2023-09-25 19:49:53 -07:00
|
|
|
this.engineConnection = undefined
|
2024-04-25 15:51:33 -04:00
|
|
|
this.pool = pool
|
2024-03-22 16:55:30 +11:00
|
|
|
}
|
|
|
|
|
|
|
|
private _camControlsCameraChange = () => {}
|
|
|
|
set camControlsCameraChange(cb: () => void) {
|
|
|
|
this._camControlsCameraChange = cb
|
|
|
|
}
|
|
|
|
|
|
|
|
private getAst: () => Program = () =>
|
|
|
|
({ start: 0, end: 0, body: [], nonCodeMeta: {} } as any)
|
|
|
|
set getAstCb(cb: () => Program) {
|
|
|
|
this.getAst = cb
|
2023-09-25 19:49:53 -07:00
|
|
|
}
|
2024-04-15 17:18:32 -07:00
|
|
|
private makeDefaultPlanes: () => Promise<DefaultPlanes> | null = () => null
|
2024-06-30 19:21:24 -07:00
|
|
|
private modifyGrid: (hidden: boolean) => Promise<void> | null = () => null
|
2023-09-25 19:49:53 -07:00
|
|
|
|
|
|
|
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,
|
2024-04-15 17:18:32 -07:00
|
|
|
makeDefaultPlanes,
|
2024-06-30 19:21:24 -07:00
|
|
|
modifyGrid,
|
2024-04-19 00:58:32 -04:00
|
|
|
settings = {
|
|
|
|
theme: Themes.Dark,
|
|
|
|
highlightEdges: true,
|
2024-04-24 13:59:25 -07:00
|
|
|
enableSSAO: true,
|
2024-04-19 00:58:32 -04:00
|
|
|
},
|
2023-08-18 16:16:16 -04:00
|
|
|
}: {
|
|
|
|
setMediaStream: (stream: MediaStream) => void
|
|
|
|
setIsStreamReady: (isStreamReady: boolean) => void
|
|
|
|
width: number
|
|
|
|
height: number
|
2024-04-17 20:18:07 -07:00
|
|
|
executeCode: () => void
|
2023-08-18 16:16:16 -04:00
|
|
|
token?: string
|
2024-04-15 17:18:32 -07:00
|
|
|
makeDefaultPlanes: () => Promise<DefaultPlanes>
|
2024-06-30 19:21:24 -07:00
|
|
|
modifyGrid: (hidden: boolean) => Promise<void>
|
2024-04-19 00:58:32 -04:00
|
|
|
settings?: {
|
|
|
|
theme: Themes
|
|
|
|
highlightEdges: boolean
|
2024-04-24 13:59:25 -07:00
|
|
|
enableSSAO: boolean
|
2024-04-19 00:58:32 -04:00
|
|
|
}
|
2023-08-18 16:16:16 -04:00
|
|
|
}) {
|
2024-04-15 17:18:32 -07:00
|
|
|
this.makeDefaultPlanes = makeDefaultPlanes
|
2024-06-30 19:21:24 -07:00
|
|
|
this.modifyGrid = modifyGrid
|
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
|
|
|
|
}
|
|
|
|
|
2024-04-24 13:59:25 -07:00
|
|
|
const additionalSettings = settings.enableSSAO ? '&post_effect=ssao' : ''
|
2024-04-25 22:02:11 -07:00
|
|
|
const pool = this.pool === undefined ? '' : `&pool=${this.pool}`
|
2024-04-25 15:51:33 -04:00
|
|
|
const url = `${VITE_KC_API_WS_MODELING_URL}?video_res_width=${width}&video_res_height=${height}${additionalSettings}${pool}`
|
2023-08-18 16:16:16 -04:00
|
|
|
this.engineConnection = new EngineConnection({
|
2024-03-22 16:55:30 +11:00
|
|
|
engineCommandManager: this,
|
2023-08-18 16:16:16 -04:00
|
|
|
url,
|
|
|
|
token,
|
2024-06-04 08:32:24 -04:00
|
|
|
})
|
|
|
|
|
|
|
|
this.dispatchEvent(
|
|
|
|
new CustomEvent(EngineCommandManagerEvents.EngineAvailable, {
|
|
|
|
detail: this.engineConnection,
|
|
|
|
})
|
|
|
|
)
|
|
|
|
|
|
|
|
this.engineConnection.addEventListener(
|
|
|
|
EngineConnectionEvents.Opened,
|
|
|
|
() => {
|
2024-03-22 09:35:07 -04:00
|
|
|
// Set the stream background color
|
2024-06-04 08:32:24 -04:00
|
|
|
// This takes RGBA values from 0-1
|
|
|
|
// So we convert from the conventional 0-255 found in Figma
|
|
|
|
|
|
|
|
void this.sendSceneCommand({
|
2024-03-22 09:35:07 -04:00
|
|
|
type: 'modeling_cmd_req',
|
|
|
|
cmd_id: uuidv4(),
|
|
|
|
cmd: {
|
|
|
|
type: 'set_background_color',
|
2024-04-19 00:58:32 -04:00
|
|
|
color: getThemeColorForEngine(settings.theme),
|
|
|
|
},
|
|
|
|
})
|
2024-04-24 13:59:25 -07:00
|
|
|
|
|
|
|
// Sets the default line colors
|
|
|
|
const opposingTheme = getOppositeTheme(settings.theme)
|
|
|
|
this.sendSceneCommand({
|
|
|
|
cmd_id: uuidv4(),
|
|
|
|
type: 'modeling_cmd_req',
|
|
|
|
cmd: {
|
|
|
|
type: 'set_default_system_properties',
|
|
|
|
color: getThemeColorForEngine(opposingTheme),
|
|
|
|
},
|
|
|
|
})
|
|
|
|
|
2024-04-19 00:58:32 -04:00
|
|
|
// Set the edge lines visibility
|
|
|
|
this.sendSceneCommand({
|
|
|
|
type: 'modeling_cmd_req',
|
|
|
|
cmd_id: uuidv4(),
|
|
|
|
cmd: {
|
|
|
|
type: 'edge_lines_visible' as any, // TODO: update kittycad.ts to use the correct type
|
|
|
|
hidden: !settings.highlightEdges,
|
2024-03-22 09:35:07 -04:00
|
|
|
},
|
|
|
|
})
|
|
|
|
|
2024-03-22 16:55:30 +11:00
|
|
|
this._camControlsCameraChange()
|
2024-03-02 08:20:50 +11:00
|
|
|
this.sendSceneCommand({
|
|
|
|
// CameraControls subscribes to default_camera_get_settings response events
|
|
|
|
// firing this at connection ensure the camera's are synced initially
|
|
|
|
type: 'modeling_cmd_req',
|
|
|
|
cmd_id: uuidv4(),
|
|
|
|
cmd: {
|
|
|
|
type: 'default_camera_get_settings',
|
|
|
|
},
|
|
|
|
})
|
2024-06-30 19:21:24 -07:00
|
|
|
// We want modify the grid first because we don't want it to flash.
|
|
|
|
// Ideally these would already be default hidden in engine (TODO do
|
|
|
|
// that) https://github.com/KittyCAD/engine/issues/2282
|
|
|
|
this.modifyGrid(true)?.then(async () => {
|
|
|
|
await this.initPlanes()
|
2024-02-26 21:02:33 +11:00
|
|
|
this.resolveReady()
|
|
|
|
setIsStreamReady(true)
|
2024-05-23 17:05:54 -07:00
|
|
|
await executeCode()
|
2024-02-17 07:04:24 +11:00
|
|
|
})
|
2024-06-04 08:32:24 -04:00
|
|
|
}
|
|
|
|
)
|
|
|
|
|
|
|
|
this.engineConnection.addEventListener(
|
|
|
|
EngineConnectionEvents.Closed,
|
|
|
|
() => {
|
2023-08-21 16:53:31 -04:00
|
|
|
setIsStreamReady(false)
|
2024-06-04 08:32:24 -04:00
|
|
|
}
|
|
|
|
)
|
|
|
|
|
|
|
|
this.engineConnection.addEventListener(
|
|
|
|
EngineConnectionEvents.ConnectionStarted,
|
|
|
|
({ detail: engineConnection }: any) => {
|
|
|
|
engineConnection?.pc?.addEventListener(
|
|
|
|
'datachannel',
|
|
|
|
(event: RTCDataChannelEvent) => {
|
|
|
|
let unreliableDataChannel = event.channel
|
|
|
|
|
|
|
|
unreliableDataChannel.addEventListener(
|
|
|
|
'message',
|
|
|
|
(event: MessageEvent) => {
|
|
|
|
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,
|
|
|
|
// 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
|
|
|
|
// per unreliable subscription.
|
|
|
|
(callback) => {
|
|
|
|
let data = result?.data
|
|
|
|
if (isHighlightSetEntity_type(data)) {
|
|
|
|
if (
|
|
|
|
data.sequence !== undefined &&
|
|
|
|
data.sequence > this.inSequence
|
|
|
|
) {
|
|
|
|
this.inSequence = data.sequence
|
|
|
|
callback(result)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
)
|
|
|
|
}
|
|
|
|
)
|
|
|
|
}
|
|
|
|
)
|
|
|
|
|
2023-08-18 16:16:16 -04:00
|
|
|
// When the EngineConnection starts a connection, we want to register
|
|
|
|
// callbacks into the WebSocket/PeerConnection.
|
2024-06-04 08:32:24 -04:00
|
|
|
engineConnection.websocket?.addEventListener('message', ((
|
|
|
|
event: MessageEvent
|
|
|
|
) => {
|
2024-03-19 20:39:49 -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.
|
2024-05-29 18:04:27 -04:00
|
|
|
exportSave(event.data).then(() => {
|
|
|
|
this.pendingExport?.resolve()
|
|
|
|
}, this.pendingExport?.reject)
|
2024-03-19 20:39:49 -04:00
|
|
|
} else {
|
|
|
|
const message: Models['WebSocketResponse_type'] = JSON.parse(
|
|
|
|
event.data
|
|
|
|
)
|
|
|
|
if (
|
|
|
|
message.success &&
|
2024-06-19 13:57:50 -07:00
|
|
|
(message.resp.type === 'modeling' ||
|
|
|
|
message.resp.type === 'modeling_batch') &&
|
2024-03-19 20:39:49 -04:00
|
|
|
message.request_id
|
|
|
|
) {
|
2024-04-09 11:51:41 -07:00
|
|
|
this.handleModelingCommand(
|
|
|
|
message.resp,
|
|
|
|
message.request_id,
|
|
|
|
message
|
|
|
|
)
|
2024-03-19 20:39:49 -04:00
|
|
|
} else if (
|
|
|
|
!message.success &&
|
|
|
|
message.request_id &&
|
|
|
|
this.artifactMap[message.request_id]
|
|
|
|
) {
|
2024-04-17 09:11:35 -07:00
|
|
|
this.handleFailedModelingCommand(message.request_id, message)
|
2023-08-09 20:49:10 +10:00
|
|
|
}
|
|
|
|
}
|
2024-06-04 08:32:24 -04:00
|
|
|
}) as EventListener)
|
2023-08-22 20:07:39 -04:00
|
|
|
|
2024-06-04 08:32:24 -04:00
|
|
|
this.engineConnection?.addEventListener(
|
|
|
|
EngineConnectionEvents.NewTrack,
|
|
|
|
(({ detail: { mediaStream } }: CustomEvent<NewTrackArgs>) => {
|
|
|
|
mediaStream.getVideoTracks()[0].addEventListener('mute', () => {
|
2024-06-06 11:40:39 -04:00
|
|
|
console.error(
|
|
|
|
'video track mute: check webrtc internals -> inbound rtp'
|
|
|
|
)
|
2024-06-04 08:32:24 -04:00
|
|
|
})
|
|
|
|
|
|
|
|
setMediaStream(mediaStream)
|
|
|
|
}) as EventListener
|
|
|
|
)
|
2023-08-24 23:46:45 +10:00
|
|
|
|
2024-06-04 08:32:24 -04:00
|
|
|
this.engineConnection?.connect()
|
|
|
|
}
|
|
|
|
)
|
2023-06-22 16:43:33 +10:00
|
|
|
}
|
2024-06-04 08:32:24 -04: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)
|
|
|
|
}
|
2024-04-09 11:51:41 -07:00
|
|
|
handleModelingCommand(
|
|
|
|
message: OkWebSocketResponseData,
|
|
|
|
id: string,
|
|
|
|
raw: WebSocketResponse
|
|
|
|
) {
|
2024-06-19 13:57:50 -07:00
|
|
|
if (!(message.type === 'modeling' || message.type === 'modeling_batch')) {
|
2023-08-21 16:53:31 -04:00
|
|
|
return
|
|
|
|
}
|
2024-06-19 13:57:50 -07:00
|
|
|
|
2023-11-28 21:23:20 +11:00
|
|
|
const command = this.artifactMap[id]
|
2024-06-19 13:57:50 -07:00
|
|
|
let modelingResponse: Models['OkModelingCmdResponse_type'] = {
|
|
|
|
type: 'empty',
|
|
|
|
}
|
|
|
|
if ('modeling_response' in message.data) {
|
|
|
|
modelingResponse = message.data.modeling_response
|
|
|
|
}
|
2024-03-23 15:45:55 -07:00
|
|
|
if (
|
|
|
|
command?.type === 'pending' &&
|
|
|
|
command.commandType === 'batch' &&
|
|
|
|
command?.additionalData?.type === 'batch-ids'
|
|
|
|
) {
|
2024-06-19 13:57:50 -07:00
|
|
|
if ('responses' in message.data) {
|
|
|
|
const batchResponse = message.data.responses as BatchResponseMap
|
|
|
|
// Iterate over the map of responses.
|
|
|
|
Object.entries(batchResponse).forEach(([key, response]) => {
|
|
|
|
// If the response is a success, we resolve the promise.
|
|
|
|
if ('response' in response && response.response) {
|
|
|
|
this.handleModelingCommand(
|
|
|
|
{
|
|
|
|
type: 'modeling',
|
|
|
|
data: {
|
|
|
|
modeling_response: response.response,
|
|
|
|
},
|
|
|
|
},
|
|
|
|
key,
|
|
|
|
{
|
|
|
|
request_id: key,
|
|
|
|
resp: {
|
|
|
|
type: 'modeling',
|
|
|
|
data: {
|
|
|
|
modeling_response: response.response,
|
|
|
|
},
|
|
|
|
},
|
|
|
|
success: true,
|
|
|
|
}
|
|
|
|
)
|
|
|
|
} else if ('errors' in response) {
|
|
|
|
this.handleFailedModelingCommand(key, {
|
|
|
|
request_id: key,
|
|
|
|
success: false,
|
|
|
|
errors: response.errors,
|
|
|
|
})
|
|
|
|
}
|
|
|
|
})
|
|
|
|
} else {
|
|
|
|
command.additionalData.ids.forEach((id) => {
|
|
|
|
this.handleModelingCommand(message, id, raw)
|
|
|
|
})
|
|
|
|
}
|
2024-03-23 15:45:55 -07:00
|
|
|
// batch artifact is just a container, we don't need to keep it
|
|
|
|
// once we process all the commands inside it
|
|
|
|
const resolve = command.resolve
|
|
|
|
delete this.artifactMap[id]
|
|
|
|
resolve({
|
|
|
|
id,
|
|
|
|
commandType: command.commandType,
|
|
|
|
range: command.range,
|
2024-04-09 11:51:41 -07:00
|
|
|
raw,
|
2024-03-23 15:45:55 -07:00
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
2024-02-13 18:47:37 +11:00
|
|
|
const sceneCommand = this.sceneCommandArtifacts[id]
|
2023-11-24 08:59:24 +11:00
|
|
|
this.addCommandLog({
|
|
|
|
type: 'receive-reliable',
|
|
|
|
data: message,
|
|
|
|
id,
|
2024-04-22 20:14:06 +10:00
|
|
|
cmd_type:
|
|
|
|
command?.commandType ||
|
|
|
|
this.lastArtifactMap[id]?.commandType ||
|
|
|
|
sceneCommand?.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
|
2024-06-21 21:49:12 -07:00
|
|
|
const oldArtifact = this.artifactMap[id] as ArtifactMapCommand & {
|
|
|
|
extrusions?: string[]
|
|
|
|
}
|
2024-02-11 15:08:54 -08:00
|
|
|
const artifact = {
|
2023-08-21 16:53:31 -04:00
|
|
|
type: 'result',
|
2023-09-08 17:50:37 +10:00
|
|
|
range: command.range,
|
2024-02-11 12:59:00 +11:00
|
|
|
pathToNode: command.pathToNode,
|
2023-09-08 17:50:37 +10:00
|
|
|
commandType: command.commandType,
|
|
|
|
parentId: command.parentId ? command.parentId : undefined,
|
2023-08-25 00:16:37 -04:00
|
|
|
data: modelingResponse,
|
2024-04-09 11:51:41 -07:00
|
|
|
raw,
|
2024-06-21 21:49:12 -07:00
|
|
|
} as ArtifactMapCommand & { extrusions?: string[] }
|
|
|
|
if (oldArtifact?.extrusions) {
|
|
|
|
artifact.extrusions = oldArtifact.extrusions
|
|
|
|
}
|
2024-02-11 15:08:54 -08:00
|
|
|
this.artifactMap[id] = artifact
|
2024-02-15 07:24:54 +11:00
|
|
|
if (
|
2024-03-22 10:23:04 +11:00
|
|
|
(command.commandType === 'entity_linear_pattern' &&
|
|
|
|
modelingResponse.type === 'entity_linear_pattern') ||
|
|
|
|
(command.commandType === 'entity_circular_pattern' &&
|
|
|
|
modelingResponse.type === 'entity_circular_pattern')
|
2024-02-15 07:24:54 +11:00
|
|
|
) {
|
2024-03-22 10:23:04 +11:00
|
|
|
const entities = modelingResponse.data.entity_ids
|
2024-02-11 15:08:54 -08:00
|
|
|
entities?.forEach((entity: string) => {
|
|
|
|
this.artifactMap[entity] = artifact
|
|
|
|
})
|
2023-08-21 16:53:31 -04:00
|
|
|
}
|
2024-03-22 10:23:04 +11:00
|
|
|
if (
|
|
|
|
command?.commandType === 'solid3d_get_extrusion_face_info' &&
|
|
|
|
modelingResponse.type === 'solid3d_get_extrusion_face_info'
|
|
|
|
) {
|
|
|
|
const parent = this.artifactMap[command?.parentId || '']
|
|
|
|
modelingResponse.data.faces.forEach((face) => {
|
|
|
|
if (face.cap !== 'none' && face.face_id && parent) {
|
|
|
|
this.artifactMap[face.face_id] = {
|
|
|
|
...parent,
|
|
|
|
commandType: 'solid3d_get_extrusion_face_info',
|
|
|
|
additionalData: {
|
|
|
|
type: 'cap',
|
|
|
|
info: face.cap === 'bottom' ? 'start' : 'end',
|
|
|
|
},
|
|
|
|
}
|
|
|
|
}
|
|
|
|
const curveArtifact = this.artifactMap[face?.curve_id || '']
|
|
|
|
if (curveArtifact && face?.face_id) {
|
|
|
|
this.artifactMap[face.face_id] = {
|
|
|
|
...curveArtifact,
|
|
|
|
commandType: 'solid3d_get_extrusion_face_info',
|
|
|
|
}
|
|
|
|
}
|
|
|
|
})
|
|
|
|
}
|
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,
|
2024-04-09 11:51:41 -07:00
|
|
|
raw,
|
2023-08-21 16:53:31 -04:00
|
|
|
})
|
2024-02-13 18:47:37 +11:00
|
|
|
} else if (sceneCommand && sceneCommand.type === 'pending') {
|
|
|
|
const resolve = sceneCommand.resolve
|
|
|
|
const artifact = {
|
|
|
|
type: 'result',
|
|
|
|
range: sceneCommand.range,
|
|
|
|
pathToNode: sceneCommand.pathToNode,
|
|
|
|
commandType: sceneCommand.commandType,
|
|
|
|
parentId: sceneCommand.parentId ? sceneCommand.parentId : undefined,
|
|
|
|
data: modelingResponse,
|
2024-04-09 11:51:41 -07:00
|
|
|
raw,
|
2024-02-13 18:47:37 +11:00
|
|
|
} as const
|
|
|
|
this.sceneCommandArtifacts[id] = artifact
|
|
|
|
resolve({
|
|
|
|
id,
|
|
|
|
commandType: sceneCommand.commandType,
|
|
|
|
range: sceneCommand.range,
|
|
|
|
data: modelingResponse,
|
2024-04-09 11:51:41 -07:00
|
|
|
raw,
|
2024-02-13 18:47:37 +11:00
|
|
|
})
|
|
|
|
} else if (command) {
|
2023-08-21 16:53:31 -04:00
|
|
|
this.artifactMap[id] = {
|
|
|
|
type: 'result',
|
2023-09-08 17:50:37 +10:00
|
|
|
commandType: command?.commandType,
|
|
|
|
range: command?.range,
|
2024-02-11 12:59:00 +11:00
|
|
|
pathToNode: command?.pathToNode,
|
2023-08-25 00:16:37 -04:00
|
|
|
data: modelingResponse,
|
2024-04-09 11:51:41 -07:00
|
|
|
raw,
|
2023-08-21 16:53:31 -04:00
|
|
|
}
|
2024-02-13 18:47:37 +11:00
|
|
|
} else {
|
|
|
|
this.sceneCommandArtifacts[id] = {
|
|
|
|
type: 'result',
|
|
|
|
commandType: sceneCommand?.commandType,
|
|
|
|
range: sceneCommand?.range,
|
|
|
|
pathToNode: sceneCommand?.pathToNode,
|
|
|
|
data: modelingResponse,
|
2024-04-09 11:51:41 -07:00
|
|
|
raw,
|
2024-02-13 18:47:37 +11:00
|
|
|
}
|
2023-08-21 16:53:31 -04:00
|
|
|
}
|
|
|
|
}
|
2024-04-17 09:11:35 -07:00
|
|
|
handleFailedModelingCommand(id: string, raw: WebSocketResponse) {
|
2024-04-09 11:51:41 -07:00
|
|
|
const failed = raw as Models['FailureWebSocketResponse_type']
|
|
|
|
const errors = failed.errors
|
2023-09-21 14:32:47 +10:00
|
|
|
if (!id) return
|
|
|
|
const command = this.artifactMap[id]
|
|
|
|
if (command && command.type === 'pending') {
|
|
|
|
this.artifactMap[id] = {
|
|
|
|
type: 'failed',
|
|
|
|
range: command.range,
|
2024-02-11 12:59:00 +11:00
|
|
|
pathToNode: command.pathToNode,
|
2023-09-21 14:32:47 +10:00
|
|
|
commandType: command.commandType,
|
|
|
|
parentId: command.parentId ? command.parentId : undefined,
|
|
|
|
errors,
|
|
|
|
}
|
2024-04-17 09:11:35 -07:00
|
|
|
if (
|
|
|
|
command?.type === 'pending' &&
|
|
|
|
command.commandType === 'batch' &&
|
|
|
|
command?.additionalData?.type === 'batch-ids'
|
|
|
|
) {
|
|
|
|
command.additionalData.ids.forEach((id) => {
|
|
|
|
this.handleFailedModelingCommand(id, raw)
|
|
|
|
})
|
|
|
|
}
|
|
|
|
// batch artifact is just a container, we don't need to keep it
|
|
|
|
// once we process all the commands inside it
|
|
|
|
const resolve = command.resolve
|
|
|
|
delete this.artifactMap[id]
|
2023-09-21 14:32:47 +10:00
|
|
|
resolve({
|
|
|
|
id,
|
|
|
|
commandType: command.commandType,
|
|
|
|
range: command.range,
|
|
|
|
errors,
|
2024-04-09 11:51:41 -07:00
|
|
|
raw,
|
2023-09-21 14:32:47 +10:00
|
|
|
})
|
|
|
|
} else {
|
|
|
|
this.artifactMap[id] = {
|
|
|
|
type: 'failed',
|
|
|
|
range: command.range,
|
2024-02-11 12:59:00 +11:00
|
|
|
pathToNode: command.pathToNode,
|
2023-09-21 14:32:47 +10:00
|
|
|
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
|
|
|
}
|
2024-04-15 17:18:32 -07:00
|
|
|
async startNewSession() {
|
2023-11-28 21:23:20 +11:00
|
|
|
this.lastArtifactMap = this.artifactMap
|
2023-06-22 16:43:33 +10:00
|
|
|
this.artifactMap = {}
|
2024-04-15 17:18:32 -07:00
|
|
|
await this.initPlanes()
|
2023-06-22 16:43:33 +10:00
|
|
|
}
|
2023-08-31 05:19:37 +10:00
|
|
|
subscribeTo<T extends ModelTypes>({
|
|
|
|
event,
|
|
|
|
callback,
|
|
|
|
}: Subscription<T>): () => void {
|
|
|
|
const localUnsubscribeId = uuidv4()
|
2024-05-09 15:04:33 +10:00
|
|
|
if (!this.subscriptions[event]) {
|
|
|
|
this.subscriptions[event] = {}
|
2023-08-31 05:19:37 +10:00
|
|
|
}
|
2024-05-09 15:04:33 +10:00
|
|
|
this.subscriptions[event][localUnsubscribeId] = callback
|
|
|
|
|
2023-08-31 05:19:37 +10:00
|
|
|
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()
|
2024-05-09 15:04:33 +10:00
|
|
|
if (!this.unreliableSubscriptions[event]) {
|
|
|
|
this.unreliableSubscriptions[event] = {}
|
2023-08-31 05:19:37 +10:00
|
|
|
}
|
2024-05-09 15:04:33 +10:00
|
|
|
this.unreliableSubscriptions[event][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
|
|
|
}
|
2024-04-15 17:18:32 -07:00
|
|
|
// We make this a separate function so we can call it from wasm.
|
|
|
|
clearDefaultPlanes() {
|
|
|
|
this.defaultPlanes = null
|
|
|
|
}
|
|
|
|
async wasmGetDefaultPlanes(): Promise<string> {
|
|
|
|
if (this.defaultPlanes === null) {
|
|
|
|
await this.initPlanes()
|
|
|
|
}
|
|
|
|
return JSON.stringify(this.defaultPlanes)
|
|
|
|
}
|
2023-06-22 16:43:33 +10:00
|
|
|
endSession() {
|
2024-03-26 21:49:05 +11:00
|
|
|
const deleteCmd: EngineCommand = {
|
|
|
|
type: 'modeling_cmd_req',
|
|
|
|
cmd_id: uuidv4(),
|
|
|
|
cmd: {
|
|
|
|
type: 'scene_clear_all',
|
|
|
|
},
|
|
|
|
}
|
2024-04-15 17:18:32 -07:00
|
|
|
this.clearDefaultPlanes()
|
2024-03-26 21:49:05 +11:00
|
|
|
this.engineConnection?.send(deleteCmd)
|
2023-06-22 16:43:33 +10:00
|
|
|
}
|
2023-11-24 08:59:24 +11:00
|
|
|
addCommandLog(message: CommandLog) {
|
2024-04-15 17:18:32 -07:00
|
|
|
if (this.commandLogs.length > 500) {
|
|
|
|
this.commandLogs.shift()
|
2023-11-24 08:59:24 +11:00
|
|
|
}
|
2024-04-15 17:18:32 -07:00
|
|
|
this.commandLogs.push(message)
|
2023-11-24 08:59:24 +11:00
|
|
|
|
2024-04-15 17:18:32 -07:00
|
|
|
this._commandLogCallBack([...this.commandLogs])
|
2023-11-24 08:59:24 +11:00
|
|
|
}
|
|
|
|
clearCommandLogs() {
|
2024-04-15 17:18:32 -07:00
|
|
|
this.commandLogs = []
|
|
|
|
this._commandLogCallBack(this.commandLogs)
|
2023-11-24 08:59:24 +11:00
|
|
|
}
|
|
|
|
registerCommandLogCallback(callback: (command: CommandLog[]) => void) {
|
|
|
|
this._commandLogCallBack = callback
|
|
|
|
}
|
2024-02-11 12:59:00 +11:00
|
|
|
sendSceneCommand(
|
|
|
|
command: EngineCommand,
|
|
|
|
forceWebsocket = false
|
|
|
|
): 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' ||
|
2024-02-11 12:59:00 +11:00
|
|
|
command.cmd.type === 'camera_drag_move' ||
|
|
|
|
command.cmd.type === ('default_camera_perspective_settings' as any))
|
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
|
|
|
|
) {
|
|
|
|
lastMessage = command.cmd.type
|
|
|
|
}
|
2024-02-11 12:59:00 +11:00
|
|
|
if (command.type === 'modeling_cmd_batch_req') {
|
|
|
|
this.engineConnection?.send(command)
|
|
|
|
// TODO - handlePendingCommands does not handle batch commands
|
|
|
|
// return this.handlePendingCommand(command.requests[0].cmd_id, command.cmd)
|
|
|
|
return Promise.resolve()
|
|
|
|
}
|
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' ||
|
2024-02-11 12:59:00 +11:00
|
|
|
cmd.type === 'handle_mouse_drag_move' ||
|
2024-07-01 15:22:31 -04:00
|
|
|
cmd.type === 'default_camera_zoom' ||
|
2024-02-11 12:59:00 +11:00
|
|
|
cmd.type === ('default_camera_perspective_settings' as any)) &&
|
|
|
|
this.engineConnection?.unreliableDataChannel &&
|
|
|
|
!forceWebsocket
|
2023-08-18 16:16:16 -04:00
|
|
|
) {
|
2024-02-11 12:59:00 +11:00
|
|
|
;(cmd as any).sequence = this.outSequence
|
2023-08-09 20:49:10 +10:00
|
|
|
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()
|
2024-05-29 18:04:27 -04:00
|
|
|
} else if (cmd.type === 'export') {
|
|
|
|
const promise = new Promise((resolve, reject) => {
|
|
|
|
this.pendingExport = { resolve, reject }
|
|
|
|
})
|
|
|
|
this.engineConnection?.send(command)
|
|
|
|
return promise
|
2023-06-23 09:56:37 +10:00
|
|
|
}
|
2024-02-11 12:59:00 +11:00
|
|
|
if (
|
|
|
|
command.cmd.type === 'default_camera_look_at' ||
|
|
|
|
command.cmd.type === ('default_camera_perspective_settings' as any)
|
|
|
|
) {
|
|
|
|
;(cmd as any).sequence = this.outSequence++
|
|
|
|
}
|
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)
|
2024-02-13 18:47:37 +11:00
|
|
|
return this.handlePendingSceneCommand(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,
|
2024-02-11 12:59:00 +11:00
|
|
|
ast,
|
2024-03-23 15:45:55 -07:00
|
|
|
idToRangeMap,
|
2023-06-22 16:43:33 +10:00
|
|
|
}: {
|
|
|
|
id: string
|
|
|
|
range: SourceRange
|
2024-04-19 13:30:59 -07:00
|
|
|
command: EngineCommand
|
2024-02-11 12:59:00 +11:00
|
|
|
ast: Program
|
2024-03-23 15:45:55 -07:00
|
|
|
idToRangeMap?: { [key: string]: SourceRange }
|
2024-06-19 13:57:50 -07:00
|
|
|
}): Promise<ResolveCommand | void> {
|
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') {
|
2024-02-11 12:59:00 +11:00
|
|
|
return this.handlePendingCommand(id, command?.cmd, ast, range)
|
2024-03-23 15:45:55 -07:00
|
|
|
} else if (
|
|
|
|
typeof command !== 'string' &&
|
|
|
|
command.type === 'modeling_cmd_batch_req'
|
|
|
|
) {
|
|
|
|
return this.handlePendingBatchCommand(id, command.requests, idToRangeMap)
|
2023-09-08 17:50:37 +10:00
|
|
|
} else if (typeof command === 'string') {
|
|
|
|
const parseCommand: EngineCommand = JSON.parse(command)
|
2024-03-23 15:45:55 -07:00
|
|
|
if (parseCommand.type === 'modeling_cmd_req') {
|
2024-02-11 12:59:00 +11:00
|
|
|
return this.handlePendingCommand(id, parseCommand?.cmd, ast, range)
|
2024-03-23 15:45:55 -07:00
|
|
|
} else if (parseCommand.type === 'modeling_cmd_batch_req') {
|
|
|
|
return this.handlePendingBatchCommand(
|
|
|
|
id,
|
|
|
|
parseCommand.requests,
|
|
|
|
idToRangeMap
|
|
|
|
)
|
|
|
|
}
|
2023-09-08 17:50:37 +10:00
|
|
|
}
|
2024-06-24 11:45:40 -04:00
|
|
|
return Promise.reject(new Error('Expected unreachable reached'))
|
2023-08-31 05:19:37 +10:00
|
|
|
}
|
2024-02-13 18:47:37 +11:00
|
|
|
handlePendingSceneCommand(
|
|
|
|
id: string,
|
|
|
|
command: Models['ModelingCmd_type'],
|
|
|
|
ast?: Program,
|
|
|
|
range?: SourceRange
|
|
|
|
) {
|
|
|
|
let resolve: (val: any) => void = () => {}
|
|
|
|
const promise = new Promise((_resolve, reject) => {
|
|
|
|
resolve = _resolve
|
|
|
|
})
|
|
|
|
const pathToNode = ast
|
|
|
|
? getNodePathFromSourceRange(ast, range || [0, 0])
|
|
|
|
: []
|
|
|
|
this.sceneCommandArtifacts[id] = {
|
|
|
|
range: range || [0, 0],
|
|
|
|
pathToNode,
|
|
|
|
type: 'pending',
|
|
|
|
commandType: command.type,
|
|
|
|
promise,
|
|
|
|
resolve,
|
|
|
|
}
|
|
|
|
return promise
|
|
|
|
}
|
2023-09-08 17:50:37 +10:00
|
|
|
handlePendingCommand(
|
|
|
|
id: string,
|
|
|
|
command: Models['ModelingCmd_type'],
|
2024-02-11 12:59:00 +11:00
|
|
|
ast?: Program,
|
2023-09-08 17:50:37 +10:00
|
|
|
range?: SourceRange
|
2024-06-19 13:57:50 -07:00
|
|
|
): Promise<ResolveCommand | void> {
|
2023-06-22 16:43:33 +10:00
|
|
|
let resolve: (val: any) => void = () => {}
|
2024-06-19 13:57:50 -07:00
|
|
|
const promise: Promise<ResolveCommand | void> = new Promise(
|
|
|
|
(_resolve, reject) => {
|
|
|
|
resolve = _resolve
|
|
|
|
}
|
|
|
|
)
|
2023-09-08 17:50:37 +10:00
|
|
|
const getParentId = (): string | undefined => {
|
2024-03-22 10:23:04 +11:00
|
|
|
if (command.type === 'extend_path') return command.path
|
|
|
|
if (command.type === 'solid3d_get_extrusion_face_info') {
|
|
|
|
const edgeArtifact = this.artifactMap[command.edge_id]
|
|
|
|
// edges's parent id is to the original "start_path" artifact
|
2024-06-04 13:57:01 -04:00
|
|
|
if (edgeArtifact && edgeArtifact.parentId) {
|
|
|
|
return edgeArtifact.parentId
|
|
|
|
}
|
2023-09-08 17:50:37 +10:00
|
|
|
}
|
2024-03-22 10:23:04 +11:00
|
|
|
if (command.type === 'close_path') return command.path_id
|
2024-06-04 13:57:01 -04:00
|
|
|
if (command.type === 'extrude') return command.target
|
2024-03-22 10:23:04 +11:00
|
|
|
// handle other commands that have a parent here
|
2023-09-08 17:50:37 +10:00
|
|
|
}
|
2024-02-11 12:59:00 +11:00
|
|
|
const pathToNode = ast
|
|
|
|
? getNodePathFromSourceRange(ast, range || [0, 0])
|
|
|
|
: []
|
2023-06-22 16:43:33 +10:00
|
|
|
this.artifactMap[id] = {
|
2023-09-08 17:50:37 +10:00
|
|
|
range: range || [0, 0],
|
2024-02-11 12:59:00 +11:00
|
|
|
pathToNode,
|
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,
|
|
|
|
}
|
2024-06-04 13:57:01 -04:00
|
|
|
if (command.type === 'extrude') {
|
|
|
|
this.artifactMap[id] = {
|
|
|
|
range: range || [0, 0],
|
|
|
|
pathToNode,
|
|
|
|
type: 'pending',
|
|
|
|
commandType: 'extrude',
|
|
|
|
parentId: getParentId(),
|
|
|
|
promise,
|
|
|
|
target: command.target,
|
|
|
|
resolve,
|
|
|
|
}
|
|
|
|
const target = this.artifactMap[command.target]
|
|
|
|
if (target.commandType === 'start_path') {
|
|
|
|
// tsc cannot infer that target can have extrusions
|
|
|
|
// from the commandType (why?) so we need to cast it
|
|
|
|
const typedTarget = target as (
|
|
|
|
| PendingCommand
|
|
|
|
| ResultCommand
|
|
|
|
| FailedCommand
|
|
|
|
) & { extrusions?: string[] }
|
|
|
|
if (typedTarget?.extrusions?.length) {
|
|
|
|
typedTarget.extrusions.push(id)
|
|
|
|
} else {
|
|
|
|
typedTarget.extrusions = [id]
|
|
|
|
}
|
2024-06-21 21:49:12 -07:00
|
|
|
// Update in the map.
|
|
|
|
this.artifactMap[command.target] = typedTarget
|
2024-06-04 13:57:01 -04:00
|
|
|
}
|
|
|
|
}
|
2023-06-22 16:43:33 +10:00
|
|
|
return promise
|
|
|
|
}
|
2024-03-23 15:45:55 -07:00
|
|
|
async handlePendingBatchCommand(
|
|
|
|
id: string,
|
|
|
|
commands: Models['ModelingCmdReq_type'][],
|
|
|
|
idToRangeMap?: { [key: string]: SourceRange },
|
|
|
|
ast?: Program,
|
|
|
|
range?: SourceRange
|
2024-06-19 13:57:50 -07:00
|
|
|
): Promise<ResolveCommand | void> {
|
2024-03-23 15:45:55 -07:00
|
|
|
let resolve: (val: any) => void = () => {}
|
2024-06-19 13:57:50 -07:00
|
|
|
const promise: Promise<ResolveCommand | void> = new Promise(
|
|
|
|
(_resolve, reject) => {
|
|
|
|
resolve = _resolve
|
|
|
|
}
|
|
|
|
)
|
2024-03-23 15:45:55 -07:00
|
|
|
|
|
|
|
if (!idToRangeMap) {
|
2024-06-24 11:45:40 -04:00
|
|
|
return Promise.reject(
|
|
|
|
new Error('idToRangeMap is required for batch commands')
|
|
|
|
)
|
2024-03-23 15:45:55 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
// Add the overall batch command to the artifact map just so we can track all of the
|
|
|
|
// individual commands that are part of the batch.
|
|
|
|
// we'll delete this artifact once all of the individual commands have been processed.
|
|
|
|
this.artifactMap[id] = {
|
|
|
|
range: range || [0, 0],
|
|
|
|
pathToNode: [],
|
|
|
|
type: 'pending',
|
|
|
|
commandType: 'batch',
|
|
|
|
additionalData: { type: 'batch-ids', ids: commands.map((c) => c.cmd_id) },
|
|
|
|
parentId: undefined,
|
|
|
|
promise,
|
|
|
|
resolve,
|
|
|
|
}
|
|
|
|
|
2024-06-19 13:57:50 -07:00
|
|
|
Promise.all(
|
2024-03-23 15:45:55 -07:00
|
|
|
commands.map((c) =>
|
|
|
|
this.handlePendingCommand(c.cmd_id, c.cmd, ast, idToRangeMap[c.cmd_id])
|
|
|
|
)
|
|
|
|
)
|
|
|
|
return promise
|
|
|
|
}
|
2024-06-24 11:45:40 -04:00
|
|
|
async sendModelingCommandFromWasm(
|
2023-08-24 15:34:51 -07:00
|
|
|
id: string,
|
|
|
|
rangeStr: string,
|
2024-03-23 15:45:55 -07:00
|
|
|
commandStr: string,
|
|
|
|
idToRangeStr: string
|
2024-06-19 13:57:50 -07:00
|
|
|
): Promise<string | void> {
|
2023-09-25 19:49:53 -07:00
|
|
|
if (this.engineConnection === undefined) {
|
|
|
|
return Promise.resolve()
|
|
|
|
}
|
2024-03-11 17:50:31 -07:00
|
|
|
if (!this.engineConnection?.isReady()) {
|
|
|
|
return Promise.resolve()
|
|
|
|
}
|
2023-08-24 15:34:51 -07:00
|
|
|
if (id === undefined) {
|
2024-06-24 11:45:40 -04:00
|
|
|
return Promise.reject(new Error('id is undefined'))
|
2023-08-24 15:34:51 -07:00
|
|
|
}
|
|
|
|
if (rangeStr === undefined) {
|
2024-06-24 11:45:40 -04:00
|
|
|
return Promise.reject(new Error('rangeStr is undefined'))
|
2023-08-24 15:34:51 -07:00
|
|
|
}
|
|
|
|
if (commandStr === undefined) {
|
2024-06-24 11:45:40 -04:00
|
|
|
return Promise.reject(new Error('commandStr is undefined'))
|
2023-08-24 15:34:51 -07:00
|
|
|
}
|
|
|
|
const range: SourceRange = JSON.parse(rangeStr)
|
2024-03-23 15:45:55 -07:00
|
|
|
const idToRangeMap: { [key: string]: SourceRange } =
|
|
|
|
JSON.parse(idToRangeStr)
|
2023-08-24 15:34:51 -07:00
|
|
|
|
2024-04-19 13:30:59 -07:00
|
|
|
const command: EngineCommand = JSON.parse(commandStr)
|
|
|
|
|
2023-09-17 21:57:43 -07:00
|
|
|
// We only care about the modeling command response.
|
2024-02-11 12:59:00 +11:00
|
|
|
return this.sendModelingCommand({
|
|
|
|
id,
|
|
|
|
range,
|
2024-04-19 13:30:59 -07:00
|
|
|
command,
|
2024-02-11 12:59:00 +11:00
|
|
|
ast: this.getAst(),
|
2024-03-23 15:45:55 -07:00
|
|
|
idToRangeMap,
|
2024-06-19 13:57:50 -07:00
|
|
|
}).then((resp) => {
|
|
|
|
if (!resp) {
|
2024-06-24 11:45:40 -04:00
|
|
|
return Promise.reject(
|
|
|
|
new Error(
|
|
|
|
'returning modeling cmd response to the rust side is undefined or null'
|
|
|
|
)
|
2024-04-09 11:51:41 -07:00
|
|
|
)
|
|
|
|
}
|
2024-06-19 13:57:50 -07:00
|
|
|
return JSON.stringify(resp.raw)
|
2024-04-09 11:51:41 -07:00
|
|
|
})
|
2023-08-24 15:34:51 -07:00
|
|
|
}
|
2024-06-24 11:45:40 -04:00
|
|
|
async commandResult(id: string): Promise<any> {
|
2023-06-22 16:43:33 +10:00
|
|
|
const command = this.artifactMap[id]
|
|
|
|
if (!command) {
|
2024-06-24 11:45:40 -04:00
|
|
|
return Promise.reject(new Error('No command found'))
|
2023-06-22 16:43:33 +10:00
|
|
|
}
|
|
|
|
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,
|
|
|
|
}
|
|
|
|
}
|
2024-02-17 07:04:24 +11:00
|
|
|
private async initPlanes() {
|
2024-03-28 16:52:36 +11:00
|
|
|
if (this.planesInitialized()) return
|
2024-04-15 17:18:32 -07:00
|
|
|
const planes = await this.makeDefaultPlanes()
|
|
|
|
this.defaultPlanes = planes
|
2024-02-17 07:04:24 +11:00
|
|
|
|
|
|
|
this.subscribeTo({
|
|
|
|
event: 'select_with_point',
|
|
|
|
callback: ({ data }) => {
|
|
|
|
if (!data?.entity_id) return
|
2024-04-15 17:18:32 -07:00
|
|
|
if (!planes) return
|
|
|
|
if (![planes.xy, planes.yz, planes.xz].includes(data.entity_id)) return
|
2024-02-17 07:04:24 +11:00
|
|
|
this.onPlaneSelectCallback(data.entity_id)
|
|
|
|
},
|
|
|
|
})
|
|
|
|
}
|
|
|
|
planesInitialized(): boolean {
|
|
|
|
return (
|
2024-03-28 16:52:36 +11:00
|
|
|
!!this.defaultPlanes &&
|
2024-02-17 07:04:24 +11:00
|
|
|
this.defaultPlanes.xy !== '' &&
|
|
|
|
this.defaultPlanes.yz !== '' &&
|
|
|
|
this.defaultPlanes.xz !== ''
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
|
|
|
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,
|
|
|
|
},
|
|
|
|
})
|
|
|
|
}
|
2023-06-22 16:43:33 +10:00
|
|
|
}
|