From 9f5eb5fd2dd5a0e6c91bed5ddf978f964379017f Mon Sep 17 00:00:00 2001 From: PepperLola Date: Tue, 30 Jun 2026 11:23:09 -0700 Subject: [PATCH 01/17] feat: camera feed to preview and code sim --- fission/src/mirabuf/MirabufSceneObject.ts | 39 +++ fission/src/mirabuf/RobotCameraSceneObject.ts | 222 +++++++++++++++ .../systems/preferences/PreferenceTypes.ts | 32 +++ .../wpilib_brain/CameraFrameSocket.ts | 56 ++++ .../simulation/wpilib_brain/WPILibState.ts | 2 + .../simulation/wpilib_brain/WPILibTypes.ts | 10 + .../simulation/wpilib_brain/sim/SimCamera.ts | 43 +++ fission/src/test/PreferencesSystem.test.ts | 2 + .../assembly-config/ConfigTypes.ts | 1 + .../assembly-config/ConfigurePanel.tsx | 9 + .../interfaces/ConfigureCameraInterface.tsx | 260 ++++++++++++++++++ .../panels/simulation/CameraPreviewPanel.tsx | 83 ++++++ simulation/SyntheSimJava/build.gradle | 13 + .../gradle/wrapper/gradle-wrapper.jar | Bin 43453 -> 43705 bytes .../gradle/wrapper/gradle-wrapper.properties | 2 +- simulation/SyntheSimJava/gradlew | 6 +- simulation/SyntheSimJava/gradlew.bat | 2 + .../com/autodesk/synthesis/cscore/Camera.java | 120 ++++++++ .../synthesis/cscore/CameraFrameServer.java | 90 ++++++ .../com/autodesk/synthesis/cscore/CvSink.java | 65 +++++ .../autodesk/synthesis/cscore/UsbCamera.java | 107 +++++++ .../src/main/java/frc/robot/Robot.java | 50 ++++ 22 files changed, 1211 insertions(+), 3 deletions(-) create mode 100644 fission/src/mirabuf/RobotCameraSceneObject.ts create mode 100644 fission/src/systems/simulation/wpilib_brain/CameraFrameSocket.ts create mode 100644 fission/src/systems/simulation/wpilib_brain/sim/SimCamera.ts create mode 100644 fission/src/ui/panels/configuring/assembly-config/interfaces/ConfigureCameraInterface.tsx create mode 100644 fission/src/ui/panels/simulation/CameraPreviewPanel.tsx create mode 100644 simulation/SyntheSimJava/src/main/java/com/autodesk/synthesis/cscore/Camera.java create mode 100644 simulation/SyntheSimJava/src/main/java/com/autodesk/synthesis/cscore/CameraFrameServer.java create mode 100644 simulation/SyntheSimJava/src/main/java/com/autodesk/synthesis/cscore/CvSink.java create mode 100644 simulation/SyntheSimJava/src/main/java/com/autodesk/synthesis/cscore/UsbCamera.java diff --git a/fission/src/mirabuf/MirabufSceneObject.ts b/fission/src/mirabuf/MirabufSceneObject.ts index 9032469349..118e906015 100644 --- a/fission/src/mirabuf/MirabufSceneObject.ts +++ b/fission/src/mirabuf/MirabufSceneObject.ts @@ -19,6 +19,7 @@ import { defaultFieldPreferences, defaultFieldSpawnLocation, defaultRobotPreferences, + type CameraPreferences, type EjectorPreferences, type FieldPreferences, type IntakePreferences, @@ -47,6 +48,7 @@ import { SceneOverlayTag } from "@/ui/components/SceneOverlayEvents" import { ConfigMode } from "@/ui/panels/configuring/assembly-config/ConfigTypes" import ConfigurePanel from "@/ui/panels/configuring/assembly-config/ConfigurePanel" import AutoTestPanel from "@/ui/panels/simulation/AutoTestPanel" +import CameraPreviewPanel from "@/ui/panels/simulation/CameraPreviewPanel" import JOLT from "@/util/loading/JoltSyncLoader" import { convertJoltMat44ToThreeMatrix4, @@ -61,6 +63,7 @@ import FieldMiraEditor from "./FieldMiraEditor" import IntakeSensorSceneObject from "./IntakeSensorSceneObject" import MirabufInstance from "./MirabufInstance" import MirabufCachingService, { MiraType } from "./MirabufLoader" +import RobotCameraSceneObject from "./RobotCameraSceneObject" import MirabufParser, { ParseErrorSeverity, type RigidNodeId, type RigidNodeReadOnly } from "./MirabufParser" import ProtectedZoneSceneObject from "./ProtectedZoneSceneObject" import ScoringZoneSceneObject from "./ScoringZoneSceneObject" @@ -108,6 +111,7 @@ class MirabufSceneObject extends SceneObject implements ContextSupplier { private _ejectables: EjectableSceneObject[] = [] private _intakeSensor?: IntakeSensorSceneObject + private _cameras: RobotCameraSceneObject[] = [] private _scoringZones: ScoringZoneSceneObject[] = [] private _protectedZones: ProtectedZoneSceneObject[] = [] @@ -150,6 +154,18 @@ class MirabufSceneObject extends SceneObject implements ContextSupplier { this.robotPreferences.ejector = val } + public get cameraPreferences(): CameraPreferences[] { + return this.robotPreferences.cameras + } + public set cameraPreferences(val: CameraPreferences[]) { + this.robotPreferences.cameras = val + } + + /** Active camera scene objects, exposed for the camera preview UI. */ + public get cameras(): Readonly { + return this._cameras + } + public get multiplayerOwnerName(): string | undefined { if (this.multiplayerOwningClientId == null) return undefined return World.multiplayerSystem?._clientToInfoMap?.get(this.multiplayerOwningClientId)?.displayName @@ -304,6 +320,7 @@ class MirabufSceneObject extends SceneObject implements ContextSupplier { // Intake this.updateIntakeSensor() + this.updateCameras() this.updateScoringZones() this.updateProtectedZones() @@ -447,6 +464,9 @@ class MirabufSceneObject extends SceneObject implements ContextSupplier { this._intakeSensor = undefined } + this._cameras.forEach(c => World.sceneRenderer.removeSceneObject(c.id)) + this._cameras = [] + this._scoringZones.forEach(zone => World.sceneRenderer.removeSceneObject(zone.id)) this._scoringZones.length = 0 @@ -625,6 +645,24 @@ class MirabufSceneObject extends SceneObject implements ContextSupplier { } } + /* + * Recreates the robot-mounted camera scene objects from preferences. Like + * `updateIntakeSensor`, this only runs on `setup` or occasional user input, so + * recreating the objects (and their render targets) here is acceptable. + */ + public updateCameras() { + this._cameras.forEach(c => World.sceneRenderer.removeSceneObject(c.id)) + this._cameras = [] + + if (this.miraType !== MiraType.ROBOT) return + + for (const camPref of this.cameraPreferences) { + const camera = new RobotCameraSceneObject(this, camPref) + this._cameras.push(camera) + World.sceneRenderer.registerSceneObject(camera) + } + } + public setIntakeVisualIndicatorVisible(visible: boolean) { if (this._intakeSensor) { this._intakeSensor.setVisualIndicatorVisible(visible) @@ -968,6 +1006,7 @@ class MirabufSceneObject extends SceneObject implements ContextSupplier { this.updateScoringZones() this.updateProtectedZones() this.updateIntakeSensor() + this.updateCameras() } public updateSimConfig(config: SimConfigData | undefined) { diff --git a/fission/src/mirabuf/RobotCameraSceneObject.ts b/fission/src/mirabuf/RobotCameraSceneObject.ts new file mode 100644 index 0000000000..d9736008ef --- /dev/null +++ b/fission/src/mirabuf/RobotCameraSceneObject.ts @@ -0,0 +1,222 @@ +import type Jolt from "@azaleacolburn/jolt-physics" +import * as THREE from "three" +import type { CameraPreferences } from "@/systems/preferences/PreferenceTypes" +import SceneObject from "@/systems/scene/SceneObject" +import { sendCameraFrame } from "@/systems/simulation/wpilib_brain/CameraFrameSocket" +import SimCamera from "@/systems/simulation/wpilib_brain/sim/SimCamera" +import World from "@/systems/World" +import { convertArrayToThreeMatrix4, convertJoltMat44ToThreeMatrix4 } from "@/util/TypeConversions" +import type MirabufSceneObject from "./MirabufSceneObject" + +// Guard rails so a misconfigured camera can't tank frame rate. +const MAX_DIMENSION = 1280 +const MIN_DIMENSION = 16 +const MAX_FPS = 60 +const JPEG_QUALITY = 0.6 + +// A THREE.PerspectiveCamera looks down its local -Z, but the placeholder mesh in the +// config gizmo points down +Z. Rotate the camera 180° about its up axis so it looks where +// the gizmo points (away from the robot) instead of back into it. +const FORWARD_FLIP = new THREE.Matrix4().makeRotationY(Math.PI) + +/** + * A USB camera mounted to a robot. Each frame (throttled to the configured fps) it + * positions a secondary perspective camera relative to a robot rigid node, renders the + * scene into an offscreen target, and reads the pixels back. The latest frame is kept on + * an internal canvas for the preview UI, and—when the robot code has created the matching + * camera sim device—is JPEG encoded and streamed to the robot code via {@link SimCamera}. + * + * Mounting math mirrors {@link IntakeSensorSceneObject}: the world transform is + * `deltaTransformation * parentBodyWorldTransform`. + */ +class RobotCameraSceneObject extends SceneObject { + // Number of active frame consumers (e.g. open preview panels). Capture is skipped + // entirely unless something needs the frame, so simply having a camera configured + // (e.g. while in the config panel) costs nothing. + private static _previewConsumers = 0 + public static get previewConsumers(): number { + return RobotCameraSceneObject._previewConsumers + } + public static addPreviewConsumer(): void { + RobotCameraSceneObject._previewConsumers++ + } + public static removePreviewConsumer(): void { + RobotCameraSceneObject._previewConsumers = Math.max(0, RobotCameraSceneObject._previewConsumers - 1) + } + + private _parentAssembly: MirabufSceneObject + private _prefs: CameraPreferences + + private _camera: THREE.PerspectiveCamera + private _renderTarget?: THREE.WebGLRenderTarget + private _parentBodyId?: Jolt.BodyID + private _deltaTransformation: THREE.Matrix4 + + private _width = 0 + private _height = 0 + private _pixelBuffer?: Uint8Array + private _frameCanvas?: HTMLCanvasElement + private _frameCtx?: CanvasRenderingContext2D + private _imageData?: ImageData + + private _timeSinceCapture = Number.POSITIVE_INFINITY + + public constructor(parentAssembly: MirabufSceneObject, prefs: CameraPreferences) { + super() + this._parentAssembly = parentAssembly + this._prefs = prefs + this._camera = new THREE.PerspectiveCamera(prefs.fovDegrees, 1, 0.05, 1000) + this._deltaTransformation = convertArrayToThreeMatrix4(prefs.deltaTransformation) + } + + /** The sim device key, e.g. `"USB Camera 0[0]"`. */ + public get deviceName(): string { + return `${this._prefs.name}[${this._prefs.id}]` + } + + /** Human-readable label used by the preview panel. */ + public get displayName(): string { + return `${this._parentAssembly.assemblyName} – ${this._prefs.name}` + } + + /** Canvas holding the most recently captured frame (for previews). */ + public get frameCanvas(): HTMLCanvasElement | undefined { + return this._frameCanvas + } + + /** Current capture width in pixels (falls back to the configured default). */ + public get width(): number { + return this._width || this._prefs.resolutionWidth + } + + /** Current capture height in pixels (falls back to the configured default). */ + public get height(): number { + return this._height || this._prefs.resolutionHeight + } + + public setup(): void { + this._parentBodyId = this._parentAssembly.mechanism.nodeToBody.get( + this._prefs.parentNode ?? this._parentAssembly.rootNodeId + ) + this.resize(this._prefs.resolutionWidth, this._prefs.resolutionHeight) + } + + private resize(width: number, height: number): void { + const w = Math.max(MIN_DIMENSION, Math.min(MAX_DIMENSION, Math.round(width))) + const h = Math.max(MIN_DIMENSION, Math.min(MAX_DIMENSION, Math.round(height))) + if (w === this._width && h === this._height && this._renderTarget) return + + this._width = w + this._height = h + + this._renderTarget?.dispose() + this._renderTarget = new THREE.WebGLRenderTarget(w, h) + + this._pixelBuffer = new Uint8Array(w * h * 4) + this._imageData = new ImageData(w, h) + + const canvas = this._frameCanvas ?? document.createElement("canvas") + canvas.width = w + canvas.height = h + this._frameCanvas = canvas + this._frameCtx = canvas.getContext("2d") ?? undefined + + this._camera.aspect = w / h + this._camera.updateProjectionMatrix() + } + + public update(): void { + if (!this._parentBodyId || !this._renderTarget || !this._pixelBuffer || !this._imageData) return + + const device = this.deviceName + // Stream to robot code once it has created the camera (discovered over HALSim). + const streaming = SimCamera.isPresent(device) + // Skip all rendering/readback unless someone actually needs a frame. This keeps the + // config panel (and idle robots) fully interactive — the GPU readback below is a + // synchronous stall that must not run every frame for no reason. + if (!streaming && RobotCameraSceneObject.previewConsumers === 0) return + + // Pick up resolution / fps / fov that the robot code requested (defaults to prefs). + const reqWidth = SimCamera.getWidth(device, this._prefs.resolutionWidth) + const reqHeight = SimCamera.getHeight(device, this._prefs.resolutionHeight) + const fps = Math.max(1, Math.min(MAX_FPS, SimCamera.getFps(device, this._prefs.fps))) + this.resize(reqWidth, reqHeight) + + if (this._camera.fov !== this._prefs.fovDegrees) { + this._camera.fov = this._prefs.fovDegrees + this._camera.updateProjectionMatrix() + } + + // Throttle capture to the configured frame rate. + this._timeSinceCapture += World.currentDeltaT + if (this._timeSinceCapture < 1 / fps) return + this._timeSinceCapture = 0 + + // Position the camera relative to its parent rigid node, then flip it to look + // forward (where the gizmo points) rather than back into the robot. + const parentBody = World.physicsSystem.getBody(this._parentBodyId) + if (!parentBody) return + const worldTransform = this._deltaTransformation + .clone() + .premultiply(convertJoltMat44ToThreeMatrix4(parentBody.GetWorldTransform())) + .multiply(FORWARD_FLIP) + this._camera.position.setFromMatrixPosition(worldTransform) + this._camera.quaternion.setFromRotationMatrix(worldTransform) + this._camera.updateMatrixWorld() + + // A failure here must never abort SceneRenderer.update (which would freeze the + // scene render, camera controls, and input handling for the whole app). + const renderer = World.sceneRenderer.renderer + // The postprocessing EffectComposer leaves autoClear=false, so we must clear the + // (depth) buffer ourselves or the camera renders a stale, partially depth-rejected + // strip. setRenderTarget already applies the target's full-size viewport (without + // pixel-ratio scaling), so we must NOT call setViewport — doing so re-scales by + // devicePixelRatio and crops the capture. + const prevTarget = renderer.getRenderTarget() + const prevAutoClear = renderer.autoClear + try { + renderer.autoClear = true + renderer.setRenderTarget(this._renderTarget) + renderer.clear() + renderer.render(World.sceneRenderer.scene, this._camera) + renderer.readRenderTargetPixels(this._renderTarget, 0, 0, this._width, this._height, this._pixelBuffer) + + // GL pixels are bottom-up; flip into the (top-down) ImageData for the canvas. + this.flipInto(this._imageData, this._pixelBuffer) + this._frameCtx?.putImageData(this._imageData, 0, 0) + + // Frames travel over the dedicated frame socket, only while streaming. + if (streaming && this._frameCanvas) { + const dataUrl = this._frameCanvas.toDataURL("image/jpeg", JPEG_QUALITY) + const base64 = dataUrl.slice(dataUrl.indexOf(",") + 1) + sendCameraFrame(device, base64) + } + } catch (e) { + console.error(`Camera capture failed for '${device}'`, e) + } finally { + // Restore renderer state so the main scene render is unaffected. + renderer.setRenderTarget(prevTarget) + renderer.autoClear = prevAutoClear + } + } + + private flipInto(target: ImageData, source: Uint8Array): void { + const w = this._width + const h = this._height + const rowBytes = w * 4 + const dst = target.data + for (let y = 0; y < h; y++) { + const srcStart = (h - 1 - y) * rowBytes + dst.set(source.subarray(srcStart, srcStart + rowBytes), y * rowBytes) + } + } + + public dispose(): void { + this._renderTarget?.dispose() + this._renderTarget = undefined + this._frameCanvas = undefined + this._frameCtx = undefined + } +} + +export default RobotCameraSceneObject diff --git a/fission/src/systems/preferences/PreferenceTypes.ts b/fission/src/systems/preferences/PreferenceTypes.ts index a506c84ef4..0fba210634 100644 --- a/fission/src/systems/preferences/PreferenceTypes.ts +++ b/fission/src/systems/preferences/PreferenceTypes.ts @@ -145,6 +145,36 @@ export type EjectorPreferences = { ejectOrder: "FIFO" | "LIFO" } +/** + * A simulated USB camera mounted to the robot. `name` and `id` must match the + * arguments the robot code passes to the Synthesis `UsbCamera` wrapper; together they + * form the sim device key `"[]"`. The camera is positioned relative to + * `parentNode` via `deltaTransformation`, identical to the intake / ejector pattern. + */ +export type CameraPreferences = { + name: string + id: number + deltaTransformation: number[] + parentNode: string | undefined + fovDegrees: number + resolutionWidth: number + resolutionHeight: number + fps: number +} + +export function defaultCameraPreferences(id: number): CameraPreferences { + return { + name: `USB Camera ${id}`, + id: id, + deltaTransformation: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], + parentNode: undefined, + fovDegrees: 60, + resolutionWidth: 640, + resolutionHeight: 480, + fps: 30, + } +} + /** The behavior types that can be sequenced. */ export type BehaviorType = "Elevator" | "Arm" @@ -171,6 +201,7 @@ export type RobotPreferences = { motors: MotorPreferences[] intake: IntakePreferences ejector: EjectorPreferences + cameras: CameraPreferences[] driveVelocity: number driveAcceleration: number unstickForce: number @@ -259,6 +290,7 @@ export function defaultRobotPreferences(): RobotPreferences { parentNode: undefined, ejectOrder: "FIFO", }, + cameras: [], driveVelocity: 0, driveAcceleration: 0, unstickForce: 8000, diff --git a/fission/src/systems/simulation/wpilib_brain/CameraFrameSocket.ts b/fission/src/systems/simulation/wpilib_brain/CameraFrameSocket.ts new file mode 100644 index 0000000000..38b6735f87 --- /dev/null +++ b/fission/src/systems/simulation/wpilib_brain/CameraFrameSocket.ts @@ -0,0 +1,56 @@ +/** + * Outbound WebSocket used to stream rendered camera frames to the running robot code. + * + * Frame bytes are too large for the HALSim SimDevice channel (which only carries + * numbers/booleans), so the robot process hosts a small WebSocket server (SyntheSimJava's + * `CameraFrameServer`) and Synthesis connects out to it — mirroring how it already + * connects to the HALSim WebSocket on `ws://localhost:3300`. + * + * Each message is the text `"\n"`. Connection is lazy and + * self-healing: failures are retried quietly, and frames are dropped while disconnected. + */ + +const PORT = 5808 +const RETRY_MS = 2000 +// Drop frames rather than queue them if the socket is backed up. Without this, a slow +// consumer causes frames to pile up in the send buffer and arrive in erratic bursts. +const MAX_BUFFERED_BYTES = 1_000_000 + +let socket: WebSocket | undefined +let lastAttempt = Number.NEGATIVE_INFINITY + +function ensureSocket(): void { + if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) return + + const now = performance.now() + if (now - lastAttempt < RETRY_MS) return + lastAttempt = now + + try { + const ws = new WebSocket(`ws://localhost:${PORT}`) + ws.addEventListener("close", () => { + if (socket === ws) socket = undefined + }) + // Swallow errors; the next send will trigger a throttled reconnect. + ws.addEventListener("error", () => { + if (socket === ws) socket = undefined + }) + socket = ws + } catch { + socket = undefined + } +} + +/** + * Streams a single encoded frame for a camera device. No-ops (dropping the frame) if the + * robot's frame server isn't currently reachable. + * + * @param device The sim device key, e.g. `"USB Camera 0[0]"`. + * @param base64Jpeg The frame encoded as a base64 JPEG (without the data-URL prefix). + */ +export function sendCameraFrame(device: string, base64Jpeg: string): void { + ensureSocket() + if (socket && socket.readyState === WebSocket.OPEN && socket.bufferedAmount < MAX_BUFFERED_BYTES) { + socket.send(`${device}\n${base64Jpeg}`) + } +} diff --git a/fission/src/systems/simulation/wpilib_brain/WPILibState.ts b/fission/src/systems/simulation/wpilib_brain/WPILibState.ts index e11baf8a2f..3328ee78e5 100644 --- a/fission/src/systems/simulation/wpilib_brain/WPILibState.ts +++ b/fission/src/systems/simulation/wpilib_brain/WPILibState.ts @@ -54,6 +54,7 @@ export const supplierTypeMap: { [k in SimType]: NoraTypes | undefined } = { [SimType.AI]: undefined, [SimType.AO]: NoraTypes.NUMBER, [SimType.DRIVERS_STATION]: undefined, + [SimType.CAMERA]: undefined, } export const receiverTypeMap: { [k in SimType]: NoraTypes | undefined } = { @@ -68,4 +69,5 @@ export const receiverTypeMap: { [k in SimType]: NoraTypes | undefined } = { [SimType.AI]: NoraTypes.NUMBER, [SimType.AO]: undefined, [SimType.DRIVERS_STATION]: undefined, + [SimType.CAMERA]: undefined, } diff --git a/fission/src/systems/simulation/wpilib_brain/WPILibTypes.ts b/fission/src/systems/simulation/wpilib_brain/WPILibTypes.ts index ffb177284b..cc7f4f78c6 100644 --- a/fission/src/systems/simulation/wpilib_brain/WPILibTypes.ts +++ b/fission/src/systems/simulation/wpilib_brain/WPILibTypes.ts @@ -18,6 +18,7 @@ export enum SimType { AI = "AI", AO = "AO", DRIVERS_STATION = "DriverStation", + CAMERA = "Camera", } export enum FieldType { @@ -55,4 +56,13 @@ export const CANMOTOR_BUS_VOLTAGE = ">busVoltage" export const CANENCODER_POSITION = ">position" export const CANENCODER_VELOCITY = ">velocity" +// USB Camera fields. Resolution / fps / connected are configured by the robot code +// (outputs, read by Synthesis). The rendered frame itself does not travel over HALSim — +// SimDevices only carry numbers/booleans — and is streamed over a side channel instead +// (see CameraFrameSocket). +export const CAMERA_WIDTH = " = new Lazy(() => new WPILibWSWorker()) diff --git a/fission/src/systems/simulation/wpilib_brain/sim/SimCamera.ts b/fission/src/systems/simulation/wpilib_brain/sim/SimCamera.ts new file mode 100644 index 0000000000..1c0241b81f --- /dev/null +++ b/fission/src/systems/simulation/wpilib_brain/sim/SimCamera.ts @@ -0,0 +1,43 @@ +import { getSimMap } from "../WPILibState" +import { CAMERA_CONNECTED, CAMERA_FPS, CAMERA_HEIGHT, CAMERA_WIDTH, SimType } from "../WPILibTypes" +import SimGeneric from "./SimGeneric" + +/** + * Accessor for simulated USB cameras over HALSim. + * + * The robot code configures the desired resolution / fps / connected state, which + * Synthesis reads back here to size and throttle its render, and to discover that the + * camera exists. The rendered frame itself is streamed separately (see CameraFrameSocket) + * because SimDevice fields only carry numbers/booleans. + */ +export default class SimCamera { + private constructor() {} + + /** Resolution width requested by the robot code (falls back to `defaultValue`). */ + public static getWidth(device: string, defaultValue: number): number { + return SimGeneric.get(SimType.CAMERA, device, CAMERA_WIDTH, defaultValue) + } + + /** Resolution height requested by the robot code (falls back to `defaultValue`). */ + public static getHeight(device: string, defaultValue: number): number { + return SimGeneric.get(SimType.CAMERA, device, CAMERA_HEIGHT, defaultValue) + } + + /** Frame rate requested by the robot code (falls back to `defaultValue`). */ + public static getFps(device: string, defaultValue: number): number { + return SimGeneric.get(SimType.CAMERA, device, CAMERA_FPS, defaultValue) + } + + /** Whether the robot code has marked the camera as connected. */ + public static getConnected(device: string): boolean { + return SimGeneric.get(SimType.CAMERA, device, CAMERA_CONNECTED, false) + } + + /** + * Whether the robot code has created this camera device yet. Frames are only worth + * encoding/sending once the device is present in the sim map. + */ + public static isPresent(device: string): boolean { + return !!getSimMap()?.get(SimType.CAMERA)?.get(device) + } +} diff --git a/fission/src/test/PreferencesSystem.test.ts b/fission/src/test/PreferencesSystem.test.ts index 7f2b99804d..b62315051e 100644 --- a/fission/src/test/PreferencesSystem.test.ts +++ b/fission/src/test/PreferencesSystem.test.ts @@ -118,6 +118,7 @@ describe("Preference System Robot/Field", () => { parentNode: undefined, ejectOrder: "FIFO", }, + cameras: [], driveVelocity: 3, driveAcceleration: 6, unstickForce: 8000, @@ -139,6 +140,7 @@ describe("Preference System Robot/Field", () => { parentNode: undefined, ejectOrder: "LIFO", }, + cameras: [], driveVelocity: 1.5, driveAcceleration: 8, unstickForce: 10000, diff --git a/fission/src/ui/panels/configuring/assembly-config/ConfigTypes.ts b/fission/src/ui/panels/configuring/assembly-config/ConfigTypes.ts index 480cb5d96c..26b4abe34a 100644 --- a/fission/src/ui/panels/configuring/assembly-config/ConfigTypes.ts +++ b/fission/src/ui/panels/configuring/assembly-config/ConfigTypes.ts @@ -38,4 +38,5 @@ export enum ConfigMode { DRIVETRAIN, ALLIANCE, METADATA, + CAMERA, } diff --git a/fission/src/ui/panels/configuring/assembly-config/ConfigurePanel.tsx b/fission/src/ui/panels/configuring/assembly-config/ConfigurePanel.tsx index 81a35f60d6..cb21593b25 100644 --- a/fission/src/ui/panels/configuring/assembly-config/ConfigurePanel.tsx +++ b/fission/src/ui/panels/configuring/assembly-config/ConfigurePanel.tsx @@ -21,6 +21,7 @@ import AssemblySelection, { type AssemblySelectionOption } from "./configure/Ass import ConfigModeSelection, { ConfigModeSelectionOption } from "./configure/ConfigModeSelection" import AllianceSelectionInterface from "./interfaces/AllianceSelectionInterface" import BrainSelectionInterface from "./interfaces/BrainSelectionInterface" +import ConfigureCameraInterface from "./interfaces/ConfigureCameraInterface" import ConfigureGamepiecePickupInterface from "./interfaces/ConfigureGamepiecePickupInterface" import ConfigureShotTrajectoryInterface from "./interfaces/ConfigureShotTrajectoryInterface" import ConfigureSubsystemsInterface from "./interfaces/ConfigureSubsystemsInterface" @@ -165,6 +166,8 @@ const ConfigInterface: React.FC case ConfigMode.EJECTOR: return + case ConfigMode.CAMERA: + return case ConfigMode.SUBSYSTEMS: return case ConfigMode.CONTROLS: { @@ -374,6 +377,12 @@ const ConfigurePanel: React.FC> "Configure the robot’s ejector mechanism, which controls the release or expulsion of game pieces." ), + new ConfigModeSelectionOption( + "USB Cameras", + ConfigMode.CAMERA, + "Add USB cameras and configure their position, resolution, and field of view for code simulation." + ), + new ConfigModeSelectionOption( "Configure Joints", ConfigMode.SUBSYSTEMS, diff --git a/fission/src/ui/panels/configuring/assembly-config/interfaces/ConfigureCameraInterface.tsx b/fission/src/ui/panels/configuring/assembly-config/interfaces/ConfigureCameraInterface.tsx new file mode 100644 index 0000000000..3900ae705d --- /dev/null +++ b/fission/src/ui/panels/configuring/assembly-config/interfaces/ConfigureCameraInterface.tsx @@ -0,0 +1,260 @@ +import type Jolt from "@azaleacolburn/jolt-physics" +import { Stack, TextField } from "@mui/material" +import { useCallback, useEffect, useMemo, useRef, useState } from "react" +import * as THREE from "three" +import SelectButton from "@/components/SelectButton" +import type { RigidNodeId } from "@/mirabuf/MirabufParser" +import type MirabufSceneObject from "@/mirabuf/MirabufSceneObject" +import type { RigidNodeAssociate } from "@/mirabuf/MirabufSceneObject" +import EventSystem from "@/systems/EventSystem.ts" +import { PAUSE_REF_ASSEMBLY_CONFIG } from "@/systems/physics/PhysicsTypes" +import PreferencesSystem from "@/systems/preferences/PreferencesSystem" +import { type CameraPreferences, defaultCameraPreferences } from "@/systems/preferences/PreferenceTypes" +import type GizmoSceneObject from "@/systems/scene/GizmoSceneObject" +import World from "@/systems/World" +import Label from "@/ui/components/Label" +import StatefulSlider from "@/ui/components/StatefulSlider" +import { Button } from "@/ui/components/StyledComponents" +import TransformGizmoControl from "@/ui/components/TransformGizmoControl" +import { + convertArrayToThreeMatrix4, + convertJoltMat44ToThreeMatrix4, + convertReactRgbaColorToThreeColor, + convertThreeMatrix4ToArray, +} from "@/util/TypeConversions" + +const MIN_FOV = 10 +const MAX_FOV = 170 +const MIN_RES = 64 +const MAX_RES = 1280 +const MIN_FPS = 1 +const MAX_FPS = 60 + +/** Resolves the body id of a camera's parent node, falling back to the robot root. */ +function parentBodyId(robot: MirabufSceneObject, parentNode: string | undefined): Jolt.BodyID | undefined { + return robot.mechanism.nodeToBody.get(parentNode ?? robot.rootNodeId) ?? robot.getRootNodeId() +} + +interface ConfigCameraProps { + selectedRobot: MirabufSceneObject +} + +/** + * Configures the robot's USB cameras. Each camera is placed relative to a rigid node + * with a transform gizmo (identical math to the ejector interface) and has its own + * resolution / fps / fov. The placement is stored as a delta transform so the camera + * tracks the node as the robot moves. + */ +const ConfigureCameraInterface: React.FC = ({ selectedRobot }) => { + const [version, setVersion] = useState(0) + const [selectedIndex, setSelectedIndex] = useState(selectedRobot.cameraPreferences.length > 0 ? 0 : -1) + const [selectedNode, setSelectedNode] = useState(undefined) + + const gizmoRef = useRef(undefined) + + const cameras = selectedRobot.cameraPreferences + const camera: CameraPreferences | undefined = cameras[selectedIndex] + + const forceRender = useCallback(() => setVersion(v => v + 1), []) + + // Persists prefs and recreates the live camera scene objects. + const commit = useCallback(() => { + // Capture the gizmo placement into the selected camera before saving. + if (camera && gizmoRef.current) { + const nodeBodyId = parentBodyId(selectedRobot, selectedNode) + if (nodeBodyId) { + const gizmoWorld = gizmoRef.current.obj.matrixWorld.clone() + const robotWorld = convertJoltMat44ToThreeMatrix4(World.physicsSystem.getBody(nodeBodyId)!.GetWorldTransform()) + const delta = gizmoWorld.premultiply(robotWorld.invert()) + camera.deltaTransformation = convertThreeMatrix4ToArray(delta) + camera.parentNode = selectedNode + } + } + PreferencesSystem.setRobotPreferences(selectedRobot.assemblyName, selectedRobot.robotPreferences) + PreferencesSystem.savePreferences() + selectedRobot.updateCameras() + }, [camera, selectedRobot, selectedNode]) + + useEffect(() => { + return EventSystem.listen("ConfigurationSavedEvent", commit) + }, [commit]) + + useEffect(() => { + World.physicsSystem.holdPause(PAUSE_REF_ASSEMBLY_CONFIG) + return () => { + World.physicsSystem.releasePause(PAUSE_REF_ASSEMBLY_CONFIG) + } + }, []) + + // Sync the selected node with the active camera. + useEffect(() => { + setSelectedNode(camera?.parentNode) + }, [selectedIndex, camera]) + + const placeholderMesh = useMemo(() => { + const mesh = new THREE.Mesh( + new THREE.BoxGeometry(0.12, 0.08, 0.16).translate(0, 0, 0.08), + World.sceneRenderer.createToonMaterial(convertReactRgbaColorToThreeColor({ r: 80, g: 180, b: 255, a: 255 })) + ) + return mesh + // Recreate when switching cameras so the gizmo re-spawns positioned correctly. + }, [selectedIndex]) + + const gizmoComponent = useMemo(() => { + if (!camera) { + gizmoRef.current = undefined + return <> + } + + const postGizmoCreation = (gizmo: GizmoSceneObject) => { + const material = (gizmo.obj as THREE.Mesh).material as THREE.Material + material.depthTest = false + + const delta = convertArrayToThreeMatrix4(camera.deltaTransformation) + const nodeBodyId = parentBodyId(selectedRobot, camera.parentNode) + if (!nodeBodyId) return + + const robotWorld = convertJoltMat44ToThreeMatrix4(World.physicsSystem.getBody(nodeBodyId)!.GetWorldTransform()) + const gizmoWorld = delta.premultiply(robotWorld) + gizmo.obj.position.setFromMatrixPosition(gizmoWorld) + gizmo.obj.rotation.setFromRotationMatrix(gizmoWorld) + } + + return ( + + ) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [placeholderMesh, selectedIndex, camera, selectedRobot]) + + const trySetSelectedNode = useCallback( + (body: Jolt.BodyID) => { + const assoc = World.physicsSystem.getBodyAssociation(body) as RigidNodeAssociate + if (!assoc || assoc.sceneObject !== selectedRobot) return false + setSelectedNode(assoc.rigidNodeId) + if (camera) camera.parentNode = assoc.rigidNodeId + commit() + return true + }, + [selectedRobot, camera, commit] + ) + + const addCamera = useCallback(() => { + const nextId = cameras.reduce((max, c) => Math.max(max, c.id + 1), 0) + cameras.push(defaultCameraPreferences(nextId)) + setSelectedIndex(cameras.length - 1) + commit() + forceRender() + }, [cameras, commit, forceRender]) + + const removeCamera = useCallback(() => { + if (selectedIndex < 0) return + cameras.splice(selectedIndex, 1) + setSelectedIndex(cameras.length > 0 ? Math.max(0, selectedIndex - 1) : -1) + commit() + forceRender() + }, [cameras, selectedIndex, commit, forceRender]) + + return ( + + {/* Camera selector */} + + {cameras.map((c, i) => ( + + ))} + + + + + {camera ? : null} + + + {camera ? ( + <> + { + camera.name = e.target.value + commit() + }} + /> + + trySetSelectedNode(body.GetID())} + /> + + { + camera.fovDegrees = v + commit() + }} + /> + { + camera.resolutionWidth = v + commit() + }} + /> + { + camera.resolutionHeight = v + commit() + }} + /> + { + camera.fps = v + commit() + }} + /> + + {gizmoComponent} + + ) : ( + + )} + + ) +} + +export default ConfigureCameraInterface diff --git a/fission/src/ui/panels/simulation/CameraPreviewPanel.tsx b/fission/src/ui/panels/simulation/CameraPreviewPanel.tsx new file mode 100644 index 0000000000..b15ebb76eb --- /dev/null +++ b/fission/src/ui/panels/simulation/CameraPreviewPanel.tsx @@ -0,0 +1,83 @@ +import { Stack } from "@mui/material" +import type React from "react" +import { useEffect, useMemo, useRef } from "react" +import RobotCameraSceneObject from "@/mirabuf/RobotCameraSceneObject" +import World from "@/systems/World" +import Label from "@/ui/components/Label" +import type { PanelImplProps } from "@/ui/components/Panel" +import { useUIContext } from "@/ui/helpers/UIProviderHelpers" + +/** + * Live preview of every robot-mounted USB camera. Each frame we blit the latest captured + * frame from each {@link RobotCameraSceneObject} onto a visible canvas, so users can see + * exactly what their simulated camera streams to the robot code. + */ +const CameraPreviewPanel: React.FC> = ({ panel }) => { + const { configureScreen } = useUIContext() + const canvasRefs = useRef>(new Map()) + + useEffect(() => { + configureScreen(panel!, { title: "Camera Preview", hideCancel: true, acceptText: "Close" }, {}) + }, []) + + // Tell cameras to actually render/capture while this preview is open. + useEffect(() => { + RobotCameraSceneObject.addPreviewConsumer() + return () => RobotCameraSceneObject.removePreviewConsumer() + }, []) + + // Snapshot the current cameras on mount; reopen the panel to pick up new ones. + const cameras = useMemo( + () => World.sceneRenderer.mirabufSceneObjects.getRobots().flatMap(r => [...r.cameras]), + [] + ) + + useEffect(() => { + let handle = requestAnimationFrame(function draw() { + cameras.forEach(cam => { + const dst = canvasRefs.current.get(cam.deviceName) + const src = cam.frameCanvas + if (!dst || !src) return + if (dst.width !== src.width || dst.height !== src.height) { + dst.width = src.width + dst.height = src.height + } + dst.getContext("2d")?.drawImage(src, 0, 0) + }) + handle = requestAnimationFrame(draw) + }) + return () => cancelAnimationFrame(handle) + }, [cameras]) + + return ( + + {cameras.length === 0 ? ( + + ) : ( + cameras.map(cam => ( + + + { + canvasRefs.current.set(cam.deviceName, el) + }} + style={{ + display: "block", + width: "320px", + // Pin the display height so the canvas can't render at its + // full bitmap height and slip under the panel footer. + aspectRatio: `${cam.width} / ${cam.height}`, + borderRadius: "8px", + background: "#000", + }} + /> + + )) + )} + + ) +} + +export default CameraPreviewPanel diff --git a/simulation/SyntheSimJava/build.gradle b/simulation/SyntheSimJava/build.gradle index d0b00b1dfa..ac35d079df 100644 --- a/simulation/SyntheSimJava/build.gradle +++ b/simulation/SyntheSimJava/build.gradle @@ -41,6 +41,11 @@ def WPI_Version = '2026.2.2' def REV_Version = '2026.0.5' def CTRE_Version = '26.3.0' def STUDICA_Version = '2026.0.0' +// WPILib-published OpenCV. The OpenCV vendoring is still published under the frc2025 +// group for the 2026 season (there is no frc2026 group); keep the version in sync with +// the OpenCV bundled with the installed WPILib release. +def OPENCV_Group = 'edu.wpi.first.thirdparty.frc2025.opencv' +def OPENCV_Version = '4.10.0-3' dependencies { // This dependency is exported to consumers, that is to say found on their compile classpath. @@ -57,6 +62,14 @@ dependencies { implementation "edu.wpi.first.ntcore:ntcore-java:$WPI_Version" implementation "edu.wpi.first.wpiunits:wpiunits-java:$WPI_Version" + // cscore + OpenCV, for the simulated USB camera (CameraServer-compatible) support. + implementation "edu.wpi.first.cscore:cscore-java:$WPI_Version" + implementation "$OPENCV_Group:opencv-java:$OPENCV_Version" + + // WebSocket server used to receive rendered camera frames from Synthesis. Frames are + // too large for the HALSim SimDevice channel, so they stream over a side channel. + implementation "org.java-websocket:Java-WebSocket:1.5.7" + // REVRobotics implementation "com.revrobotics.frc:REVLib-java:$REV_Version" diff --git a/simulation/SyntheSimJava/gradle/wrapper/gradle-wrapper.jar b/simulation/SyntheSimJava/gradle/wrapper/gradle-wrapper.jar index e6441136f3d4ba8a0da8d277868979cfbc8ad796..9bbc975c742b298b441bfb90dbc124400a3751b9 100644 GIT binary patch delta 34877 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�{ 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 diff --git a/simulation/SyntheSimJava/gradle/wrapper/gradle-wrapper.properties b/simulation/SyntheSimJava/gradle/wrapper/gradle-wrapper.properties index a4413138c9..a351597e62 100644 --- a/simulation/SyntheSimJava/gradle/wrapper/gradle-wrapper.properties +++ b/simulation/SyntheSimJava/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/simulation/SyntheSimJava/gradlew b/simulation/SyntheSimJava/gradlew index b740cf1339..faf93008b7 100755 --- a/simulation/SyntheSimJava/gradlew +++ b/simulation/SyntheSimJava/gradlew @@ -15,6 +15,8 @@ # See the License for the specific language governing permissions and # limitations under the License. # +# SPDX-License-Identifier: Apache-2.0 +# ############################################################################## # @@ -84,7 +86,7 @@ done # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum @@ -203,7 +205,7 @@ fi DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Collect all arguments for the java command: -# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, # and any embedded shellness will be escaped. # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be # treated as '${Hostname}' itself on the command line. diff --git a/simulation/SyntheSimJava/gradlew.bat b/simulation/SyntheSimJava/gradlew.bat index 25da30dbde..9d21a21834 100644 --- a/simulation/SyntheSimJava/gradlew.bat +++ b/simulation/SyntheSimJava/gradlew.bat @@ -13,6 +13,8 @@ @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem @if "%DEBUG%"=="" @echo off @rem ########################################################################## diff --git a/simulation/SyntheSimJava/src/main/java/com/autodesk/synthesis/cscore/Camera.java b/simulation/SyntheSimJava/src/main/java/com/autodesk/synthesis/cscore/Camera.java new file mode 100644 index 0000000000..a4d6f96dd4 --- /dev/null +++ b/simulation/SyntheSimJava/src/main/java/com/autodesk/synthesis/cscore/Camera.java @@ -0,0 +1,120 @@ +package com.autodesk.synthesis.cscore; + +import org.opencv.core.Mat; +import org.opencv.core.MatOfByte; +import org.opencv.imgcodecs.Imgcodecs; + +import edu.wpi.first.hal.SimBoolean; +import edu.wpi.first.hal.SimDevice; +import edu.wpi.first.hal.SimDevice.Direction; +import edu.wpi.first.hal.SimInt; + +/** + * Backing sim device for a simulated USB camera. + * + * The requested resolution / fps / connected state are published as HALSim outputs (read + * by Synthesis to size and throttle its render). The rendered frame itself cannot travel + * over HALSim — {@link SimDevice} only supports numeric and boolean values — so frames + * are streamed separately by Synthesis to {@link CameraFrameServer} and matched back to + * this device by name. + * + * See https://github.com/wpilibsuite/allwpilib/blob/main/simulation/halsim_ws_core/doc/hardware_ws_api.md + * for documentation on the WebSocket API Specification. + */ +public class Camera { + + private SimDevice m_device; + + private SimInt m_width; + private SimInt m_height; + private SimInt m_fps; + private SimBoolean m_connected; + + private final String m_deviceName; + private final CameraFrameServer m_frameServer; + + /** + * Creates a Camera sim device. The resulting sim device key seen by Synthesis is + * {@code "[]"}; Synthesis streams frames tagged with that same key. + * + * @param name Name of the camera (matches the name configured in Synthesis). + * @param deviceId USB device index. + * @param width Requested frame width in pixels. + * @param height Requested frame height in pixels. + * @param fps Requested frame rate. + */ + public Camera(String name, int deviceId, int width, int height, int fps) { + m_device = SimDevice.create("Camera:" + name, deviceId); + + if (m_device != null) { + m_width = m_device.createInt("width", Direction.kOutput, width); + m_height = m_device.createInt("height", Direction.kOutput, height); + m_fps = m_device.createInt("fps", Direction.kOutput, fps); + m_connected = m_device.createBoolean("connected", Direction.kOutput, true); + } + + m_deviceName = name + "[" + deviceId + "]"; + m_frameServer = CameraFrameServer.getInstance(); + } + + /** + * Sets the requested resolution, read by Synthesis to size its render. + * + * @param width Frame width in pixels. + * @param height Frame height in pixels. + */ + public void setResolution(int width, int height) { + if (m_width != null) { + m_width.set(width); + m_height.set(height); + } + } + + /** + * Sets the requested frame rate, read by Synthesis to throttle its render. + * + * @param fps Frames per second. + */ + public void setFPS(int fps) { + if (m_fps != null) { + m_fps.set(fps); + } + } + + /** + * Sets whether the camera is connected. + * + * @param connected Whether the camera is connected. + */ + public void setConnected(boolean connected) { + if (m_connected != null) { + m_connected.set(connected); + } + } + + /** + * Decodes the latest frame supplied by Synthesis into the provided destination Mat. + * + * @param dst Destination matrix to receive the decoded BGR image. + * @return true if a frame was available and decoded, false otherwise. + */ + public boolean grabFrame(Mat dst) { + byte[] bytes = m_frameServer.getFrame(m_deviceName); + if (bytes == null || bytes.length == 0) { + return false; + } + + MatOfByte buffer = new MatOfByte(bytes); + Mat decoded = Imgcodecs.imdecode(buffer, Imgcodecs.IMREAD_COLOR); + buffer.release(); + + if (decoded.empty()) { + decoded.release(); + return false; + } + + decoded.copyTo(dst); + decoded.release(); + return true; + } +} diff --git a/simulation/SyntheSimJava/src/main/java/com/autodesk/synthesis/cscore/CameraFrameServer.java b/simulation/SyntheSimJava/src/main/java/com/autodesk/synthesis/cscore/CameraFrameServer.java new file mode 100644 index 0000000000..e44d0a09fa --- /dev/null +++ b/simulation/SyntheSimJava/src/main/java/com/autodesk/synthesis/cscore/CameraFrameServer.java @@ -0,0 +1,90 @@ +package com.autodesk.synthesis.cscore; + +import java.net.InetSocketAddress; +import java.util.Base64; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import org.java_websocket.WebSocket; +import org.java_websocket.handshake.ClientHandshake; +import org.java_websocket.server.WebSocketServer; + +/** + * A small localhost WebSocket server that receives rendered camera frames from Synthesis. + * + * Frame bytes are too large to travel over the HALSim {@link edu.wpi.first.hal.SimDevice} + * channel (which only carries numbers and booleans), so Synthesis streams them here + * instead — mirroring how it already connects out to the HALSim WebSocket. Each message is + * the text {@code "\n"}; the most recent frame per device is kept and + * served to {@link Camera#grabFrame}. + */ +public class CameraFrameServer extends WebSocketServer { + + /** Port Synthesis connects to in order to stream camera frames. */ + public static final int PORT = 5808; + + private static CameraFrameServer instance; + + private final Map m_frames = new ConcurrentHashMap<>(); + + private CameraFrameServer(int port) { + super(new InetSocketAddress("localhost", port)); + setReuseAddr(true); + } + + /** + * Returns the shared frame server, lazily starting it on first use. + * + * @return The singleton {@link CameraFrameServer}. + */ + public static synchronized CameraFrameServer getInstance() { + if (instance == null) { + instance = new CameraFrameServer(PORT); + instance.setDaemon(true); + instance.start(); + } + return instance; + } + + /** + * Gets the most recent frame received for a device. + * + * @param device The sim device key, e.g. {@code "USB Camera 0[0]"}. + * @return The most recent encoded JPEG bytes, or null if none have been received. + */ + public byte[] getFrame(String device) { + return m_frames.get(device); + } + + @Override + public void onMessage(WebSocket conn, String message) { + int idx = message.indexOf('\n'); + if (idx < 0) { + return; + } + + String device = message.substring(0, idx); + String encoded = message.substring(idx + 1); + try { + m_frames.put(device, Base64.getDecoder().decode(encoded)); + } catch (IllegalArgumentException e) { + // Ignore malformed frames. + } + } + + @Override + public void onOpen(WebSocket conn, ClientHandshake handshake) { + } + + @Override + public void onClose(WebSocket conn, int code, String reason, boolean remote) { + } + + @Override + public void onError(WebSocket conn, Exception ex) { + } + + @Override + public void onStart() { + } +} diff --git a/simulation/SyntheSimJava/src/main/java/com/autodesk/synthesis/cscore/CvSink.java b/simulation/SyntheSimJava/src/main/java/com/autodesk/synthesis/cscore/CvSink.java new file mode 100644 index 0000000000..e42d00285a --- /dev/null +++ b/simulation/SyntheSimJava/src/main/java/com/autodesk/synthesis/cscore/CvSink.java @@ -0,0 +1,65 @@ +package com.autodesk.synthesis.cscore; + +import org.opencv.core.Mat; + +/** + * CvSink wrapper to add WPILib HALSim camera support. Rather than reading frames from a + * physical video source, the frame-grabbing methods are overridden to return the frame + * rendered by Synthesis for the associated {@link Camera}. + * + * Mirrors the swap-in pattern used for motors (see + * {@code com.autodesk.synthesis.revrobotics.spark.SparkMax}): use this in place of + * {@code edu.wpi.first.cscore.CvSink} and existing OpenCV processing keeps working. + * + * See original documentation for more information + * https://github.wpilib.org/allwpilib/docs/release/java/edu/wpi/first/cscore/CvSink.html + */ +public class CvSink extends edu.wpi.first.cscore.CvSink { + + private Camera m_camera; + + /** + * Creates a new simulation-backed CvSink. + * + * @param name Name of the sink. + * @param camera The Synthesis camera supplying frames. + */ + public CvSink(String name, Camera camera) { + super(name); + this.m_camera = camera; + } + + /** + * Grabs the most recent frame supplied by Synthesis. + * + * @param image Destination matrix. + * @return A non-zero value on success, 0 if no frame was available. + */ + @Override + public long grabFrame(Mat image) { + return grabFrameNoTimeout(image); + } + + /** + * Grabs the most recent frame supplied by Synthesis, ignoring the timeout. + * + * @param image Destination matrix. + * @param timeout Ignored; the simulated frame is always the latest available. + * @return A non-zero value on success, 0 if no frame was available. + */ + @Override + public long grabFrame(Mat image, double timeout) { + return grabFrameNoTimeout(image); + } + + /** + * Grabs the most recent frame supplied by Synthesis. + * + * @param image Destination matrix. + * @return A non-zero value on success, 0 if no frame was available. + */ + @Override + public long grabFrameNoTimeout(Mat image) { + return m_camera.grabFrame(image) ? 1L : 0L; + } +} diff --git a/simulation/SyntheSimJava/src/main/java/com/autodesk/synthesis/cscore/UsbCamera.java b/simulation/SyntheSimJava/src/main/java/com/autodesk/synthesis/cscore/UsbCamera.java new file mode 100644 index 0000000000..dec7c32c4f --- /dev/null +++ b/simulation/SyntheSimJava/src/main/java/com/autodesk/synthesis/cscore/UsbCamera.java @@ -0,0 +1,107 @@ +package com.autodesk.synthesis.cscore; + +import org.opencv.core.Mat; + +/** + * UsbCamera wrapper to add WPILib HALSim camera support. Use this in place of + * {@code edu.wpi.first.cscore.UsbCamera}; instead of a physical USB device, frames are + * supplied by Synthesis, which renders the scene from the camera's configured pose on the + * robot. + * + * Mirrors the swap-in pattern used for motors (see + * {@code com.autodesk.synthesis.revrobotics.spark.SparkMax}). + * + * Typical usage: + *
+ *     var camera = new com.autodesk.synthesis.cscore.UsbCamera("USB Camera 0", 0);
+ *     var sink = camera.getVideo();
+ *     Mat frame = new Mat();
+ *     if (sink.grabFrame(frame) != 0) {
+ *         // ... run vision processing on frame ...
+ *     }
+ * 
+ * + * See original documentation for more information + * https://github.wpilib.org/allwpilib/docs/release/java/edu/wpi/first/cscore/UsbCamera.html + */ +public class UsbCamera extends edu.wpi.first.cscore.UsbCamera { + + private Camera m_camera; + + /** + * Creates a new simulation-backed UsbCamera with a default 640x480 @ 30fps stream. + * + * @param name Name of the camera (matches the name configured in Synthesis). + * @param dev USB device index (matches the index configured in Synthesis). + */ + public UsbCamera(String name, int dev) { + this(name, dev, 640, 480, 30); + } + + /** + * Creates a new simulation-backed UsbCamera. + * + * @param name Name of the camera (matches the name configured in Synthesis). + * @param dev USB device index (matches the index configured in Synthesis). + * @param width Requested frame width in pixels. + * @param height Requested frame height in pixels. + * @param fps Requested frame rate. + */ + public UsbCamera(String name, int dev, int width, int height, int fps) { + super(name, dev); + this.m_camera = new Camera(name, dev, width, height, fps); + } + + /** + * Gets the underlying Synthesis camera sim device. + * + * @return The backing {@link Camera}. + */ + public Camera getCamera() { + return this.m_camera; + } + + /** + * Gets a simulation-backed CvSink for grabbing frames from this camera. + * + * @return A {@link CvSink} that returns Synthesis-rendered frames. + */ + public CvSink getVideo() { + return new CvSink(this.getName() + " - sink", this.m_camera); + } + + /** + * Convenience method to grab the most recent frame directly from this camera. + * + * @param dst Destination matrix. + * @return A non-zero value on success, 0 if no frame was available. + */ + public long grabFrame(Mat dst) { + return this.m_camera.grabFrame(dst) ? 1L : 0L; + } + + /** + * Sets the requested resolution on both the real and simulated camera. + * + * @param width Frame width in pixels. + * @param height Frame height in pixels. + * @return true. + */ + @Override + public boolean setResolution(int width, int height) { + this.m_camera.setResolution(width, height); + return super.setResolution(width, height); + } + + /** + * Sets the requested frame rate on both the real and simulated camera. + * + * @param fps Frames per second. + * @return true. + */ + @Override + public boolean setFPS(int fps) { + this.m_camera.setFPS(fps); + return super.setFPS(fps); + } +} diff --git a/simulation/samples/JavaAutoSample/src/main/java/frc/robot/Robot.java b/simulation/samples/JavaAutoSample/src/main/java/frc/robot/Robot.java index f5694a80c3..23c815e31e 100644 --- a/simulation/samples/JavaAutoSample/src/main/java/frc/robot/Robot.java +++ b/simulation/samples/JavaAutoSample/src/main/java/frc/robot/Robot.java @@ -8,6 +8,12 @@ import com.autodesk.synthesis.io.*; +import org.opencv.core.Core; +import org.opencv.core.Mat; +import org.opencv.core.Scalar; + +import edu.wpi.first.cameraserver.CameraServer; +import edu.wpi.first.cscore.CvSource; import edu.wpi.first.wpilibj.SPI; import edu.wpi.first.wpilibj.ADXL362; @@ -17,6 +23,8 @@ import edu.wpi.first.wpilibj.smartdashboard.SmartDashboard; import edu.wpi.first.wpilibj.XboxController; +import com.autodesk.synthesis.cscore.CvSink; +import com.autodesk.synthesis.cscore.UsbCamera; import com.autodesk.synthesis.revrobotics.spark.SparkMax; import com.autodesk.synthesis.revrobotics.RelativeEncoder; import com.autodesk.synthesis.revrobotics.SparkAbsoluteEncoder; @@ -51,6 +59,16 @@ public class Robot extends TimedRobot { private double m_initAngle = 0; + // Simulated USB camera. The name and device index ("USB Camera 0", 0) must match the + // camera configured in Synthesis (Configure -> USB Cameras), which renders the robot's + // point of view and streams frames here. + private static final int kCameraWidth = 640; + private static final int kCameraHeight = 480; + private UsbCamera m_camera; + private CvSink m_cvSink; + private CvSource m_outputStream; + private Mat m_frame; + /** * This function is run when the robot is first started up and should be used * for any @@ -66,6 +84,16 @@ public void robotInit() { // 4 inch diameter wheels, default is 1 unit = 1 radian. // Following conversion factor is 1 unit = 1 inch travelled. m_encoder.setPositionConversionFactor(2.0); + + // Set up the simulated USB camera and a sink to grab frames from it. + m_camera = new UsbCamera("USB Camera 0", 0, kCameraWidth, kCameraHeight, 30); + m_cvSink = m_camera.getVideo(); + + // Republish the processed feed so it can be viewed (e.g. on a dashboard). Creating + // a CameraServer source also forces the OpenCV native library to load before we + // allocate the destination Mat below. + m_outputStream = CameraServer.putVideo("Synthesis Camera", kCameraWidth, kCameraHeight); + m_frame = new Mat(); } /** @@ -96,6 +124,28 @@ public void robotPeriodic() { SmartDashboard.putNumber("AHRS/VelX", m_Gyro.getVelocityX()); SmartDashboard.putNumber("AHRS/VelY", m_Gyro.getVelocityY()); SmartDashboard.putNumber("AHRS/VelZ", m_Gyro.getVelocityZ()); + + // Grab the latest frame rendered by Synthesis and report some basic diagnostics so + // the camera can be verified end-to-end. + if (m_cvSink == null) { + return; + } + + long frameTime = m_cvSink.grabFrame(m_frame); + if (frameTime != 0 && !m_frame.empty()) { + Scalar mean = Core.mean(m_frame); + double brightness = (mean.val[0] + mean.val[1] + mean.val[2]) / 3.0; + + SmartDashboard.putBoolean("Camera/Frame Received", true); + SmartDashboard.putNumber("Camera/Width", m_frame.width()); + SmartDashboard.putNumber("Camera/Height", m_frame.height()); + SmartDashboard.putNumber("Camera/Mean Brightness", brightness); + + // Republish the frame for viewing on a dashboard. + m_outputStream.putFrame(m_frame); + } else { + SmartDashboard.putBoolean("Camera/Frame Received", false); + } } /** From e693740323991e2df6c417ceef052438c5b44e3b Mon Sep 17 00:00:00 2001 From: PepperLola Date: Tue, 30 Jun 2026 11:29:48 -0700 Subject: [PATCH 02/17] chore: cleaned up comments --- fission/src/mirabuf/MirabufSceneObject.ts | 6 -- fission/src/mirabuf/RobotCameraSceneObject.ts | 47 +++------------ .../systems/preferences/PreferenceTypes.ts | 8 +-- .../wpilib_brain/CameraFrameSocket.ts | 26 ++------- .../simulation/wpilib_brain/WPILibTypes.ts | 6 +- .../simulation/wpilib_brain/sim/SimCamera.ts | 18 +----- .../interfaces/ConfigureCameraInterface.tsx | 15 +---- .../panels/simulation/CameraPreviewPanel.tsx | 12 +--- simulation/SyntheSimJava/build.gradle | 4 +- .../com/autodesk/synthesis/cscore/Camera.java | 45 ++------------- .../synthesis/cscore/CameraFrameServer.java | 23 ++------ .../com/autodesk/synthesis/cscore/CvSink.java | 38 +------------ .../autodesk/synthesis/cscore/UsbCamera.java | 57 +------------------ .../src/main/java/frc/robot/Robot.java | 12 ++-- 14 files changed, 45 insertions(+), 272 deletions(-) diff --git a/fission/src/mirabuf/MirabufSceneObject.ts b/fission/src/mirabuf/MirabufSceneObject.ts index 118e906015..b736f60e58 100644 --- a/fission/src/mirabuf/MirabufSceneObject.ts +++ b/fission/src/mirabuf/MirabufSceneObject.ts @@ -161,7 +161,6 @@ class MirabufSceneObject extends SceneObject implements ContextSupplier { this.robotPreferences.cameras = val } - /** Active camera scene objects, exposed for the camera preview UI. */ public get cameras(): Readonly { return this._cameras } @@ -645,11 +644,6 @@ class MirabufSceneObject extends SceneObject implements ContextSupplier { } } - /* - * Recreates the robot-mounted camera scene objects from preferences. Like - * `updateIntakeSensor`, this only runs on `setup` or occasional user input, so - * recreating the objects (and their render targets) here is acceptable. - */ public updateCameras() { this._cameras.forEach(c => World.sceneRenderer.removeSceneObject(c.id)) this._cameras = [] diff --git a/fission/src/mirabuf/RobotCameraSceneObject.ts b/fission/src/mirabuf/RobotCameraSceneObject.ts index d9736008ef..8017bc5aa9 100644 --- a/fission/src/mirabuf/RobotCameraSceneObject.ts +++ b/fission/src/mirabuf/RobotCameraSceneObject.ts @@ -8,31 +8,16 @@ import World from "@/systems/World" import { convertArrayToThreeMatrix4, convertJoltMat44ToThreeMatrix4 } from "@/util/TypeConversions" import type MirabufSceneObject from "./MirabufSceneObject" -// Guard rails so a misconfigured camera can't tank frame rate. const MAX_DIMENSION = 1280 const MIN_DIMENSION = 16 const MAX_FPS = 60 const JPEG_QUALITY = 0.6 -// A THREE.PerspectiveCamera looks down its local -Z, but the placeholder mesh in the -// config gizmo points down +Z. Rotate the camera 180° about its up axis so it looks where -// the gizmo points (away from the robot) instead of back into it. +// A camera looks down its local -Z, but the config gizmo's placeholder points +Z; flip so +// the camera looks where the gizmo points (away from the robot) rather than back into it. const FORWARD_FLIP = new THREE.Matrix4().makeRotationY(Math.PI) -/** - * A USB camera mounted to a robot. Each frame (throttled to the configured fps) it - * positions a secondary perspective camera relative to a robot rigid node, renders the - * scene into an offscreen target, and reads the pixels back. The latest frame is kept on - * an internal canvas for the preview UI, and—when the robot code has created the matching - * camera sim device—is JPEG encoded and streamed to the robot code via {@link SimCamera}. - * - * Mounting math mirrors {@link IntakeSensorSceneObject}: the world transform is - * `deltaTransformation * parentBodyWorldTransform`. - */ class RobotCameraSceneObject extends SceneObject { - // Number of active frame consumers (e.g. open preview panels). Capture is skipped - // entirely unless something needs the frame, so simply having a camera configured - // (e.g. while in the config panel) costs nothing. private static _previewConsumers = 0 public static get previewConsumers(): number { return RobotCameraSceneObject._previewConsumers @@ -74,22 +59,18 @@ class RobotCameraSceneObject extends SceneObject { return `${this._prefs.name}[${this._prefs.id}]` } - /** Human-readable label used by the preview panel. */ public get displayName(): string { return `${this._parentAssembly.assemblyName} – ${this._prefs.name}` } - /** Canvas holding the most recently captured frame (for previews). */ public get frameCanvas(): HTMLCanvasElement | undefined { return this._frameCanvas } - /** Current capture width in pixels (falls back to the configured default). */ public get width(): number { return this._width || this._prefs.resolutionWidth } - /** Current capture height in pixels (falls back to the configured default). */ public get height(): number { return this._height || this._prefs.resolutionHeight } @@ -129,14 +110,11 @@ class RobotCameraSceneObject extends SceneObject { if (!this._parentBodyId || !this._renderTarget || !this._pixelBuffer || !this._imageData) return const device = this.deviceName - // Stream to robot code once it has created the camera (discovered over HALSim). const streaming = SimCamera.isPresent(device) - // Skip all rendering/readback unless someone actually needs a frame. This keeps the - // config panel (and idle robots) fully interactive — the GPU readback below is a - // synchronous stall that must not run every frame for no reason. + // The readback below is a synchronous GPU stall, so skip it entirely unless a frame + // is actually consumed — otherwise a configured camera would freeze the app. if (!streaming && RobotCameraSceneObject.previewConsumers === 0) return - // Pick up resolution / fps / fov that the robot code requested (defaults to prefs). const reqWidth = SimCamera.getWidth(device, this._prefs.resolutionWidth) const reqHeight = SimCamera.getHeight(device, this._prefs.resolutionHeight) const fps = Math.max(1, Math.min(MAX_FPS, SimCamera.getFps(device, this._prefs.fps))) @@ -147,13 +125,10 @@ class RobotCameraSceneObject extends SceneObject { this._camera.updateProjectionMatrix() } - // Throttle capture to the configured frame rate. this._timeSinceCapture += World.currentDeltaT if (this._timeSinceCapture < 1 / fps) return this._timeSinceCapture = 0 - // Position the camera relative to its parent rigid node, then flip it to look - // forward (where the gizmo points) rather than back into the robot. const parentBody = World.physicsSystem.getBody(this._parentBodyId) if (!parentBody) return const worldTransform = this._deltaTransformation @@ -164,14 +139,10 @@ class RobotCameraSceneObject extends SceneObject { this._camera.quaternion.setFromRotationMatrix(worldTransform) this._camera.updateMatrixWorld() - // A failure here must never abort SceneRenderer.update (which would freeze the - // scene render, camera controls, and input handling for the whole app). const renderer = World.sceneRenderer.renderer - // The postprocessing EffectComposer leaves autoClear=false, so we must clear the - // (depth) buffer ourselves or the camera renders a stale, partially depth-rejected - // strip. setRenderTarget already applies the target's full-size viewport (without - // pixel-ratio scaling), so we must NOT call setViewport — doing so re-scales by - // devicePixelRatio and crops the capture. + // The EffectComposer leaves autoClear=false, so clear the depth buffer ourselves or + // the view is partially depth-rejected. setRenderTarget already sets the full-size + // viewport; setViewport would re-scale it by devicePixelRatio and crop the capture. const prevTarget = renderer.getRenderTarget() const prevAutoClear = renderer.autoClear try { @@ -181,11 +152,9 @@ class RobotCameraSceneObject extends SceneObject { renderer.render(World.sceneRenderer.scene, this._camera) renderer.readRenderTargetPixels(this._renderTarget, 0, 0, this._width, this._height, this._pixelBuffer) - // GL pixels are bottom-up; flip into the (top-down) ImageData for the canvas. this.flipInto(this._imageData, this._pixelBuffer) this._frameCtx?.putImageData(this._imageData, 0, 0) - // Frames travel over the dedicated frame socket, only while streaming. if (streaming && this._frameCanvas) { const dataUrl = this._frameCanvas.toDataURL("image/jpeg", JPEG_QUALITY) const base64 = dataUrl.slice(dataUrl.indexOf(",") + 1) @@ -194,12 +163,12 @@ class RobotCameraSceneObject extends SceneObject { } catch (e) { console.error(`Camera capture failed for '${device}'`, e) } finally { - // Restore renderer state so the main scene render is unaffected. renderer.setRenderTarget(prevTarget) renderer.autoClear = prevAutoClear } } + // GL pixels are bottom-up; flip rows into the top-down ImageData. private flipInto(target: ImageData, source: Uint8Array): void { const w = this._width const h = this._height diff --git a/fission/src/systems/preferences/PreferenceTypes.ts b/fission/src/systems/preferences/PreferenceTypes.ts index 0fba210634..6a16dfcda5 100644 --- a/fission/src/systems/preferences/PreferenceTypes.ts +++ b/fission/src/systems/preferences/PreferenceTypes.ts @@ -145,12 +145,8 @@ export type EjectorPreferences = { ejectOrder: "FIFO" | "LIFO" } -/** - * A simulated USB camera mounted to the robot. `name` and `id` must match the - * arguments the robot code passes to the Synthesis `UsbCamera` wrapper; together they - * form the sim device key `"[]"`. The camera is positioned relative to - * `parentNode` via `deltaTransformation`, identical to the intake / ejector pattern. - */ +// `name`/`id` must match the robot code's UsbCamera args; together they form the sim +// device key `"[]"`. export type CameraPreferences = { name: string id: number diff --git a/fission/src/systems/simulation/wpilib_brain/CameraFrameSocket.ts b/fission/src/systems/simulation/wpilib_brain/CameraFrameSocket.ts index 38b6735f87..0f970603d8 100644 --- a/fission/src/systems/simulation/wpilib_brain/CameraFrameSocket.ts +++ b/fission/src/systems/simulation/wpilib_brain/CameraFrameSocket.ts @@ -1,19 +1,11 @@ -/** - * Outbound WebSocket used to stream rendered camera frames to the running robot code. - * - * Frame bytes are too large for the HALSim SimDevice channel (which only carries - * numbers/booleans), so the robot process hosts a small WebSocket server (SyntheSimJava's - * `CameraFrameServer`) and Synthesis connects out to it — mirroring how it already - * connects to the HALSim WebSocket on `ws://localhost:3300`. - * - * Each message is the text `"\n"`. Connection is lazy and - * self-healing: failures are retried quietly, and frames are dropped while disconnected. - */ +// Frame bytes are too large for the HALSim SimDevice channel (numbers/booleans only), so +// the robot process hosts a WebSocket server (SyntheSimJava's CameraFrameServer) and we +// connect out to stream "\n" messages to it. const PORT = 5808 const RETRY_MS = 2000 -// Drop frames rather than queue them if the socket is backed up. Without this, a slow -// consumer causes frames to pile up in the send buffer and arrive in erratic bursts. +// Drop frames instead of queuing when the socket is backed up, so a slow consumer doesn't +// cause frames to pile up and arrive in erratic bursts. const MAX_BUFFERED_BYTES = 1_000_000 let socket: WebSocket | undefined @@ -31,7 +23,6 @@ function ensureSocket(): void { ws.addEventListener("close", () => { if (socket === ws) socket = undefined }) - // Swallow errors; the next send will trigger a throttled reconnect. ws.addEventListener("error", () => { if (socket === ws) socket = undefined }) @@ -41,13 +32,6 @@ function ensureSocket(): void { } } -/** - * Streams a single encoded frame for a camera device. No-ops (dropping the frame) if the - * robot's frame server isn't currently reachable. - * - * @param device The sim device key, e.g. `"USB Camera 0[0]"`. - * @param base64Jpeg The frame encoded as a base64 JPEG (without the data-URL prefix). - */ export function sendCameraFrame(device: string, base64Jpeg: string): void { ensureSocket() if (socket && socket.readyState === WebSocket.OPEN && socket.bufferedAmount < MAX_BUFFERED_BYTES) { diff --git a/fission/src/systems/simulation/wpilib_brain/WPILibTypes.ts b/fission/src/systems/simulation/wpilib_brain/WPILibTypes.ts index cc7f4f78c6..43d852e7a9 100644 --- a/fission/src/systems/simulation/wpilib_brain/WPILibTypes.ts +++ b/fission/src/systems/simulation/wpilib_brain/WPILibTypes.ts @@ -56,10 +56,8 @@ export const CANMOTOR_BUS_VOLTAGE = ">busVoltage" export const CANENCODER_POSITION = ">position" export const CANENCODER_VELOCITY = ">velocity" -// USB Camera fields. Resolution / fps / connected are configured by the robot code -// (outputs, read by Synthesis). The rendered frame itself does not travel over HALSim — -// SimDevices only carry numbers/booleans — and is streamed over a side channel instead -// (see CameraFrameSocket). +// USB Camera config, set by robot code and read by Synthesis. The frame itself can't ride +// HALSim (numbers/booleans only) and streams over a side channel (see CameraFrameSocket). export const CAMERA_WIDTH = "(SimType.CAMERA, device, CAMERA_WIDTH, defaultValue) } - /** Resolution height requested by the robot code (falls back to `defaultValue`). */ public static getHeight(device: string, defaultValue: number): number { return SimGeneric.get(SimType.CAMERA, device, CAMERA_HEIGHT, defaultValue) } - /** Frame rate requested by the robot code (falls back to `defaultValue`). */ public static getFps(device: string, defaultValue: number): number { return SimGeneric.get(SimType.CAMERA, device, CAMERA_FPS, defaultValue) } - /** Whether the robot code has marked the camera as connected. */ public static getConnected(device: string): boolean { return SimGeneric.get(SimType.CAMERA, device, CAMERA_CONNECTED, false) } - /** - * Whether the robot code has created this camera device yet. Frames are only worth - * encoding/sending once the device is present in the sim map. - */ public static isPresent(device: string): boolean { return !!getSimMap()?.get(SimType.CAMERA)?.get(device) } diff --git a/fission/src/ui/panels/configuring/assembly-config/interfaces/ConfigureCameraInterface.tsx b/fission/src/ui/panels/configuring/assembly-config/interfaces/ConfigureCameraInterface.tsx index 3900ae705d..111ff7ff3b 100644 --- a/fission/src/ui/panels/configuring/assembly-config/interfaces/ConfigureCameraInterface.tsx +++ b/fission/src/ui/panels/configuring/assembly-config/interfaces/ConfigureCameraInterface.tsx @@ -30,7 +30,6 @@ const MAX_RES = 1280 const MIN_FPS = 1 const MAX_FPS = 60 -/** Resolves the body id of a camera's parent node, falling back to the robot root. */ function parentBodyId(robot: MirabufSceneObject, parentNode: string | undefined): Jolt.BodyID | undefined { return robot.mechanism.nodeToBody.get(parentNode ?? robot.rootNodeId) ?? robot.getRootNodeId() } @@ -39,12 +38,7 @@ interface ConfigCameraProps { selectedRobot: MirabufSceneObject } -/** - * Configures the robot's USB cameras. Each camera is placed relative to a rigid node - * with a transform gizmo (identical math to the ejector interface) and has its own - * resolution / fps / fov. The placement is stored as a delta transform so the camera - * tracks the node as the robot moves. - */ +// Camera placement reuses the ejector interface's gizmo + delta-transform math. const ConfigureCameraInterface: React.FC = ({ selectedRobot }) => { const [version, setVersion] = useState(0) const [selectedIndex, setSelectedIndex] = useState(selectedRobot.cameraPreferences.length > 0 ? 0 : -1) @@ -59,7 +53,6 @@ const ConfigureCameraInterface: React.FC = ({ selectedRobot } // Persists prefs and recreates the live camera scene objects. const commit = useCallback(() => { - // Capture the gizmo placement into the selected camera before saving. if (camera && gizmoRef.current) { const nodeBodyId = parentBodyId(selectedRobot, selectedNode) if (nodeBodyId) { @@ -86,18 +79,16 @@ const ConfigureCameraInterface: React.FC = ({ selectedRobot } } }, []) - // Sync the selected node with the active camera. useEffect(() => { setSelectedNode(camera?.parentNode) }, [selectedIndex, camera]) + // Keyed on selectedIndex so the gizmo re-spawns positioned for the newly selected camera. const placeholderMesh = useMemo(() => { - const mesh = new THREE.Mesh( + return new THREE.Mesh( new THREE.BoxGeometry(0.12, 0.08, 0.16).translate(0, 0, 0.08), World.sceneRenderer.createToonMaterial(convertReactRgbaColorToThreeColor({ r: 80, g: 180, b: 255, a: 255 })) ) - return mesh - // Recreate when switching cameras so the gizmo re-spawns positioned correctly. }, [selectedIndex]) const gizmoComponent = useMemo(() => { diff --git a/fission/src/ui/panels/simulation/CameraPreviewPanel.tsx b/fission/src/ui/panels/simulation/CameraPreviewPanel.tsx index b15ebb76eb..44a90aaf81 100644 --- a/fission/src/ui/panels/simulation/CameraPreviewPanel.tsx +++ b/fission/src/ui/panels/simulation/CameraPreviewPanel.tsx @@ -7,11 +7,6 @@ import Label from "@/ui/components/Label" import type { PanelImplProps } from "@/ui/components/Panel" import { useUIContext } from "@/ui/helpers/UIProviderHelpers" -/** - * Live preview of every robot-mounted USB camera. Each frame we blit the latest captured - * frame from each {@link RobotCameraSceneObject} onto a visible canvas, so users can see - * exactly what their simulated camera streams to the robot code. - */ const CameraPreviewPanel: React.FC> = ({ panel }) => { const { configureScreen } = useUIContext() const canvasRefs = useRef>(new Map()) @@ -20,13 +15,12 @@ const CameraPreviewPanel: React.FC> = ({ panel }) => configureScreen(panel!, { title: "Camera Preview", hideCancel: true, acceptText: "Close" }, {}) }, []) - // Tell cameras to actually render/capture while this preview is open. + // Marks a frame consumer so the cameras actually render while this panel is open. useEffect(() => { RobotCameraSceneObject.addPreviewConsumer() return () => RobotCameraSceneObject.removePreviewConsumer() }, []) - // Snapshot the current cameras on mount; reopen the panel to pick up new ones. const cameras = useMemo( () => World.sceneRenderer.mirabufSceneObjects.getRobots().flatMap(r => [...r.cameras]), [] @@ -66,8 +60,8 @@ const CameraPreviewPanel: React.FC> = ({ panel }) => style={{ display: "block", width: "320px", - // Pin the display height so the canvas can't render at its - // full bitmap height and slip under the panel footer. + // Pin display height so the canvas can't render at its full + // bitmap height and slip under the panel footer. aspectRatio: `${cam.width} / ${cam.height}`, borderRadius: "8px", background: "#000", diff --git a/simulation/SyntheSimJava/build.gradle b/simulation/SyntheSimJava/build.gradle index ac35d079df..e3a823aa36 100644 --- a/simulation/SyntheSimJava/build.gradle +++ b/simulation/SyntheSimJava/build.gradle @@ -41,9 +41,7 @@ def WPI_Version = '2026.2.2' def REV_Version = '2026.0.5' def CTRE_Version = '26.3.0' def STUDICA_Version = '2026.0.0' -// WPILib-published OpenCV. The OpenCV vendoring is still published under the frc2025 -// group for the 2026 season (there is no frc2026 group); keep the version in sync with -// the OpenCV bundled with the installed WPILib release. +// OpenCV is still published under the frc2025 group for the 2026 season (no frc2026 group). def OPENCV_Group = 'edu.wpi.first.thirdparty.frc2025.opencv' def OPENCV_Version = '4.10.0-3' diff --git a/simulation/SyntheSimJava/src/main/java/com/autodesk/synthesis/cscore/Camera.java b/simulation/SyntheSimJava/src/main/java/com/autodesk/synthesis/cscore/Camera.java index a4d6f96dd4..30522be3d3 100644 --- a/simulation/SyntheSimJava/src/main/java/com/autodesk/synthesis/cscore/Camera.java +++ b/simulation/SyntheSimJava/src/main/java/com/autodesk/synthesis/cscore/Camera.java @@ -10,16 +10,9 @@ import edu.wpi.first.hal.SimInt; /** - * Backing sim device for a simulated USB camera. - * - * The requested resolution / fps / connected state are published as HALSim outputs (read - * by Synthesis to size and throttle its render). The rendered frame itself cannot travel - * over HALSim — {@link SimDevice} only supports numeric and boolean values — so frames - * are streamed separately by Synthesis to {@link CameraFrameServer} and matched back to - * this device by name. - * - * See https://github.com/wpilibsuite/allwpilib/blob/main/simulation/halsim_ws_core/doc/hardware_ws_api.md - * for documentation on the WebSocket API Specification. + * Backing sim device for a simulated USB camera. Config (resolution/fps/connected) is + * published over HALSim; the frame can't (SimDevice carries only numbers/booleans) and is + * streamed by Synthesis to {@link CameraFrameServer}, matched back to this device by name. */ public class Camera { @@ -33,19 +26,10 @@ public class Camera { private final String m_deviceName; private final CameraFrameServer m_frameServer; - /** - * Creates a Camera sim device. The resulting sim device key seen by Synthesis is - * {@code "[]"}; Synthesis streams frames tagged with that same key. - * - * @param name Name of the camera (matches the name configured in Synthesis). - * @param deviceId USB device index. - * @param width Requested frame width in pixels. - * @param height Requested frame height in pixels. - * @param fps Requested frame rate. - */ public Camera(String name, int deviceId, int width, int height, int fps) { m_device = SimDevice.create("Camera:" + name, deviceId); + // Null outside of simulation. if (m_device != null) { m_width = m_device.createInt("width", Direction.kOutput, width); m_height = m_device.createInt("height", Direction.kOutput, height); @@ -57,12 +41,6 @@ public Camera(String name, int deviceId, int width, int height, int fps) { m_frameServer = CameraFrameServer.getInstance(); } - /** - * Sets the requested resolution, read by Synthesis to size its render. - * - * @param width Frame width in pixels. - * @param height Frame height in pixels. - */ public void setResolution(int width, int height) { if (m_width != null) { m_width.set(width); @@ -70,22 +48,12 @@ public void setResolution(int width, int height) { } } - /** - * Sets the requested frame rate, read by Synthesis to throttle its render. - * - * @param fps Frames per second. - */ public void setFPS(int fps) { if (m_fps != null) { m_fps.set(fps); } } - /** - * Sets whether the camera is connected. - * - * @param connected Whether the camera is connected. - */ public void setConnected(boolean connected) { if (m_connected != null) { m_connected.set(connected); @@ -93,10 +61,9 @@ public void setConnected(boolean connected) { } /** - * Decodes the latest frame supplied by Synthesis into the provided destination Mat. + * Decodes the latest frame from Synthesis into {@code dst}. * - * @param dst Destination matrix to receive the decoded BGR image. - * @return true if a frame was available and decoded, false otherwise. + * @return true if a frame was available and decoded. */ public boolean grabFrame(Mat dst) { byte[] bytes = m_frameServer.getFrame(m_deviceName); diff --git a/simulation/SyntheSimJava/src/main/java/com/autodesk/synthesis/cscore/CameraFrameServer.java b/simulation/SyntheSimJava/src/main/java/com/autodesk/synthesis/cscore/CameraFrameServer.java index e44d0a09fa..25d9719ef9 100644 --- a/simulation/SyntheSimJava/src/main/java/com/autodesk/synthesis/cscore/CameraFrameServer.java +++ b/simulation/SyntheSimJava/src/main/java/com/autodesk/synthesis/cscore/CameraFrameServer.java @@ -10,17 +10,13 @@ import org.java_websocket.server.WebSocketServer; /** - * A small localhost WebSocket server that receives rendered camera frames from Synthesis. - * - * Frame bytes are too large to travel over the HALSim {@link edu.wpi.first.hal.SimDevice} - * channel (which only carries numbers and booleans), so Synthesis streams them here - * instead — mirroring how it already connects out to the HALSim WebSocket. Each message is - * the text {@code "\n"}; the most recent frame per device is kept and - * served to {@link Camera#grabFrame}. + * Localhost WebSocket server that receives rendered camera frames from Synthesis. Frames + * can't ride HALSim (SimDevice carries only numbers/booleans), so Synthesis streams them + * here as {@code "\n"}; the latest frame per device is kept for + * {@link Camera#grabFrame}. */ public class CameraFrameServer extends WebSocketServer { - /** Port Synthesis connects to in order to stream camera frames. */ public static final int PORT = 5808; private static CameraFrameServer instance; @@ -32,11 +28,6 @@ private CameraFrameServer(int port) { setReuseAddr(true); } - /** - * Returns the shared frame server, lazily starting it on first use. - * - * @return The singleton {@link CameraFrameServer}. - */ public static synchronized CameraFrameServer getInstance() { if (instance == null) { instance = new CameraFrameServer(PORT); @@ -46,12 +37,6 @@ public static synchronized CameraFrameServer getInstance() { return instance; } - /** - * Gets the most recent frame received for a device. - * - * @param device The sim device key, e.g. {@code "USB Camera 0[0]"}. - * @return The most recent encoded JPEG bytes, or null if none have been received. - */ public byte[] getFrame(String device) { return m_frames.get(device); } diff --git a/simulation/SyntheSimJava/src/main/java/com/autodesk/synthesis/cscore/CvSink.java b/simulation/SyntheSimJava/src/main/java/com/autodesk/synthesis/cscore/CvSink.java index e42d00285a..af06df3b0c 100644 --- a/simulation/SyntheSimJava/src/main/java/com/autodesk/synthesis/cscore/CvSink.java +++ b/simulation/SyntheSimJava/src/main/java/com/autodesk/synthesis/cscore/CvSink.java @@ -3,61 +3,29 @@ import org.opencv.core.Mat; /** - * CvSink wrapper to add WPILib HALSim camera support. Rather than reading frames from a - * physical video source, the frame-grabbing methods are overridden to return the frame - * rendered by Synthesis for the associated {@link Camera}. - * - * Mirrors the swap-in pattern used for motors (see - * {@code com.autodesk.synthesis.revrobotics.spark.SparkMax}): use this in place of - * {@code edu.wpi.first.cscore.CvSink} and existing OpenCV processing keeps working. - * - * See original documentation for more information - * https://github.wpilib.org/allwpilib/docs/release/java/edu/wpi/first/cscore/CvSink.html + * Swap-in for {@code edu.wpi.first.cscore.CvSink}: the frame-grab methods return the frame + * Synthesis rendered for the associated {@link Camera} instead of a physical source, so + * existing OpenCV processing keeps working. */ public class CvSink extends edu.wpi.first.cscore.CvSink { private Camera m_camera; - /** - * Creates a new simulation-backed CvSink. - * - * @param name Name of the sink. - * @param camera The Synthesis camera supplying frames. - */ public CvSink(String name, Camera camera) { super(name); this.m_camera = camera; } - /** - * Grabs the most recent frame supplied by Synthesis. - * - * @param image Destination matrix. - * @return A non-zero value on success, 0 if no frame was available. - */ @Override public long grabFrame(Mat image) { return grabFrameNoTimeout(image); } - /** - * Grabs the most recent frame supplied by Synthesis, ignoring the timeout. - * - * @param image Destination matrix. - * @param timeout Ignored; the simulated frame is always the latest available. - * @return A non-zero value on success, 0 if no frame was available. - */ @Override public long grabFrame(Mat image, double timeout) { return grabFrameNoTimeout(image); } - /** - * Grabs the most recent frame supplied by Synthesis. - * - * @param image Destination matrix. - * @return A non-zero value on success, 0 if no frame was available. - */ @Override public long grabFrameNoTimeout(Mat image) { return m_camera.grabFrame(image) ? 1L : 0L; diff --git a/simulation/SyntheSimJava/src/main/java/com/autodesk/synthesis/cscore/UsbCamera.java b/simulation/SyntheSimJava/src/main/java/com/autodesk/synthesis/cscore/UsbCamera.java index dec7c32c4f..b56ae93d2f 100644 --- a/simulation/SyntheSimJava/src/main/java/com/autodesk/synthesis/cscore/UsbCamera.java +++ b/simulation/SyntheSimJava/src/main/java/com/autodesk/synthesis/cscore/UsbCamera.java @@ -3,15 +3,9 @@ import org.opencv.core.Mat; /** - * UsbCamera wrapper to add WPILib HALSim camera support. Use this in place of - * {@code edu.wpi.first.cscore.UsbCamera}; instead of a physical USB device, frames are - * supplied by Synthesis, which renders the scene from the camera's configured pose on the - * robot. + * Swap-in for {@code edu.wpi.first.cscore.UsbCamera} (mirrors the SparkMax wrapper + * pattern): frames come from Synthesis instead of a physical device. * - * Mirrors the swap-in pattern used for motors (see - * {@code com.autodesk.synthesis.revrobotics.spark.SparkMax}). - * - * Typical usage: *
  *     var camera = new com.autodesk.synthesis.cscore.UsbCamera("USB Camera 0", 0);
  *     var sink = camera.getVideo();
@@ -20,85 +14,38 @@
  *         // ... run vision processing on frame ...
  *     }
  * 
- * - * See original documentation for more information - * https://github.wpilib.org/allwpilib/docs/release/java/edu/wpi/first/cscore/UsbCamera.html */ public class UsbCamera extends edu.wpi.first.cscore.UsbCamera { private Camera m_camera; - /** - * Creates a new simulation-backed UsbCamera with a default 640x480 @ 30fps stream. - * - * @param name Name of the camera (matches the name configured in Synthesis). - * @param dev USB device index (matches the index configured in Synthesis). - */ public UsbCamera(String name, int dev) { this(name, dev, 640, 480, 30); } - /** - * Creates a new simulation-backed UsbCamera. - * - * @param name Name of the camera (matches the name configured in Synthesis). - * @param dev USB device index (matches the index configured in Synthesis). - * @param width Requested frame width in pixels. - * @param height Requested frame height in pixels. - * @param fps Requested frame rate. - */ public UsbCamera(String name, int dev, int width, int height, int fps) { super(name, dev); this.m_camera = new Camera(name, dev, width, height, fps); } - /** - * Gets the underlying Synthesis camera sim device. - * - * @return The backing {@link Camera}. - */ public Camera getCamera() { return this.m_camera; } - /** - * Gets a simulation-backed CvSink for grabbing frames from this camera. - * - * @return A {@link CvSink} that returns Synthesis-rendered frames. - */ public CvSink getVideo() { return new CvSink(this.getName() + " - sink", this.m_camera); } - /** - * Convenience method to grab the most recent frame directly from this camera. - * - * @param dst Destination matrix. - * @return A non-zero value on success, 0 if no frame was available. - */ public long grabFrame(Mat dst) { return this.m_camera.grabFrame(dst) ? 1L : 0L; } - /** - * Sets the requested resolution on both the real and simulated camera. - * - * @param width Frame width in pixels. - * @param height Frame height in pixels. - * @return true. - */ @Override public boolean setResolution(int width, int height) { this.m_camera.setResolution(width, height); return super.setResolution(width, height); } - /** - * Sets the requested frame rate on both the real and simulated camera. - * - * @param fps Frames per second. - * @return true. - */ @Override public boolean setFPS(int fps) { this.m_camera.setFPS(fps); diff --git a/simulation/samples/JavaAutoSample/src/main/java/frc/robot/Robot.java b/simulation/samples/JavaAutoSample/src/main/java/frc/robot/Robot.java index 23c815e31e..323d4cf75c 100644 --- a/simulation/samples/JavaAutoSample/src/main/java/frc/robot/Robot.java +++ b/simulation/samples/JavaAutoSample/src/main/java/frc/robot/Robot.java @@ -59,9 +59,8 @@ public class Robot extends TimedRobot { private double m_initAngle = 0; - // Simulated USB camera. The name and device index ("USB Camera 0", 0) must match the - // camera configured in Synthesis (Configure -> USB Cameras), which renders the robot's - // point of view and streams frames here. + // Name and device index must match the camera configured in Synthesis (Configure -> + // USB Cameras). private static final int kCameraWidth = 640; private static final int kCameraHeight = 480; private UsbCamera m_camera; @@ -85,13 +84,11 @@ public void robotInit() { // Following conversion factor is 1 unit = 1 inch travelled. m_encoder.setPositionConversionFactor(2.0); - // Set up the simulated USB camera and a sink to grab frames from it. m_camera = new UsbCamera("USB Camera 0", 0, kCameraWidth, kCameraHeight, 30); m_cvSink = m_camera.getVideo(); - // Republish the processed feed so it can be viewed (e.g. on a dashboard). Creating - // a CameraServer source also forces the OpenCV native library to load before we - // allocate the destination Mat below. + // putVideo both republishes the feed for dashboards and forces the OpenCV native + // to load before we allocate the Mat below. m_outputStream = CameraServer.putVideo("Synthesis Camera", kCameraWidth, kCameraHeight); m_frame = new Mat(); } @@ -141,7 +138,6 @@ public void robotPeriodic() { SmartDashboard.putNumber("Camera/Height", m_frame.height()); SmartDashboard.putNumber("Camera/Mean Brightness", brightness); - // Republish the frame for viewing on a dashboard. m_outputStream.putFrame(m_frame); } else { SmartDashboard.putBoolean("Camera/Frame Received", false); From a732209ad7739481ba80ef6225688d316524be90 Mon Sep 17 00:00:00 2001 From: PepperLola Date: Tue, 30 Jun 2026 11:57:12 -0700 Subject: [PATCH 03/17] chore: cleanup --- fission/src/mirabuf/MirabufSceneObject.ts | 2 ++ fission/src/mirabuf/RobotCameraSceneObject.ts | 23 ++++++++-------- fission/src/systems/EventSystem.ts | 1 + .../systems/preferences/PreferenceTypes.ts | 3 +-- .../wpilib_brain/CameraFrameSocket.ts | 8 +++--- .../simulation/wpilib_brain/WPILibTypes.ts | 4 +-- .../simulation/wpilib_brain/sim/SimCamera.ts | 10 +++---- .../interfaces/ConfigureCameraInterface.tsx | 26 ++++++++++--------- .../panels/simulation/CameraPreviewPanel.tsx | 17 ++++++------ simulation/SyntheSimJava/build.gradle | 8 +++--- .../com/autodesk/synthesis/cscore/Camera.java | 10 +++---- .../synthesis/cscore/CameraFrameServer.java | 4 +-- .../com/autodesk/synthesis/cscore/CvSink.java | 10 +++++-- .../autodesk/synthesis/cscore/UsbCamera.java | 12 +-------- .../src/main/java/frc/robot/Robot.java | 8 +++--- 15 files changed, 70 insertions(+), 76 deletions(-) diff --git a/fission/src/mirabuf/MirabufSceneObject.ts b/fission/src/mirabuf/MirabufSceneObject.ts index b736f60e58..cc838af62f 100644 --- a/fission/src/mirabuf/MirabufSceneObject.ts +++ b/fission/src/mirabuf/MirabufSceneObject.ts @@ -655,6 +655,8 @@ class MirabufSceneObject extends SceneObject implements ContextSupplier { this._cameras.push(camera) World.sceneRenderer.registerSceneObject(camera) } + + EventSystem.dispatch("RobotCamerasChangeEvent") } public setIntakeVisualIndicatorVisible(visible: boolean) { diff --git a/fission/src/mirabuf/RobotCameraSceneObject.ts b/fission/src/mirabuf/RobotCameraSceneObject.ts index 8017bc5aa9..d6f7ecd969 100644 --- a/fission/src/mirabuf/RobotCameraSceneObject.ts +++ b/fission/src/mirabuf/RobotCameraSceneObject.ts @@ -13,8 +13,8 @@ const MIN_DIMENSION = 16 const MAX_FPS = 60 const JPEG_QUALITY = 0.6 -// A camera looks down its local -Z, but the config gizmo's placeholder points +Z; flip so -// the camera looks where the gizmo points (away from the robot) rather than back into it. +// camera looks down local -Z but the gizmo placeholder points +Z; flip so it faces where +// the gizmo points (away from the robot), not back into it const FORWARD_FLIP = new THREE.Matrix4().makeRotationY(Math.PI) class RobotCameraSceneObject extends SceneObject { @@ -36,6 +36,7 @@ class RobotCameraSceneObject extends SceneObject { private _renderTarget?: THREE.WebGLRenderTarget private _parentBodyId?: Jolt.BodyID private _deltaTransformation: THREE.Matrix4 + private readonly _worldTransform = new THREE.Matrix4() private _width = 0 private _height = 0 @@ -54,7 +55,7 @@ class RobotCameraSceneObject extends SceneObject { this._deltaTransformation = convertArrayToThreeMatrix4(prefs.deltaTransformation) } - /** The sim device key, e.g. `"USB Camera 0[0]"`. */ + /** sim device key, e.g. `"USB Camera 0[0]"` */ public get deviceName(): string { return `${this._prefs.name}[${this._prefs.id}]` } @@ -111,8 +112,8 @@ class RobotCameraSceneObject extends SceneObject { const device = this.deviceName const streaming = SimCamera.isPresent(device) - // The readback below is a synchronous GPU stall, so skip it entirely unless a frame - // is actually consumed — otherwise a configured camera would freeze the app. + // readback below is a synchronous GPU stall; skip unless a frame is consumed, else a + // configured camera would freeze the app if (!streaming && RobotCameraSceneObject.previewConsumers === 0) return const reqWidth = SimCamera.getWidth(device, this._prefs.resolutionWidth) @@ -131,8 +132,8 @@ class RobotCameraSceneObject extends SceneObject { const parentBody = World.physicsSystem.getBody(this._parentBodyId) if (!parentBody) return - const worldTransform = this._deltaTransformation - .clone() + const worldTransform = this._worldTransform + .copy(this._deltaTransformation) .premultiply(convertJoltMat44ToThreeMatrix4(parentBody.GetWorldTransform())) .multiply(FORWARD_FLIP) this._camera.position.setFromMatrixPosition(worldTransform) @@ -140,9 +141,9 @@ class RobotCameraSceneObject extends SceneObject { this._camera.updateMatrixWorld() const renderer = World.sceneRenderer.renderer - // The EffectComposer leaves autoClear=false, so clear the depth buffer ourselves or - // the view is partially depth-rejected. setRenderTarget already sets the full-size - // viewport; setViewport would re-scale it by devicePixelRatio and crop the capture. + // EffectComposer leaves autoClear=false, so clear the depth buffer ourselves or the + // view is partially depth-rejected. setRenderTarget already sets the full-size + // viewport; setViewport would re-scale by devicePixelRatio and crop the capture const prevTarget = renderer.getRenderTarget() const prevAutoClear = renderer.autoClear try { @@ -168,7 +169,7 @@ class RobotCameraSceneObject extends SceneObject { } } - // GL pixels are bottom-up; flip rows into the top-down ImageData. + // GL pixels are bottom-up; flip rows into the top-down ImageData private flipInto(target: ImageData, source: Uint8Array): void { const w = this._width const h = this._height diff --git a/fission/src/systems/EventSystem.ts b/fission/src/systems/EventSystem.ts index 5467d1f313..b64482af71 100644 --- a/fission/src/systems/EventSystem.ts +++ b/fission/src/systems/EventSystem.ts @@ -39,6 +39,7 @@ interface EventDataMap { // Code Sim SimMapUpdateEvent: { internalUpdate: boolean } + RobotCamerasChangeEvent: never // Context Menu ContextSupplierEvent: { data: ContextData; mousePosition: [number, number] } diff --git a/fission/src/systems/preferences/PreferenceTypes.ts b/fission/src/systems/preferences/PreferenceTypes.ts index 6a16dfcda5..61cb69e0b8 100644 --- a/fission/src/systems/preferences/PreferenceTypes.ts +++ b/fission/src/systems/preferences/PreferenceTypes.ts @@ -145,8 +145,7 @@ export type EjectorPreferences = { ejectOrder: "FIFO" | "LIFO" } -// `name`/`id` must match the robot code's UsbCamera args; together they form the sim -// device key `"[]"`. +// name/id must match the robot code's UsbCamera args, key `"[]"` export type CameraPreferences = { name: string id: number diff --git a/fission/src/systems/simulation/wpilib_brain/CameraFrameSocket.ts b/fission/src/systems/simulation/wpilib_brain/CameraFrameSocket.ts index 0f970603d8..bdc11bc16f 100644 --- a/fission/src/systems/simulation/wpilib_brain/CameraFrameSocket.ts +++ b/fission/src/systems/simulation/wpilib_brain/CameraFrameSocket.ts @@ -1,11 +1,11 @@ -// Frame bytes are too large for the HALSim SimDevice channel (numbers/booleans only), so +// frame bytes are too large for the HALSim SimDevice channel (numbers/booleans only), so // the robot process hosts a WebSocket server (SyntheSimJava's CameraFrameServer) and we -// connect out to stream "\n" messages to it. +// connect out to stream "\n" messages to it const PORT = 5808 const RETRY_MS = 2000 -// Drop frames instead of queuing when the socket is backed up, so a slow consumer doesn't -// cause frames to pile up and arrive in erratic bursts. +// drop frames instead of queuing when the socket is backed up, else a slow consumer makes +// frames pile up and arrive in erratic bursts const MAX_BUFFERED_BYTES = 1_000_000 let socket: WebSocket | undefined diff --git a/fission/src/systems/simulation/wpilib_brain/WPILibTypes.ts b/fission/src/systems/simulation/wpilib_brain/WPILibTypes.ts index 43d852e7a9..dc08230b7d 100644 --- a/fission/src/systems/simulation/wpilib_brain/WPILibTypes.ts +++ b/fission/src/systems/simulation/wpilib_brain/WPILibTypes.ts @@ -56,8 +56,8 @@ export const CANMOTOR_BUS_VOLTAGE = ">busVoltage" export const CANENCODER_POSITION = ">position" export const CANENCODER_VELOCITY = ">velocity" -// USB Camera config, set by robot code and read by Synthesis. The frame itself can't ride -// HALSim (numbers/booleans only) and streams over a side channel (see CameraFrameSocket). +// USB camera config, set by robot code and read by Synthesis. the frame can't ride HALSim +// (numbers/booleans only) and streams over a side channel (see CameraFrameSocket) export const CAMERA_WIDTH = "(SimType.CAMERA, device, CAMERA_FPS, defaultValue) } - public static getConnected(device: string): boolean { - return SimGeneric.get(SimType.CAMERA, device, CAMERA_CONNECTED, false) - } - public static isPresent(device: string): boolean { return !!getSimMap()?.get(SimType.CAMERA)?.get(device) } diff --git a/fission/src/ui/panels/configuring/assembly-config/interfaces/ConfigureCameraInterface.tsx b/fission/src/ui/panels/configuring/assembly-config/interfaces/ConfigureCameraInterface.tsx index 111ff7ff3b..b1c5d34046 100644 --- a/fission/src/ui/panels/configuring/assembly-config/interfaces/ConfigureCameraInterface.tsx +++ b/fission/src/ui/panels/configuring/assembly-config/interfaces/ConfigureCameraInterface.tsx @@ -31,14 +31,13 @@ const MIN_FPS = 1 const MAX_FPS = 60 function parentBodyId(robot: MirabufSceneObject, parentNode: string | undefined): Jolt.BodyID | undefined { - return robot.mechanism.nodeToBody.get(parentNode ?? robot.rootNodeId) ?? robot.getRootNodeId() + return robot.mechanism.nodeToBody.get(parentNode ?? robot.rootNodeId) } interface ConfigCameraProps { selectedRobot: MirabufSceneObject } -// Camera placement reuses the ejector interface's gizmo + delta-transform math. const ConfigureCameraInterface: React.FC = ({ selectedRobot }) => { const [version, setVersion] = useState(0) const [selectedIndex, setSelectedIndex] = useState(selectedRobot.cameraPreferences.length > 0 ? 0 : -1) @@ -51,13 +50,14 @@ const ConfigureCameraInterface: React.FC = ({ selectedRobot } const forceRender = useCallback(() => setVersion(v => v + 1), []) - // Persists prefs and recreates the live camera scene objects. const commit = useCallback(() => { if (camera && gizmoRef.current) { const nodeBodyId = parentBodyId(selectedRobot, selectedNode) if (nodeBodyId) { const gizmoWorld = gizmoRef.current.obj.matrixWorld.clone() - const robotWorld = convertJoltMat44ToThreeMatrix4(World.physicsSystem.getBody(nodeBodyId)!.GetWorldTransform()) + const robotWorld = convertJoltMat44ToThreeMatrix4( + World.physicsSystem.getBody(nodeBodyId)!.GetWorldTransform() + ) const delta = gizmoWorld.premultiply(robotWorld.invert()) camera.deltaTransformation = convertThreeMatrix4ToArray(delta) camera.parentNode = selectedNode @@ -83,7 +83,7 @@ const ConfigureCameraInterface: React.FC = ({ selectedRobot } setSelectedNode(camera?.parentNode) }, [selectedIndex, camera]) - // Keyed on selectedIndex so the gizmo re-spawns positioned for the newly selected camera. + // keyed on selectedIndex so the gizmo re-spawns positioned for the newly selected camera const placeholderMesh = useMemo(() => { return new THREE.Mesh( new THREE.BoxGeometry(0.12, 0.08, 0.16).translate(0, 0, 0.08), @@ -105,7 +105,9 @@ const ConfigureCameraInterface: React.FC = ({ selectedRobot } const nodeBodyId = parentBodyId(selectedRobot, camera.parentNode) if (!nodeBodyId) return - const robotWorld = convertJoltMat44ToThreeMatrix4(World.physicsSystem.getBody(nodeBodyId)!.GetWorldTransform()) + const robotWorld = convertJoltMat44ToThreeMatrix4( + World.physicsSystem.getBody(nodeBodyId)!.GetWorldTransform() + ) const gizmoWorld = delta.premultiply(robotWorld) gizmo.obj.position.setFromMatrixPosition(gizmoWorld) gizmo.obj.rotation.setFromRotationMatrix(gizmoWorld) @@ -129,9 +131,12 @@ const ConfigureCameraInterface: React.FC = ({ selectedRobot } (body: Jolt.BodyID) => { const assoc = World.physicsSystem.getBodyAssociation(body) as RigidNodeAssociate if (!assoc || assoc.sceneObject !== selectedRobot) return false + setSelectedNode(assoc.rigidNodeId) if (camera) camera.parentNode = assoc.rigidNodeId + commit() + return true }, [selectedRobot, camera, commit] @@ -141,21 +146,23 @@ const ConfigureCameraInterface: React.FC = ({ selectedRobot } const nextId = cameras.reduce((max, c) => Math.max(max, c.id + 1), 0) cameras.push(defaultCameraPreferences(nextId)) setSelectedIndex(cameras.length - 1) + commit() forceRender() }, [cameras, commit, forceRender]) const removeCamera = useCallback(() => { if (selectedIndex < 0) return + cameras.splice(selectedIndex, 1) setSelectedIndex(cameras.length > 0 ? Math.max(0, selectedIndex - 1) : -1) + commit() forceRender() }, [cameras, selectedIndex, commit, forceRender]) return ( - {/* Camera selector */} {cameras.map((c, i) => ( + ))} + + )} + + {selected && ( +
+ + +
)} -
+ ) } diff --git a/simulation/SyntheSimJava/src/main/java/com/autodesk/synthesis/cscore/Camera.java b/simulation/SyntheSimJava/src/main/java/com/autodesk/synthesis/cscore/Camera.java index 72bcb87349..fb61f31190 100644 --- a/simulation/SyntheSimJava/src/main/java/com/autodesk/synthesis/cscore/Camera.java +++ b/simulation/SyntheSimJava/src/main/java/com/autodesk/synthesis/cscore/Camera.java @@ -25,6 +25,8 @@ public class Camera { private final String m_deviceName; private final CameraFrameServer m_frameServer; + private byte[] m_lastFrame; + public Camera(String name, int deviceId, int width, int height, int fps) { m_device = SimDevice.create("Camera:" + name, deviceId); @@ -64,9 +66,11 @@ public boolean grabFrame(Mat dst) { if (m_frameServer == null) return false; byte[] bytes = m_frameServer.getFrame(m_deviceName); - if (bytes == null || bytes.length == 0) { + // check bytes ref against m_lastFrame, only republish if new frame + if (bytes == null || bytes.length == 0 || bytes == m_lastFrame) { return false; } + m_lastFrame = bytes; MatOfByte buffer = new MatOfByte(bytes); Mat decoded = Imgcodecs.imdecode(buffer, Imgcodecs.IMREAD_COLOR); diff --git a/simulation/SyntheSimJava/src/main/java/com/autodesk/synthesis/cscore/CameraFrameServer.java b/simulation/SyntheSimJava/src/main/java/com/autodesk/synthesis/cscore/CameraFrameServer.java index 3ee7f86b40..8be152ce8b 100644 --- a/simulation/SyntheSimJava/src/main/java/com/autodesk/synthesis/cscore/CameraFrameServer.java +++ b/simulation/SyntheSimJava/src/main/java/com/autodesk/synthesis/cscore/CameraFrameServer.java @@ -1,6 +1,9 @@ package com.autodesk.synthesis.cscore; import java.net.InetSocketAddress; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; import java.util.Base64; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @@ -10,10 +13,9 @@ import org.java_websocket.server.WebSocketServer; /** - * Localhost WebSocket server that receives rendered camera frames from Synthesis. Frames - * can't ride HALSim (SimDevice carries only numbers/booleans), so Synthesis streams them - * here as {@code "\n"}; the latest frame per device is kept for - * {@link Camera#grabFrame}. + * localhost WebSocket server that receives rendered camera frames from Synthesis. Frames + * can't use HALSim, so Synthesis streams them here as binary {@code "\n"} + * messages; the latest frame per device is kept for {@link Camera#grabFrame}. */ public class CameraFrameServer extends WebSocketServer { @@ -41,17 +43,34 @@ public byte[] getFrame(String device) { return m_frames.get(device); } + @Override + public void onMessage(WebSocket conn, ByteBuffer message) { + byte[] data = new byte[message.remaining()]; + message.get(data); + + int idx = -1; + for (int i = 0; i < data.length; i++) { + if (data[i] == '\n') { + idx = i; + break; + } + } + if (idx < 0) { + return; + } + + String device = new String(data, 0, idx, StandardCharsets.UTF_8); + m_frames.put(device, Arrays.copyOfRange(data, idx + 1, data.length)); + } + @Override public void onMessage(WebSocket conn, String message) { int idx = message.indexOf('\n'); if (idx < 0) { return; } - - String device = message.substring(0, idx); - String encoded = message.substring(idx + 1); try { - m_frames.put(device, Base64.getDecoder().decode(encoded)); + m_frames.put(message.substring(0, idx), Base64.getDecoder().decode(message.substring(idx + 1))); } catch (IllegalArgumentException e) { // ignore malformed frames } From 6e536a0c9e53ce9baa6168a7d7e304c254fc9f63 Mon Sep 17 00:00:00 2001 From: BrandonPacewic <92102436+BrandonPacewic@users.noreply.github.com> Date: Wed, 8 Jul 2026 16:12:28 -0700 Subject: [PATCH 07/17] feat: prev next button & correct camera preview sizing Co-authored-by: Julian Wright <20529380+PepperLola@users.noreply.github.com> --- fission/src/ui/components/Panel.tsx | 3 +- .../panels/simulation/CameraPreviewPanel.tsx | 118 ++++++++++-------- 2 files changed, 65 insertions(+), 56 deletions(-) diff --git a/fission/src/ui/components/Panel.tsx b/fission/src/ui/components/Panel.tsx index 8e1a7f78f0..db4f0cd8b9 100644 --- a/fission/src/ui/components/Panel.tsx +++ b/fission/src/ui/components/Panel.tsx @@ -104,12 +104,13 @@ export const Panel = ({ children, panel, parent }: PanelElementProps sx={{ p: 2, flex: "1 1 auto", + minHeight: 0, overflowY: "auto", "&:last-child": { pb: 2 }, backgroundColor: "inherit", }} > -
+
{React.Children.map(children, child => { if (React.isValidElement(child)) return React.cloneElement(child, { panel, parent }) })} diff --git a/fission/src/ui/panels/simulation/CameraPreviewPanel.tsx b/fission/src/ui/panels/simulation/CameraPreviewPanel.tsx index bd08b05cab..be8672652f 100644 --- a/fission/src/ui/panels/simulation/CameraPreviewPanel.tsx +++ b/fission/src/ui/panels/simulation/CameraPreviewPanel.tsx @@ -15,15 +15,13 @@ function resolveCameras(): RobotCameraSceneObject[] { const BOX_W = 480 const BOX_H = 360 const PANEL_WIDTH = BOX_W + 40 -const PANEL_CHROME_H = 150 +const PANEL_HEIGHT = `min(${BOX_H + 200}px, 85vh)` const CameraPreviewPanel: React.FC> = ({ panel }) => { const { configureScreen } = useUIContext() - const canvasRef = useRef(null) - const contentRef = useRef(null) + const previewRef = useRef(null) const [cameras, setCameras] = useState(resolveCameras) const [selectedIndex, setSelectedIndex] = useState(0) - const [contentHeight, setContentHeight] = useState(0) useEffect(() => { configureScreen( @@ -33,11 +31,11 @@ const CameraPreviewPanel: React.FC> = ({ panel }) => hideCancel: true, acceptText: "Close", width: PANEL_WIDTH, - height: contentHeight ? contentHeight + PANEL_CHROME_H : undefined, + height: PANEL_HEIGHT, }, {} ) - }, [contentHeight]) + }, [configureScreen, panel]) useEffect(() => { // cameras don't render if no consumer @@ -47,40 +45,30 @@ const CameraPreviewPanel: React.FC> = ({ panel }) => useEffect(() => EventSystem.listen("RobotCamerasChangeEvent", () => setCameras(resolveCameras())), []) - useEffect(() => { - const el = contentRef.current - if (!el) return - const observer = new ResizeObserver(() => setContentHeight(el.offsetHeight)) - observer.observe(el) - return () => observer.disconnect() - }, []) - const index = Math.min(selectedIndex, Math.max(0, cameras.length - 1)) const selected = cameras[index] - const scale = selected ? Math.min(BOX_W / Math.max(1, selected.width), BOX_H / Math.max(1, selected.height)) : 1 - const displayW = selected ? Math.round(selected.width * scale) : BOX_W - const displayH = selected ? Math.round(selected.height * scale) : BOX_H - useEffect(() => { - let handle = requestAnimationFrame(function draw() { - const dst = canvasRef.current - const src = selected?.frameCanvas + const preview = previewRef.current + const canvas = selected?.frameCanvas + if (!preview || !canvas) return - if (dst && src) { - if (dst.width !== selected.width || dst.height !== selected.height) { - dst.width = selected.width - dst.height = selected.height - } - const ctx = dst.getContext("2d") - if (ctx) { - ctx.clearRect(0, 0, dst.width, dst.height) - ctx.drawImage(src, 0, 0) - } - } - handle = requestAnimationFrame(draw) + Object.assign(canvas.style, { + display: "block", + position: "absolute", + top: "50%", + left: "50%", + transform: "translate(-50%, -50%)", + maxWidth: "100%", + maxHeight: "100%", + width: "auto", + height: "auto", + borderRadius: "8px", + background: "#000", }) - return () => cancelAnimationFrame(handle) + preview.appendChild(canvas) + + return () => canvas.remove() }, [selected]) if (cameras.length === 0) { @@ -92,44 +80,64 @@ const CameraPreviewPanel: React.FC> = ({ panel }) => } return ( -
+
{cameras.length > 1 && (
- {cameras.map((cam, i) => ( - - ))} + + +
)} {selected && ( -
+
-
From b9bce8c68c71cfd870c01c0fcf16a50087f23a96 Mon Sep 17 00:00:00 2001 From: PepperLola Date: Tue, 14 Jul 2026 10:59:05 -0700 Subject: [PATCH 08/17] fix: hide camera settings in code sim mode --- .../interfaces/ConfigureCameraInterface.tsx | 65 ++++++++++--------- 1 file changed, 35 insertions(+), 30 deletions(-) diff --git a/fission/src/ui/panels/configuring/assembly-config/interfaces/ConfigureCameraInterface.tsx b/fission/src/ui/panels/configuring/assembly-config/interfaces/ConfigureCameraInterface.tsx index 8ab2d05838..a3a07e33ba 100644 --- a/fission/src/ui/panels/configuring/assembly-config/interfaces/ConfigureCameraInterface.tsx +++ b/fission/src/ui/panels/configuring/assembly-config/interfaces/ConfigureCameraInterface.tsx @@ -207,36 +207,41 @@ const ConfigureCameraInterface: React.FC = ({ selectedRobot } camera.fovDegrees = v }} /> - { - camera.resolutionWidth = v - }} - /> - { - camera.resolutionHeight = v - }} - /> - { - camera.fps = v - }} - /> + {/* NOTE: in code sim mode, these are only configurable through robot code */} + {selectedRobot.brain?.brainType === "synthesis" && ( + <> + { + camera.resolutionWidth = v + }} + /> + { + camera.resolutionHeight = v + }} + /> + { + camera.fps = v + }} + /> + + )} {gizmoComponent} From b966ff4f22ac144ce62ca2affd356d592d111a8f Mon Sep 17 00:00:00 2001 From: PepperLola Date: Mon, 20 Jul 2026 11:21:15 -0700 Subject: [PATCH 09/17] feat: support for proper camera creation process --- .../com/autodesk/synthesis/cscore/Camera.java | 44 ++- .../synthesis/cscore/CameraFrameServer.java | 2 +- .../synthesis/cscore/CameraServer.java | 366 ++++++++++++++++-- .../com/autodesk/synthesis/cscore/CvSink.java | 14 +- .../autodesk/synthesis/cscore/UsbCamera.java | 17 +- .../src/main/java/frc/robot/Robot.java | 9 +- 6 files changed, 409 insertions(+), 43 deletions(-) diff --git a/simulation/SyntheSimJava/src/main/java/com/autodesk/synthesis/cscore/Camera.java b/simulation/SyntheSimJava/src/main/java/com/autodesk/synthesis/cscore/Camera.java index fb61f31190..011a657578 100644 --- a/simulation/SyntheSimJava/src/main/java/com/autodesk/synthesis/cscore/Camera.java +++ b/simulation/SyntheSimJava/src/main/java/com/autodesk/synthesis/cscore/Camera.java @@ -22,11 +22,13 @@ public class Camera { private SimInt m_fps; private SimBoolean m_connected; + private final int m_defaultWidth; + private final int m_defaultHeight; + private final int m_defaultFps; + private final String m_deviceName; private final CameraFrameServer m_frameServer; - private byte[] m_lastFrame; - public Camera(String name, int deviceId, int width, int height, int fps) { m_device = SimDevice.create("Camera:" + name, deviceId); @@ -38,10 +40,19 @@ public Camera(String name, int deviceId, int width, int height, int fps) { m_connected = m_device.createBoolean("connected", Direction.kOutput, true); } + m_defaultWidth = width; + m_defaultHeight = height; + m_defaultFps = fps; + m_deviceName = name + "[" + deviceId + "]"; m_frameServer = m_device != null ? CameraFrameServer.getInstance() : null; } + /** @return true if running in simulation (frames come from Fission) */ + public boolean isSimulated() { + return m_device != null; + } + public void setResolution(int width, int height) { if (m_width != null) { m_width.set(width); @@ -61,16 +72,31 @@ public void setConnected(boolean connected) { } } - /** @return true if a frame was available and decoded into {@code dst} */ - public boolean grabFrame(Mat dst) { - if (m_frameServer == null) return false; + public int getWidth() { + return m_width != null ? m_width.get() : m_defaultWidth; + } + + public int getHeight() { + return m_height != null ? m_height.get() : m_defaultHeight; + } + + public int getFPS() { + return m_fps != null ? m_fps.get() : m_defaultFps; + } + + /** + * Latest JPEG frame received from Fission, or null. Callers must track the returned + * reference themselves to detect new frames (multiple consumers may grab concurrently). + */ + public byte[] getLatestFrame() { + return m_frameServer != null ? m_frameServer.getFrame(m_deviceName) : null; + } - byte[] bytes = m_frameServer.getFrame(m_deviceName); - // check bytes ref against m_lastFrame, only republish if new frame - if (bytes == null || bytes.length == 0 || bytes == m_lastFrame) { + /** @return true if {@code bytes} decoded into {@code dst} */ + public static boolean decodeFrame(byte[] bytes, Mat dst) { + if (bytes == null || bytes.length == 0) { return false; } - m_lastFrame = bytes; MatOfByte buffer = new MatOfByte(bytes); Mat decoded = Imgcodecs.imdecode(buffer, Imgcodecs.IMREAD_COLOR); diff --git a/simulation/SyntheSimJava/src/main/java/com/autodesk/synthesis/cscore/CameraFrameServer.java b/simulation/SyntheSimJava/src/main/java/com/autodesk/synthesis/cscore/CameraFrameServer.java index 8be152ce8b..489581e404 100644 --- a/simulation/SyntheSimJava/src/main/java/com/autodesk/synthesis/cscore/CameraFrameServer.java +++ b/simulation/SyntheSimJava/src/main/java/com/autodesk/synthesis/cscore/CameraFrameServer.java @@ -15,7 +15,7 @@ /** * localhost WebSocket server that receives rendered camera frames from Synthesis. Frames * can't use HALSim, so Synthesis streams them here as binary {@code "\n"} - * messages; the latest frame per device is kept for {@link Camera#grabFrame}. + * messages; the latest frame per device is kept for {@link Camera#getLatestFrame}. */ public class CameraFrameServer extends WebSocketServer { diff --git a/simulation/SyntheSimJava/src/main/java/com/autodesk/synthesis/cscore/CameraServer.java b/simulation/SyntheSimJava/src/main/java/com/autodesk/synthesis/cscore/CameraServer.java index e279a0ad31..08e9307c03 100644 --- a/simulation/SyntheSimJava/src/main/java/com/autodesk/synthesis/cscore/CameraServer.java +++ b/simulation/SyntheSimJava/src/main/java/com/autodesk/synthesis/cscore/CameraServer.java @@ -1,41 +1,363 @@ package com.autodesk.synthesis.cscore; +import edu.wpi.first.cscore.CameraServerJNI; import edu.wpi.first.cscore.CvSource; import edu.wpi.first.cscore.MjpegServer; +import edu.wpi.first.cscore.VideoException; +import edu.wpi.first.cscore.VideoMode; import edu.wpi.first.cscore.VideoSink; +import edu.wpi.first.cscore.VideoSource; +import edu.wpi.first.networktables.NetworkTable; import edu.wpi.first.networktables.NetworkTableInstance; +import edu.wpi.first.util.PixelFormat; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import org.opencv.core.Mat; /** - * Swap-in for {@code edu.wpi.first.cameraserver.CameraServer}: creates the MJPEG output exactly - * like WPILib, then republishes a loopback stream URL. + * Swap-in for {@code edu.wpi.first.cameraserver.CameraServer}; mirrors its API so user code only + * changes imports. Implemented locally (no delegation to the WPILib class) because both keep + * port counters starting at {@code kBasePort} (collision) and WPILib's global VideoListener + * would publish conflicting NetworkTables entries for our sources. * - * CSCore advertises the stream to dashboards under the host's mDNS name (e.g. - * {@code ".local"}). Running in Synthesis, that name usually can't be - * resolved by Shuffleboard/Glass, so the camera appears but the feed never loads. Overriding - * the {@code CameraPublisher//streams} entry with a {@code 127.0.0.1} URL points the - * dashboard at the server actually running on the local machine. + * In simulation, USB camera frames come from Fission over {@link CameraFrameServer}; a pump + * thread republishes them through a CvSource + MjpegServer so dashboards get a real MJPEG + * stream. Stream URLs are published under {@code CameraPublisher//streams} with a + * {@code 127.0.0.1} address, since CSCore's default mDNS hostname usually can't be resolved by + * Shuffleboard/Glass when running in Synthesis. */ -public class CameraServer { +public final class CameraServer { + public static final int kBasePort = 1181; + + private static final String kPublishName = "CameraPublisher"; + + private static final AtomicInteger m_defaultUsbDevice = new AtomicInteger(); + private static String m_primarySourceName; + private static final Map m_sources = new HashMap<>(); + private static final Map m_sinks = new HashMap<>(); + private static int m_nextPort = kBasePort; private CameraServer() {} - public static CvSource putVideo(String name, int width, int height) { - CvSource source = edu.wpi.first.cameraserver.CameraServer.putVideo(name, width, height); - - try { - VideoSink server = edu.wpi.first.cameraserver.CameraServer.getServer("serve_" + name); - if (server instanceof MjpegServer) { - int port = ((MjpegServer) server).getPort(); - NetworkTableInstance.getDefault() - .getTable("CameraPublisher") - .getSubTable(name) - .getEntry("streams") - .setStringArray(new String[] { "mjpg:http://127.0.0.1:" + port + "/?action=stream" }); + /** @return backing sim Camera if {@code camera} is a simulated UsbCamera, else null */ + private static Camera simCamera(VideoSource camera) { + if (!(camera instanceof UsbCamera)) { + return null; + } + Camera c = ((UsbCamera) camera).getCamera(); + return c.isSimulated() ? c : null; + } + + private static void publishCamera(String name, boolean usb, boolean sim, VideoMode mode, int port) { + String address = sim ? "127.0.0.1" : CameraServerJNI.getHostname() + ".local"; + String modeString = mode.width + "x" + mode.height + " MJPEG " + mode.fps + " fps"; + NetworkTable table = NetworkTableInstance.getDefault().getTable(kPublishName).getSubTable(name); + table.getEntry("source").setString(usb ? "usb:" + name : "cv:"); + table.getEntry("description").setString(name); + table.getEntry("connected").setBoolean(true); + table.getEntry("mode").setString(modeString); + table.getEntry("modes").setStringArray(new String[] { modeString }); + table.getEntry("streams") + .setStringArray(new String[] { "mjpg:http://" + address + ":" + port + "/?action=stream" }); + } + + /** Decode Fission websocket frames and push into a CvSource the MjpegServer can serve. */ + private static CvSource startFramePump(UsbCamera camera) { + Camera simCamera = camera.getCamera(); + CvSource source = new CvSource(camera.getName() + "_synthesis", PixelFormat.kMJPEG, + simCamera.getWidth(), simCamera.getHeight(), simCamera.getFPS()); + + Thread pump = new Thread(() -> { + Mat frame = new Mat(); + byte[] lastFrame = null; + while (!Thread.interrupted()) { + byte[] bytes = simCamera.getLatestFrame(); + if (bytes != null && bytes != lastFrame) { + lastFrame = bytes; + if (Camera.decodeFrame(bytes, frame)) { + source.putFrame(frame); + } + } + try { + Thread.sleep(1000 / Math.max(1, simCamera.getFPS())); + } catch (InterruptedException e) { + return; + } + } + }, "Synthesis camera pump: " + camera.getName()); + pump.setDaemon(true); + pump.start(); + + return source; + } + + /** + * Start automatically capturing images to send to the dashboard. + * + *

The first time this overload is called, it calls {@link #startAutomaticCapture(int)} with + * device 0, creating a camera named "USB Camera 0". Subsequent calls increment the device + * number (e.g. 1, 2, etc). + * + * @return The USB camera capturing images. + */ + public static UsbCamera startAutomaticCapture() { + return startAutomaticCapture(m_defaultUsbDevice.getAndIncrement()); + } + + /** + * Start automatically capturing images to send to the dashboard. + * + * @param dev The device number of the camera interface + * @return The USB camera capturing images. + */ + public static UsbCamera startAutomaticCapture(int dev) { + UsbCamera camera = new UsbCamera("USB Camera " + dev, dev); + startAutomaticCapture(camera); + return camera; + } + + /** + * Start automatically capturing images to send to the dashboard. + * + * @param name The name to give the camera + * @param dev The device number of the camera interface + * @return The USB camera capturing images. + */ + public static UsbCamera startAutomaticCapture(String name, int dev) { + UsbCamera camera = new UsbCamera(name, dev); + startAutomaticCapture(camera); + return camera; + } + + // TODO: startAutomaticCapture(String name, String path) + + /** + * Start automatically capturing images to send to the dashboard from an existing camera. + * + * @param camera Camera + * @return The MJPEG server serving images from the given camera. + */ + public static synchronized MjpegServer startAutomaticCapture(VideoSource camera) { + addCamera(camera); + MjpegServer server = addServer("serve_" + camera.getName()); + + boolean usb = camera instanceof UsbCamera; + boolean sim = simCamera(camera) != null; + VideoMode mode; + if (sim) { + CvSource pumpSource = startFramePump((UsbCamera) camera); + server.setSource(pumpSource); + mode = pumpSource.getVideoMode(); + } else { + server.setSource(camera); + try { + mode = camera.getVideoMode(); + } catch (VideoException e) { + mode = new VideoMode(PixelFormat.kMJPEG, 0, 0, 0); } - } catch (Exception e) { - System.err.println("Could not override camera stream URL for '" + name + "': " + e.getMessage()); } + publishCamera(camera.getName(), usb, sim, mode, server.getPort()); + return server; + } + + /** + * Adds a virtual camera for switching between two streams. Calling setSource() on the returned + * object can be used to switch the actual source of the stream. + * + * @param name The name to give the camera + * @return The MJPEG server serving images from the given camera. + */ + public static MjpegServer addSwitchedCamera(String name) { + CvSource source = new CvSource(name, PixelFormat.kMJPEG, 160, 120, 30); + return startAutomaticCapture(source); + } + + /** + * Get OpenCV access to the primary camera feed. This allows you to get images from the camera + * for image processing. + * + *

This is only valid to call after a camera feed has been added with + * startAutomaticCapture() or addServer(). + * + * @return OpenCV sink for the primary camera feed + */ + public static CvSink getVideo() { + VideoSource source; + synchronized (CameraServer.class) { + if (m_primarySourceName == null) { + throw new VideoException("no camera available"); + } + source = m_sources.get(m_primarySourceName); + } + if (source == null) { + throw new VideoException("no camera available"); + } + return getVideo(source); + } + + /** + * Get OpenCV access to the specified camera. This allows you to get images from the camera for + * image processing. + * + * @param camera Camera (e.g. as returned by startAutomaticCapture). + * @return OpenCV sink for the specified camera + */ + public static CvSink getVideo(VideoSource camera) { + String name = "opencv_" + camera.getName(); + + synchronized (CameraServer.class) { + VideoSink sink = m_sinks.get(name); + if (sink != null) { + if (!(sink instanceof CvSink)) { + throw new VideoException("expected OpenCV sink, but got " + sink.getKind()); + } + return (CvSink) sink; + } + } + + CvSink newsink = new CvSink(name, simCamera(camera)); + newsink.setSource(camera); + addServer(newsink); + return newsink; + } + + /** + * Get OpenCV access to the specified camera. This allows you to get images from the camera for + * image processing. + * + * @param name Camera name + * @return OpenCV sink for the specified camera + */ + public static CvSink getVideo(String name) { + VideoSource source; + synchronized (CameraServer.class) { + source = m_sources.get(name); + } + if (source == null) { + throw new VideoException("could not find camera " + name); + } + return getVideo(source); + } + + /** + * Create a MJPEG stream with OpenCV input. This can be called to pass custom annotated images + * to the dashboard. + * + * @param name Name to give the stream + * @param width Width of the image being sent + * @param height Height of the image being sent + * @return OpenCV source for the MJPEG stream + */ + public static CvSource putVideo(String name, int width, int height) { + CvSource source = new CvSource(name, PixelFormat.kMJPEG, width, height, 30); + startAutomaticCapture(source); return source; } + + /** + * Adds a MJPEG server at the next available port. + * + * @param name Server name + * @return The MJPEG server + */ + public static MjpegServer addServer(String name) { + int port; + synchronized (CameraServer.class) { + port = m_nextPort; + m_nextPort++; + } + return addServer(name, port); + } + + /** + * Adds a MJPEG server. + * + * @param name Server name + * @param port Server port + * @return The MJPEG server + */ + public static MjpegServer addServer(String name, int port) { + MjpegServer server = new MjpegServer(name, port); + addServer(server); + return server; + } + + /** + * Adds an already created server. + * + * @param server Server + */ + public static void addServer(VideoSink server) { + synchronized (CameraServer.class) { + m_sinks.put(server.getName(), server); + } + } + + /** + * Removes a server by name. + * + * @param name Server name + */ + public static void removeServer(String name) { + synchronized (CameraServer.class) { + m_sinks.remove(name); + } + } + + /** + * Get server for the primary camera feed. + * + *

This is only valid to call after a camera feed has been added with + * startAutomaticCapture() or addServer(). + * + * @return The server for the primary camera feed + */ + public static VideoSink getServer() { + synchronized (CameraServer.class) { + if (m_primarySourceName == null) { + throw new VideoException("no camera available"); + } + return getServer("serve_" + m_primarySourceName); + } + } + + /** + * Gets a server by name. + * + * @param name Server name + * @return The server + */ + public static VideoSink getServer(String name) { + synchronized (CameraServer.class) { + return m_sinks.get(name); + } + } + + /** + * Adds an already created camera. + * + * @param camera Camera + */ + public static void addCamera(VideoSource camera) { + String name = camera.getName(); + synchronized (CameraServer.class) { + if (m_primarySourceName == null) { + m_primarySourceName = name; + } + m_sources.put(name, camera); + } + } + + /** + * Removes a camera by name. + * + * @param name Camera name + */ + public static void removeCamera(String name) { + synchronized (CameraServer.class) { + m_sources.remove(name); + } + } } diff --git a/simulation/SyntheSimJava/src/main/java/com/autodesk/synthesis/cscore/CvSink.java b/simulation/SyntheSimJava/src/main/java/com/autodesk/synthesis/cscore/CvSink.java index f87d5501ba..8903a9c5b2 100644 --- a/simulation/SyntheSimJava/src/main/java/com/autodesk/synthesis/cscore/CvSink.java +++ b/simulation/SyntheSimJava/src/main/java/com/autodesk/synthesis/cscore/CvSink.java @@ -10,7 +10,9 @@ public class CvSink extends edu.wpi.first.cscore.CvSink { private Camera m_camera; + private byte[] m_lastFrame; + /** If camera is null, behave as real cscore CvSink */ public CvSink(String name, Camera camera) { super(name); this.m_camera = camera; @@ -18,19 +20,25 @@ public CvSink(String name, Camera camera) { @Override public long grabFrame(Mat image) { - return grabFrameNoTimeout(image); + return m_camera == null ? super.grabFrame(image) : grabFrameNoTimeout(image); } @Override public long grabFrame(Mat image, double timeout) { - return grabFrameNoTimeout(image); + return m_camera == null ? super.grabFrame(image, timeout) : grabFrameNoTimeout(image); } @Override public long grabFrameNoTimeout(Mat image) { - if (!m_camera.grabFrame(image)) { + if (m_camera == null) { + return super.grabFrameNoTimeout(image); + } + + byte[] bytes = m_camera.getLatestFrame(); + if (bytes == null || bytes == m_lastFrame || !Camera.decodeFrame(bytes, image)) { return 0L; } + m_lastFrame = bytes; return WPIUtilJNI.now(); } diff --git a/simulation/SyntheSimJava/src/main/java/com/autodesk/synthesis/cscore/UsbCamera.java b/simulation/SyntheSimJava/src/main/java/com/autodesk/synthesis/cscore/UsbCamera.java index d99d927ff7..11514a1142 100644 --- a/simulation/SyntheSimJava/src/main/java/com/autodesk/synthesis/cscore/UsbCamera.java +++ b/simulation/SyntheSimJava/src/main/java/com/autodesk/synthesis/cscore/UsbCamera.java @@ -4,8 +4,8 @@ * Swap-in for {@code edu.wpi.first.cscore.UsbCamera} * *

- *     var camera = new com.autodesk.synthesis.cscore.UsbCamera("USB Camera 0", 0);
- *     var sink = camera.getVideo();
+ *     var camera = CameraServer.startAutomaticCapture();
+ *     var sink = CameraServer.getVideo();
  *     Mat frame = new Mat();
  *     if (sink.grabFrame(frame) != 0) {
  *         // ... run vision processing on frame ...
@@ -25,14 +25,23 @@ public UsbCamera(String name, int dev, int width, int height, int fps) {
         this.m_camera = new Camera(name, dev, width, height, fps);
     }
 
+    Camera getCamera() {
+        return this.m_camera;
+    }
+
     public CvSink getVideo() {
-        return new CvSink(this.getName() + " - sink", this.m_camera);
+        return CameraServer.getVideo(this);
     }
 
     @Override
     public boolean setResolution(int width, int height) {
         this.m_camera.setResolution(width, height);
-        return super.setResolution(width, height);
+
+        if (!this.m_camera.isSimulated()) {
+            return super.setResolution(width, height);
+        }
+
+        return true;
     }
 
     @Override
diff --git a/simulation/samples/JavaAutoSample/src/main/java/frc/robot/Robot.java b/simulation/samples/JavaAutoSample/src/main/java/frc/robot/Robot.java
index ccbeaf00f8..d3c0a7a783 100644
--- a/simulation/samples/JavaAutoSample/src/main/java/frc/robot/Robot.java
+++ b/simulation/samples/JavaAutoSample/src/main/java/frc/robot/Robot.java
@@ -84,11 +84,12 @@ public void robotInit() {
         // Following conversion factor is 1 unit = 1 inch travelled.
         m_encoder.setPositionConversionFactor(2.0);
 
-        m_camera = new UsbCamera("USB Camera 0", 0, kCameraWidth, kCameraHeight, 30);
-        m_cvSink = m_camera.getVideo();
+        m_camera = CameraServer.startAutomaticCapture("USB Camera 0", 0);
+        m_camera.setResolution(kCameraWidth, kCameraHeight);
+        UsbCamera camera1 = CameraServer.startAutomaticCapture("USB Camera 1", 1);
+        camera1.setResolution(kCameraWidth, kCameraHeight);
 
-        // putVideo republishes the feed for dashboards and forces the OpenCV native to load
-        // before we allocate the Mat below
+        m_cvSink = CameraServer.getVideo();
         m_outputStream = CameraServer.putVideo("Synthesis Camera", kCameraWidth, kCameraHeight);
         m_frame = new Mat();
     }

From bde600e7dcfa8465909ea428724a8486512998f0 Mon Sep 17 00:00:00 2001
From: PepperLola 
Date: Mon, 20 Jul 2026 15:19:55 -0700
Subject: [PATCH 10/17] chore: added license comments I copied/referenced the
 bodies of some methods from WPILib

---
 .../java/com/autodesk/synthesis/cscore/CameraServer.java | 9 +++++++++
 .../main/java/com/autodesk/synthesis/cscore/CvSink.java  | 9 +++++++++
 2 files changed, 18 insertions(+)

diff --git a/simulation/SyntheSimJava/src/main/java/com/autodesk/synthesis/cscore/CameraServer.java b/simulation/SyntheSimJava/src/main/java/com/autodesk/synthesis/cscore/CameraServer.java
index 08e9307c03..c56de013a1 100644
--- a/simulation/SyntheSimJava/src/main/java/com/autodesk/synthesis/cscore/CameraServer.java
+++ b/simulation/SyntheSimJava/src/main/java/com/autodesk/synthesis/cscore/CameraServer.java
@@ -1,3 +1,12 @@
+// Copyright (c) 2009-2026 FIRST and other WPILib contributors All rights reserved.
+//
+// Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
+//
+// Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
+// Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
+// Neither the name of FIRST, WPILib, nor the names of other WPILib contributors may be used to endorse or promote products derived from this software without specific prior written permission.
+// THIS SOFTWARE IS PROVIDED BY FIRST AND OTHER WPILIB CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY NONINFRINGEMENT AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL FIRST OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
 package com.autodesk.synthesis.cscore;
 
 import edu.wpi.first.cscore.CameraServerJNI;
diff --git a/simulation/SyntheSimJava/src/main/java/com/autodesk/synthesis/cscore/CvSink.java b/simulation/SyntheSimJava/src/main/java/com/autodesk/synthesis/cscore/CvSink.java
index 8903a9c5b2..74ce9fe072 100644
--- a/simulation/SyntheSimJava/src/main/java/com/autodesk/synthesis/cscore/CvSink.java
+++ b/simulation/SyntheSimJava/src/main/java/com/autodesk/synthesis/cscore/CvSink.java
@@ -1,3 +1,12 @@
+// Copyright (c) 2009-2026 FIRST and other WPILib contributors All rights reserved.
+//
+// Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
+//
+// Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
+// Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
+// Neither the name of FIRST, WPILib, nor the names of other WPILib contributors may be used to endorse or promote products derived from this software without specific prior written permission.
+// THIS SOFTWARE IS PROVIDED BY FIRST AND OTHER WPILIB CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY NONINFRINGEMENT AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL FIRST OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
 package com.autodesk.synthesis.cscore;
 
 import org.opencv.core.Mat;

From 04e2539c3f7ac55edebbc465d560640784b9a8f3 Mon Sep 17 00:00:00 2001
From: PepperLola 
Date: Thu, 23 Jul 2026 16:39:44 -0700
Subject: [PATCH 11/17] chore: addressed feedback

Co-authored-by: Azalea Colburn <62953415+azaleacolburn@users.noreply.github.com>
---
 fission/src/mirabuf/MirabufSceneObject.ts      |  6 +++---
 fission/src/mirabuf/RobotCameraSceneObject.ts  |  9 ++++-----
 .../wpilib_brain/CameraFrameSocket.ts          | 18 ++++++++++--------
 3 files changed, 17 insertions(+), 16 deletions(-)

diff --git a/fission/src/mirabuf/MirabufSceneObject.ts b/fission/src/mirabuf/MirabufSceneObject.ts
index 4881a7764c..595e1cf688 100644
--- a/fission/src/mirabuf/MirabufSceneObject.ts
+++ b/fission/src/mirabuf/MirabufSceneObject.ts
@@ -676,11 +676,11 @@ class MirabufSceneObject extends SceneObject implements ContextSupplier {
 
         if (this.miraType !== MiraType.ROBOT) return
 
-        for (const camPref of this.cameraPreferences) {
+        this._cameras = this.cameraPreferences.map(camPref => {
             const camera = new RobotCameraSceneObject(this, camPref)
-            this._cameras.push(camera)
             World.sceneRenderer.registerSceneObject(camera)
-        }
+            return camera
+        })
 
         EventSystem.dispatch("RobotCamerasChangeEvent")
     }
diff --git a/fission/src/mirabuf/RobotCameraSceneObject.ts b/fission/src/mirabuf/RobotCameraSceneObject.ts
index 20d55de580..b1ed44f523 100644
--- a/fission/src/mirabuf/RobotCameraSceneObject.ts
+++ b/fission/src/mirabuf/RobotCameraSceneObject.ts
@@ -145,6 +145,7 @@ class RobotCameraSceneObject extends SceneObject {
             .copy(this._deltaTransformation)
             .premultiply(convertJoltMat44ToThreeMatrix4(parentBody.GetWorldTransform()))
             .multiply(FORWARD_FLIP)
+
         this._camera.position.setFromMatrixPosition(worldTransform)
         this._camera.quaternion.setFromRotationMatrix(worldTransform)
         this._camera.updateMatrixWorld()
@@ -179,12 +180,10 @@ class RobotCameraSceneObject extends SceneObject {
 
     // GL pixels are bottom-up; flip rows into the top-down ImageData
     private flipInto(target: ImageData, source: Uint8Array): void {
-        const w = this._width
-        const h = this._height
-        const rowBytes = w * 4
+        const rowBytes = this._width * 4
         const dst = target.data
-        for (let y = 0; y < h; y++) {
-            const srcStart = (h - 1 - y) * rowBytes
+        for (let y = 0; y < this._height; y++) {
+            const srcStart = (this._height - 1 - y) * rowBytes
             dst.set(source.subarray(srcStart, srcStart + rowBytes), y * rowBytes)
         }
     }
diff --git a/fission/src/systems/simulation/wpilib_brain/CameraFrameSocket.ts b/fission/src/systems/simulation/wpilib_brain/CameraFrameSocket.ts
index e6e8f4afd1..a4bea8c0e8 100644
--- a/fission/src/systems/simulation/wpilib_brain/CameraFrameSocket.ts
+++ b/fission/src/systems/simulation/wpilib_brain/CameraFrameSocket.ts
@@ -21,17 +21,19 @@ function ensureSocket(): void {
     lastAttempt = now
 
     try {
-        const ws = new WebSocket(`ws://localhost:${PORT}`)
-        ws.addEventListener("close", () => {
-            if (socket === ws) socket = undefined
-        })
-        ws.addEventListener("error", () => {
-            if (socket === ws) socket = undefined
-        })
-        socket = ws
+        socket = new WebSocket(`ws://localhost:${PORT}`)
     } catch {
         socket = undefined
     }
+
+    if (socket !== undefined) {
+        socket.addEventListener("close", () => {
+            socket = undefined
+        })
+        socket.addEventListener("error", () => {
+            socket = undefined
+        })
+    }
 }
 
 export function sendCameraFrame(device: string, jpeg: Uint8Array): void {

From dc09f7c57d3bf5d770ca570cac50810f5c3a8d87 Mon Sep 17 00:00:00 2001
From: PepperLola 
Date: Thu, 23 Jul 2026 17:46:25 -0700
Subject: [PATCH 12/17] feat: refactored camera configuration

---
 .../assembly-config/ConfigurePanel.tsx        |   2 +-
 .../interfaces/ConfigureCameraInterface.tsx   | 257 ------------------
 .../cameras/CameraConfigInterface.tsx         | 198 ++++++++++++++
 .../cameras/ConfigureCameraInterface.tsx      | 100 +++++++
 4 files changed, 299 insertions(+), 258 deletions(-)
 delete mode 100644 fission/src/ui/panels/configuring/assembly-config/interfaces/ConfigureCameraInterface.tsx
 create mode 100644 fission/src/ui/panels/configuring/assembly-config/interfaces/cameras/CameraConfigInterface.tsx
 create mode 100644 fission/src/ui/panels/configuring/assembly-config/interfaces/cameras/ConfigureCameraInterface.tsx

diff --git a/fission/src/ui/panels/configuring/assembly-config/ConfigurePanel.tsx b/fission/src/ui/panels/configuring/assembly-config/ConfigurePanel.tsx
index cb21593b25..1662489586 100644
--- a/fission/src/ui/panels/configuring/assembly-config/ConfigurePanel.tsx
+++ b/fission/src/ui/panels/configuring/assembly-config/ConfigurePanel.tsx
@@ -21,7 +21,6 @@ import AssemblySelection, { type AssemblySelectionOption } from "./configure/Ass
 import ConfigModeSelection, { ConfigModeSelectionOption } from "./configure/ConfigModeSelection"
 import AllianceSelectionInterface from "./interfaces/AllianceSelectionInterface"
 import BrainSelectionInterface from "./interfaces/BrainSelectionInterface"
-import ConfigureCameraInterface from "./interfaces/ConfigureCameraInterface"
 import ConfigureGamepiecePickupInterface from "./interfaces/ConfigureGamepiecePickupInterface"
 import ConfigureShotTrajectoryInterface from "./interfaces/ConfigureShotTrajectoryInterface"
 import ConfigureSubsystemsInterface from "./interfaces/ConfigureSubsystemsInterface"
@@ -41,6 +40,7 @@ import { globalAddToast, globalOpenPanel } from "@/ui/components/GlobalUIControl
 import AssemblyExportButton from "@/panels/configuring/assembly-config/configure/AssemblyExport.tsx"
 import MetadataConfigInterface from "@/panels/configuring/assembly-config/interfaces/MetadataConfigInterface.tsx"
 import { FaArrowsRotate } from "react-icons/fa6"
+import ConfigureCameraInterface from "./interfaces/cameras/ConfigureCameraInterface"
 
 // Register command: Configure Assets (module-scope side effect)
 CommandRegistry.get().registerCommands([
diff --git a/fission/src/ui/panels/configuring/assembly-config/interfaces/ConfigureCameraInterface.tsx b/fission/src/ui/panels/configuring/assembly-config/interfaces/ConfigureCameraInterface.tsx
deleted file mode 100644
index a3a07e33ba..0000000000
--- a/fission/src/ui/panels/configuring/assembly-config/interfaces/ConfigureCameraInterface.tsx
+++ /dev/null
@@ -1,257 +0,0 @@
-import type Jolt from "@azaleacolburn/jolt-physics"
-import { Stack, TextField } from "@mui/material"
-import { useCallback, useEffect, useMemo, useRef, useState } from "react"
-import * as THREE from "three"
-import SelectButton from "@/components/SelectButton"
-import type { RigidNodeId } from "@/mirabuf/MirabufParser"
-import type MirabufSceneObject from "@/mirabuf/MirabufSceneObject"
-import type { RigidNodeAssociate } from "@/mirabuf/MirabufSceneObject"
-import EventSystem from "@/systems/EventSystem.ts"
-import { PAUSE_REF_ASSEMBLY_CONFIG } from "@/systems/physics/PhysicsTypes"
-import PreferencesSystem from "@/systems/preferences/PreferencesSystem"
-import { type CameraPreferences, defaultCameraPreferences } from "@/systems/preferences/PreferenceTypes"
-import type GizmoSceneObject from "@/systems/scene/GizmoSceneObject"
-import World from "@/systems/World"
-import Label from "@/ui/components/Label"
-import StatefulSlider from "@/ui/components/StatefulSlider"
-import { Button } from "@/ui/components/StyledComponents"
-import TransformGizmoControl from "@/ui/components/TransformGizmoControl"
-import {
-    convertArrayToThreeMatrix4,
-    convertJoltMat44ToThreeMatrix4,
-    convertReactRgbaColorToThreeColor,
-    convertThreeMatrix4ToArray,
-} from "@/util/TypeConversions"
-
-const MIN_FOV = 10
-const MAX_FOV = 170
-const MIN_RES = 64
-const MAX_RES = 1280
-const MIN_FPS = 1
-const MAX_FPS = 60
-
-function parentBodyId(robot: MirabufSceneObject, parentNode: string | undefined): Jolt.BodyID | undefined {
-    return robot.mechanism.nodeToBody.get(parentNode ?? robot.rootNodeId)
-}
-
-interface ConfigCameraProps {
-    selectedRobot: MirabufSceneObject
-}
-
-const ConfigureCameraInterface: React.FC = ({ selectedRobot }) => {
-    const [version, setVersion] = useState(0)
-    const [selectedIndex, setSelectedIndex] = useState(selectedRobot.cameraPreferences.length > 0 ? 0 : -1)
-    const [selectedNode, setSelectedNode] = useState(undefined)
-
-    const gizmoRef = useRef(undefined)
-
-    const cameras = selectedRobot.cameraPreferences
-    const camera: CameraPreferences | undefined = cameras[selectedIndex]
-
-    const forceRender = useCallback(() => setVersion(v => v + 1), [])
-
-    const commit = useCallback(() => {
-        if (camera && gizmoRef.current) {
-            const nodeBodyId = parentBodyId(selectedRobot, camera.parentNode)
-            if (nodeBodyId) {
-                const gizmoWorld = gizmoRef.current.obj.matrixWorld.clone()
-                const robotWorld = convertJoltMat44ToThreeMatrix4(
-                    World.physicsSystem.getBody(nodeBodyId)!.GetWorldTransform()
-                )
-                const delta = gizmoWorld.premultiply(robotWorld.invert())
-                camera.deltaTransformation = convertThreeMatrix4ToArray(delta)
-            }
-        }
-        PreferencesSystem.setRobotPreferences(selectedRobot.assemblyName, selectedRobot.robotPreferences)
-        PreferencesSystem.savePreferences()
-        selectedRobot.updateCameras()
-    }, [camera, selectedRobot])
-
-    useEffect(() => {
-        return EventSystem.listen("ConfigurationSavedEvent", commit)
-    }, [commit])
-
-    useEffect(() => {
-        World.physicsSystem.holdPause(PAUSE_REF_ASSEMBLY_CONFIG)
-        return () => {
-            World.physicsSystem.releasePause(PAUSE_REF_ASSEMBLY_CONFIG)
-        }
-    }, [])
-
-    useEffect(() => {
-        setSelectedNode(camera?.parentNode)
-    }, [selectedIndex, camera])
-
-    // selectedIndex dep so gizmo respawns for newly selected camera
-    const placeholderMesh = useMemo(() => {
-        return new THREE.Mesh(
-            new THREE.BoxGeometry(0.12, 0.08, 0.16).translate(0, 0, 0.08),
-            World.sceneRenderer.createToonMaterial(convertReactRgbaColorToThreeColor({ r: 80, g: 180, b: 255, a: 255 }))
-        )
-    }, [selectedIndex])
-
-    const gizmoComponent = useMemo(() => {
-        if (!camera) {
-            gizmoRef.current = undefined
-            return <>
-        }
-
-        const postGizmoCreation = (gizmo: GizmoSceneObject) => {
-            const material = (gizmo.obj as THREE.Mesh).material as THREE.Material
-            material.depthTest = false
-
-            const delta = convertArrayToThreeMatrix4(camera.deltaTransformation)
-            const nodeBodyId = parentBodyId(selectedRobot, camera.parentNode)
-            if (!nodeBodyId) return
-
-            const robotWorld = convertJoltMat44ToThreeMatrix4(
-                World.physicsSystem.getBody(nodeBodyId)!.GetWorldTransform()
-            )
-            const gizmoWorld = delta.premultiply(robotWorld)
-            gizmo.obj.position.setFromMatrixPosition(gizmoWorld)
-            gizmo.obj.rotation.setFromRotationMatrix(gizmoWorld)
-        }
-
-        return (
-            
-        )
-        // eslint-disable-next-line react-hooks/exhaustive-deps
-    }, [placeholderMesh, selectedIndex, camera, selectedRobot])
-
-    const trySetSelectedNode = useCallback(
-        (body: Jolt.BodyID) => {
-            const assoc = World.physicsSystem.getBodyAssociation(body) as RigidNodeAssociate
-            if (!assoc || assoc.sceneObject !== selectedRobot) return false
-
-            setSelectedNode(assoc.rigidNodeId)
-            if (camera) camera.parentNode = assoc.rigidNodeId
-
-            commit()
-
-            return true
-        },
-        [selectedRobot, camera, commit]
-    )
-
-    const addCamera = useCallback(() => {
-        const nextId = cameras.reduce((max, c) => Math.max(max, c.id + 1), 0)
-        cameras.push(defaultCameraPreferences(nextId))
-        setSelectedIndex(cameras.length - 1)
-
-        commit()
-        forceRender()
-    }, [cameras, commit, forceRender])
-
-    const removeCamera = useCallback(() => {
-        if (selectedIndex < 0) return
-
-        cameras.splice(selectedIndex, 1)
-        setSelectedIndex(cameras.length > 0 ? Math.max(0, selectedIndex - 1) : -1)
-
-        commit()
-        forceRender()
-    }, [cameras, selectedIndex, commit, forceRender])
-
-    return (
-        
-            
-                {cameras.map((c, i) => (
-                    
-                ))}
-            
-
-            
-                
-                {camera ?  : null}
-            
-
-            {camera ? (
-                <>
-                     {
-                            camera.name = e.target.value
-                        }}
-                    />
-
-                     trySetSelectedNode(body.GetID())}
-                    />
-
-                     {
-                            camera.fovDegrees = v
-                        }}
-                    />
-                    {/* NOTE: in code sim mode, these are only configurable through robot code */}
-                    {selectedRobot.brain?.brainType === "synthesis" && (
-                        <>
-                             {
-                                    camera.resolutionWidth = v
-                                }}
-                            />
-                             {
-                                    camera.resolutionHeight = v
-                                }}
-                            />
-                             {
-                                    camera.fps = v
-                                }}
-                            />
-                        
-                    )}
-
-                    {gizmoComponent}
-                
-            ) : (
-                
-            )}
-        
-    )
-}
-
-export default ConfigureCameraInterface
diff --git a/fission/src/ui/panels/configuring/assembly-config/interfaces/cameras/CameraConfigInterface.tsx b/fission/src/ui/panels/configuring/assembly-config/interfaces/cameras/CameraConfigInterface.tsx
new file mode 100644
index 0000000000..8b43d44b61
--- /dev/null
+++ b/fission/src/ui/panels/configuring/assembly-config/interfaces/cameras/CameraConfigInterface.tsx
@@ -0,0 +1,198 @@
+import type { RigidNodeId } from "@/mirabuf/MirabufParser"
+import * as THREE from "three"
+import type MirabufSceneObject from "@/mirabuf/MirabufSceneObject"
+import type { RigidNodeAssociate } from "@/mirabuf/MirabufSceneObject"
+import PreferencesSystem from "@/systems/preferences/PreferencesSystem"
+import type { CameraPreferences } from "@/systems/preferences/PreferenceTypes"
+import type GizmoSceneObject from "@/systems/scene/GizmoSceneObject"
+import World from "@/systems/World"
+import SelectButton from "@/ui/components/SelectButton"
+import StatefulSlider from "@/ui/components/StatefulSlider"
+import {
+    convertArrayToThreeMatrix4,
+    convertJoltMat44ToThreeMatrix4,
+    convertReactRgbaColorToThreeColor,
+    convertThreeMatrix4ToArray,
+} from "@/util/TypeConversions"
+import { TextField } from "@mui/material"
+import type Jolt from "@synthesis.adsk/jolt-physics"
+import type React from "react"
+import { useCallback, useEffect, useMemo, useRef, useState } from "react"
+import TransformGizmoControl from "@/ui/components/TransformGizmoControl"
+import EventSystem from "@/systems/EventSystem"
+
+const MIN_FOV = 10
+const MAX_FOV = 170
+const MIN_RES = 64
+const MAX_RES = 1280
+const MIN_FPS = 1
+const MAX_FPS = 60
+
+function parentBodyId(robot: MirabufSceneObject, parentNode: string | undefined): Jolt.BodyID | undefined {
+    return robot.mechanism.nodeToBody.get(parentNode ?? robot.rootNodeId)
+}
+
+interface CameraConfigInterfaceProps {
+    selectedRobot: MirabufSceneObject
+    camera: CameraPreferences
+}
+
+const CameraConfigInterface: React.FC = ({ selectedRobot, camera }) => {
+    const [selectedNode, setSelectedNode] = useState(undefined)
+
+    const gizmoRef = useRef(undefined)
+
+    const commit = useCallback(() => {
+        if (camera && gizmoRef.current) {
+            const nodeBodyId = parentBodyId(selectedRobot, camera.parentNode)
+            if (nodeBodyId) {
+                const gizmoWorld = gizmoRef.current.obj.matrixWorld.clone()
+                const robotWorld = convertJoltMat44ToThreeMatrix4(
+                    World.physicsSystem.getBody(nodeBodyId)!.GetWorldTransform()
+                )
+                const delta = gizmoWorld.premultiply(robotWorld.invert())
+                camera.deltaTransformation = convertThreeMatrix4ToArray(delta)
+            }
+        }
+        PreferencesSystem.setRobotPreferences(selectedRobot.assemblyName, selectedRobot.robotPreferences)
+        PreferencesSystem.savePreferences()
+        selectedRobot.updateCameras()
+    }, [camera, selectedRobot])
+
+    useEffect(() => {
+        return EventSystem.listen("ConfigurationSavedEvent", commit)
+    }, [commit])
+
+    useEffect(() => {
+        setSelectedNode(camera?.parentNode)
+    }, [camera])
+
+    // selectedIndex dep so gizmo respawns for newly selected camera
+    const placeholderMesh = useMemo(() => {
+        return new THREE.Mesh(
+            new THREE.BoxGeometry(0.12, 0.08, 0.16).translate(0, 0, 0.08),
+            World.sceneRenderer.createToonMaterial(convertReactRgbaColorToThreeColor({ r: 80, g: 180, b: 255, a: 255 }))
+        )
+    }, [camera])
+
+    const gizmoComponent = useMemo(() => {
+        if (!camera) {
+            gizmoRef.current = undefined
+            return <>
+        }
+
+        const postGizmoCreation = (gizmo: GizmoSceneObject) => {
+            const material = (gizmo.obj as THREE.Mesh).material as THREE.Material
+            material.depthTest = false
+
+            const delta = convertArrayToThreeMatrix4(camera.deltaTransformation)
+            const nodeBodyId = parentBodyId(selectedRobot, camera.parentNode)
+            if (!nodeBodyId) return
+
+            const robotWorld = convertJoltMat44ToThreeMatrix4(
+                World.physicsSystem.getBody(nodeBodyId)!.GetWorldTransform()
+            )
+            const gizmoWorld = delta.premultiply(robotWorld)
+            gizmo.obj.position.setFromMatrixPosition(gizmoWorld)
+            gizmo.obj.rotation.setFromRotationMatrix(gizmoWorld)
+        }
+
+        return (
+            
+        )
+        // eslint-disable-next-line react-hooks/exhaustive-deps
+    }, [placeholderMesh, camera, selectedRobot])
+
+    const trySetSelectedNode = useCallback(
+        (body: Jolt.BodyID) => {
+            const assoc = World.physicsSystem.getBodyAssociation(body) as RigidNodeAssociate
+            if (!assoc || assoc.sceneObject !== selectedRobot) return false
+
+            setSelectedNode(assoc.rigidNodeId)
+            if (camera) camera.parentNode = assoc.rigidNodeId
+
+            commit()
+
+            return true
+        },
+        [selectedRobot, camera, commit]
+    )
+
+    return (
+        <>
+             {
+                    camera.name = e.target.value
+                }}
+            />
+
+             trySetSelectedNode(body.GetID())}
+            />
+
+             {
+                    camera.fovDegrees = v
+                }}
+            />
+            {/* NOTE: in code sim mode, these are only configurable through robot code */}
+            {selectedRobot.brain?.brainType === "synthesis" && (
+                <>
+                     {
+                            camera.resolutionWidth = v
+                        }}
+                    />
+                     {
+                            camera.resolutionHeight = v
+                        }}
+                    />
+                     {
+                            camera.fps = v
+                        }}
+                    />
+                
+            )}
+
+            {gizmoComponent}
+        
+    )
+}
+
+export default CameraConfigInterface
diff --git a/fission/src/ui/panels/configuring/assembly-config/interfaces/cameras/ConfigureCameraInterface.tsx b/fission/src/ui/panels/configuring/assembly-config/interfaces/cameras/ConfigureCameraInterface.tsx
new file mode 100644
index 0000000000..cdcedb9635
--- /dev/null
+++ b/fission/src/ui/panels/configuring/assembly-config/interfaces/cameras/ConfigureCameraInterface.tsx
@@ -0,0 +1,100 @@
+import { Box, Divider, Stack } from "@mui/material"
+import { useCallback, useEffect, useState } from "react"
+import type MirabufSceneObject from "@/mirabuf/MirabufSceneObject"
+import { PAUSE_REF_ASSEMBLY_CONFIG } from "@/systems/physics/PhysicsTypes"
+import { CameraPreferences, defaultCameraPreferences } from "@/systems/preferences/PreferenceTypes"
+import World from "@/systems/World"
+import { Button, DeleteButton, EditButton, SynthesisIcons } from "@/ui/components/StyledComponents"
+import CameraConfigInterface from "./CameraConfigInterface"
+import Label from "@/ui/components/Label"
+import { SelectMenuHeader } from "@/ui/components/SelectMenu"
+import EventSystem from "@/systems/EventSystem"
+
+interface ConfigCameraProps {
+    selectedRobot: MirabufSceneObject
+}
+
+const ConfigureCameraInterface: React.FC = ({ selectedRobot }) => {
+    const [version, setVersion] = useState(0)
+    const [selectedCamera, setSelectedCamera] = useState(null)
+
+    const cameras = selectedRobot.cameraPreferences
+
+    const forceRender = useCallback(() => setVersion(v => v + 1), [])
+
+    useEffect(() => {
+        World.physicsSystem.holdPause(PAUSE_REF_ASSEMBLY_CONFIG)
+        return () => {
+            World.physicsSystem.releasePause(PAUSE_REF_ASSEMBLY_CONFIG)
+        }
+    }, [])
+
+    return (
+        <>
+            {selectedCamera !== null ? (
+                <>
+                     {
+                            EventSystem.dispatch("ConfigurationSavedEvent")
+                            setSelectedCamera(null)
+                        }}
+                    />
+                    
+                    
+                
+            ) : (
+                
+                    {cameras.length > 0 ? (
+                        cameras.map(cameraPrefs => (
+                            
+                                
+                                    
+                                    
+                                         {
+                                                setSelectedCamera(cameraPrefs)
+                                            }}
+                                        />
+                                         {
+                                                selectedRobot.cameraPreferences =
+                                                    selectedRobot.cameraPreferences.filter(
+                                                        cpref => cpref.id !== cameraPrefs.id
+                                                    )
+                                                setSelectedCamera(null)
+                                                selectedRobot.updateCameras()
+                                                forceRender()
+                                            }}
+                                        />
+                                    
+                                
+                            
+                        ))
+                    ) : (
+                        
+                    )}
+                    
+                
+            )}
+        
+    )
+}
+
+export default ConfigureCameraInterface

