Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
25533ca
feat: init wheel assignment mode
BrandonPacewic Jul 6, 2026
9c36861
feat: wheel axis fix
BrandonPacewic Jul 8, 2026
da6d701
fix: trust wheel geometry
BrandonPacewic Jul 8, 2026
94616ac
doc: document weird behaviour for the future
BrandonPacewic Jul 8, 2026
3136018
doc: corrected wheel assignment function requirements
BrandonPacewic Jul 8, 2026
32cb017
chore: cleanup logging
BrandonPacewic Jul 9, 2026
77ce87d
chore: merge branch `branp/171/urdf-import-support`
BrandonPacewic Jul 9, 2026
3449ad2
Merge remote-tracking branch 'origin/dev' into branp/198/user-select-…
BrandonPacewic Jul 14, 2026
62572f4
chore: merge branch `dev`
BrandonPacewic Jul 14, 2026
c24ca02
feat: highlight components
BrandonPacewic Jul 14, 2026
a343487
feat: kitbot floating driving
BrandonPacewic Jul 21, 2026
f0ba889
feat: use orignial inferred wheel radius geometry
BrandonPacewic Jul 21, 2026
51367ad
feat: deal with mixed geometry and deep part trees
BrandonPacewic Jul 21, 2026
0e36811
feat: assume root component is parent component
BrandonPacewic Jul 22, 2026
0e42b62
feat: support flipping of the drivetrain
BrandonPacewic Jul 22, 2026
99e8823
chore: cleanup logging
BrandonPacewic Jul 24, 2026
9f5293d
chore: cleanup logging
BrandonPacewic Jul 28, 2026
6e7a2ff
chore: merge branch `dev`
BrandonPacewic Jul 28, 2026
b2f0b92
chore: add back auto wheel assignment
BrandonPacewic Jul 28, 2026
63bc70b
chore: merge branch 'dev`
BrandonPacewic Aug 4, 2026
63476aa
Merge remote-tracking branch 'origin/dev' into branp/198/user-select-…
rutmanz Aug 5, 2026
1f2c1e3
chore: make tests pass
rutmanz Aug 5, 2026
d4a9cf4
Merge branch 'dev' into branp/198/user-select-add-joints
rutmanz Aug 7, 2026
44e05e4
Merge remote-tracking branch 'origin/dev' into branp/198/user-select-…
rutmanz Aug 7, 2026
7b83c73
Merge remote-tracking branch 'origin/dev' into branp/198/user-select-…
rutmanz Aug 12, 2026
b8c4361
Merge remote-tracking branch 'origin/dev' into branp/198/user-select-…
rutmanz Aug 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions fission/src/Synthesis.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import ProgressNotifications from "@/components/ProgressNotification.tsx"
import SceneOverlay from "@/components/overlays/SceneOverlay.tsx"
import PortraitOverlay from "@/components/overlays/PortraitOverlay.tsx"
import TouchControls from "./ui/components/TouchControls.tsx"
import WheelAssignmentDebugPanel from "./ui/components/WheelAssignmentDebugPanel.tsx"
import { StateProvider } from "./ui/StateProvider.tsx"
import { ThemeProvider } from "./ui/ThemeProvider.tsx"
import { UIProvider } from "./ui/UIProvider.tsx"
Expand Down Expand Up @@ -112,6 +113,7 @@ const Synthesis = () => {
<DragModeIndicator />
<MultiplayerHUD />
</Stack>
<WheelAssignmentDebugPanel />
<PortraitOverlay />

{!consentPopupDisable && (
Expand Down
155 changes: 155 additions & 0 deletions fission/src/mirabuf/WheelJointBuilder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import MirabufParser from "@/mirabuf/MirabufParser"
import { mirabuf } from "@/proto/mirabuf"
import { isWheel } from "@/systems/physics/ConstraintSettingsUtilities"
import type { WheelAxis } from "@/util/geometry/WheelAxisFit"

/** Prefix for throwaway separator joints; never made into real constraints. */
export const WHEEL_SEPARATOR_JOINT_PREFIX = "manual_wheel_separator_"

export interface WheelAssignment {
/** Part-instance GUID of the occurrence the user picked as the wheel. */
wheelPartGuid: string
/** Part-instance GUID of the occurrence the user picked as the wheel's parent/chassis. */
parentPartGuid: string
/** World-space (assembly rest-pose) axis fit — center in metres, axis normalized. */
axisFit: WheelAxis
}

/** Dry-run parse with RigidGroups emptied to find the wheel's true post-split subtree. */
function computeWheelSubtreeParts(assembly: mirabuf.Assembly, wheelPartGuid: string): ReadonlySet<string> {
const joints = assembly.data!.joints!
const savedRigidGroups = joints.rigidGroups
joints.rigidGroups = []
try {
const dryRunParser = new MirabufParser(assembly)
const node = dryRunParser.partToNodeMap.get(wheelPartGuid)
if (!node) {
return new Set([wheelPartGuid])
}
return new Set(node.parts)
} finally {
joints.rigidGroups = savedRigidGroups
}
}

/** Removes wheelSubtree's parts from every RigidGroup, dropping groups left under 2 occurrences. */
function unbandageWheelSubtree(assembly: mirabuf.Assembly, wheelSubtree: ReadonlySet<string>): void {
const rigidGroups = assembly.data?.joints?.rigidGroups
if (!rigidGroups) {
return
}

for (let index = rigidGroups.length - 1; index >= 0; index--) {
const group = rigidGroups[index]
if (!group.occurrences) continue

const before = group.occurrences.length
group.occurrences = group.occurrences.filter(guid => !wheelSubtree.has(guid))
if (group.occurrences.length === before) continue

if (group.occurrences.length < 2) rigidGroups.splice(index, 1)
}
}

/** Adds a throwaway separator joint between every pair of wheels (existing + new) to keep each in its own rigid node. */
function addWheelSeparatorJoints(assembly: mirabuf.Assembly, assignments: WheelAssignment[]): void {
const joints = assembly.data!.joints!

const existingWheelParts = Object.values(joints.jointInstances!)
.filter(inst => {
const jDef = joints.jointDefinitions?.[inst.jointReference!] as mirabuf.joint.Joint | undefined
return jDef && isWheel(jDef)
})
.map(inst => inst.childPart!)
const wheelParts = [...existingWheelParts, ...assignments.map(a => a.wheelPartGuid)]

for (let i = 0; i < wheelParts.length; i++) {
for (let j = i + 1; j < wheelParts.length; j++) {
const token = `${WHEEL_SEPARATOR_JOINT_PREFIX}${crypto.randomUUID()}`
const name = `Manual Wheel Separator ${i}-${j}`

joints.jointDefinitions![token] = {
info: { GUID: token, name, version: 1 },
origin: { x: 0, y: 0, z: 0 },
jointMotionType: mirabuf.joint.JointMotion.REVOLUTE,
rotational: {
rotationalFreedom: {
axis: { x: 0, y: 1, z: 0 },
dynamics: { damping: 0, friction: 0 },
value: 0,
},
},
}

joints.jointInstances![token] = {
info: { GUID: token, name, version: 1 },
parentPart: wheelParts[i],
childPart: wheelParts[j],
jointReference: token,
offset: { x: 0, y: 0, z: 0 },
}
}
}
}

/** Mutates assembly in place: adds a REVOLUTE wheel joint per assignment and unbandages its rigid-node subtree. */
export function applyWheelAssignments(assembly: mirabuf.Assembly, assignments: WheelAssignment[]): void {
const joints = assembly.data?.joints
if (!joints) throw new Error("Assembly has no joints container")

joints.jointDefinitions ??= {}
joints.jointInstances ??= {}

// Must run before the per-assignment loop below.
addWheelSeparatorJoints(assembly, assignments)

assignments.forEach((assignment, i) => {
const token = `manual_wheel_${crypto.randomUUID()}`
const name = `Manual Wheel ${i + 1}`

const origin: mirabuf.IVector3 = {
x: assignment.axisFit.center.x * 100,
y: assignment.axisFit.center.y * 100,
z: assignment.axisFit.center.z * 100,
}
const axis: mirabuf.IVector3 = {
x: assignment.axisFit.axis.x,
y: assignment.axisFit.axis.y,
z: assignment.axisFit.axis.z,
}

joints.jointDefinitions![token] = {
info: { GUID: token, name, version: 1 },
origin,
jointMotionType: mirabuf.joint.JointMotion.REVOLUTE,
rotational: {
rotationalFreedom: {
axis,
dynamics: { damping: 0, friction: 0 },
value: 0,
},
},
// wheelRadius/wheelWidth are centimetres, matching origin.
userData: {
data: {
wheel: "true",
wheelType: "0",
wheelRadius: String(assignment.axisFit.radius * 100),
wheelWidth: String(assignment.axisFit.width * 100),
},
},
}

joints.jointInstances![token] = {
info: { GUID: token, name, version: 1 },
parentPart: assignment.parentPartGuid,
childPart: assignment.wheelPartGuid,
jointReference: token,
offset: { x: 0, y: 0, z: 0 },
}

// Must run after the joint above is added.
const wheelSubtree = computeWheelSubtreeParts(assembly, assignment.wheelPartGuid)
unbandageWheelSubtree(assembly, wheelSubtree)
})
}
3 changes: 3 additions & 0 deletions fission/src/systems/EventSystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ interface EventDataMap {
SetTouchControlsVisibilityEvent: boolean

DragModeToggled: { enabled: boolean }
WheelAssignmentModeToggled: { enabled: boolean }
WheelAssignmentPendingCountChanged: { count: number }
WheelAssignmentDriveReversedChanged: { reversed: boolean }

CameraModeChangedEvent: { mode: string }
CameraFocusChangedEvent: { focusProvider: MirabufSceneObject | undefined }
Expand Down
8 changes: 8 additions & 0 deletions fission/src/systems/World.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type MultiplayerSystem from "./multiplayer/MultiplayerSystem"
import PhysicsSystem from "./physics/PhysicsSystem"
import DragModeSystem from "./scene/DragModeSystem"
import SceneRenderer from "./scene/SceneRenderer"
import WheelAssignmentMode from "./scene/WheelAssignmentMode"
import RobotPositionTracker from "./simulation/RobotPositionTracker"
import SimulationSystem from "./simulation/SimulationSystem"

Expand All @@ -25,6 +26,7 @@ class World {
private _multiplayerSystem?: MultiplayerSystem
private _analyticsSystem: AnalyticsSystem | undefined = undefined
private _dragModeSystem: DragModeSystem
private _wheelAssignmentMode: WheelAssignmentMode
private _performanceMonitorSystem: PerformanceMonitoringSystem
private _scoreTracker: ScoreTracker = new ScoreTracker()

Expand Down Expand Up @@ -71,6 +73,9 @@ class World {
public static get scoreTracker() {
return this._instance?._scoreTracker!
}
public static get wheelAssignmentMode() {
return this._instance?._wheelAssignmentMode!
}

public static getOwnRobots() {
return World.multiplayerSystem?.getOwnRobots() ?? World.sceneRenderer.mirabufSceneObjects.getRobots()
Expand Down Expand Up @@ -104,6 +109,7 @@ class World {
this._simulationSystem = new SimulationSystem()
this._inputSystem = new InputSystem()
this._dragModeSystem = new DragModeSystem()
this._wheelAssignmentMode = new WheelAssignmentMode()
this._performanceMonitorSystem = new PerformanceMonitoringSystem()

try {
Expand Down Expand Up @@ -146,6 +152,7 @@ class World {
this._inputSystem.destroy()
this._multiplayerSystem?.destroy()
this._dragModeSystem.destroy()
this._wheelAssignmentMode.destroy()

this._performanceMonitorSystem.destroy()
this._analyticsSystem?.destroy()
Expand All @@ -167,6 +174,7 @@ class World {
this._accumTimes.inputTime += this.time(() => this._inputSystem.update(this._currentDeltaT))
this._accumTimes.sceneTime += this.time(() => this._sceneRenderer.update(this._currentDeltaT))
this._dragModeSystem.update(this._currentDeltaT)
this._wheelAssignmentMode.update(this._currentDeltaT)
})

this._analyticsSystem?.update(this._currentDeltaT)
Expand Down
16 changes: 16 additions & 0 deletions fission/src/systems/physics/ConstraintSettingsUtilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,3 +147,19 @@ export function createDOFSpecs(dofs: mirabuf.joint.IDOF[]): DOFSpecs[] {
export function isWheel(jDef: mirabuf.joint.Joint): boolean {
return (jDef.info?.name !== "grounded" && (jDef.userData?.data?.wheel ?? "false") === "true") ?? false
}

/** Explicit radius set on a manually-assigned wheel's userData (centimetres); undefined falls back to AABB inference. */
export function getExplicitWheelRadius(jDef: mirabuf.joint.Joint): number | undefined {
const raw = jDef.userData?.data?.wheelRadius
if (raw === undefined) return undefined
const radius = Number(raw) / 100.0
return Number.isFinite(radius) && radius > 0 ? radius : undefined
}

/** Same idea as getExplicitWheelRadius, but for axle-direction width. */
export function getExplicitWheelWidth(jDef: mirabuf.joint.Joint): number | undefined {
const raw = jDef.userData?.data?.wheelWidth
if (raw === undefined) return undefined
const width = Number(raw) / 100.0
return Number.isFinite(width) && width > 0 ? width : undefined
}
2 changes: 2 additions & 0 deletions fission/src/systems/physics/Mechanism.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ class Mechanism {
public ghostBodies: Jolt.BodyID[] = []
public touchedBodies: [SceneObjectId, RigidNodeId][] = []

public urdfWheelForward?: { x: number; y: number; z: number }

public constructor(
rootBody: string,
bodyMap: Map<string, Jolt.BodyID>,
Expand Down
23 changes: 21 additions & 2 deletions fission/src/systems/physics/PhysicsSystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
type RigidNodeId,
type RigidNodeReadOnly,
} from "@/mirabuf/MirabufParser.ts"
import { WHEEL_SEPARATOR_JOINT_PREFIX } from "@/mirabuf/WheelJointBuilder.ts"
import { mirabuf } from "@/proto/mirabuf"
import type { Message } from "../multiplayer/MultiplayerTypes.ts"
import PreferencesSystem from "../preferences/PreferencesSystem"
Expand All @@ -43,6 +44,8 @@ import {
createDOFSpecs,
createVehicleController,
getAxis,
getExplicitWheelRadius,
getExplicitWheelWidth,
getPerpendicular,
isWheel,
setAxes,
Expand Down Expand Up @@ -126,6 +129,9 @@ const DEFAULT_FRICTION = 0.7
const SUSPENSION_MIN_FACTOR = 0.0001
const SUSPENSION_MAX_FACTOR = 0.0001

// Manually-assigned wheels need more suspension travel to tolerate circle-fit origin error.
const MANUAL_WHEEL_SUSPENSION_MAX_FACTOR = 0.2

// Wheels whose inferred radii fall within this relative tolerance of each other are treated as the
// same size and snapped to a common radius. Sits well above mesh-tessellation noise (<1%) and well
// below the gap between genuinely different wheel sizes, so distinct sizes stay in separate groups.
Expand Down Expand Up @@ -478,6 +484,8 @@ class PhysicsSystem extends WorldSystem {

joints.forEach(([jointGuid, jointInst]) => {
if (jointGuid == GROUNDED_JOINT_ID) return
// Structural-only separator joint, not a real constraint.
if (jointGuid.startsWith(WHEEL_SEPARATOR_JOINT_PREFIX)) return

const rnA = parser.partToNodeMap.get(jointInst.parentPart!)
const rnB = parser.partToNodeMap.get(jointInst.childPart!)
Expand Down Expand Up @@ -832,21 +840,31 @@ class PhysicsSystem extends WorldSystem {
wheelDimensions.radius = resolvedRadius
}

// Manual wheels carry explicit width too; AABB width would read the whole shared rigid body.
const explicitWidth = getExplicitWheelWidth(jointDefinition)
if (explicitWidth !== undefined) {
wheelDimensions.width = explicitWidth
}

const wheelPos = urdfWheelBasis
? convertJoltRVec3ToJoltVec3(anchorPoint)
: convertJoltRVec3ToJoltVec3(anchorPoint.Add(axis))

const wheelSettings = new JOLT.WheelSettingsWV()

const simulatedRadius = wheelDimensions.radius * 1.05

wheelSettings.mPosition = wheelPos
JOLT.destroy(wheelPos)

wheelSettings.mMaxSteerAngle = 0.0
wheelSettings.mMaxHandBrakeTorque = 0.0
wheelSettings.mRadius = wheelDimensions.radius * 1.05
wheelSettings.mRadius = simulatedRadius
wheelSettings.mWidth = wheelDimensions.width
const isManualWheel = getExplicitWheelRadius(jointDefinition) !== undefined
wheelSettings.mSuspensionMinLength = wheelDimensions.radius * SUSPENSION_MIN_FACTOR
wheelSettings.mSuspensionMaxLength = wheelDimensions.radius * SUSPENSION_MAX_FACTOR
wheelSettings.mSuspensionMaxLength =
wheelDimensions.radius * (isManualWheel ? MANUAL_WHEEL_SUSPENSION_MAX_FACTOR : SUSPENSION_MAX_FACTOR)
wheelSettings.mInertia = 1

if (urdfWheelBasis) {
Expand Down Expand Up @@ -980,6 +998,7 @@ class PhysicsSystem extends WorldSystem {
const maxBounds = new JOLT.Vec3(-1000000.0, -1000000.0, -1000000.0)

nonPhysicsNodes.forEach(rn => {
// Note: CompoundShapeSubShape.GetPositionCOM() is COM-relative, not assembly-space.
const compoundShapeSettings = new JOLT.StaticCompoundShapeSettings()

let shapesAdded = 0
Expand Down
Loading
Loading