Skip to content

Commit 874db0c

Browse files
feat: mix and match session document schema
1 parent 3306573 commit 874db0c

3 files changed

Lines changed: 324 additions & 0 deletions

File tree

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import type { mirabuf } from "@/proto/mirabuf"
2+
import {
3+
createEmptySession,
4+
MIX_AND_MATCH_SESSION_VERSION,
5+
MIX_AND_MATCH_USER_DATA_KEY,
6+
type MixAndMatchComponent,
7+
type MixAndMatchSession,
8+
type TimelineEntry,
9+
type TransformArray,
10+
} from "./MixAndMatchTypes"
11+
12+
/**
13+
* Reads and writes the session document to `Parts.user_data["mixAndMatchSession"]` as a JSON string.
14+
*
15+
* A typed proto message is deliberately not used here: the map field is forward compatible, so a
16+
* mira carrying this key is still an ordinary robot to every other consumer.
17+
*/
18+
19+
const TRANSFORM_LENGTH = 16
20+
21+
function isTransform(value: unknown): value is TransformArray {
22+
return Array.isArray(value) && value.length === TRANSFORM_LENGTH && value.every(x => typeof x === "number")
23+
}
24+
25+
function isRecord(value: unknown): value is Record<string, unknown> {
26+
return typeof value === "object" && value != null && !Array.isArray(value)
27+
}
28+
29+
function parseEntry(value: unknown): TimelineEntry | undefined {
30+
if (!isRecord(value)) return undefined
31+
32+
switch (value.type) {
33+
case "spawn":
34+
return typeof value.componentId === "string" &&
35+
typeof value.libraryPartRef === "string" &&
36+
isTransform(value.transform)
37+
? {
38+
type: "spawn",
39+
componentId: value.componentId,
40+
libraryPartRef: value.libraryPartRef,
41+
transform: [...value.transform],
42+
}
43+
: undefined
44+
case "move":
45+
return typeof value.componentId === "string" && isTransform(value.transform)
46+
? { type: "move", componentId: value.componentId, transform: [...value.transform] }
47+
: undefined
48+
case "weld":
49+
return typeof value.componentA === "string" &&
50+
typeof value.componentB === "string" &&
51+
isTransform(value.relativeOffset)
52+
? {
53+
type: "weld",
54+
componentA: value.componentA,
55+
componentB: value.componentB,
56+
relativeOffset: [...value.relativeOffset],
57+
}
58+
: undefined
59+
case "resize":
60+
return typeof value.componentId === "string" && typeof value.sizeOption === "string"
61+
? { type: "resize", componentId: value.componentId, sizeOption: value.sizeOption }
62+
: undefined
63+
case "delete":
64+
return typeof value.componentId === "string"
65+
? { type: "delete", componentId: value.componentId }
66+
: undefined
67+
default:
68+
return undefined
69+
}
70+
}
71+
72+
function parseComponent(value: unknown): MixAndMatchComponent | undefined {
73+
if (!isRecord(value)) return undefined
74+
if (typeof value.id !== "string" || typeof value.libraryPartRef !== "string") return undefined
75+
76+
return { id: value.id, libraryPartRef: value.libraryPartRef }
77+
}
78+
79+
export function serializeSession(session: MixAndMatchSession): string {
80+
return JSON.stringify(session)
81+
}
82+
83+
/**
84+
* Parses a serialized session, dropping anything malformed rather than throwing. A partially
85+
* readable document is more useful than none: the user can still see and fix their build.
86+
*
87+
* @param json Raw JSON previously written by {@link serializeSession}.
88+
* @returns The session, or undefined if the document isn't a mix-and-match session of a known version.
89+
*/
90+
export function parseSession(json: string): MixAndMatchSession | undefined {
91+
let raw: unknown
92+
try {
93+
raw = JSON.parse(json)
94+
} catch (e) {
95+
console.warn("Malformed mix-and-match session", e)
96+
return undefined
97+
}
98+
99+
if (!isRecord(raw)) return undefined
100+
if (raw.version !== MIX_AND_MATCH_SESSION_VERSION) {
101+
console.warn(`Unsupported mix-and-match session version: ${raw.version}`)
102+
return undefined
103+
}
104+
105+
const session = createEmptySession()
106+
if (Array.isArray(raw.components)) {
107+
raw.components.forEach(x => {
108+
const component = parseComponent(x)
109+
if (component) session.components.push(component)
110+
})
111+
}
112+
if (Array.isArray(raw.timeline)) {
113+
raw.timeline.forEach(x => {
114+
const entry = parseEntry(x)
115+
if (entry) session.timeline.push(entry)
116+
})
117+
}
118+
119+
return session
120+
}
121+
122+
export function hasMixAndMatchSession(assembly: mirabuf.IAssembly): boolean {
123+
return assembly.data?.parts?.userData?.data?.[MIX_AND_MATCH_USER_DATA_KEY] != null
124+
}
125+
126+
export function readSessionFromAssembly(assembly: mirabuf.IAssembly): MixAndMatchSession | undefined {
127+
const raw = assembly.data?.parts?.userData?.data?.[MIX_AND_MATCH_USER_DATA_KEY]
128+
return raw == null ? undefined : parseSession(raw)
129+
}
130+
131+
/**
132+
* Stamps the session onto an assembly's part user data. Mutates `assembly` in place, creating the
133+
* `userData` map if the exporter left it off.
134+
*/
135+
export function writeSessionToAssembly(assembly: mirabuf.IAssembly, session: MixAndMatchSession): boolean {
136+
const parts = assembly.data?.parts
137+
if (!parts) {
138+
console.warn("Cannot write mix-and-match session: assembly has no parts")
139+
return false
140+
}
141+
142+
parts.userData ??= { data: {} }
143+
parts.userData.data ??= {}
144+
parts.userData.data[MIX_AND_MATCH_USER_DATA_KEY] = serializeSession(session)
145+
146+
return true
147+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/**
2+
* Document schema for a mix-and-match build session.
3+
*
4+
* The timeline is the source of truth, not the derived weld graph. Re-entering build mode replays
5+
* every entry in order to restore the exact state as of the last "OK", rather than reconstructing an
6+
* equivalent-looking arrangement.
7+
*/
8+
9+
/** Key under `Parts.user_data` that the serialized session lives at. Same field `urdfImport` uses. */
10+
export const MIX_AND_MATCH_USER_DATA_KEY = "mixAndMatchSession"
11+
12+
export const MIX_AND_MATCH_SESSION_VERSION = 1
13+
14+
/** Identifies one placed component within a session. Unique per session, not globally. */
15+
export type ComponentId = string
16+
17+
/** Points at the library part a component was spawned from. Hash key into `MirabufCachingService`. */
18+
export type LibraryPartRef = string
19+
20+
/** Column-major 4x4, matching `convertThreeMatrix4ToArray` / `convertArrayToThreeMatrix4`. */
21+
export type TransformArray = number[]
22+
23+
export interface MixAndMatchComponent {
24+
id: ComponentId
25+
libraryPartRef: LibraryPartRef
26+
}
27+
28+
/** A part was added to the build. `transform` is the world transform of its root body. */
29+
export interface SpawnTimelineEntry {
30+
type: "spawn"
31+
componentId: ComponentId
32+
libraryPartRef: LibraryPartRef
33+
transform: TransformArray
34+
}
35+
36+
/** A part was repositioned. `transform` is absolute, not a delta, so replay never accumulates error. */
37+
export interface MoveTimelineEntry {
38+
type: "move"
39+
componentId: ComponentId
40+
transform: TransformArray
41+
}
42+
43+
/**
44+
* `componentB` was welded onto `componentA`; B is the child. A component has at most one active weld,
45+
* so a later weld naming the same B replaces this one.
46+
*/
47+
export interface WeldTimelineEntry {
48+
type: "weld"
49+
componentA: ComponentId
50+
componentB: ComponentId
51+
relativeOffset: TransformArray
52+
}
53+
54+
export interface ResizeTimelineEntry {
55+
type: "resize"
56+
componentId: ComponentId
57+
sizeOption: string
58+
}
59+
60+
export interface DeleteTimelineEntry {
61+
type: "delete"
62+
componentId: ComponentId
63+
}
64+
65+
export type TimelineEntry =
66+
| SpawnTimelineEntry
67+
| MoveTimelineEntry
68+
| WeldTimelineEntry
69+
| ResizeTimelineEntry
70+
| DeleteTimelineEntry
71+
72+
export type TimelineEntryType = TimelineEntry["type"]
73+
74+
export interface MixAndMatchSession {
75+
version: typeof MIX_AND_MATCH_SESSION_VERSION
76+
components: MixAndMatchComponent[]
77+
timeline: TimelineEntry[]
78+
}
79+
80+
export function createEmptySession(): MixAndMatchSession {
81+
return { version: MIX_AND_MATCH_SESSION_VERSION, components: [], timeline: [] }
82+
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { describe, expect, test } from "vitest"
2+
import {
3+
hasMixAndMatchSession,
4+
parseSession,
5+
readSessionFromAssembly,
6+
serializeSession,
7+
writeSessionToAssembly,
8+
} from "@/mix-and-match/MixAndMatchDocument"
9+
import { createEmptySession, type MixAndMatchSession } from "@/mix-and-match/MixAndMatchTypes"
10+
import { mirabuf } from "@/proto/mirabuf"
11+
12+
const IDENTITY = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]
13+
const SHIFTED = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 2, 0, 3, 1]
14+
15+
function sampleSession(): MixAndMatchSession {
16+
const session = createEmptySession()
17+
session.components.push({ id: "c1", libraryPartRef: "hash-frame" }, { id: "c2", libraryPartRef: "hash-pod" })
18+
session.timeline.push(
19+
{ type: "spawn", componentId: "c1", libraryPartRef: "hash-frame", transform: IDENTITY },
20+
{ type: "spawn", componentId: "c2", libraryPartRef: "hash-pod", transform: SHIFTED },
21+
{ type: "move", componentId: "c2", transform: SHIFTED },
22+
{ type: "weld", componentA: "c1", componentB: "c2", relativeOffset: SHIFTED },
23+
{ type: "resize", componentId: "c1", sizeOption: "28x28" },
24+
{ type: "delete", componentId: "c2" }
25+
)
26+
27+
return session
28+
}
29+
30+
function emptyAssembly(): mirabuf.Assembly {
31+
return mirabuf.Assembly.create({ data: mirabuf.AssemblyData.create({ parts: mirabuf.Parts.create({}) }) })
32+
}
33+
34+
describe("Mix and Match Document", () => {
35+
test("Round Trips Every Entry Type", () => {
36+
const original = sampleSession()
37+
const parsed = parseSession(serializeSession(original))
38+
39+
expect(parsed).toEqual(original)
40+
})
41+
42+
test("Rejects Unknown Version", () => {
43+
expect(parseSession(JSON.stringify({ version: 99, components: [], timeline: [] }))).toBeUndefined()
44+
})
45+
46+
test("Rejects Malformed JSON", () => {
47+
expect(parseSession("{ not json")).toBeUndefined()
48+
})
49+
50+
test("Drops Malformed Entries Instead Of Failing", () => {
51+
const parsed = parseSession(
52+
JSON.stringify({
53+
version: 1,
54+
components: [{ id: "c1", libraryPartRef: "hash" }, { id: 7 }],
55+
timeline: [
56+
{ type: "spawn", componentId: "c1", libraryPartRef: "hash", transform: IDENTITY },
57+
{ type: "spawn", componentId: "c2", libraryPartRef: "hash", transform: [1, 2, 3] },
58+
{ type: "not-a-real-type", componentId: "c1" },
59+
{ type: "delete", componentId: "c1" },
60+
],
61+
})
62+
)
63+
64+
expect(parsed?.components).toHaveLength(1)
65+
expect(parsed?.timeline.map(x => x.type)).toEqual(["spawn", "delete"])
66+
})
67+
68+
test("Writes And Reads Back Through Part User Data", () => {
69+
const assembly = emptyAssembly()
70+
expect(hasMixAndMatchSession(assembly)).toBe(false)
71+
72+
const session = sampleSession()
73+
expect(writeSessionToAssembly(assembly, session)).toBe(true)
74+
expect(hasMixAndMatchSession(assembly)).toBe(true)
75+
expect(readSessionFromAssembly(assembly)).toEqual(session)
76+
})
77+
78+
test("Survives A Protobuf Encode And Decode", () => {
79+
const assembly = emptyAssembly()
80+
const session = sampleSession()
81+
writeSessionToAssembly(assembly, session)
82+
83+
const decoded = mirabuf.Assembly.decode(mirabuf.Assembly.encode(assembly).finish())
84+
85+
expect(readSessionFromAssembly(decoded)).toEqual(session)
86+
})
87+
88+
test("Reports No Session On An Ordinary Assembly", () => {
89+
const assembly = emptyAssembly()
90+
assembly.data!.parts!.userData = mirabuf.UserData.create({ data: { urdfImport: "true" } })
91+
92+
expect(hasMixAndMatchSession(assembly)).toBe(false)
93+
expect(readSessionFromAssembly(assembly)).toBeUndefined()
94+
})
95+
})

0 commit comments

Comments
 (0)