From 32b77b4977aadb5beaa12740a6d4b9328b68c600 Mon Sep 17 00:00:00 2001
From: PepperLola 
Date: Thu, 23 Jul 2026 17:51:43 -0700
Subject: [PATCH 13/17] feat: slightly improved camera item style in list

---
 .../interfaces/cameras/ConfigureCameraInterface.tsx           | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/fission/src/ui/panels/configuring/assembly-config/interfaces/cameras/ConfigureCameraInterface.tsx b/fission/src/ui/panels/configuring/assembly-config/interfaces/cameras/ConfigureCameraInterface.tsx
index cdcedb9635..b7bfcc3fc4 100644
--- a/fission/src/ui/panels/configuring/assembly-config/interfaces/cameras/ConfigureCameraInterface.tsx
+++ b/fission/src/ui/panels/configuring/assembly-config/interfaces/cameras/ConfigureCameraInterface.tsx
@@ -52,9 +52,9 @@ const ConfigureCameraInterface: React.FC = ({ selectedRobot }
                                 sx={{ bgcolor: "background.paper", p: 2, borderRadius: 5, width: "100%" }}
                                 key={`${cameraPrefs.id}`}
                             >
-                                
+                                
                                     
-                                    
+                                    
                                          {
                                                 setSelectedCamera(cameraPrefs)

From dc7236dca2bb3359b023c4d34eb5aff1ed3f94f4 Mon Sep 17 00:00:00 2001
From: PepperLola 
Date: Thu, 23 Jul 2026 17:56:46 -0700
Subject: [PATCH 14/17] style: formatter

---
 .../interfaces/cameras/ConfigureCameraInterface.tsx           | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/fission/src/ui/panels/configuring/assembly-config/interfaces/cameras/ConfigureCameraInterface.tsx b/fission/src/ui/panels/configuring/assembly-config/interfaces/cameras/ConfigureCameraInterface.tsx
index b7bfcc3fc4..26cdcce7b7 100644
--- a/fission/src/ui/panels/configuring/assembly-config/interfaces/cameras/ConfigureCameraInterface.tsx
+++ b/fission/src/ui/panels/configuring/assembly-config/interfaces/cameras/ConfigureCameraInterface.tsx
@@ -2,7 +2,7 @@ import { Box, Divider, Stack } from "@mui/material"
 import { useCallback, useEffect, useState } from "react"
 import type MirabufSceneObject from "@/mirabuf/MirabufSceneObject"
 import { PAUSE_REF_ASSEMBLY_CONFIG } from "@/systems/physics/PhysicsTypes"
-import { CameraPreferences, defaultCameraPreferences } from "@/systems/preferences/PreferenceTypes"
+import { type CameraPreferences, defaultCameraPreferences } from "@/systems/preferences/PreferenceTypes"
 import World from "@/systems/World"
 import { Button, DeleteButton, EditButton, SynthesisIcons } from "@/ui/components/StyledComponents"
 import CameraConfigInterface from "./CameraConfigInterface"
@@ -15,7 +15,7 @@ interface ConfigCameraProps {
 }
 
 const ConfigureCameraInterface: React.FC = ({ selectedRobot }) => {
-    const [version, setVersion] = useState(0)
+    const [_version, setVersion] = useState(0)
     const [selectedCamera, setSelectedCamera] = useState(null)
 
     const cameras = selectedRobot.cameraPreferences

From 69b6d52aaa0b3be109b09c4bf9e2fd493ce94a33 Mon Sep 17 00:00:00 2001
From: PepperLola 
Date: Fri, 24 Jul 2026 09:55:55 -0700
Subject: [PATCH 15/17] fix: handle cameraPreferences undefined in
 MirabufSceneObject

---
 fission/src/mirabuf/MirabufSceneObject.ts | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/fission/src/mirabuf/MirabufSceneObject.ts b/fission/src/mirabuf/MirabufSceneObject.ts
index 29e9220e19..2b0e2a35f1 100644
--- a/fission/src/mirabuf/MirabufSceneObject.ts
+++ b/fission/src/mirabuf/MirabufSceneObject.ts
@@ -676,7 +676,7 @@ class MirabufSceneObject extends SceneObject implements ContextSupplier {
 
         if (this.miraType !== MiraType.ROBOT) return
 
-        this._cameras = this.cameraPreferences.map(camPref => {
+        this._cameras = this.cameraPreferences?.map(camPref => {
             const camera = new RobotCameraSceneObject(this, camPref)
             World.sceneRenderer.registerSceneObject(camera)
             return camera

From 226bee3b8f8a7118714942017862ce3cc80db716 Mon Sep 17 00:00:00 2001
From: PepperLola 
Date: Fri, 24 Jul 2026 12:23:37 -0700
Subject: [PATCH 16/17] fix: preview panel shows per-robot, adding camera
 automatically configures

Co-authored-by: Alexey Dmitriev <157652245+AlexD717@users.noreply.github.com>
---
 fission/src/mirabuf/MirabufSceneObject.ts     |  3 +++
 .../cameras/ConfigureCameraInterface.tsx      |  1 +
 .../panels/simulation/CameraPreviewPanel.tsx  | 21 ++++++++++++-------
 3 files changed, 17 insertions(+), 8 deletions(-)

diff --git a/fission/src/mirabuf/MirabufSceneObject.ts b/fission/src/mirabuf/MirabufSceneObject.ts
index 2b0e2a35f1..5c1f93981f 100644
--- a/fission/src/mirabuf/MirabufSceneObject.ts
+++ b/fission/src/mirabuf/MirabufSceneObject.ts
@@ -1294,6 +1294,9 @@ class MirabufSceneObject extends SceneObject implements ContextSupplier {
                 name: "Camera Preview",
                 screen: CameraPreviewPanel,
                 type: "panel",
+                customProps: {
+                    selectedAssembly: this
+                }
             })
         }
 
diff --git a/fission/src/ui/panels/configuring/assembly-config/interfaces/cameras/ConfigureCameraInterface.tsx b/fission/src/ui/panels/configuring/assembly-config/interfaces/cameras/ConfigureCameraInterface.tsx
index 26cdcce7b7..427cf14bfd 100644
--- a/fission/src/ui/panels/configuring/assembly-config/interfaces/cameras/ConfigureCameraInterface.tsx
+++ b/fission/src/ui/panels/configuring/assembly-config/interfaces/cameras/ConfigureCameraInterface.tsx
@@ -84,6 +84,7 @@ const ConfigureCameraInterface: React.FC = ({ selectedRobot }
                         onClick={() => {
                             const nextId = cameras.reduce((max, c) => Math.max(max, c.id + 1), 0)
                             cameras.push(defaultCameraPreferences(nextId))
+                            setSelectedCamera(cameras.at(-1)!) // should be safe since we just added one
                             selectedRobot.updateCameras()
                             forceRender()
                         }}
diff --git a/fission/src/ui/panels/simulation/CameraPreviewPanel.tsx b/fission/src/ui/panels/simulation/CameraPreviewPanel.tsx
index be8672652f..752aa2d80c 100644
--- a/fission/src/ui/panels/simulation/CameraPreviewPanel.tsx
+++ b/fission/src/ui/panels/simulation/CameraPreviewPanel.tsx
@@ -1,24 +1,29 @@
 import type React from "react"
-import { useEffect, useRef, useState } from "react"
+import { useCallback, useEffect, useRef, useState } from "react"
 import RobotCameraSceneObject from "@/mirabuf/RobotCameraSceneObject"
 import EventSystem from "@/systems/EventSystem"
-import World from "@/systems/World"
 import Label from "@/ui/components/Label"
 import type { PanelImplProps } from "@/ui/components/Panel"
 import { Button } from "@/ui/components/StyledComponents"
 import { useUIContext } from "@/ui/helpers/UIProviderHelpers"
-
-function resolveCameras(): RobotCameraSceneObject[] {
-    return World.sceneRenderer.mirabufSceneObjects.getRobots().flatMap(r => [...r.cameras])
-}
+import type MirabufSceneObject from "@/mirabuf/MirabufSceneObject"
 
 const BOX_W = 480
 const BOX_H = 360
 const PANEL_WIDTH = BOX_W + 40
 const PANEL_HEIGHT = `min(${BOX_H + 200}px, 85vh)`
 
-const CameraPreviewPanel: React.FC> = ({ panel }) => {
+interface CameraPreviewPanelProps {
+    selectedAssembly: MirabufSceneObject
+}
+
+const CameraPreviewPanel: React.FC> = ({ panel }) => {
     const { configureScreen } = useUIContext()
+
+    const { selectedAssembly } = panel!.props.custom
+
+    const resolveCameras = useCallback(() => [...selectedAssembly.cameras], [selectedAssembly])
+
     const previewRef = useRef(null)
     const [cameras, setCameras] = useState(resolveCameras)
     const [selectedIndex, setSelectedIndex] = useState(0)
@@ -43,7 +48,7 @@ const CameraPreviewPanel: React.FC> = ({ panel }) =>
         return () => RobotCameraSceneObject.removePreviewConsumer()
     }, [])
 
-    useEffect(() => EventSystem.listen("RobotCamerasChangeEvent", () => setCameras(resolveCameras())), [])
+    useEffect(() => EventSystem.listen("RobotCamerasChangeEvent", () => setCameras(resolveCameras())), [resolveCameras])
 
     const index = Math.min(selectedIndex, Math.max(0, cameras.length - 1))
     const selected = cameras[index]

From 16a9aa07c06d40e1f5d2ccac842aa7d38756e0b5 Mon Sep 17 00:00:00 2001
From: PepperLola 
Date: Fri, 24 Jul 2026 14:19:30 -0700
Subject: [PATCH 17/17] chore: added WPILib SyntheSimJava license to NOTICE.txt

---
 NOTICE.txt | 6 +++++-
 1 file changed, 5 insertions(+), 1 deletion(-)

diff --git a/NOTICE.txt b/NOTICE.txt
index da2c1201aa..5821f2eced 100644
--- a/NOTICE.txt
+++ b/NOTICE.txt
@@ -18,4 +18,8 @@ This product bundles the following third-party sound assets:
 
    For more information, contact FIRST Robotics or refer to your licensing agreement.
 
-This product also includes Autodesk brand marks. See MARKS-LICENSE.md for more information.
\ No newline at end of file
+3. Files licensed from WPILib:
+   - simulation/SyntheSimJava/src/main/java/com/autodesk/synthesis/cscore/CameraServer.java
+   - simulation/SyntheSimJava/src/main/java/com/autodesk/synthesis/cscore/CvSink.java
+
+This product also includes Autodesk brand marks. See MARKS-LICENSE.md for more information.