diff --git a/fission/biome.json b/fission/biome.json index de8213a982..198d12075a 100644 --- a/fission/biome.json +++ b/fission/biome.json @@ -288,6 +288,15 @@ "enabled": false } }, + { + "includes": ["src/systems/multiplayer/bindings/**/*"], + "formatter": { + "enabled": false + }, + "linter": { + "enabled": false + } + }, { "includes": ["src/test/**/*"], "linter": { diff --git a/fission/manifest.d.ts b/fission/manifest.d.ts index 19b1dbc6ef..f9a1f4acbc 100644 --- a/fission/manifest.d.ts +++ b/fission/manifest.d.ts @@ -1 +1,2 @@ -export type ManifestFileType = Record<"robots" | "private" | "fields", { filename: string; hash: string }[]> +export type ManifestFileEntry = { filename: string; hash: string; year?: number; thumbnail?: string } +export type ManifestFileType = Record<"robots" | "private" | "fields", ManifestFileEntry[]> diff --git a/fission/public/assetpack.zip b/fission/public/assetpack.zip index 70d0d1e26d..cc5ee2fde7 100644 --- a/fission/public/assetpack.zip +++ b/fission/public/assetpack.zip @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2213eb7905d6fe15beb5fca656065c248da0c4f4b7630ced943bb5cf4a32c3fb -size 43082276 +oid sha256:4c12f2df25d9f98365e4ed3bf9564abe19e062b4db86de29a3fd65f02a577b2e +size 43783327 diff --git a/fission/src/Synthesis.tsx b/fission/src/Synthesis.tsx index 561f853227..ab613cc85b 100644 --- a/fission/src/Synthesis.tsx +++ b/fission/src/Synthesis.tsx @@ -19,6 +19,8 @@ import { StateProvider } from "./ui/StateProvider.tsx" import { ThemeProvider } from "./ui/ThemeProvider.tsx" import { UIProvider } from "./ui/UIProvider.tsx" import CommandPalette from "@/ui/components/CommandPalette.tsx" +import { TourProvider } from "./ui/tour/TourProvider.tsx" +import TourOverlay from "./ui/tour/TourOverlay.tsx" import SessionStorage, { applyAutoToast } from "@/util/SessionStorage.ts" import { globalOpenModal } from "@/components/GlobalUIControls.ts" import { startMultiplayerWorld } from "@/ui/helpers/StartMultiplayerWorld.ts" @@ -99,22 +101,25 @@ const Synthesis = () => { > - - - - - - - - - - - - + + + + + + + + + + + + + + - {!consentPopupDisable && ( - - )} + {!consentPopupDisable && ( + + )} + diff --git a/fission/src/mirabuf/DefaultAssetLoader.ts b/fission/src/mirabuf/DefaultAssetLoader.ts index a794d06208..0a40ae1124 100644 --- a/fission/src/mirabuf/DefaultAssetLoader.ts +++ b/fission/src/mirabuf/DefaultAssetLoader.ts @@ -2,7 +2,10 @@ import { type MirabufCacheInfo, MiraType } from "@/mirabuf/MirabufLoader.ts" import type { ManifestFileType } from "../../manifest.d.ts" import { API_URL } from "@/util/Consts.ts" -export type DefaultAssetInfo = Required> +export type DefaultAssetInfo = Required> & { + year?: number + thumbnail?: string +} class DefaultAssetLoader { private static _assets: DefaultAssetInfo[] = [] @@ -34,6 +37,8 @@ class DefaultAssetLoader { hash: obj.hash, miraType, name: obj.filename, + year: obj.year, + thumbnail: obj.thumbnail ? `${baseUrl}/${dir}/${obj.thumbnail}` : undefined, }) }) }) diff --git a/fission/src/mirabuf/MiraType.ts b/fission/src/mirabuf/MiraType.ts new file mode 100644 index 0000000000..871dae16d0 --- /dev/null +++ b/fission/src/mirabuf/MiraType.ts @@ -0,0 +1,14 @@ +/** + * Kind of Mirabuf assembly. + * + * Kept in its own leaf module: `MirabufLoader` initializes its storage backend with a + * top-level `await`, so importing this enum from there makes the importer wait on that + * async module. Modules that need only the enum at evaluation time (e.g. the tour step + * table) would otherwise observe it as `undefined` when an import cycle is involved. + * + * The numeric values are persisted in the localStorage asset cache - do not renumber. + */ +export enum MiraType { + ROBOT = 1, + FIELD, +} diff --git a/fission/src/mirabuf/MirabufLoader.ts b/fission/src/mirabuf/MirabufLoader.ts index cc3409ccf5..e349d60252 100644 --- a/fission/src/mirabuf/MirabufLoader.ts +++ b/fission/src/mirabuf/MirabufLoader.ts @@ -1,17 +1,12 @@ import { type Data, downloadData } from "@/aps/APSDataManagement" -import { globalAddToast, globalOpenPanel } from "@/components/GlobalUIControls" +import { globalAddToast } from "@/components/GlobalUIControls" import { mirabuf } from "@/proto/mirabuf" import World from "@/systems/World" import { type MirabufStorageBackend, initStorageBackend } from "@/mirabuf/MirabufStorageBackend" +import { MiraType } from "@/mirabuf/MiraType" import { hashBuffer, unzipMira } from "@/util/Utility.ts" -import InitialConfigPanel from "@/panels/configuring/initial-config/InitialConfigPanel.tsx" -import { PAUSE_REF_ASSEMBLY_SPAWNING } from "@/systems/physics/PhysicsTypes.ts" -import { createMirabuf } from "@/mirabuf/MirabufSceneObject.ts" -import { getTargetControls } from "@/systems/scene/CameraControls.ts" -import { ProgressHandle } from "@/components/ProgressNotificationData.ts" -import type { EncodedAssembly, Message } from "@/systems/multiplayer/MultiplayerTypes" -import { consolePrefixer } from "console-prefixer" import { detectAndTagWheels } from "@/systems/simulation/synthesis_brain/WheelDetector" +import { consolePrefixer } from "console-prefixer" const console = consolePrefixer({ defaultPrefix: { @@ -28,7 +23,15 @@ export interface MirabufCacheInfo { name: string miraType: MiraType remotePath?: string - thumbnailStorageID?: string + year?: number + thumbnail?: string +} + +export interface CacheRemoteOptions { + name?: string + expectedHash?: string + year?: number + thumbnail?: string } export interface MirabufRemoteInfo { @@ -165,23 +168,23 @@ class MirabufCachingService { * * @param {string} fetchLocation Location of Mirabuf file. * @param {MiraType} miraType Type of Mirabuf Assembly. - * @param {string} name Optional display name for the cached file. + * @param {CacheRemoteOptions} options Optional metadata to store alongside the cached file. * * @returns {Promise} Promise with the result of the promise. Metadata on the mirabuf file if successful, undefined if not. */ public static async cacheRemote( fetchLocation: string, miraType: MiraType, - name?: string, - expectedHash?: string + options: CacheRemoteOptions = {} ): Promise { + const { expectedHash, year, thumbnail } = options try { // grab file remote const resp = await fetch(encodeURI(fetchLocation), import.meta.env.DEV ? { cache: "no-store" } : undefined) if (!resp.ok) throw new Error(`${resp.status} ${resp.statusText}`) const miraBuff = await resp.arrayBuffer() - name ??= this.assemblyFromBuffer(miraBuff).info?.name ?? fetchLocation + const name = options.name ?? this.assemblyFromBuffer(miraBuff).info?.name ?? fetchLocation World.analyticsSystem?.event("Remote Download", { assemblyName: name, @@ -195,6 +198,8 @@ class MirabufCachingService { miraType, name, remotePath: fetchLocation, + year, + thumbnail, }, expectedHash ) @@ -214,6 +219,8 @@ class MirabufCachingService { hash: await hashBuffer(miraBuff), miraType: miraType, name: name, + year, + thumbnail, } } catch (e) { console.warn("Caching failed", e) @@ -360,6 +367,10 @@ class MirabufCachingService { return this._cacheMap.getAll(miraType) } + public static has(hash: string): boolean { + return this._cacheMap.get(hash) != null + } + /** * Removes a given Mirabuf item from the cache */ @@ -463,84 +474,6 @@ class MirabufCachingService { } } -export enum MiraType { - ROBOT = 1, - FIELD, -} +export { MiraType } export default MirabufCachingService - -export async function spawnCachedMira( - info: MirabufCacheInfo, - progressHandle: ProgressHandle = new ProgressHandle(info.name) -) { - // If spawning a field, then remove all other fields - if (info.miraType === MiraType.FIELD) { - if (World.multiplayerSystem != null && World.sceneRenderer.mirabufSceneObjects.getField() != null) { - globalAddToast("warning", "Cannot spawn a second field!") - progressHandle.fail("Cannot spawn a second field") - return - } - World.sceneRenderer.removeAllFields() - } - - World.physicsSystem.holdPause(PAUSE_REF_ASSEMBLY_SPAWNING) - await MirabufCachingService.get(info.hash) - .then(async assembly => { - if (!assembly) { - progressHandle.fail() - console.error("Failed to spawn robot") - - return - } - - await createMirabuf(info.hash, assembly, progressHandle).then(async mirabufSceneObject => { - if (!mirabufSceneObject) { - progressHandle.fail("No object!") - return - } - - World.sceneRenderer.registerSceneObject(mirabufSceneObject) - - const targetControls = getTargetControls() - - if (World.multiplayerSystem != null) { - const encodedAssembly = - mirabufSceneObject.miraType !== MiraType.FIELD - ? (mirabuf.Assembly.encode(assembly).finish() as EncodedAssembly) - : undefined - - const message: Message = { - type: "newObject", - timestamp: Date.now(), - data: { - sceneObjectId: mirabufSceneObject.id, - assembly: encodedAssembly, - assemblyHash: info.hash, - miraType: info.miraType, - initialPreferences: mirabufSceneObject.getPreferenceData(), - }, - } - World.multiplayerSystem?.broadcast(message) - World.multiplayerSystem?.registerOwnSceneObject(mirabufSceneObject.id) - } - - if (targetControls && (info.miraType === MiraType.ROBOT || !targetControls.focusProvider)) { - targetControls.focusProvider = mirabufSceneObject - } - - progressHandle.done() - World.physicsSystem.deactivateGamepieces() - if (mirabufSceneObject.miraType == MiraType.ROBOT) { - globalOpenPanel(InitialConfigPanel, undefined) - } - }) - }) - .catch(e => { - console.error(e) - progressHandle.fail() - }) - .finally(() => { - setTimeout(() => World.physicsSystem.releasePause(PAUSE_REF_ASSEMBLY_SPAWNING), 500) - }) -} diff --git a/fission/src/mirabuf/MirabufSceneObject.ts b/fission/src/mirabuf/MirabufSceneObject.ts index f7e2cab560..a0d50de424 100644 --- a/fission/src/mirabuf/MirabufSceneObject.ts +++ b/fission/src/mirabuf/MirabufSceneObject.ts @@ -103,6 +103,8 @@ class MirabufSceneObject extends SceneObject implements ContextSupplier { public readonly mirabufInstance: MirabufInstance public readonly mechanism: Mechanism + public assemblyHash?: string + private _brain: Brain | undefined public alliance: Alliance | undefined public station: Station | undefined @@ -1443,9 +1445,10 @@ export async function createMirabuf( ): Promise { const parser = new MirabufParser(assembly, progressHandle) - if (!parser.assembly.info?.GUID?.match(/\w{8}-\w{4}-\w{4}-\w{4}-\w{12}/)) { - await migrateUUID(parser, hash) - } + const resolvedHash = parser.assembly.info?.GUID?.match(/\w{8}-\w{4}-\w{4}-\w{4}-\w{12}/) + ? hash + : ((await migrateUUID(parser, hash)) ?? hash) + if (parser.maxErrorSeverity >= ParseErrorSeverity.UNIMPORTABLE) { console.error(`Assembly Parser produced significant errors for '${assembly.info!.name!}'`) return @@ -1456,10 +1459,12 @@ export async function createMirabuf( progressHandle?.update("Created Mirabuf Instance", URDFImportProgressBar.MIRABUF_INSTANCE) await yieldToMain() - return new MirabufSceneObject(mirabufInstance, progressHandle, multiplayerOwnerId) + const sceneObject = new MirabufSceneObject(mirabufInstance, progressHandle, multiplayerOwnerId) + sceneObject.assemblyHash = MirabufCachingService.has(resolvedHash) ? resolvedHash : undefined + return sceneObject } -async function migrateUUID(parser: MirabufParser, hash: string) { +async function migrateUUID(parser: MirabufParser, hash: string): Promise { parser.assembly.info ??= {} const newGUID = uuidV4({ random: hexStringToUint8Array(hash).slice(0, 16) }) // using deterministic random to prevent the same model from being assigned different uuids after being imported multiple times. Once initially set, uuid will be persistent across hash changes console.warn("Migrating UUID", parser.assembly.info.GUID, "->", newGUID) @@ -1477,6 +1482,7 @@ async function migrateUUID(parser: MirabufParser, hash: string) { if (cacheInfo == null) { globalAddToast("warning", "Migration Error", "Importing failed to save") } + return cacheInfo?.hash } /** * Body association to a rigid node with a given mirabuf scene object. diff --git a/fission/src/mirabuf/MirabufThumbnail.ts b/fission/src/mirabuf/MirabufThumbnail.ts new file mode 100644 index 0000000000..188f77a9b4 --- /dev/null +++ b/fission/src/mirabuf/MirabufThumbnail.ts @@ -0,0 +1,104 @@ +import { Reader } from "protobufjs/minimal" +import MirabufCachingService, { MiraType } from "@/mirabuf/MirabufLoader" +import type MirabufSceneObject from "@/mirabuf/MirabufSceneObject" +import { mirabuf } from "@/proto/mirabuf" +import { + THUMBNAIL_EXTENSION, + THUMBNAIL_IS_TRANSPARENT, + THUMBNAIL_SIZE, + thumbnailMimeType, +} from "@/systems/scene/ThumbnailCapture" +import World from "@/systems/World" +import { unzipMira } from "@/util/Utility" + +/** + * wire tag protobuf has for thumbnails + * derived so renumbering the schema can't desync from the generated code + */ +const ASSEMBLY_THUMBNAIL_TAG = Reader.create( + mirabuf.Assembly.encode(new mirabuf.Assembly({ thumbnail: new mirabuf.Thumbnail() })).finish() +).uint32() + +const thumbnailsByAssemblyHash = new Map>() + +export async function embedAssemblyThumbnail(target: MirabufSceneObject): Promise { + const blob = await World.sceneRenderer.captureAssemblyThumbnail(target) + if (!blob) return + + const assembly = target.mirabufInstance.parser.assembly + assembly.thumbnail = new mirabuf.Thumbnail({ + width: THUMBNAIL_SIZE, + height: THUMBNAIL_SIZE, + extension: THUMBNAIL_EXTENSION, + transparent: THUMBNAIL_IS_TRANSPARENT, + data: new Uint8Array(await blob.arrayBuffer()), + }) + + await recacheAssembly(target, blob) +} + +async function recacheAssembly(target: MirabufSceneObject, thumbnail: Blob): Promise { + const previousHash = target.assemblyHash + if (previousHash == null || !MirabufCachingService.has(previousHash)) return + + const assembly = target.mirabufInstance.parser.assembly + const info = await MirabufCachingService.storeAssemblyInCache(assembly, { + miraType: assembly.dynamic ? MiraType.ROBOT : MiraType.FIELD, + }) + if (!info) return + + if (info.hash !== previousHash) { + await MirabufCachingService.remove(previousHash) + thumbnailsByAssemblyHash.delete(previousHash) + } + + target.assemblyHash = info.hash + thumbnailsByAssemblyHash.set(info.hash, Promise.resolve(thumbnail)) +} + +export function getCachedThumbnail(hash: string): Promise { + if (!MirabufCachingService.has(hash)) return Promise.resolve(undefined) + + const cached = thumbnailsByAssemblyHash.get(hash) + if (cached) return cached + + const pending = readCachedThumbnail(hash).catch((e: unknown) => { + thumbnailsByAssemblyHash.delete(hash) + throw e + }) + thumbnailsByAssemblyHash.set(hash, pending) + return pending +} + +async function readCachedThumbnail(hash: string): Promise { + const encoded = await MirabufCachingService.getEncoded(hash) + if (!encoded) return undefined + + const thumbnail = decodeThumbnailField(unzipMira(new Uint8Array(encoded.buffer))) + if (!thumbnail) return undefined + + return new Blob([thumbnail.data as BlobPart], { + type: thumbnailMimeType(thumbnail.extension || THUMBNAIL_EXTENSION), + }) +} + +/** + * stripping the message structure to only get the wiretype (which are the low 3 bits) + * + * See "Message Structure" in the protobuf encoding spec: + * https://protobuf.dev/programming-guides/encoding/#structure + */ +const WIRE_TYPE_MASK = 0b111 + +/** Pulls only the thumbnail out of an encoded assembly. */ +function decodeThumbnailField(assemblyBuffer: Uint8Array): mirabuf.Thumbnail | undefined { + const reader = Reader.create(assemblyBuffer) + while (reader.pos < reader.len) { + const tag = reader.uint32() + if (tag === ASSEMBLY_THUMBNAIL_TAG) return mirabuf.Thumbnail.decode(reader, reader.uint32()) + + // not the thumbnail, so jumping past it instead of decoding + reader.skipType(tag & WIRE_TYPE_MASK) + } + return undefined +} diff --git a/fission/src/systems/EventSystem.ts b/fission/src/systems/EventSystem.ts index 6d9e7e8919..65ad7cf282 100644 --- a/fission/src/systems/EventSystem.ts +++ b/fission/src/systems/EventSystem.ts @@ -14,6 +14,7 @@ interface EventDataMap { // Mirabuf ProgressEvent: ProgressHandle MirabufObjectChangeEvent: MirabufSceneObject | null + SpawnPendingChangeEvent: boolean // APS MirabufFilesUpdateEvent: Data[] @@ -33,6 +34,8 @@ interface EventDataMap { ConfigurationSavedEvent: never InputSchemeChanged: { panelId?: string } + TourRestartEvent: never + // Match Mode ScoreChangedEvent: { red: number; blue: number } TimeChangedEvent: { time: number } diff --git a/fission/src/systems/input/InputSystem.ts b/fission/src/systems/input/InputSystem.ts index 9c2ee9b88c..73395c4fd4 100644 --- a/fission/src/systems/input/InputSystem.ts +++ b/fission/src/systems/input/InputSystem.ts @@ -7,6 +7,11 @@ import type Input from "./inputs/Input" const LOG_GAMEPAD_EVENTS = false +// returns true if 'esc' was consumed +type EscapeHandler = () => boolean + +export const ESCAPE_PRIORITY = { COMMAND_PALETTE: 30, TOUR: 20, MODAL: 10, PANEL: 0 } as const + /** * The input system listens for and records key presses and joystick positions to be used by robots. * It also maps robot behaviors (such as an arcade drivetrain or an arm) to specific keys through customizable input schemes. @@ -45,8 +50,18 @@ class InputSystem extends WorldSystem { return this.brainIndexSchemeMap.get(index) } - // Janky solution to centralize escape key closing logic, first in the list is higher priority, returning true consumes the keypress - public static escapeKeyListeners: (null | (() => boolean))[] = [null, null, null] + private static _escapeHandlers: { priority: number; handler: EscapeHandler }[] = [] + + /** highest priority is asked first. call the result to unregister. */ + public static addEscapeHandler(handler: EscapeHandler, priority = 0): () => void { + const entry = { priority, handler } + InputSystem._escapeHandlers.push(entry) + InputSystem._escapeHandlers.sort((a, b) => b.priority - a.priority) + + return () => { + InputSystem._escapeHandlers = InputSystem._escapeHandlers.filter(e => e !== entry) + } + } /** * Sets whether the command palette is open, which blocks all robot inputs @@ -139,8 +154,7 @@ class InputSystem extends WorldSystem { private checkEscapeKey(event: KeyboardEvent) { if (event.key == "Escape") { - const anyMatched = InputSystem.escapeKeyListeners.some(cb => cb != null && cb()) - if (anyMatched) { + if (InputSystem._escapeHandlers.some(entry => entry.handler())) { event.preventDefault() } } diff --git a/fission/src/systems/preferences/PreferenceTypes.ts b/fission/src/systems/preferences/PreferenceTypes.ts index 170764b38b..c798a1d31f 100644 --- a/fission/src/systems/preferences/PreferenceTypes.ts +++ b/fission/src/systems/preferences/PreferenceTypes.ts @@ -27,6 +27,7 @@ export type UserPreferences = { MultiplayerPort: number MultiplayerHost: string MultiplayerSecure: boolean + HasSeenOnboardingTour: boolean } export type UserPreference = keyof UserPreferences @@ -69,6 +70,7 @@ export function defaultUserPreferences(): UserPreferences { MultiplayerHost: "", MultiplayerPort: DEFAULT_MULTIPLAYER_PORT, MultiplayerSecure: false, + HasSeenOnboardingTour: false, } } diff --git a/fission/src/systems/scene/SceneRenderer.ts b/fission/src/systems/scene/SceneRenderer.ts index 9c5c406d06..3ce3bbdd4f 100644 --- a/fission/src/systems/scene/SceneRenderer.ts +++ b/fission/src/systems/scene/SceneRenderer.ts @@ -16,10 +16,9 @@ import { CustomTargetControls, } from "@/systems/scene/CameraControls" import type { ContextData } from "@/ui/components/ContextMenuData" -import { globalOpenPanel } from "@/ui/components/GlobalUIControls" +import { globalOpenModal } from "@/ui/components/GlobalUIControls" import type { PixelSpaceCoord } from "@/components/overlays/SceneOverlayEvents.ts" -import type { ConfigurationType } from "@/ui/panels/configuring/assembly-config/ConfigTypes" -import ImportMirabufPanel from "@/ui/panels/mirabuf/ImportMirabufPanel" +import LibraryModal from "@/ui/modals/mirabuf/LibraryModal" import { rayCastForRigidBody } from "@/util/RaycastUtils" import PreferencesSystem from "../preferences/PreferencesSystem" import type { GraphicsPreferences } from "../preferences/PreferenceTypes" @@ -28,6 +27,7 @@ import WorldSystem from "../WorldSystem" import GizmoSceneObject from "./GizmoSceneObject" import type SceneObject from "./SceneObject" import ScreenInteractionHandler, { type InteractionEnd } from "./ScreenInteractionHandler" +import { captureSceneThumbnail } from "./ThumbnailCapture.ts" const CLEAR_COLOR = 0x121212 const GROUND_COLOR = 0xfffef0 @@ -403,6 +403,20 @@ class SceneRenderer extends WorldSystem { this.setupCSMMaterials() } + public async captureAssemblyThumbnail(target: MirabufSceneObject): Promise { + try { + return await captureSceneThumbnail({ + renderer: this._renderer, + scene: this._scene, + skybox: this._skybox, + target, + }) + } catch (e) { + console.warn("Thumbnail capture failed", e) + return undefined + } + } + public registerSceneObject(obj: T, id?: SceneObjectId): SceneObjectId { id ??= uuidv4() as SceneObjectId @@ -605,9 +619,7 @@ class SceneRenderer extends WorldSystem { miraSupplierData.items.push({ name: "Add", func: () => { - globalOpenPanel(ImportMirabufPanel, { - configurationType: "ROBOTS" as ConfigurationType, - }) + globalOpenModal(LibraryModal, undefined) }, }) } diff --git a/fission/src/systems/scene/ThumbnailCapture.ts b/fission/src/systems/scene/ThumbnailCapture.ts new file mode 100644 index 0000000000..5d71f8ee09 --- /dev/null +++ b/fission/src/systems/scene/ThumbnailCapture.ts @@ -0,0 +1,209 @@ +import * as THREE from "three" +import type MirabufSceneObject from "@/mirabuf/MirabufSceneObject" + +export const THUMBNAIL_SIZE = 512 +export const THUMBNAIL_EXTENSION = ".webp" +const THUMBNAIL_QUALITY = 0.85 + +export const THUMBNAIL_IS_TRANSPARENT = false + +export function thumbnailMimeType(extension: string): string { + return `image/${extension.replace(".", "")}` +} + +const CAPTURE_SUPERSAMPLE = 2 + +// thumbnail angle constants +export const THUMBNAIL_FOV_Y_DEGREES = 45 +export const THUMBNAIL_THETA = -Math.PI / 4 +export const THUMBNAIL_PHI = -Math.PI / 6 +export const THUMBNAIL_FILL = { x: 0.9, y: 0.7 } as const + +export function collectInstanceBoundsPoints( + instances: Iterable +): THREE.Vector3[] { + const points: THREE.Vector3[] = [] + const box = new THREE.Box3() + const matrix = new THREE.Matrix4() + for (const [batch, instanceId] of instances) { + if (!batch.getBoundingBoxAt(batch.getGeometryIdAt(instanceId), box)) continue + batch.updateWorldMatrix(true, false) + box.applyMatrix4(batch.getMatrixAt(instanceId, matrix).premultiply(batch.matrixWorld)) + points.push(...boxCorners(box)) + } + return points +} + +/* Computing thumbnail bounds */ + +/** unit vector from origin (relative) toward camera */ +export function canonicalCameraOffset(): THREE.Vector3 { + return new THREE.Vector3(0, 0, 1).applyEuler(new THREE.Euler(THUMBNAIL_PHI, THUMBNAIL_THETA, 0, "YXZ")) +} + +export function boxCorners(box: THREE.Box3): THREE.Vector3[] { + const corners: THREE.Vector3[] = [] + for (let i = 0; i < 8; i++) { + corners.push( + new THREE.Vector3( + i & 1 ? box.max.x : box.min.x, + i & 2 ? box.max.y : box.min.y, + i & 4 ? box.max.z : box.min.z + ) + ) + } + return corners +} + +export interface ThumbnailFraming { + position: THREE.Vector3 + lookAt: THREE.Vector3 +} + +export function computeThumbnailFraming(bounds: THREE.Box3 | readonly THREE.Vector3[]): ThumbnailFraming | undefined { + const points = bounds instanceof THREE.Box3 ? boxCorners(bounds) : bounds + if (points.length === 0) return undefined + + const pointBounds = new THREE.Box3().setFromPoints([...points]) + const toCamera: THREE.Vector3 = canonicalCameraOffset() + + const center = pointBounds.getCenter(new THREE.Vector3()) + const basis = new THREE.Matrix4().lookAt(toCamera, new THREE.Vector3(), THREE.Object3D.DEFAULT_UP) + const right = new THREE.Vector3().setFromMatrixColumn(basis, 0) + const up = new THREE.Vector3().setFromMatrixColumn(basis, 1) + + const halfFovY = THREE.MathUtils.degToRad(THUMBNAIL_FOV_Y_DEGREES) / 2 + // square frame + const tanX = Math.tan(halfFovY) * THUMBNAIL_FILL.x + const tanY = Math.tan(halfFovY) * THUMBNAIL_FILL.y + + /* camera distance D from the center must satisfy `D >= p.toCamera + l / tan` for every point */ + let distance = 0 + const relative = new THREE.Vector3() + for (const point of points) { + relative.copy(point).sub(center) + const lateral = Math.max(Math.abs(relative.dot(right)) / tanX, Math.abs(relative.dot(up)) / tanY) + distance = Math.max(distance, relative.dot(toCamera) + lateral) + } + if (distance <= 0) return undefined + + return { position: toCamera.multiplyScalar(distance).add(center), lookAt: center } +} + +/** square camera */ +export function createThumbnailCamera(framing: ThumbnailFraming): THREE.PerspectiveCamera { + const cameraDistance = framing.position.distanceTo(framing.lookAt) + const camera = new THREE.PerspectiveCamera(THUMBNAIL_FOV_Y_DEGREES, 1, Math.min(0.1, cameraDistance / 10), 2000) + camera.position.copy(framing.position) + camera.lookAt(framing.lookAt) + camera.updateMatrixWorld() + return camera +} + +function computeTargetBounds(targets: readonly THREE.Object3D[]): THREE.Box3 { + const bounds = new THREE.Box3() + const targetBox = new THREE.Box3() + for (const target of targets) { + if (target instanceof THREE.BatchedMesh) { + target.computeBoundingBox() + target.computeBoundingSphere() + if (!target.boundingBox) continue + target.updateWorldMatrix(true, false) + targetBox.copy(target.boundingBox).applyMatrix4(target.matrixWorld) + } else { + targetBox.setFromObject(target) + } + bounds.union(targetBox) + } + return bounds +} + +/* rendering thumbnail */ + +export interface ThumbnailCaptureProps { + renderer: THREE.WebGLRenderer + scene: THREE.Scene + skybox: THREE.Object3D // skybox stays visible + target: MirabufSceneObject +} + +/** renders the targets to an off-screen render target */ +export async function captureSceneThumbnail(props: ThumbnailCaptureProps): Promise { + const { renderer, scene, skybox, target } = props + const framingPoints = collectInstanceBoundsPoints([...target.mirabufInstance.meshes.values()].flat()) + const targets = target.mirabufInstance.batches + + const framing = computeThumbnailFraming(framingPoints?.length ? framingPoints : computeTargetBounds(targets)) + if (!framing) return undefined + + const camera = createThumbnailCamera(framing) + + const renderSize = THUMBNAIL_SIZE * CAPTURE_SUPERSAMPLE + const pixels = new Uint8Array(renderSize * renderSize * 4) + + const keepVisible = new Set([skybox, ...targets]) + const prevVisibility = new Map() + const prevRenderTarget = renderer.getRenderTarget() + const prevSkyboxPosition = skybox.position.clone() + const renderTarget = new THREE.WebGLRenderTarget(renderSize, renderSize, { depthBuffer: true }) + renderTarget.texture.colorSpace = renderer.outputColorSpace + + // no awaits so the render loop doesn't paint a frame where everything is hidden to the user + try { + for (const child of scene.children) { + prevVisibility.set(child, child.visible) + if (!keepVisible.has(child) && !(child instanceof THREE.Light)) child.visible = false + } + skybox.position.copy(camera.position) + + renderer.setRenderTarget(renderTarget) + renderer.render(scene, camera) + renderer.readRenderTargetPixels(renderTarget, 0, 0, renderSize, renderSize, pixels) + } finally { + renderer.setRenderTarget(prevRenderTarget) + prevVisibility.forEach((visible, child) => { + child.visible = visible + }) + + skybox.position.copy(prevSkyboxPosition) + renderTarget.dispose() + } + + return encodePixels(pixels, renderSize) +} + +function createSquareCanvas(size: number): OffscreenCanvas | HTMLCanvasElement { + if (typeof OffscreenCanvas !== "undefined") return new OffscreenCanvas(size, size) + const canvas = document.createElement("canvas") + canvas.width = size + canvas.height = size + return canvas +} + +/** encoding rendered offscreen scene */ +async function encodePixels(pixels: Uint8Array, renderSize: number): Promise { + const flipped = new Uint8ClampedArray(pixels.length) + + const rowBytes = renderSize * 4 + for (let y = 0; y < renderSize; y++) { + flipped.set(pixels.subarray(y * rowBytes, (y + 1) * rowBytes), (renderSize - 1 - y) * rowBytes) + } + + const full = createSquareCanvas(renderSize) + const fullContext = full.getContext("2d") as OffscreenCanvasRenderingContext2D | null + if (!fullContext) return undefined + fullContext.putImageData(new ImageData(flipped, renderSize, renderSize), 0, 0) + + const scaled = createSquareCanvas(THUMBNAIL_SIZE) + const scaledContext = scaled.getContext("2d") as OffscreenCanvasRenderingContext2D | null + if (!scaledContext) return undefined + scaledContext.imageSmoothingEnabled = true + scaledContext.imageSmoothingQuality = "high" + scaledContext.drawImage(full, 0, 0, THUMBNAIL_SIZE, THUMBNAIL_SIZE) + + const mimeType = thumbnailMimeType(THUMBNAIL_EXTENSION) + if (scaled instanceof HTMLCanvasElement) { + return new Promise(resolve => scaled.toBlob(blob => resolve(blob ?? undefined), mimeType, THUMBNAIL_QUALITY)) + } + return scaled.convertToBlob({ type: mimeType, quality: THUMBNAIL_QUALITY }) +} diff --git a/fission/src/test/mirabuf/ProtectedZoneSceneObject.test.ts b/fission/src/test/mirabuf/ProtectedZoneSceneObject.test.ts index edb0c93668..adc931b743 100644 --- a/fission/src/test/mirabuf/ProtectedZoneSceneObject.test.ts +++ b/fission/src/test/mirabuf/ProtectedZoneSceneObject.test.ts @@ -4,8 +4,8 @@ import { MiraType } from "@/mirabuf/MirabufLoader" import { ContactType } from "@/mirabuf/ZoneTypes" import { MatchModeType } from "@/systems/match_mode/MatchModeTypes" import type { ProtectedZonePreferences } from "@/systems/preferences/PreferenceTypes" -import type MirabufSceneObject from "../../mirabuf/MirabufSceneObject" -import ProtectedZoneSceneObject from "../../mirabuf/ProtectedZoneSceneObject" +import type MirabufSceneObject from "@/mirabuf/MirabufSceneObject" +import ProtectedZoneSceneObject from "@/mirabuf/ProtectedZoneSceneObject" import { createBodyMock } from "../mocks/jolt" import JOLT from "@/util/loading/JoltSyncLoader" import { convertAxisAlignedToOrientedBoundingBox } from "@/util/TypeConversions" diff --git a/fission/src/test/scene/ThumbnailCapture.test.ts b/fission/src/test/scene/ThumbnailCapture.test.ts new file mode 100644 index 0000000000..1d7ba26cc4 --- /dev/null +++ b/fission/src/test/scene/ThumbnailCapture.test.ts @@ -0,0 +1,69 @@ +import type * as THREE from "three" +import { Box3, Vector3 } from "three" +import { describe, expect, test, vi } from "vitest" +import { + boxCorners, + collectInstanceBoundsPoints, + computeThumbnailFraming, + createThumbnailCamera, + THUMBNAIL_FILL, +} from "@/systems/scene/ThumbnailCapture" +import { getMiraInstance } from "@/test/GetAssets.ts" + +vi.mock("@/systems/World", () => ({ + default: { + sceneRenderer: { + setupMaterial: vi.fn(), + }, + }, +})) + +// testing shapes for thumbnail generation +const SHAPES: readonly [string, Box3][] = [ + ["robot", new Box3(new Vector3(-0.4, 0, -0.5), new Vector3(0.4, 1.4, 0.5))], + ["field", new Box3(new Vector3(-11, 0, -4.6), new Vector3(11, 3, 4.6))], + ["off-origin", new Box3(new Vector3(20, 5, -30), new Vector3(21, 6, -29))], +] + +const ASSEMBLIES = ["DOZER", 2018] as const + +function fillRatio(points: readonly THREE.Vector3[]): number { + const framing = computeThumbnailFraming(points) + if (!framing) throw new Error("expected a framing") + + const camera = createThumbnailCamera(framing) + let ratio = 0 + const projected = new Vector3() + for (const point of points) { + projected.copy(point).project(camera) + ratio = Math.max(ratio, Math.abs(projected.x) / THUMBNAIL_FILL.x, Math.abs(projected.y) / THUMBNAIL_FILL.y) + } + return ratio +} + +function rounded(vector: THREE.Vector3): number[] { + return vector.toArray().map(component => Number(component.toFixed(4))) +} + +describe("computeThumbnailFraming", () => { + test("returns undefined when there is nothing to frame", () => { + expect(computeThumbnailFraming([])).toBeUndefined() + }) + + test.each(SHAPES)("frames the %s tightly, with every corner in view", (_name, box) => { + expect(fillRatio(boxCorners(box))).toBeCloseTo(1) + }) + + test.each(ASSEMBLIES)("frames %s the same way it always has", async name => { + const instance = await getMiraInstance(name) + if (!instance) throw new Error(`could not load ${name}`) + + const points = collectInstanceBoundsPoints([...instance.meshes.values()].flat()) + expect(points.length).toBeGreaterThan(0) + expect(fillRatio(points)).toBeCloseTo(1) + + const framing = computeThumbnailFraming(points) + if (!framing) throw new Error("expected a framing") + expect({ position: rounded(framing.position), lookAt: rounded(framing.lookAt) }).toMatchSnapshot() + }) +}) diff --git a/fission/src/test/scene/__snapshots__/ThumbnailCapture.test.ts.snap b/fission/src/test/scene/__snapshots__/ThumbnailCapture.test.ts.snap new file mode 100644 index 0000000000..feb7e32033 --- /dev/null +++ b/fission/src/test/scene/__snapshots__/ThumbnailCapture.test.ts.snap @@ -0,0 +1,31 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`computeThumbnailFraming > frames 2018 the same way it always has 1`] = ` +{ + "lookAt": [ + 0, + 1.0654, + -0, + ], + "position": [ + -20.6003, + 17.8855, + 20.6003, + ], +} +`; + +exports[`computeThumbnailFraming > frames DOZER the same way it always has 1`] = ` +{ + "lookAt": [ + -0.1873, + 0.1881, + -0.6366, + ], + "position": [ + -1.4547, + 1.2229, + 0.6308, + ], +} +`; diff --git a/fission/src/test/ui/TourConditions.test.ts b/fission/src/test/ui/TourConditions.test.ts new file mode 100644 index 0000000000..dc05012308 --- /dev/null +++ b/fission/src/test/ui/TourConditions.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, test } from "vitest" +import { ConfigMode } from "@/ui/panels/configuring/assembly-config/ConfigTypes" +import { + advanceConditionMet, + CONDITIONS, + reconcile, + type TourResult, + type TourRuntime, + type TourSnapshot, +} from "@/ui/tour/TourConditions" +import { TOUR_STEPS, type TourStepId } from "@/ui/tour/TourSteps" + +const stepOf = (id: TourStepId) => TOUR_STEPS.findIndex(step => step.id === id) + +const snapshot = (overrides: Partial = {}): TourSnapshot => ({ + panels: [], + appMode: "Configure", + fieldCount: 0, + robotCount: 0, + spawnPending: false, + ...overrides, +}) + +const fresh: TourRuntime = { step: -1 } + +function run(stepIndex: number, snapshots: TourSnapshot[], runtime: TourRuntime = fresh): TourResult { + let result: TourResult = { stepIndex, runtime } + for (const s of snapshots) { + result = reconcile(result.stepIndex, s, result.runtime) + } + return result +} + +function settle(stepIndex: number, s: TourSnapshot, runtime: TourRuntime = fresh): TourResult { + let result: TourResult = { stepIndex, runtime } + for (let i = 0; i < TOUR_STEPS.length + 1; i++) { + const pass = reconcile(result.stepIndex, s, result.runtime) + if (pass.stepIndex === result.stepIndex && i > 0) return pass + result = pass + } + throw new Error("reconcile never settled") +} + +describe("tour reconciler", () => { + test("advances as soon as the step's condition holds", () => { + const result = run(stepOf("add-field"), [snapshot(), snapshot({ modal: "LibraryModal" })]) + expect(result.stepIndex).toBe(stepOf("spawn-field")) + }) + + test("advances when the step's screen closes", () => { + const open = snapshot({ fieldCount: 1, robotCount: 1, panels: [{ id: "InitialConfigPanel" }] }) + const result = run(stepOf("setup-assembly"), [open, { ...open, panels: [] }]) + expect(result.stepIndex).toBe(stepOf("select-assembly")) + }) + + test("does not re-ask for work the user already did", () => { + const done = snapshot({ fieldCount: 1, robotCount: 1 }) + expect(run(stepOf("spawn-robot"), [{ ...done, modal: "LibraryModal" }]).stepIndex).toBe( + stepOf("setup-assembly") + ) + expect(run(stepOf("setup-assembly"), [done]).stepIndex).toBe(stepOf("select-assembly")) + }) + + test("skips every spawn pair whose asset already exists", () => { + expect(settle(stepOf("add-field"), snapshot({ fieldCount: 1, robotCount: 1 })).stepIndex).toBe( + stepOf("select-assembly") + ) + expect(settle(stepOf("add-field"), snapshot({ fieldCount: 1 })).stepIndex).toBe(stepOf("add-robot")) + }) + + test("rewinds to the step that reopens a screen the user closed", () => { + const result = run(stepOf("spawn-robot"), [snapshot({ fieldCount: 1 })]) + expect(result.stepIndex).toBe(stepOf("add-robot")) + expect(result.toast).toBeDefined() + }) + + test("cascades back to the first step when everything is gone", () => { + expect(settle(stepOf("spawn-robot"), snapshot()).stepIndex).toBe(stepOf("add-field")) + }) + + test("explains a cascade once, with the reason the tour moved", () => { + const empty = snapshot() + const toasts: string[] = [] + let result: TourResult = { stepIndex: stepOf("select-assembly"), runtime: fresh } + for (let i = 0; i < TOUR_STEPS.length; i++) { + result = reconcile(result.stepIndex, empty, result.runtime) + if (result.toast) toasts.push(result.toast) + } + expect(toasts).toEqual([CONDITIONS.robot.hint]) + }) + + test("holds the step, once, when nothing in the tour re-establishes the condition", () => { + const gameplay = snapshot({ robotCount: 1, fieldCount: 1, appMode: "Gameplay" }) + const first = run(stepOf("select-assembly"), [gameplay]) + expect(first.stepIndex).toBe(stepOf("select-assembly")) + expect(first.toast).toBeDefined() + + const second = reconcile(first.stepIndex, gameplay, first.runtime) + expect(second.toast).toBeUndefined() + }) + + test("leaves requirements alone while a spawn is in flight", () => { + const result = run(stepOf("spawn-robot"), [snapshot({ fieldCount: 1, spawnPending: true })]) + expect(result.stepIndex).toBe(stepOf("spawn-robot")) + expect(result.toast).toBeUndefined() + }) + + test("completes a spawn step even though the library closed first", () => { + const result = run(stepOf("spawn-robot"), [ + snapshot({ fieldCount: 1, modal: "LibraryModal" }), + snapshot({ fieldCount: 1, spawnPending: true }), + snapshot({ fieldCount: 1, robotCount: 1 }), + ]) + expect(result.stepIndex).toBe(stepOf("setup-assembly")) + }) + + test("offers Next on a step whose screen is already closed", () => { + const closed = snapshot({ fieldCount: 1, robotCount: 1 }) + expect(advanceConditionMet(TOUR_STEPS[stepOf("setup-assembly")], closed)).toBe(true) + expect(advanceConditionMet(TOUR_STEPS[stepOf("save-config")], closed)).toBe(true) + }) + + test("withholds Next while a step is still waiting on its condition", () => { + const empty = snapshot() + expect(advanceConditionMet(TOUR_STEPS[stepOf("add-field")], empty)).toBe(false) + expect(advanceConditionMet(TOUR_STEPS[stepOf("spawn-robot")], empty)).toBe(false) + }) + + test("recovers the intake panel by sending the user back to the step that opens it", () => { + const configured = snapshot({ fieldCount: 1, robotCount: 1 }) + const result = run(stepOf("adjust-intake"), [ + { ...configured, panels: [{ id: "ConfigurePanel", configMode: ConfigMode.INTAKE }] }, + configured, + ]) + expect(result.stepIndex).toBe(stepOf("pick-config")) + }) + + test("settles from every step, whatever the world already looks like", () => { + for (let world = 0; world < 64; world++) { + const s = snapshot({ + modal: world & 1 ? "LibraryModal" : undefined, + fieldCount: world & 2 ? 1 : 0, + robotCount: world & 4 ? 1 : 0, + appMode: world & 8 ? "Gameplay" : "Configure", + panels: [ + ...(world & 16 ? [{ id: "InitialConfigPanel" as const }] : []), + ...(world & 32 ? [{ id: "ConfigurePanel" as const, configMode: ConfigMode.INTAKE }] : []), + ], + }) + TOUR_STEPS.forEach((_, index) => expect(() => settle(index, s)).not.toThrow()) + } + }) +}) diff --git a/fission/src/test/ui/TourOverlay.test.tsx b/fission/src/test/ui/TourOverlay.test.tsx new file mode 100644 index 0000000000..4224b9572a --- /dev/null +++ b/fission/src/test/ui/TourOverlay.test.tsx @@ -0,0 +1,98 @@ +import { render } from "@testing-library/react" +import { describe, expect, test } from "vitest" +import { UIContext, type UIBlockState, type UIContextProps } from "@/ui/helpers/UIProviderHelpers" +import { TourContext, type TourContextValue } from "@/ui/tour/TourProviderHelpers" +import { visibleRect } from "@/ui/tour/AnchorGeometry" +import TourOverlay from "@/ui/tour/TourOverlay" +import { TOUR_STEPS } from "@/ui/tour/TourSteps" + +const SCRIM_TEST_ID = "tour-scrim" +const SCREEN_STEP = TOUR_STEPS.findIndex(step => step.focus === "screen") +const GATED_STEP = TOUR_STEPS.findIndex(step => step.advanceOn) + +const uiContext = (blockState: UIBlockState): UIContextProps => ({ + panels: [], + blockState, + openModal: () => null, + openPanel: () => null, + togglePanel: () => null, + closeModal: () => {}, + closePanel: () => {}, + addToast: () => {}, + configureScreen: () => {}, +}) + +const tourContext = (stepIndex: number, canAdvance: boolean): TourContextValue => ({ + active: true, + stepIndex, + canAdvance, + next: () => {}, + prev: () => {}, + skip: () => {}, + registerAnchor: () => {}, + getAnchor: () => null, + anchorVersion: 0, +}) + +const overlay = ({ + stepIndex, + canAdvance = true, + blockState = { blocked: false }, +}: { + stepIndex: number + canAdvance?: boolean + blockState?: UIBlockState +}) => ( + + + + + +) + +describe("tour scrim", () => { + test("scrims the screen on a focused step", () => { + const { queryAllByTestId } = render(overlay({ stepIndex: SCREEN_STEP })) + expect(queryAllByTestId(SCRIM_TEST_ID)).not.toHaveLength(0) + }) + + test("does not scrim while a blocking panel is open", () => { + const { queryAllByTestId } = render( + overlay({ + stepIndex: SCREEN_STEP, + blockState: { blocked: true, blockMessage: "Finish Assembly Setup first!" }, + }) + ) + expect(queryAllByTestId(SCRIM_TEST_ID)).toHaveLength(0) + }) +}) + +describe("tour anchor geometry", () => { + test("measures an anchor as the part its scroll container shows", () => { + const container = document.createElement("div") + container.style.cssText = + "position: fixed; top: 100px; left: 50px; width: 300px; height: 100px; overflow: auto;" + const anchor = document.createElement("div") + anchor.style.cssText = "height: 1000px;" + container.appendChild(anchor) + document.body.appendChild(container) + + try { + const rect = visibleRect(anchor)! + expect(rect.top).toBeCloseTo(100, 0) + expect(rect.height).toBeCloseTo(100, 0) + } finally { + container.remove() + } + }) +}) + +describe("tour next control", () => { + test("unlocks Next once a gated step's condition is met", () => { + const { getByLabelText, rerender } = render(overlay({ stepIndex: GATED_STEP, canAdvance: false })) + expect(getByLabelText("Next step")).toBeDisabled() + + rerender(overlay({ stepIndex: GATED_STEP, canAdvance: true })) + expect(getByLabelText("Next step")).not.toBeDisabled() + }) +}) diff --git a/fission/src/ui/UIProvider.tsx b/fission/src/ui/UIProvider.tsx index fae80ed2f2..2bbca3c52c 100644 --- a/fission/src/ui/UIProvider.tsx +++ b/fission/src/ui/UIProvider.tsx @@ -5,7 +5,7 @@ import type React from "react" import { useRef } from "react" import { useMemo } from "react" import type { FunctionComponent, ReactNode } from "react" -import { Fragment, useCallback, useReducer, useState } from "react" +import { Fragment, useCallback, useEffect, useReducer, useState } from "react" import { v4 as uuidv4 } from "uuid" import type { ModalImplProps } from "./components/Modal" import type { PanelImplProps } from "./components/Panel" @@ -27,7 +27,7 @@ import { type UIScreenProps, } from "./helpers/UIProviderHelpers" import { UICallback } from "./UICallbacks" -import InputSystem from "@/systems/input/InputSystem.ts" +import InputSystem, { ESCAPE_PRIORITY } from "@/systems/input/InputSystem.ts" export type UIProviderProps = { children?: ReactNode @@ -86,27 +86,6 @@ export const UIProvider: React.FC = ({ children }) => { const { enqueueSnackbar, closeSnackbar } = useSnackbar() - InputSystem.escapeKeyListeners[1] = () => { - if (modal != null) { - if (!modal.props.hideCancel) { - closeModal(CloseType.CANCEL) - } - return true - } - return false - } - - InputSystem.escapeKeyListeners[2] = () => { - if (panels.length > 0) { - const panel = panels[panels.length - 1] - if (!panel.props.hideCancel) { - closePanel(panel.id, CloseType.CANCEL) - return true - } - } - return false - } - const blockState: UIBlockState = useMemo(() => { const blockingPanel = panels.find(p => p.props.blocking) if (blockingPanel != null) { @@ -276,6 +255,28 @@ export const UIProvider: React.FC = ({ children }) => { }) }, []) + // 'esc' closes modal + useEffect( + () => + InputSystem.addEscapeHandler(() => { + if (modal == null) return false + if (!modal.props.hideCancel) closeModal(CloseType.CANCEL) + return true + }, ESCAPE_PRIORITY.MODAL), + [modal, closeModal] + ) + + useEffect( + () => + InputSystem.addEscapeHandler(() => { + const panel = panels[panels.length - 1] + if (panel == null || panel.props.hideCancel) return false + closePanel(panel.id, CloseType.CANCEL) + return true + }, ESCAPE_PRIORITY.PANEL), + [panels, closePanel] + ) + const togglePanel: TogglePanelFn = useCallback( ( content: FunctionComponent>, diff --git a/fission/src/ui/components/CommandPalette.tsx b/fission/src/ui/components/CommandPalette.tsx index e0ed83d65f..e4ffd537b8 100644 --- a/fission/src/ui/components/CommandPalette.tsx +++ b/fission/src/ui/components/CommandPalette.tsx @@ -4,12 +4,12 @@ import type React from "react" import { useCallback, useEffect, useMemo, useRef, useState } from "react" import EventSystem from "@/systems/EventSystem" import World from "@/systems/World" -import InputSystem from "@/systems/input/InputSystem" +import InputSystem, { ESCAPE_PRIORITY } from "@/systems/input/InputSystem" import { useUIContext } from "@/ui/helpers/UIProviderHelpers" import CommandRegistry, { type CommandDefinition } from "@/ui/components/CommandRegistry" import "@/ui/panels/DebugPanel" import "@/ui/modals/configuring/SettingsModal" -import "@/ui/panels/mirabuf/ImportMirabufPanel" +import "@/ui/modals/mirabuf/LibraryModal" import "@/ui/panels/configuring/assembly-config/ConfigurePanel" import "@/ui/panels/configuring/MatchModeConfigPanel" @@ -98,13 +98,16 @@ const CommandPalette: React.FC = () => { }) }, [commands]) - InputSystem.escapeKeyListeners[0] = () => { - if (isOpen) { - closePalette() - return true - } - return false - } + // command palette should be highest priority in 'esc' queue + useEffect( + () => + InputSystem.addEscapeHandler(() => { + if (!isOpen) return false + closePalette() + return true + }, ESCAPE_PRIORITY.COMMAND_PALETTE), + [isOpen, closePalette] + ) const filtered = useMemo(() => { const q = query.trim().toLowerCase() diff --git a/fission/src/ui/components/StyledComponents.tsx b/fission/src/ui/components/StyledComponents.tsx index 0231a8b100..208ed0f3a1 100644 --- a/fission/src/ui/components/StyledComponents.tsx +++ b/fission/src/ui/components/StyledComponents.tsx @@ -233,7 +233,7 @@ export const PositiveIconButton: React.FC = ({ children, onClic export const DownloadButton: React.FC = ({ onClick, ...props }) => { return ( - + ) } diff --git a/fission/src/ui/components/TopBar.tsx b/fission/src/ui/components/TopBar.tsx index a4fa14313f..344653d1b8 100644 --- a/fission/src/ui/components/TopBar.tsx +++ b/fission/src/ui/components/TopBar.tsx @@ -11,11 +11,10 @@ import { deobf } from "@/util/Utility" import { useUIContext } from "@/ui/helpers/UIProviderHelpers" import APSManagementModal from "@/modals/APSManagementModal" import SettingsModal from "@/modals/configuring/SettingsModal" -import type { ConfigurationType } from "@/panels/configuring/assembly-config/ConfigTypes" import CameraSelectionPanel from "@/panels/configuring/CameraSelectionPanel" import DeveloperToolPanel from "@/panels/DeveloperToolPanel" import DebugPanel from "@/panels/DebugPanel" -import ImportMirabufPanel from "@/ui/panels/mirabuf/ImportMirabufPanel" +import LibraryModal from "@/ui/modals/mirabuf/LibraryModal" import { setAddToast, setCloseModal, setOpenModal, setOpenPanel } from "@/ui/components/GlobalUIControls" import { SynthesisIcons } from "@/ui/components/StyledComponents" import { AssemblySelect } from "@/ui/components/topbar/AssemblySelect" @@ -31,6 +30,7 @@ import { TopBarIcon } from "@/ui/components/topbar/TopBarIcons" import { useAssemblySelection } from "@/ui/components/topbar/UseConfigureAssembly" import UserIcon from "@/ui/components/UserIcon" import { hasSimBrain } from "@/systems/simulation/wpilib_brain/WPILibState" +import { useTourAnchor } from "@/ui/tour/TourProviderHelpers" const TUTORIALS_URL = "https://synthesis.autodesk.com/tutorials" @@ -61,6 +61,11 @@ const TopBar: React.FC = () => { const isTouchDevice = useIsTouchDevice() const { assemblies, selectedAssembly, selectAssemblyById } = useAssemblySelection() + const addAssemblyRef = useTourAnchor("add-assembly") + const modeDropdownRef = useTourAnchor("mode-dropdown") + // AssemblySelect moved up from ConfigureControls in the MainHUD redesign, so its tour anchor lives here now. + const assemblySelectRef = useTourAnchor("configure-assembly-select") + setAddToast(addToast) setOpenPanel(openPanel) setOpenModal(openModal) @@ -134,6 +139,7 @@ const TopBar: React.FC = () => { disableInteractive > setModeHovered(true)} @@ -153,20 +159,21 @@ const TopBar: React.FC = () => { } - onClick={() => - togglePanel(ImportMirabufPanel, { configurationType: "ROBOTS" as ConfigurationType }) - } + onClick={() => openModal(LibraryModal, undefined)} + anchorRef={addAssemblyRef} /> {(appMode === "Configure" || appMode === "Codesim") && ( - + + + )} {appMode === "Configure" && } diff --git a/fission/src/ui/components/overlays/MobileHUD.tsx b/fission/src/ui/components/overlays/MobileHUD.tsx index c7674a5387..ede612b33d 100644 --- a/fission/src/ui/components/overlays/MobileHUD.tsx +++ b/fission/src/ui/components/overlays/MobileHUD.tsx @@ -8,8 +8,7 @@ import { useIsTouchDevice } from "@/ui/helpers/useIsMobile.ts" import { useUIContext } from "@/ui/helpers/UIProviderHelpers.ts" import APSManagementModal from "@/modals/APSManagementModal.tsx" import SettingsModal from "@/modals/configuring/SettingsModal.tsx" -import type { ConfigurationType } from "@/panels/configuring/assembly-config/ConfigTypes.ts" -import ImportMirabufPanel from "@/panels/mirabuf/ImportMirabufPanel.tsx" +import LibraryModal from "@/modals/mirabuf/LibraryModal.tsx" import { globalOpenModal, setAddToast, setOpenModal, setOpenPanel } from "../GlobalUIControls.ts" import { IconButton, SynthesisIcons } from "../StyledComponents.tsx" import { AssemblySelect } from "../topbar/AssemblySelect.tsx" @@ -77,11 +76,7 @@ const MobileHUD: React.FC = () => { - runAction(() => - openPanel(ImportMirabufPanel, { configurationType: "ROBOTS" as ConfigurationType }) - ) - } + onClick={() => runAction(() => openModal(LibraryModal, undefined))} /> setView("configure")} /> diff --git a/fission/src/ui/components/topbar/ConfigureControls.tsx b/fission/src/ui/components/topbar/ConfigureControls.tsx index 041d58ac0c..8a12b6ee25 100644 --- a/fission/src/ui/components/topbar/ConfigureControls.tsx +++ b/fission/src/ui/components/topbar/ConfigureControls.tsx @@ -2,16 +2,20 @@ import { Box } from "@mui/material" import type React from "react" import { useMemo } from "react" import type MirabufSceneObject from "@/mirabuf/MirabufSceneObject" +import { ConfigMode } from "@/panels/configuring/assembly-config/ConfigTypes" import { CollapsibleGroup, type CollapsibleItem } from "@/ui/components/topbar/CollapsibleGroup" import { ConfigureIcon } from "@/ui/components/topbar/ConfigureIcon" import ConfigureSplitDropdown from "@/ui/components/topbar/ConfigureSplitDropdown" import { TOP_BAR_DIVIDER_SX } from "@/ui/components/topbar/TopBarConfig" import { TopBarButton } from "@/ui/components/topbar/TopBarButton" import { useConfigureAssembly } from "@/ui/components/topbar/UseConfigureAssembly" +import { useTourAnchor } from "@/ui/tour/TourProviderHelpers" const ConfigureControls: React.FC<{ selectedAssembly?: MirabufSceneObject }> = ({ selectedAssembly }) => { const { configureButtons, openConfig, disabledMessage } = useConfigureAssembly(selectedAssembly) + const intakeButtonRef = useTourAnchor("configure-intake-button") + const items: CollapsibleItem[] = useMemo( () => configureButtons.map(({ icon, label, mode }) => ({ @@ -22,10 +26,11 @@ const ConfigureControls: React.FC<{ selectedAssembly?: MirabufSceneObject }> = ( icon={} disabledTooltip={disabledMessage} onClick={() => openConfig(mode)} + anchorRef={mode === ConfigMode.INTAKE ? intakeButtonRef : undefined} /> ), })), - [configureButtons, disabledMessage, openConfig] + [configureButtons, disabledMessage, openConfig, intakeButtonRef] ) // TODO: add a "..." after a long robot name to ensure it isn't rendered underneath the dropdown arrow diff --git a/fission/src/ui/components/topbar/TopBarButton.tsx b/fission/src/ui/components/topbar/TopBarButton.tsx index 85c4b0cb28..a1fcb99c8a 100644 --- a/fission/src/ui/components/topbar/TopBarButton.tsx +++ b/fission/src/ui/components/topbar/TopBarButton.tsx @@ -10,16 +10,25 @@ type TopBarButtonProps = { disabledTooltip?: string active?: boolean onClick: () => void + /** Attaches the button to a guided-tour anchor so a tour card can point at it. */ + anchorRef?: React.Ref } /** component for all buttons located on topbar */ -export const TopBarButton: React.FC = ({ label, icon, disabledTooltip, active, onClick }) => { +export const TopBarButton: React.FC = ({ + label, + icon, + disabledTooltip, + active, + onClick, + anchorRef, +}) => { const { blockState } = useUIContext() const disabled = disabledTooltip !== undefined || blockState.blocked return ( - + } -const GeneralTab: React.FC = () => ( - - - - - - - - - - - - - - - { + const { closeModal } = useUIContext() + + return ( + + + + - + - - + - - + + + + + + + + + + + + + + + - -) + ) +} type GraphicsPreset = "low" | "medium" | "high" | "custom" diff --git a/fission/src/ui/modals/mirabuf/ImportLocalMirabufModal.tsx b/fission/src/ui/modals/mirabuf/ImportLocalMirabufModal.tsx index f545808bec..e85f3745f4 100644 --- a/fission/src/ui/modals/mirabuf/ImportLocalMirabufModal.tsx +++ b/fission/src/ui/modals/mirabuf/ImportLocalMirabufModal.tsx @@ -3,6 +3,7 @@ import { type ChangeEvent, useEffect, useState } from "react" import { globalOpenModal } from "@/components/GlobalUIControls.ts" import MirabufCachingService, { MiraType } from "@/mirabuf/MirabufLoader" import { createMirabuf } from "@/mirabuf/MirabufSceneObject" +import { embedAssemblyThumbnail } from "@/mirabuf/MirabufThumbnail" import { PAUSE_REF_ASSEMBLY_SPAWNING } from "@/systems/physics/PhysicsTypes" import World from "@/systems/World" import { loadURDF } from "@/urdf/URDFLoader" @@ -16,7 +17,7 @@ import { miraTypeToConfigType, } from "@/ui/panels/configuring/assembly-config/ConfigTypes" import InitialConfigPanel from "@/ui/panels/configuring/initial-config/InitialConfigPanel" -import ImportMirabufPanel from "@/ui/panels/mirabuf/ImportMirabufPanel" +import LibraryModal from "@/ui/modals/mirabuf/LibraryModal" import { getTargetControls } from "@/systems/scene/CameraControls" import { hashBuffer, hexStringToUint8Array } from "@/util/Utility.ts" import { ProgressHandle } from "@/components/ProgressNotificationData.ts" @@ -78,7 +79,9 @@ const ImportLocalMirabufModal: React.FC { const onCancel = () => { - openPanel(ImportMirabufPanel, { configurationType: miraTypeToConfigType(miraType ?? MiraType.ROBOT) }) + // Both are modals and only one modal exists at a time; closeModal's trailing + // setModal(undefined) would clobber a synchronous reopen, so defer a tick. + setTimeout(() => globalOpenModal(LibraryModal, undefined), 0) } const onBeforeAccept = async () => { @@ -124,6 +127,7 @@ const ImportLocalMirabufModal: React.FC asset.year ?? OTHER_YEAR + +interface AssetCardProps { + name: string + thumbnail?: string + embeddedThumbnailHash?: string + miraType: MiraType + cached: boolean + onSpawn: () => void + onDelete?: () => void +} + +function useEmbeddedThumbnail(hash: string | undefined): string | undefined { + const [url, setUrl] = useState(undefined) + + useEffect(() => { + setUrl(undefined) + if (!hash) return + + let objectUrl: string | undefined + let stale = false + getCachedThumbnail(hash) + .then(blob => { + if (stale || !blob) return + objectUrl = URL.createObjectURL(blob) + setUrl(objectUrl) + }) + .catch(console.error) + return () => { + stale = true + if (objectUrl) URL.revokeObjectURL(objectUrl) + } + }, [hash]) + + return url +} + +const AssetCard: React.FC = ({ + name, + thumbnail, + embeddedThumbnailHash, + miraType, + cached, + onSpawn, + onDelete, +}) => { + const [failedSrc, setFailedSrc] = useState(undefined) + const embedded = useEmbeddedThumbnail(thumbnail ? undefined : embeddedThumbnailHash) + const thumbnailSrc = thumbnail ?? embedded + const showThumb = thumbnailSrc && thumbnailSrc !== failedSrc + const PlaceholderIcon = miraType === MiraType.FIELD ? SynthesisIcons.CHESS_BOARD : SynthesisIcons.CAR + + return ( + + + {showThumb ? ( + {name} setFailedSrc(thumbnailSrc)} + className="w-full h-full object-cover" + /> + ) : ( + + + + )} + {cached && ( + + + + )} + + + + + + {cached ? : } + + {cached && onDelete && } + + + + ) +} + +/** Responsive card grid shared by the year view and the Saved section. */ +const AssetCardGrid: React.FC<{ children: ReactNode }> = ({ children }) => ( + + {children} + +) + +const AutodeskHubAccordion: React.FC<{ onSpawned: () => void }> = ({ onSpawned }) => { + const [apsType, setApsType] = useState(MiraType.ROBOT) + const [filesStatus, setFilesStatus] = useState({ + isDone: false, + message: "Waiting on APS...", + progress: 0, + }) + const [files, setFiles] = useState(undefined) + + useEffect(() => { + const unsubscribeStatus = EventSystem.listen("MirabufFilesStatusUpdateEvent", v => setFilesStatus(v)) + const unsubscribeUpdate = EventSystem.listen("MirabufFilesUpdateEvent", v => setFiles(v)) + return () => { + unsubscribeStatus() + unsubscribeUpdate() + } + }, []) + + useEffect(() => { + if (!hasMirabufFiles()) { + requestMirabufFiles().catch(console.error) + } else { + setFiles(getMirabufFiles()) + } + }, []) + + const sortedFiles = useMemo( + () => files?.slice().sort((a, b) => a.attributes.displayName!.localeCompare(b.attributes.displayName!)), + [files] + ) + + const spawnFile = (file: Data) => { + spawnAPS(file, apsType) + onSpawned() + } + + return ( + + }> + + + {files && requestMirabufFiles()} />} + + + + + v != null && setApsType(v)} + sx={{ alignSelf: "center" }} + > + Robot + Field + + {sortedFiles && sortedFiles.length > 0 ? ( + sortedFiles.map(file => ( + + + spawnFile(file)}> + + + + )) + ) : filesStatus.isDone ? ( + + ) : ( + + )} + + + + ) +} + +const LibraryModal: React.FC> = ({ modal }) => { + const { closeModal, openModal, configureScreen } = useUIContext() + const libraryRef = useTourAnchor("spawn-panel") + + const [manifestRobots, setManifestRobots] = useState(DefaultAssetLoader.robots) + const [manifestFields, setManifestFields] = useState(DefaultAssetLoader.fields) + useEffect(() => { + if (DefaultAssetLoader.robots.length === 0 && DefaultAssetLoader.fields.length === 0) { + DefaultAssetLoader.refresh() + .then(() => { + setManifestRobots(DefaultAssetLoader.robots) + setManifestFields(DefaultAssetLoader.fields) + }) + .catch(console.error) + } + }, []) + + const [cachedInfos, setCachedInfos] = useState(() => MirabufCachingService.getAll()) + const refreshCached = useCallback(() => setCachedInfos(MirabufCachingService.getAll()), []) + const cachedByHash = useMemo(() => new Map(cachedInfos.map(c => [c.hash, c])), [cachedInfos]) + + // merging fields and robots (field -> robot order) & deduping by hash + const manifestAssets = useMemo(() => { + const seen = new Set() + return [...manifestFields, ...manifestRobots].filter(asset => { + if (seen.has(asset.hash)) return false + seen.add(asset.hash) + return true + }) + }, [manifestRobots, manifestFields]) + + // cached assets not in default library + const manifestHashes = useMemo(() => new Set(manifestAssets.map(a => a.hash)), [manifestAssets]) + const savedExtra = useMemo( + () => cachedInfos.filter(c => !manifestHashes.has(c.hash)), + [cachedInfos, manifestHashes] + ) + + const years = useMemo(() => { + const set = new Set() + for (const asset of manifestAssets) set.add(yearOf(asset)) + if (savedExtra.length > 0) set.add(OTHER_YEAR) + const numeric = [...set].filter((y): y is number => typeof y === "number").sort((a, b) => b - a) + return set.has(OTHER_YEAR) ? [...numeric, OTHER_YEAR] : numeric + }, [manifestAssets, savedExtra]) + + const [activeYear, setActiveYear] = useState(undefined) + useEffect(() => { + if (years.length > 0 && (activeYear === undefined || !years.includes(activeYear))) { + setActiveYear(years[0]) + } + }, [years, activeYear]) + + const assetsForYear = useMemo( + () => (activeYear === undefined ? [] : manifestAssets.filter(asset => yearOf(asset) === activeYear)), + [manifestAssets, activeYear] + ) + + const showSaved = activeYear === OTHER_YEAR + const hasAssets = assetsForYear.length > 0 || (showSaved && savedExtra.length > 0) + + useEffect(() => { + configureScreen(modal!, { title: "Library", hideAccept: true, cancelText: "Close", allowClickAway: true }, {}) + }, []) + + const spawnLibraryAsset = useCallback( + (asset: DefaultAssetInfo) => { + const cached = cachedByHash.get(asset.hash) + if (cached) { + spawnCachedMira(cached).catch(console.error) + } else { + spawnRemote(asset) + } + closeModal(CloseType.CANCEL) + }, + [cachedByHash, closeModal] + ) + + const spawnSaved = useCallback( + (info: MirabufCacheInfo) => { + spawnCachedMira(info).catch(console.error) + closeModal(CloseType.CANCEL) + }, + [closeModal] + ) + + const deleteCached = useCallback( + async (hash: string) => { + await MirabufCachingService.remove(hash) + refreshCached() + }, + [refreshCached] + ) + + const downloadAllForYear = useCallback(() => { + downloadAll(assetsForYear, cachedInfos).catch(console.error) + closeModal(CloseType.CANCEL) + }, [assetsForYear, cachedInfos, closeModal]) + + const importFromFile = useCallback(() => { + // openModal auto-closes this Library modal (fires its onClose(Overwrite)). + openModal(ImportLocalMirabufModal, { configurationType: "ROBOTS" }) + }, [openModal]) + + const hasRemoteInYear = assetsForYear.some(asset => !cachedByHash.has(asset.hash)) + + return ( + // tour anchor + + setActiveYear(newValue)} + textColor="inherit" + indicatorColor="primary" + variant="scrollable" + scrollButtons="auto" + allowScrollButtonsMobile + // pin the year tabs while the single (modal) scroll container scrolls + sx={{ position: "sticky", top: 0, zIndex: 2, backgroundColor: "#2e2e2e" }} + {...SoundPlayer.getInstance().buttonSoundEffects()} + > + {years.map(year => ( + + ))} + + + + {activeYear === undefined ? ( + + ) : hasAssets ? ( + + {assetsForYear.map(asset => ( + spawnLibraryAsset(asset)} + onDelete={cachedByHash.has(asset.hash) ? () => deleteCached(asset.hash) : undefined} + /> + ))} + {showSaved && + savedExtra.map(info => ( + spawnSaved(info)} + onDelete={() => deleteCached(info.hash)} + /> + ))} + + ) : ( + + )} + + {hasRemoteInYear && ( + + Download All + + )} + + + closeModal(CloseType.CANCEL)} /> + + + + + + + + ) +} + +// tagging onboarding target to allow for auto-advancing despite minification +tourTarget(LibraryModal, "LibraryModal") + +export default LibraryModal + +CommandRegistry.get().registerCommands([ + { + id: "spawn-asset", + label: "Spawn Asset", + description: "Open the asset Library.", + keywords: ["spawn", "asset", "robot", "field", "import", "mirabuf", "library"], + perform: () => { + globalOpenModal(LibraryModal, undefined) + }, + }, +]) diff --git a/fission/src/ui/modals/mirabuf/LibrarySpawnActions.ts b/fission/src/ui/modals/mirabuf/LibrarySpawnActions.ts new file mode 100644 index 0000000000..9a76a002ef --- /dev/null +++ b/fission/src/ui/modals/mirabuf/LibrarySpawnActions.ts @@ -0,0 +1,191 @@ +import type { Data } from "@/aps/APSDataManagement" +import type { DefaultAssetInfo } from "@/mirabuf/DefaultAssetLoader.ts" +import MirabufCachingService, { type MirabufCacheInfo, MiraType } from "@/mirabuf/MirabufLoader" +import type MirabufSceneObject from "@/mirabuf/MirabufSceneObject" +import { createMirabuf } from "@/mirabuf/MirabufSceneObject" +import { embedAssemblyThumbnail } from "@/mirabuf/MirabufThumbnail" +import { mirabuf } from "@/proto/mirabuf" +import EventSystem from "@/systems/EventSystem" +import type { EncodedAssembly, Message } from "@/systems/multiplayer/MultiplayerTypes" +import { PAUSE_REF_ASSEMBLY_SPAWNING } from "@/systems/physics/PhysicsTypes" +import { getTargetControls } from "@/systems/scene/CameraControls" +import World from "@/systems/World" +import { globalAddToast, globalOpenPanel } from "@/ui/components/GlobalUIControls" +import { ProgressHandle } from "@/ui/components/ProgressNotificationData" +import InitialConfigPanel from "@/ui/panels/configuring/initial-config/InitialConfigPanel" + +let pendingSpawns = 0 + +export const hasPendingSpawn = () => pendingSpawns > 0 + +function trackSpawn(delta: number) { + const was = pendingSpawns > 0 + pendingSpawns += delta + const now = pendingSpawns > 0 + if (was !== now) EventSystem.dispatch("SpawnPendingChangeEvent", now) +} + +/** Announce a newly spawned scene object to the multiplayer session, if one is active. */ +function broadcastSpawn(sceneObject: MirabufSceneObject, assembly: mirabuf.Assembly, info: MirabufCacheInfo) { + const multiplayer = World.multiplayerSystem + if (multiplayer == null) return + + const encodedAssembly = + sceneObject.miraType !== MiraType.FIELD + ? (mirabuf.Assembly.encode(assembly).finish() as EncodedAssembly) + : undefined + + const message: Message = { + type: "newObject", + timestamp: Date.now(), + data: { + sceneObjectId: sceneObject.id, + assembly: encodedAssembly, + assemblyHash: info.hash, + miraType: info.miraType, + initialPreferences: sceneObject.getPreferenceData(), + }, + } + multiplayer.broadcast(message) + multiplayer.registerOwnSceneObject(sceneObject.id) +} + +/** + * Spawn a mirabuf assembly that already lives in the cache. Shared by every + * entry point of the asset Library (cached, remote, and APS spawns all funnel + * through here once their buffer is cached). + */ +export async function spawnCachedMira(info: MirabufCacheInfo, progressHandle = new ProgressHandle(info.name)) { + // If spawning a field, then remove all other fields + if (info.miraType === MiraType.FIELD) { + if (World.multiplayerSystem != null && World.sceneRenderer.mirabufSceneObjects.getField() != null) { + globalAddToast("warning", "Cannot spawn a second field!") + progressHandle.fail("Cannot spawn a second field") + return + } + World.sceneRenderer.removeAllFields() + } + + World.physicsSystem.holdPause(PAUSE_REF_ASSEMBLY_SPAWNING) + trackSpawn(1) + try { + const assembly = await MirabufCachingService.get(info.hash) + if (!assembly) { + progressHandle.fail() + console.error(`Failed to load "${info.name}" from cache`) + return + } + + const sceneObject = await createMirabuf(info.hash, assembly, progressHandle) + if (!sceneObject) { + progressHandle.fail("No object!") + return + } + + World.sceneRenderer.registerSceneObject(sceneObject) + + const targetControls = getTargetControls() + + broadcastSpawn(sceneObject, assembly, info) + + if (targetControls && (info.miraType === MiraType.ROBOT || !targetControls.focusProvider)) { + targetControls.focusProvider = sceneObject + } + + progressHandle.done() + World.physicsSystem.deactivateGamepieces() + + if (sceneObject.miraType === MiraType.ROBOT) { + globalOpenPanel(InitialConfigPanel, undefined) + } + + if (!info.remotePath && !assembly.thumbnail) embedAssemblyThumbnail(sceneObject).catch(console.error) + } catch (e) { + console.error(e) + progressHandle.fail() + } finally { + trackSpawn(-1) + setTimeout(() => World.physicsSystem.releasePause(PAUSE_REF_ASSEMBLY_SPAWNING), 500) + } +} + +/** Cache a default (remote) asset, carrying its year/thumbnail metadata into the cache entry. */ +function cacheDefaultAsset(info: DefaultAssetInfo) { + return MirabufCachingService.cacheRemote(info.remotePath, info.miraType, { + name: info.name, + expectedHash: info.hash, + year: info.year, + thumbnail: info.thumbnail, + }) +} + +/** Run `cache`, then spawn the cached assembly, reporting progress and failures on `status`. */ +async function cacheAndSpawn(status: ProgressHandle, cache: () => Promise) { + trackSpawn(1) + try { + const cacheInfo = await cache() + if (cacheInfo) { + await spawnCachedMira(cacheInfo, status) + } else { + status.fail("Failed to cache") + } + } catch (e) { + console.error(e) + status.fail() + } finally { + trackSpawn(-1) + } +} + +/** + * Download a default (remote) asset into the cache, then spawn it. + * Fire-and-forget: progress is surfaced via ProgressHandle. + */ +export function spawnRemote(info: DefaultAssetInfo) { + const status = new ProgressHandle(info.name) + status.update("Downloading from Synthesis...", 0.05) + void cacheAndSpawn(status, () => cacheDefaultAsset(info)) +} + +/** + * Cache an APS (Autodesk Hub) file, then spawn it. + */ +export function spawnAPS(data: Data, miraType: MiraType) { + const status = new ProgressHandle(data.attributes.displayName ?? data.id) + status.update("Downloading from APS...", 0.05) + void cacheAndSpawn(status, () => MirabufCachingService.cacheAPS(data, miraType)) +} + +/** + * Download every remote asset not yet cached (no spawn). Used by "Download All". + */ +export async function downloadAll(manifestAssets: DefaultAssetInfo[], cachedAssets: MirabufCacheInfo[]) { + const cachedHashes = new Set(cachedAssets.map(info => info.hash)) + const toCache = manifestAssets.filter(asset => !cachedHashes.has(asset.hash)) + if (toCache.length === 0) return + + const status = new ProgressHandle("Caching Remote Assets") + status.update(`Downloading... (0/${toCache.length})`, 0.05) + + let completeCount = 0 + const results = await Promise.all( + toCache.map(async asset => { + const cacheInfo = await cacheDefaultAsset(asset).catch(e => { + console.error(e) + return undefined + }) + if (cacheInfo) { + completeCount++ + status.update(`Downloading... (${completeCount}/${toCache.length})`, completeCount / toCache.length) + } + return cacheInfo + }) + ) + + const failedCount = results.filter(cacheInfo => !cacheInfo).length + if (failedCount > 0) { + status.fail(`Failed to cache ${failedCount} asset${failedCount === 1 ? "" : "s"}`) + } else { + status.done() + } +} diff --git a/fission/src/ui/panels/configuring/assembly-config/ConfigTypes.ts b/fission/src/ui/panels/configuring/assembly-config/ConfigTypes.ts index 18acd5c441..d451ccdaa3 100644 --- a/fission/src/ui/panels/configuring/assembly-config/ConfigTypes.ts +++ b/fission/src/ui/panels/configuring/assembly-config/ConfigTypes.ts @@ -1,4 +1,4 @@ -import { MiraType } from "@/mirabuf/MirabufLoader.ts" +import { MiraType } from "@/mirabuf/MiraType.ts" import type MirabufSceneObject from "@/mirabuf/MirabufSceneObject.ts" import type React from "react" import type { ConfigurePanelCustomProps } from "@/panels/configuring/assembly-config/ConfigurePanel.tsx" diff --git a/fission/src/ui/panels/configuring/assembly-config/ConfigurePanel.tsx b/fission/src/ui/panels/configuring/assembly-config/ConfigurePanel.tsx index 6391b5212f..3e13ebf87e 100644 --- a/fission/src/ui/panels/configuring/assembly-config/ConfigurePanel.tsx +++ b/fission/src/ui/panels/configuring/assembly-config/ConfigurePanel.tsx @@ -31,7 +31,9 @@ import ConfigureProtectedZonesInterface from "./interfaces/scoring/ConfigureProt import ConfigureScoringZonesInterface from "./interfaces/scoring/ConfigureScoringZonesInterface" import EventSystem from "@/systems/EventSystem.ts" import { MatchModeType } from "@/systems/match_mode/MatchModeTypes" -import { Tab, Tabs, type TabsActions } from "@mui/material" +import { Box, Tab, Tabs, type TabsActions } from "@mui/material" +import { tourTarget } from "@/ui/tour/TourSteps" +import { useTourAnchor } from "@/ui/tour/TourProviderHelpers" import { SoundPlayer } from "@/systems/sound/SoundPlayer" import CommandRegistry, { type CommandDefinition, type CommandProvider } from "@/ui/components/CommandRegistry" import { globalAddToast, globalOpenPanel } from "@/ui/components/GlobalUIControls" @@ -167,6 +169,8 @@ const subConfigPanels: Record = { const ConfigurePanel: React.FC> = ({ panel }) => { const { configureScreen, closePanel, addToast } = useUIContext() + const configurePanelRef = useTourAnchor("configure-panel") + const { configMode: initialConfigMode, selectedAssembly: initialSelectedAssembly, @@ -305,7 +309,7 @@ const ConfigurePanel: React.FC> }, [configMode, selectedAssembly]) return ( - <> + > )} - + ) } +// tagging onboarding target to allow for auto-advancing despite minification +tourTarget(ConfigurePanel, "ConfigurePanel") + export default ConfigurePanel diff --git a/fission/src/ui/panels/configuring/assembly-config/configure/AssemblySelection.tsx b/fission/src/ui/panels/configuring/assembly-config/configure/AssemblySelection.tsx index 6f74860717..0dcb5e2d01 100644 --- a/fission/src/ui/panels/configuring/assembly-config/configure/AssemblySelection.tsx +++ b/fission/src/ui/panels/configuring/assembly-config/configure/AssemblySelection.tsx @@ -5,7 +5,7 @@ import World from "@/systems/World.ts" import type { PanelImplProps } from "@/ui/components/Panel" import SelectMenu, { SelectMenuOption } from "@/ui/components/SelectMenu" import { CloseType, useUIContext } from "@/ui/helpers/UIProviderHelpers" -import ImportMirabufPanel from "@/ui/panels/mirabuf/ImportMirabufPanel" +import LibraryModal from "@/ui/modals/mirabuf/LibraryModal" import type { ConfigurationType } from "../ConfigTypes" import type { ConfigurePanelCustomProps } from "../ConfigurePanel" import EventSystem from "@/systems/EventSystem.ts" @@ -46,7 +46,7 @@ const AssemblySelection: React.FC { - const { openPanel, closePanel } = useUIContext() + const { openModal, closePanel } = useUIContext() const getRobots = useCallback( () => World.sceneRenderer.mirabufSceneObjects.getRobots().filter(x => !pendingDeletes.includes(x.id)), @@ -90,7 +90,7 @@ const AssemblySelection: React.FC { // Save current configuration first, then open Spawn panel next tick closePanel(panel!.id, CloseType.ACCEPT) - setTimeout(() => openPanel(ImportMirabufPanel, { configurationType }), 0) + setTimeout(() => openModal(LibraryModal, undefined), 0) }} noOptionsText={`No ${configurationType === "ROBOTS" ? "robots" : "fields"} spawned!`} defaultSelectedOption={selectedAssembly ? makeSelectionOption(selectedAssembly) : undefined} diff --git a/fission/src/ui/panels/configuring/assembly-config/interfaces/ConfigureGamepieceIntakeInterface.tsx b/fission/src/ui/panels/configuring/assembly-config/interfaces/ConfigureGamepieceIntakeInterface.tsx index 03e1d90c9c..347817a37f 100644 --- a/fission/src/ui/panels/configuring/assembly-config/interfaces/ConfigureGamepieceIntakeInterface.tsx +++ b/fission/src/ui/panels/configuring/assembly-config/interfaces/ConfigureGamepieceIntakeInterface.tsx @@ -1,5 +1,5 @@ import type Jolt from "@synthesis.adsk/jolt-physics" -import { Stack } from "@mui/material" +import { Box, Stack } from "@mui/material" import { useCallback, useEffect, useMemo, useRef, useState } from "react" import * as THREE from "three" import SelectButton from "@/components/SelectButton" @@ -13,6 +13,7 @@ import Checkbox from "@/ui/components/Checkbox" import StatefulSlider from "@/ui/components/StatefulSlider" import { Button, Spacer } from "@/ui/components/StyledComponents" import TransformGizmoControl from "@/ui/components/TransformGizmoControl" +import { useTourAnchor } from "@/ui/tour/TourProviderHelpers" import { convertArrayToThreeMatrix4, convertJoltMat44ToThreeMatrix4, @@ -98,6 +99,7 @@ const ConfigureGamepieceIntakeInterface: ConfigurationSubpanelComponent = ({ selectedAssembly, registerCleanupFunction, }) => { + const showZoneRef = useTourAnchor("intake-show-zone") const [selectedNode, setSelectedNode] = useState(undefined) const [zoneSize, setZoneSize] = useState((MIN_ZONE_SIZE + MAX_ZONE_SIZE) / 2.0) const [showZoneAlways, setShowZoneAlways] = useState(false) @@ -300,7 +302,13 @@ const ConfigureGamepieceIntakeInterface: ConfigurationSubpanelComponent = ({ /> {/* Checkbox for showing intake zone indicator at all times */} - + + + {gizmoComponent} - - - ) -} - -export default ImportMirabufPanel diff --git a/fission/src/ui/tour/AnchorGeometry.ts b/fission/src/ui/tour/AnchorGeometry.ts new file mode 100644 index 0000000000..e16afdd392 --- /dev/null +++ b/fission/src/ui/tour/AnchorGeometry.ts @@ -0,0 +1,28 @@ +export function clippingAncestors(element: HTMLElement): HTMLElement[] { + const clippers: HTMLElement[] = [] + for (let parent = element.parentElement; parent; parent = parent.parentElement) { + if (getComputedStyle(parent).overflow !== "visible") clippers.push(parent) + } + return clippers +} + +export function visibleRect(element: HTMLElement, clippers = clippingAncestors(element)): DOMRect | null { + const view = document.documentElement + let { top, left, right, bottom } = element.getBoundingClientRect() + top = Math.max(top, 0) + left = Math.max(left, 0) + bottom = Math.min(bottom, view.clientHeight) + right = Math.min(right, view.clientWidth) + + for (const clipper of clippers) { + const box = clipper.getBoundingClientRect() + const innerTop = box.top + clipper.clientTop + const innerLeft = box.left + clipper.clientLeft + top = Math.max(top, innerTop) + left = Math.max(left, innerLeft) + bottom = Math.min(bottom, innerTop + clipper.clientHeight) + right = Math.min(right, innerLeft + clipper.clientWidth) + } + + return bottom <= top || right <= left ? null : new DOMRect(left, top, right - left, bottom - top) +} diff --git a/fission/src/ui/tour/TourCard.tsx b/fission/src/ui/tour/TourCard.tsx new file mode 100644 index 0000000000..9640b77de1 --- /dev/null +++ b/fission/src/ui/tour/TourCard.tsx @@ -0,0 +1,178 @@ +import { Box, ButtonBase, Stack, Typography } from "@mui/material" +import type React from "react" +import { MdChevronLeft, MdChevronRight } from "react-icons/md" +import type { TourStep } from "./TourSteps" + +const CARD_WIDTH = 265 +const PILL_SX = { + bgcolor: "surface.main", + color: "topBarText.main", + borderRadius: "4px", + px: 1, + py: 0.25, + fontSize: 11, + fontWeight: 700, + "&:hover": { opacity: 0.85 }, +} as const +/** Length of the pointer's base, running along the card edge. */ +const ARROW_BASE = 16 +/** How far the pointer's tip protrudes past the card edge toward the anchor. */ +const ARROW_HEIGHT = 8 + +/** + * Per-edge geometry for the triangular pointer. + * + * We carve a real triangle with `clipPath` rather than rotating a square: the MUI Popper + * `arrow` modifier positions this element with an inline `transform: translate(...)`, which + * would clobber any `transform: rotate(...)` we set (inline style beats the emotion class), + * leaving a square poking out. `clipPath` never touches `transform`, so the two never fight. + * + * For each edge the base sits flush against the card and the tip points toward the anchor; + * `width`/`height` size the bounding box (base spans the edge, height is the protrusion) and + * the negative `offset` pulls the box fully outside that edge. + */ +const ARROW_GEOMETRY: Record< + "top" | "bottom" | "left" | "right", + { clipPath: string; width: number; height: number; offset: Record } +> = { + top: { + clipPath: "polygon(50% 0%, 0% 100%, 100% 100%)", + width: ARROW_BASE, + height: ARROW_HEIGHT, + offset: { top: -ARROW_HEIGHT }, + }, + bottom: { + clipPath: "polygon(50% 100%, 0% 0%, 100% 0%)", + width: ARROW_BASE, + height: ARROW_HEIGHT, + offset: { bottom: -ARROW_HEIGHT }, + }, + left: { + clipPath: "polygon(0% 50%, 100% 0%, 100% 100%)", + width: ARROW_HEIGHT, + height: ARROW_BASE, + offset: { left: -ARROW_HEIGHT }, + }, + right: { + clipPath: "polygon(100% 50%, 0% 0%, 0% 100%)", + width: ARROW_HEIGHT, + height: ARROW_BASE, + offset: { right: -ARROW_HEIGHT }, + }, +} + +interface TourCardProps { + step: TourStep + stepIndex: number + total: number + onNext: () => void + onPrev: () => void + onSkip: () => void + /** Popper arrow element ref. Omit for a centered (anchorless) card. */ + setArrowRef?: (el: HTMLElement | null) => void + /** Which card edge the pointer sits on. Omit to hide the pointer. */ + arrowEdge?: "top" | "bottom" | "left" | "right" + nextDisabled?: boolean +} + +/** + * The onboarding callout card (Figma 357-18): title, body, Skip pill, a "n of N" + * counter and `<` / `>` navigation, plus an optional triangular pointer for anchored + * steps. Purely presentational - all state lives in the TourProvider. + */ +const TourCard: React.FC = ({ + step, + stepIndex, + total, + onNext, + onPrev, + onSkip, + setArrowRef, + arrowEdge, + nextDisabled = false, +}) => { + const isFirst = stepIndex === 0 + const isLast = stepIndex === total - 1 + + return ( + + {arrowEdge && ( + + )} + + + {step.title} + + Skip + + + + {step.body} + + + + + Back + + + + {stepIndex + 1} of {total} + + + + {isLast ? "Done" : "Next"} + {!isLast && } + + + + ) +} + +export default TourCard diff --git a/fission/src/ui/tour/TourConditions.ts b/fission/src/ui/tour/TourConditions.ts new file mode 100644 index 0000000000..55abbed5bd --- /dev/null +++ b/fission/src/ui/tour/TourConditions.ts @@ -0,0 +1,108 @@ +import type { AppMode } from "@/systems/AppMode" +import { ConfigMode } from "@/ui/panels/configuring/assembly-config/ConfigTypes" +import type { TourCondition, TourStep, TourTargetId } from "./TourSteps" +import { TOUR_STEPS } from "./TourSteps" + +export interface TourSnapshot { + modal?: TourTargetId + panels: { id?: TourTargetId; configMode?: ConfigMode }[] + appMode: AppMode + fieldCount: number + robotCount: number + spawnPending: boolean +} + +const hasPanel = (snapshot: TourSnapshot, id: TourTargetId, configMode?: ConfigMode) => + snapshot.panels.some(p => p.id === id && (configMode === undefined || p.configMode === configMode)) + +interface TourConditionDef { + holds: (snapshot: TourSnapshot) => boolean + hint: string +} + +export const CONDITIONS: Record = { + libraryOpen: { + holds: s => s.modal === "LibraryModal", + hint: "Open the assets library with the Add Assembly button to continue.", + }, + field: { + holds: s => s.fieldCount > 0, + hint: "Spawn a field from the library to continue.", + }, + robot: { + holds: s => s.robotCount > 0, + hint: "Spawn a robot from the library to continue.", + }, + setupPanel: { + holds: s => hasPanel(s, "InitialConfigPanel"), + hint: "Finish Assembly Setup to continue.", + }, + intakePanel: { + holds: s => hasPanel(s, "ConfigurePanel", ConfigMode.INTAKE), + hint: "Open the intake configuration from the top bar to continue.", + }, + configureMode: { + holds: s => s.appMode === "Configure", + hint: "Switch back to Configure mode to continue the tour.", + }, +} + +export function advanceConditionMet(step: TourStep, snapshot: TourSnapshot): boolean { + if (!step.advanceOn) return true + return CONDITIONS[step.advanceOn.condition].holds(snapshot) === (step.advanceOn.state ?? true) +} + +export interface TourRuntime { + step: number + reported?: TourCondition +} + +export interface TourResult { + stepIndex: number + runtime: TourRuntime + toast?: string +} + +/** + * returning the nearest preceding step that created this condition + * + * This is used specifically when rewinding steps after a condition was not met. + */ +function producerOf(condition: TourCondition, before: number, snapshot: TourSnapshot): number | undefined { + for (let i = before - 1; i >= 0; i--) { + const step = TOUR_STEPS[i] + if (step.skipIf !== undefined && CONDITIONS[step.skipIf].holds(snapshot)) continue + const advanceOn = step.advanceOn + if (advanceOn?.condition === condition && (advanceOn.state ?? true)) return i + } + return undefined +} + +/** + * given the current step and the progress of the simulator, returns what step the user should be on. + * + * Used for rewinding in the event of a user being outside the tour and advancing when step condition met + */ +export function reconcile(stepIndex: number, snapshot: TourSnapshot, previous: TourRuntime): TourResult { + const step = TOUR_STEPS[stepIndex] + + const runtime: TourRuntime = previous.step !== stepIndex ? { step: stepIndex } : { ...previous } + + const goalAlreadyMet = step.skipIf !== undefined && CONDITIONS[step.skipIf].holds(snapshot) + if (goalAlreadyMet || (step.advanceOn && advanceConditionMet(step, snapshot))) { + return { stepIndex: stepIndex + 1, runtime: { step: stepIndex + 1 } } + } + + if (snapshot.spawnPending) return { stepIndex, runtime } + + const unmet = step.requires?.find(condition => !CONDITIONS[condition].holds(snapshot)) + if (!unmet) return { stepIndex, runtime: { ...runtime, reported: undefined } } + if (unmet === runtime.reported) return { stepIndex, runtime } + + const target = producerOf(unmet, stepIndex, snapshot) ?? stepIndex + return { + stepIndex: target, + runtime: { step: target, reported: unmet }, + toast: runtime.reported === undefined ? CONDITIONS[unmet].hint : undefined, + } +} diff --git a/fission/src/ui/tour/TourOverlay.tsx b/fission/src/ui/tour/TourOverlay.tsx new file mode 100644 index 0000000000..73d699d632 --- /dev/null +++ b/fission/src/ui/tour/TourOverlay.tsx @@ -0,0 +1,254 @@ +import { Box, Popper, type PopperPlacementType } from "@mui/material" +import type { Instance as PopperInstance } from "@popperjs/core" +import type React from "react" +import { useEffect, useMemo, useRef, useState } from "react" +import { TOP_BAR_HEIGHT } from "@/ui/components/topbar/TopBarConfig" +import { useUIContext } from "@/ui/helpers/UIProviderHelpers" +import { clippingAncestors, visibleRect } from "./AnchorGeometry" +import type { ScreenPosition } from "./TourSteps" +import { TOUR_STEPS } from "./TourSteps" +import TourCard from "./TourCard" +import { useTourContext } from "./TourProviderHelpers" + +const ZIndex = 1400 // above panels/modals (1300) and the top bar (1200) +const SCRIM_Z_INDEX = ZIndex - 10 +const SCRIM_COLOR = "rgba(0,0,0,0.5)" +const SCRIM_TEST_ID = "tour-scrim" +// Gap from the top bar / viewport edge for an anchorless card that is pinned to a corner. +const SCREEN_EDGE_GAP = 12 +const SPOTLIGHT_PAD = 6 + +// after step changes we remeasure points. Anchors can move without resizing because of MUI stuff / panels +const SETTLE_DELAYS = [0, 100, 250, 450] + +/** Fixed-position style for an anchorless card, keyed by its {@link ScreenPosition}. */ +function screenPositionStyle(position: ScreenPosition | undefined) { + if (position === "top-left") { + return { top: TOP_BAR_HEIGHT + SCREEN_EDGE_GAP, left: SCREEN_EDGE_GAP } + } + // Default: dead center. + return { top: "50%", left: "50%", transform: "translate(-50%, -50%)" } +} + +/** Maps a Popper placement to the card edge its pointer should sit on. */ +const ARROW_EDGE_BY_BASE = { top: "bottom", bottom: "top", left: "right", right: "left" } as const + +function arrowEdgeFor(placement: PopperPlacementType) { + return ARROW_EDGE_BY_BASE[placement.split("-")[0] as keyof typeof ARROW_EDGE_BY_BASE] +} + +const sameRect = (a: DOMRect | null, b: DOMRect | null) => + a === b || + (a !== null && b !== null && a.top === b.top && a.left === b.left && a.width === b.width && a.height === b.height) + +function useVisibleRect(element: HTMLElement | null) { + const [rect, setRect] = useState(null) + + useEffect(() => { + if (!element) { + setRect(null) + return + } + let frame = 0 + let clippers = clippingAncestors(element) + const observer = new ResizeObserver(() => schedule()) + + const measure = () => + setRect(prev => { + const next = visibleRect(element, clippers) + return sameRect(prev, next) ? prev : next + }) + const schedule = () => { + frame ||= requestAnimationFrame(() => { + frame = 0 + measure() + }) + } + const remeasure = () => { + observer.disconnect() + clippers = clippingAncestors(element) + observer.observe(element) + clippers.forEach(clipper => observer.observe(clipper)) + measure() + } + + remeasure() + const timers = SETTLE_DELAYS.map(delay => setTimeout(remeasure, delay)) + window.addEventListener("resize", remeasure) + window.addEventListener("scroll", schedule, { capture: true, passive: true }) + return () => { + timers.forEach(clearTimeout) + if (frame) cancelAnimationFrame(frame) + observer.disconnect() + window.removeEventListener("resize", remeasure) + window.removeEventListener("scroll", schedule, { capture: true }) + } + }, [element]) + + return rect +} + +const SpotlightScrim: React.FC<{ rect: DOMRect }> = ({ rect }) => { + const view = document.documentElement + const top = Math.max(0, rect.top - SPOTLIGHT_PAD) + const left = Math.max(0, rect.left - SPOTLIGHT_PAD) + const right = Math.min(view.clientWidth, rect.right + SPOTLIGHT_PAD) + const bottom = Math.min(view.clientHeight, rect.bottom + SPOTLIGHT_PAD) + + const bands = { + above: { top: 0, left: 0, right: 0, height: top }, + below: { top: bottom, left: 0, right: 0, bottom: 0 }, + before: { top, left: 0, width: left, height: bottom - top }, + after: { top, left: right, right: 0, height: bottom - top }, + } + + return ( + <> + {Object.entries(bands).map(([edge, band]) => ( + + ))} + + + ) +} + +/** + * Renders the current tour step's card, anchored to its registered element via an MUI + * Popper (which repositions on resize, so cards stay attached across screen sizes). + * Steps with no anchor - or whose anchor is not yet mounted - fall back to a centered card. + */ +const TourOverlay: React.FC = () => { + // Consuming the context re-renders this component whenever the provider value changes - + // including the anchorVersion bump on anchor (de)registration - so the anchor below is + // always re-resolved when a panel mounts or unmounts. + const { active, stepIndex, canAdvance, next, prev, skip, getAnchor, anchorVersion } = useTourContext() + const { blockState } = useUIContext() + const [arrowRef, setArrowRef] = useState(null) + const popperRef = useRef(null) + + const step = active ? TOUR_STEPS[stepIndex] : undefined + + // Resolved on every render; the context change from anchor (de)registration drives re-renders. + // Guard on `isConnected`: while an anchor's host (a panel/modal) unmounts, the element can be + // detached from the document for a tick before its callback ref clears the registry entry. + // Feeding a detached node to the Popper throws an MUI "invalid anchorEl" warning, so we treat + // it as absent and fall through to the centered card until a live anchor re-registers. + const rawAnchor = step?.anchorId ? getAnchor(step.anchorId) : null + const anchorEl = rawAnchor?.isConnected ? rawAnchor : null + + const anchorRect = useVisibleRect(anchorEl) + + const rectRef = useRef(null) + rectRef.current = anchorRect + const popperAnchor = useMemo( + () => + anchorEl && { + getBoundingClientRect: () => rectRef.current ?? anchorEl.getBoundingClientRect(), + contextElement: anchorEl, + }, + [anchorEl] + ) + + const modifiers = useMemo( + () => [ + { name: "offset", options: { offset: [0, 12] } }, + { name: "flip", enabled: false }, + // altAxis clamps along the placement axis itself (y for a "top-end" card) and + // tether:false lets it detach from an oversized reference, so the card stays fully + // on-screen instead of running off the edge - e.g. the near-full-screen Library + // modal, which leaves less headroom above it than the card is tall. + { name: "preventOverflow", options: { padding: 8, altAxis: true, tether: false } }, + { name: "arrow", enabled: true, options: { element: arrowRef, padding: 12 } }, + ], + [arrowRef] + ) + + useEffect(() => { + const timers = SETTLE_DELAYS.map(delay => setTimeout(() => popperRef.current?.update(), delay)) + return () => timers.forEach(clearTimeout) + }, [stepIndex, anchorVersion]) + + if (!step) return null + + const scrim = blockState.blocked ? null : step.focus === "screen" ? ( + + ) : step.focus === "anchor" && anchorRect ? ( + + ) : null + + const card = ( + + ) + + // Anchored card. + if (anchorEl) { + return ( + <> + {scrim} + + {card} + + + ) + } + + // Anchorless steps (or an anchor that has not mounted yet): pin the card to a screen position. + return ( + <> + {scrim} + + {card} + + + ) +} + +export default TourOverlay diff --git a/fission/src/ui/tour/TourProvider.tsx b/fission/src/ui/tour/TourProvider.tsx new file mode 100644 index 0000000000..74ecf3cd47 --- /dev/null +++ b/fission/src/ui/tour/TourProvider.tsx @@ -0,0 +1,142 @@ +import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react" +import EventSystem from "@/systems/EventSystem.ts" +import InputSystem, { ESCAPE_PRIORITY } from "@/systems/input/InputSystem.ts" +import PreferencesSystem from "@/systems/preferences/PreferencesSystem.ts" +import World from "@/systems/World.ts" +import { useStateContext } from "@/ui/helpers/StateProviderHelpers" +import { useIsMobile } from "@/ui/helpers/useIsMobile" +import { useUIContext } from "@/ui/helpers/UIProviderHelpers" +import { hasPendingSpawn } from "@/ui/modals/mirabuf/LibrarySpawnActions" +import type { ConfigMode } from "@/ui/panels/configuring/assembly-config/ConfigTypes" +import { advanceConditionMet, reconcile, type TourRuntime, type TourSnapshot } from "./TourConditions" +import { TourContext, type TourContextValue } from "./TourProviderHelpers" +import type { TourAnchorId } from "./TourSteps" +import { TOUR_STEPS, tourIdOf } from "./TourSteps" + +const readWorld = () => ({ + fieldCount: World.isAlive && World.sceneRenderer.mirabufSceneObjects.getField() !== undefined ? 1 : 0, + robotCount: World.isAlive ? World.sceneRenderer.mirabufSceneObjects.getRobots().length : 0, +}) + +export const TourProvider: React.FC<{ children?: ReactNode }> = ({ children }) => { + const { panels, modal, addToast } = useUIContext() + const { appMode } = useStateContext() + const isMobile = useIsMobile() + + const [active, setActive] = useState(false) + const [stepIndex, setStepIndex] = useState(0) + const [world, setWorld] = useState(readWorld) + + useEffect(() => EventSystem.listen("MirabufObjectChangeEvent", () => setWorld(readWorld())), []) + + const [spawnPending, setSpawnPending] = useState(hasPendingSpawn) + + useEffect(() => EventSystem.listen("SpawnPendingChangeEvent", setSpawnPending), []) + + // Anchor registry. The Map lives in a ref (stable identity); a version counter + // triggers overlay re-resolution when elements mount/unmount (e.g. panels opening). + const anchorsRef = useRef(new Map()) + const [anchorVersion, setAnchorVersion] = useState(0) + + const registerAnchor = useCallback((id: TourAnchorId, element: HTMLElement | null) => { + const anchors = anchorsRef.current + if (element) anchors.set(id, element) + else anchors.delete(id) + setAnchorVersion(v => v + 1) + }, []) + + const getAnchor = useCallback((id: TourAnchorId) => anchorsRef.current.get(id) ?? null, []) + + const markSeen = useCallback(() => { + PreferencesSystem.setUserPreference("HasSeenOnboardingTour", true) + PreferencesSystem.savePreferences() + }, []) + + const finish = useCallback(() => { + setActive(false) + setStepIndex(0) + markSeen() + }, [markSeen]) + + const next = useCallback(() => { + setStepIndex(i => { + if (i >= TOUR_STEPS.length - 1) { + finish() + return i + } + return i + 1 + }) + }, [finish]) + + const prev = useCallback(() => setStepIndex(i => Math.max(0, i - 1)), []) + + const skip = useCallback(() => finish(), [finish]) + + // First-visit trigger. Desktop only, once per browser (persisted preference). + const startedRef = useRef(false) + useEffect(() => { + if (isMobile) { + // Never run on mobile; end the tour if the viewport crosses into mobile mid-run. + setActive(false) + return + } + if (startedRef.current) return + if (!PreferencesSystem.getUserPreference("HasSeenOnboardingTour")) { + startedRef.current = true + setActive(true) + } + }, [isMobile]) + + const runtimeRef = useRef({ step: -1 }) + + useEffect(() => { + if (isMobile) return + return EventSystem.listen("TourRestartEvent", () => { + runtimeRef.current = { step: -1 } + setStepIndex(0) + setActive(true) + }) + }, [isMobile]) + + const snapshot = useMemo( + () => ({ + modal: tourIdOf(modal?.content), + panels: panels.map(p => ({ + id: tourIdOf(p.content), + configMode: (p.props.custom as { configMode?: ConfigMode } | undefined)?.configMode, + })), + appMode, + ...world, + spawnPending, + }), + [modal, panels, appMode, world, spawnPending] + ) + + useEffect(() => { + if (!active) return + const result = reconcile(stepIndex, snapshot, runtimeRef.current) + runtimeRef.current = result.runtime + + if (result.toast) addToast("warning", result.toast) + if (result.stepIndex >= TOUR_STEPS.length) finish() + else if (result.stepIndex !== stepIndex) setStepIndex(result.stepIndex) + }, [active, stepIndex, snapshot, addToast, finish]) + + const canAdvance = active ? advanceConditionMet(TOUR_STEPS[stepIndex], snapshot) : true + + useEffect(() => { + if (!active) return + // tour has the highest priority for 'esc'. Then next is modals and panels + return InputSystem.addEscapeHandler(() => { + skip() + return true + }, ESCAPE_PRIORITY.TOUR) + }, [active, skip]) + + const value = useMemo( + () => ({ active, stepIndex, canAdvance, next, prev, skip, registerAnchor, getAnchor, anchorVersion }), + [active, stepIndex, canAdvance, next, prev, skip, registerAnchor, getAnchor, anchorVersion] + ) + + return {children} +} diff --git a/fission/src/ui/tour/TourProviderHelpers.ts b/fission/src/ui/tour/TourProviderHelpers.ts new file mode 100644 index 0000000000..b8fc28389b --- /dev/null +++ b/fission/src/ui/tour/TourProviderHelpers.ts @@ -0,0 +1,54 @@ +import { createContext, useCallback, useContext } from "react" +import type { TourAnchorId } from "./TourSteps" + +export interface TourContextValue { + /** Whether the tour is currently running. */ + active: boolean + /** Index into `TOUR_STEPS` of the current step. */ + stepIndex: number + /** Advance to the next step (or finish on the last step). */ + next: () => void + canAdvance: boolean + /** Go back a step (no-op on the first step). */ + prev: () => void + /** Dismiss the tour and mark it as seen. */ + skip: () => void + /** Register (or clear, with `null`) a DOM element as a named tour anchor. */ + registerAnchor: (id: TourAnchorId, element: HTMLElement | null) => void + /** Resolve the current element registered for an anchor id. */ + getAnchor: (id: TourAnchorId) => HTMLElement | null + /** Bumped whenever the anchor registry changes, so the overlay re-resolves. */ + anchorVersion: number +} + +const noop = () => {} + +export const TourContext = createContext({ + active: false, + stepIndex: 0, + next: noop, + canAdvance: true, + prev: noop, + skip: noop, + registerAnchor: noop, + getAnchor: () => null, + anchorVersion: 0, +}) + +export const useTourContext = () => useContext(TourContext) + +/** + * Returns a callback ref that registers the attached DOM element as the named tour + * anchor for the lifetime it is mounted. Attach it to the element a tour step points at: + * + * ```tsx + * + * ``` + * + * The registered element is what the tour card's Popper anchors to. When the tour is + * inactive this is effectively free - it just keeps the registry up to date. + */ +export function useTourAnchor(id: TourAnchorId) { + const { registerAnchor } = useTourContext() + return useCallback((element: HTMLElement | null) => registerAnchor(id, element), [registerAnchor, id]) +} diff --git a/fission/src/ui/tour/TourSteps.ts b/fission/src/ui/tour/TourSteps.ts new file mode 100644 index 0000000000..d5090aae23 --- /dev/null +++ b/fission/src/ui/tour/TourSteps.ts @@ -0,0 +1,173 @@ +import type { PopperPlacementType } from "@mui/material" +import type { FunctionComponent } from "react" + +export type TourTargetId = "LibraryModal" | "ConfigurePanel" | "InitialConfigPanel" + +interface TourTagged { + tourId?: TourTargetId +} + +export function tourTarget

(component: FunctionComponent

, id: TourTargetId): FunctionComponent

{ + ;(component as FunctionComponent

& TourTagged).tourId = id + return component +} + +export function tourIdOf(component: FunctionComponent | undefined): TourTargetId | undefined { + return (component as (FunctionComponent & TourTagged) | undefined)?.tourId +} + +export type TourAnchorId = + | "add-assembly" + | "mode-dropdown" + | "spawn-panel" + | "assembly-setup" + | "configure-assembly-select" + | "configure-intake-button" + | "configure-panel" + | "intake-show-zone" + +export type TourCondition = "libraryOpen" | "field" | "robot" | "setupPanel" | "intakePanel" | "configureMode" + +export type TourStepId = + | "add-field" + | "spawn-field" + | "add-robot" + | "spawn-robot" + | "setup-assembly" + | "select-assembly" + | "pick-config" + | "adjust-intake" + | "show-intake-zone" + | "save-config" + | "drive" + | "switch-modes" + +// for anchorless cards +export type ScreenPosition = "center" | "top-left" + +export type TourFocus = "anchor" | "screen" + +export interface TourStep { + id: TourStepId + title: string + body: string + anchorId?: TourAnchorId + placement: PopperPlacementType + screenPosition?: ScreenPosition + advanceOn?: { condition: TourCondition; state?: boolean } + skipIf?: TourCondition + requires?: TourCondition[] + focus?: TourFocus +} + +export const TOUR_STEPS: TourStep[] = [ + { + id: "add-field", + title: "Add a Field", + body: "First we need a field. Open the assets library with the Add Assembly button.", + anchorId: "add-assembly", + placement: "bottom-start", + focus: "anchor", + advanceOn: { condition: "libraryOpen" }, + skipIf: "field", + }, + { + id: "spawn-field", + title: "Open the Library", + body: "The library groups fields and robots by year. Pick the newest year tab and spawn the field.", + anchorId: "spawn-panel", + placement: "top-end", + focus: "anchor", + advanceOn: { condition: "field" }, + requires: ["libraryOpen"], + }, + { + id: "add-robot", + title: "Add a Robot", + body: "Now open the Add Assembly library again to spawn a robot.", + anchorId: "add-assembly", + placement: "bottom-start", + focus: "anchor", + advanceOn: { condition: "libraryOpen" }, + skipIf: "robot", + requires: ["field"], + }, + { + id: "spawn-robot", + title: "Choose a Robot", + body: "With the library open, pick a robot from the newest year tab.", + anchorId: "spawn-panel", + placement: "top-end", + focus: "anchor", + advanceOn: { condition: "robot" }, + requires: ["libraryOpen", "field"], + }, + { + id: "setup-assembly", + title: "Set Up Your Assembly", + body: "Select an input scheme for your robot, or just press Finish and a compatible scheme is assigned automatically. You can change the input scheme, alliance, and station later.", + anchorId: "assembly-setup", + placement: "left", + advanceOn: { condition: "setupPanel", state: false }, + }, + { + id: "select-assembly", + title: "Select an Assembly", + body: "Your spawned robot is automatically selected here for configuration. You could switch to another assembly from this drop-down, but we will stick with your robot.", + anchorId: "configure-assembly-select", + placement: "bottom-start", + focus: "screen", + requires: ["robot", "configureMode"], + }, + { + id: "pick-config", + title: "Pick What to Configure", + body: "With your robot selected, choose what to configure. In this case, the intake.", + anchorId: "configure-intake-button", + placement: "bottom", + focus: "anchor", + advanceOn: { condition: "intakePanel" }, + requires: ["robot", "configureMode"], + }, + { + id: "adjust-intake", + title: "Adjust the Intake", + body: "This is the Configure Assets panel. Here you can align the intake with the robot's intake mechanism and tune how it picks up game pieces.", + anchorId: "configure-panel", + placement: "left", + focus: "screen", + requires: ["intakePanel"], + }, + { + id: "show-intake-zone", + title: "Show the Intake Zone", + body: "The 'Show intake zone indicator always' toggle keeps the intake's pickup zone visible, making it much easier to see.", + anchorId: "intake-show-zone", + placement: "left", + focus: "screen", + requires: ["intakePanel"], + }, + { + id: "save-config", + title: "Finish Up", + body: "When you are happy with the intake, press Save to apply your configuration and close the panel.", + anchorId: "configure-panel", + placement: "left", + advanceOn: { condition: "intakePanel", state: false }, + }, + { + id: "drive", + title: "Drive Your Robot", + body: "Now drive your robot with the input scheme you picked, and trigger its intake. When a game piece enters the sphere, your newly configured intake picks it up. Press Next when you are ready to move on.", + placement: "top", + screenPosition: "top-left", + requires: ["robot"], + }, + { + id: "switch-modes", + title: "Switch Modes", + body: "This drop-down switches between modes and changes the buttons available in the bar. Press Done to finish the tour.", + anchorId: "mode-dropdown", + placement: "right-start", + }, +] diff --git a/fission/update_manifest.ts b/fission/update_manifest.ts index 7e1882fa2e..bb5000b213 100644 --- a/fission/update_manifest.ts +++ b/fission/update_manifest.ts @@ -6,11 +6,54 @@ import { mirabuf } from "@/proto/mirabuf" import { v4 as uuidV4 } from "uuid" import FieldMiraEditor from "@/mirabuf/FieldMiraEditor.ts" -const basepath = "public/Downloadables/Mira" +const basepath = "public/Downloadables/mira" const map: ManifestFileType = { fields: [], private: [], robots: [] } const dirs = Object.keys(map) as (keyof typeof map)[] +/** + * Derive the competition year from an asset's (normalized) name. + * Prefers an explicitly parenthesized year (e.g. "KitBot (2024)"), otherwise + * falls back to the last four-digit 19xx/20xx run in the string (e.g. "FRC Field 2026 v2"). + * Returns undefined when no year is present so the asset lands in the "Other" group. + */ +function parseYear(name: string): number | undefined { + const parenthesized = name.match(/\((19|20)\d{2}\)/g) + if (parenthesized) { + return Number(parenthesized[parenthesized.length - 1].replace(/[()]/g, "")) + } + const loose = name.match(/(19|20)\d{2}/g) + if (loose) { + return Number(loose[loose.length - 1]) + } + return undefined +} + +/** + * Extract the thumbnail Fusion embeds in the Mira metadata and write it to a sibling + * "

/thumbnails/." so the asset library can preview it without downloading + * and unzipping the whole assembly. Returns the path relative to the asset directory, or + * undefined when the assembly carries no thumbnail data. + */ +async function extractThumbnail( + assembly: mirabuf.Assembly, + dirname: string, + name: string +): Promise { + const thumbnail = assembly.thumbnail + if (!thumbnail?.data?.length) { + return undefined + } + + const ext = (thumbnail.extension || "png").replace(/^\./, "") + const relative = `thumbnails/${name.replace(/\.mira$/, "")}.${ext}` + const absolute = path.join(basepath, dirname, relative) + + await fs.mkdir(path.dirname(absolute), { recursive: true }) + await fs.writeFile(absolute, thumbnail.data) + return relative +} + async function main() { for (const dirname of dirs) { const list = map[dirname] @@ -57,7 +100,12 @@ async function main() { await fs.rm(originalPath) } } - list.push({ filename: name, hash: updatedHash }) + list.push({ + filename: name, + hash: updatedHash, + year: parseYear(name), + thumbnail: await extractThumbnail(assembly, dirname, name), + }) } } await fs.writeFile(path.join(basepath, "manifest.json"), JSON.stringify(map))