Skip to content
Merged
3 changes: 2 additions & 1 deletion fission/src/mirabuf/FieldMiraEditor.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { mirabuf } from "../proto/mirabuf"
import { ScoringZonePreferences } from "@/systems/preferences/PreferenceTypes"

interface DevtoolMiraData {
"devtool:scoring_zones": unknown
"devtool:scoring_zones": ScoringZonePreferences[]
"devtool:camera_locations": unknown
"devtool:spawn_points": unknown
"devtool:a": unknown
Expand Down
103 changes: 102 additions & 1 deletion fission/src/mirabuf/MirabufLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,49 @@ class MirabufCachingService {
}
}

/**
* Caches and gets local Mirabuf file with cache info
*
* @param {ArrayBuffer} buffer ArrayBuffer of Mirabuf file.
* @param {MiraType} miraType Type of Mirabuf Assembly.
*
* @returns {Promise<{assembly: mirabuf.Assembly, cacheInfo: MirabufCacheInfo} | undefined>} Promise with the result of the promise. Assembly and cache info of the mirabuf file if successful, undefined if not.
*/
public static async cacheAndGetLocalWithInfo(
buffer: ArrayBuffer,
miraType: MiraType
): Promise<{ assembly: mirabuf.Assembly; cacheInfo: MirabufCacheInfo } | undefined> {
const key = await this.hashBuffer(buffer)
const map = MirabufCachingService.getCacheMap(miraType)
const target = map[key]
const assembly = this.assemblyFromBuffer(buffer)

// Check if assembly has devtool data and update name accordingly
let displayName = assembly.info?.name ?? undefined
if (assembly.data?.parts?.userData?.data) {
const devtoolKeys = Object.keys(assembly.data.parts.userData.data).filter(k => k.startsWith("devtool:"))
if (devtoolKeys.length > 0) {
displayName = displayName ? `Edited ${displayName}` : "Edited Field"
}
}

if (!target) {
const cacheInfo = await MirabufCachingService.storeInCache(key, buffer, miraType, displayName)
if (cacheInfo) {
return { assembly, cacheInfo }
}
} else {
// Update existing cache info with new name if it has devtool data
if (displayName && displayName !== target.name) {
await MirabufCachingService.cacheInfo(key, miraType, displayName)
target.name = displayName
}
return { assembly, cacheInfo: target }
}

return undefined
}

/**
* Caches and gets local Mirabuf file
*
Expand All @@ -258,8 +301,17 @@ class MirabufCachingService {
const target = map[key]
const assembly = this.assemblyFromBuffer(buffer)

// Check if assembly has devtool data and update name accordingly
let displayName = assembly.info?.name ?? undefined
if (assembly.data?.parts?.userData?.data) {
const devtoolKeys = Object.keys(assembly.data.parts.userData.data).filter(k => k.startsWith("devtool:"))
if (devtoolKeys.length > 0) {
displayName = displayName ? `Edited ${displayName}` : "Edited Field"
}
}

if (!target) {
await MirabufCachingService.storeInCache(key, buffer, miraType, assembly.info?.name ?? undefined)
await MirabufCachingService.storeInCache(key, buffer, miraType, displayName)
}

return assembly
Expand Down Expand Up @@ -369,6 +421,55 @@ class MirabufCachingService {
backUpFields = {}
}

/**
* Persists devtool changes back to the cache by re-encoding the assembly
*
* @param {MirabufCacheID} id ID of the cached mirabuf file
* @param {MiraType} miraType Type of Mirabuf Assembly
* @param {mirabuf.Assembly} assembly The updated assembly with devtool changes
*
* @returns {Promise<boolean>} Promise with the result. True if successful, false if not.
*/
public static async persistDevtoolChanges(
id: MirabufCacheID,
miraType: MiraType,
assembly: mirabuf.Assembly
): Promise<boolean> {
try {
// Re-encode the assembly with devtool changes
const updatedBuffer = mirabuf.Assembly.encode(assembly).finish()

// Update the cached buffer
const cache = miraType == MiraType.ROBOT ? backUpRobots : backUpFields
if (cache[id]) {
cache[id].buffer = updatedBuffer
}

// Update OPFS if available
if (canOPFS) {
const fileHandle = await (
miraType == MiraType.ROBOT ? robotFolderHandle : fieldFolderHandle
).getFileHandle(id, { create: false })
const writable = await fileHandle.createWritable()
await writable.write(updatedBuffer)
await writable.close()
}

World.analyticsSystem?.event("Devtool Cache Persist", {
key: id,
type: miraType == MiraType.ROBOT ? "robot" : "field",
assemblyName: assembly.info?.name ?? "unknown",
fileSize: updatedBuffer.byteLength,
})

return true
} catch (e) {
console.error("Failed to persist devtool changes", e)
World.analyticsSystem?.exception("Failed to persist devtool changes to cache")
return false
}
}

// Optional name for when assembly is being decoded anyway like in CacheAndGetLocal()
private static async storeInCache(
key: string,
Expand Down
53 changes: 34 additions & 19 deletions fission/src/mirabuf/MirabufSceneObject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import {
import { SimConfigData } from "@/ui/panels/simulation/SimConfigShared"
import WPILibBrain from "@/systems/simulation/wpilib_brain/WPILibBrain"
import { OnContactAddedEvent } from "@/systems/physics/ContactEvents"
import FieldMiraEditor from "./FieldMiraEditor"

const DEBUG_BODIES = false

Expand Down Expand Up @@ -90,14 +91,14 @@ class MirabufSceneObject extends SceneObject implements ContextSupplier {

private _nameTag: SceneOverlayTag | undefined
private _centerOfMassIndicator: THREE.Mesh | undefined
private _centerOfMassListenerUnsubscribe: (() => void) | undefined
private _intakeActive = false
private _ejectorActive = false

private _lastEjectableToastTime = 0
private static readonly EJECTABLE_TOAST_COOLDOWN_MS = 500

private _collision?: (event: OnContactAddedEvent) => void
private _cacheId?: string

public get intakeActive() {
return this._intakeActive
Expand Down Expand Up @@ -145,7 +146,7 @@ class MirabufSceneObject extends SceneObject implements ContextSupplier {
}

public get activeEjectables(): Jolt.BodyID[] {
return this._ejectables.map(e => e.gamePieceBodyId!)
return this._ejectables.map(e => e.gamePieceBodyId!).filter(x => x !== undefined)
}

public get miraType(): MiraType {
Expand Down Expand Up @@ -174,11 +175,21 @@ class MirabufSceneObject extends SceneObject implements ContextSupplier {
this._alliance = alliance
}

public constructor(mirabufInstance: MirabufInstance, assemblyName: string, progressHandle?: ProgressHandle) {
public get cacheId() {
return this._cacheId
}

public constructor(
mirabufInstance: MirabufInstance,
assemblyName: string,
progressHandle?: ProgressHandle,
cacheId?: string
) {
super()

this._mirabufInstance = mirabufInstance
this._assemblyName = assemblyName
this._cacheId = cacheId

progressHandle?.update("Creating mechanism...", 0.9)

Expand Down Expand Up @@ -221,18 +232,8 @@ class MirabufSceneObject extends SceneObject implements ContextSupplier {
})
material.depthTest = false
this._centerOfMassIndicator = new THREE.Mesh(new THREE.SphereGeometry(0.02), material)
this._centerOfMassIndicator.visible = PreferencesSystem.getGlobalPreference("ShowCenterOfMassIndicators")

this._centerOfMassIndicator.visible = false
World.sceneRenderer.scene.add(this._centerOfMassIndicator)

this._centerOfMassListenerUnsubscribe = PreferencesSystem.addPreferenceEventListener(
"ShowCenterOfMassIndicators",
e => {
if (this._centerOfMassIndicator) {
this._centerOfMassIndicator.visible = e.prefValue
}
}
)
}
}

Expand Down Expand Up @@ -357,9 +358,6 @@ class MirabufSceneObject extends SceneObject implements ContextSupplier {
if (this._brain && this._brain instanceof SynthesisBrain) {
this._brain.clearControls()
}
if (this._centerOfMassListenerUnsubscribe) {
this._centerOfMassListenerUnsubscribe()
}
}

public eject() {
Expand Down Expand Up @@ -453,6 +451,7 @@ class MirabufSceneObject extends SceneObject implements ContextSupplier {
if (this._centerOfMassIndicator) {
const netCoM = totalMass > 0 ? weightedCOM.Div(totalMass) : weightedCOM
this._centerOfMassIndicator.position.set(netCoM.GetX(), netCoM.GetY(), netCoM.GetZ())
this._centerOfMassIndicator.visible = PreferencesSystem.getGlobalPreference("ShowCenterOfMassIndicators")
}
}

Expand Down Expand Up @@ -660,6 +659,21 @@ class MirabufSceneObject extends SceneObject implements ContextSupplier {
}

this._fieldPreferences = PreferencesSystem.getFieldPreferences(this.assemblyName)

// For fields, sync devtool data with field preferences
if (this.miraType === MiraType.FIELD) {
const parts = this._mirabufInstance.parser.assembly.data?.parts
if (parts) {
const editor = new FieldMiraEditor(parts)
const devtoolScoringZones = editor.getUserData("devtool:scoring_zones")

if (devtoolScoringZones && Array.isArray(devtoolScoringZones)) {
this._fieldPreferences.scoringZones = devtoolScoringZones
PreferencesSystem.setFieldPreferences(this.assemblyName, this._fieldPreferences)
PreferencesSystem.savePreferences()
}
}
}
}

public updateSimConfig(config: SimConfigData | undefined) {
Expand Down Expand Up @@ -792,15 +806,16 @@ class MirabufSceneObject extends SceneObject implements ContextSupplier {

export async function createMirabuf(
assembly: mirabuf.Assembly,
progressHandle?: ProgressHandle
progressHandle?: ProgressHandle,
cacheId?: string
): Promise<MirabufSceneObject | null | undefined> {
const parser = new MirabufParser(assembly, progressHandle)
if (parser.maxErrorSeverity >= ParseErrorSeverity.UNIMPORTABLE) {
console.error(`Assembly Parser produced significant errors for '${assembly.info!.name!}'`)
return
}

return new MirabufSceneObject(new MirabufInstance(parser), assembly.info!.name!, progressHandle)
return new MirabufSceneObject(new MirabufInstance(parser), assembly.info!.name!, progressHandle, cacheId)
}

/**
Expand Down
64 changes: 60 additions & 4 deletions fission/src/test/FieldMiraEditor.test.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,39 @@
import { describe, expect, test } from "vitest"
import FieldMiraEditor from "../mirabuf/FieldMiraEditor"
import { mirabuf } from "../proto/mirabuf"
import { ScoringZonePreferences, Alliance } from "@/systems/preferences/PreferenceTypes"

function mockParts(): mirabuf.IParts {
return { userData: { data: {} } }
}

const scoringZonePayload: ScoringZonePreferences[] = [
{
name: "Red Zone",
alliance: "red" as Alliance,
parentNode: "root",
points: 5,
destroyGamepiece: false,
persistentPoints: true,
deltaTransformation: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
},
]

describe("Basic Field Mira Editor Tests", () => {
test("writes and reads devtool data", () => {
const parts = mockParts()
const editor = new FieldMiraEditor(parts)

const key = "devtool:scoring_zones"
const payload = [
const payload: ScoringZonePreferences[] = [
{
id: "zone-A",
pose: { position: { x: 0, y: 0, z: 0 }, rotation: { x: 0, y: 0, z: 0, w: 1 } },
size: { x: 1, y: 1, z: 1 },
name: "Test Zone",
alliance: "blue" as Alliance,
parentNode: "root",
points: 10,
destroyGamepiece: false,
persistentPoints: false,
deltaTransformation: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
},
]

Expand Down Expand Up @@ -75,3 +92,42 @@ describe("Basic Field Mira Editor Tests", () => {
expect(editor.getAllDevtoolKeys()).toEqual(["devtool:keep"])
})
})

describe("Devtool Scoring Zones Caching Tests", () => {
test("add scoring zones and read back", () => {
const parts = mockParts()
const editor = new FieldMiraEditor(parts)
editor.setUserData("devtool:scoring_zones", scoringZonePayload)
expect(editor.getUserData("devtool:scoring_zones")).toEqual(scoringZonePayload)
expect(editor.getAllDevtoolKeys()).toContain("devtool:scoring_zones")
})

test("overwrite and remove scoring zones", () => {
const parts = mockParts()
const editor = new FieldMiraEditor(parts)
editor.setUserData("devtool:scoring_zones", scoringZonePayload)

const newPayload: ScoringZonePreferences[] = [
{ ...scoringZonePayload[0], name: "Blue Zone", alliance: "blue" as Alliance },
]
editor.setUserData("devtool:scoring_zones", newPayload)
expect(editor.getUserData("devtool:scoring_zones")).toEqual(newPayload)

editor.removeUserData("devtool:scoring_zones")
expect(editor.getUserData("devtool:scoring_zones")).toBeUndefined()
expect(editor.getAllDevtoolKeys()).not.toContain("devtool:scoring_zones")
})
})

describe("Caching tests", () => {
test("cache round-trip preserves devtool scoring zones", () => {
const parts = mockParts()
const editor = new FieldMiraEditor(parts)
editor.setUserData("devtool:scoring_zones", scoringZonePayload)

const encoded = mirabuf.Parts.encode(parts).finish()
const decoded = mirabuf.Parts.decode(encoded)
const roundTripEditor = new FieldMiraEditor(decoded)
expect(roundTripEditor.getUserData("devtool:scoring_zones")).toEqual(scoringZonePayload)
})
})
9 changes: 7 additions & 2 deletions fission/src/ui/modals/mirabuf/ImportLocalMirabufModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,13 @@ const ImportLocalMirabufModal: React.FC<ModalPropsImpl> = ({ modalId }) => {

const hashBuffer = await selectedFile.arrayBuffer()
World.physicsSystem.holdPause(PAUSE_REF_ASSEMBLY_SPAWNING)
await MirabufCachingService.cacheAndGetLocal(hashBuffer, miraType)
.then(x => createMirabuf(x!))
await MirabufCachingService.cacheAndGetLocalWithInfo(hashBuffer, miraType)
.then(result => {
if (result) {
return createMirabuf(result.assembly, undefined, result.cacheInfo.id)
}
return undefined
})
.then(x => {
if (x) {
World.sceneRenderer.registerSceneObject(x)
Expand Down
Loading
Loading