Fix label positioning, add billboard mode, fix XR entry shift, and fix config system

Label Positioning Fixes:
- Fix labels accounting for mesh scaling using maximumWorld coordinates
- Labels now properly positioned on scaled objects (spheres, boxes, etc.)
- Restore world→local coordinate transformation in updateLabelPosition

Billboard Mode Implementation:
- Add configurable label rendering modes: fixed, billboard, dynamic, distance-based
- Implement billboard mode (labels always face camera using BILLBOARDMODE_Y)
- Add label rendering mode to AppConfig with default 'billboard'
- Add UI selector in ConfigModal for label rendering mode
- Observable pattern updates all existing labels when mode changes

XR Entry Positioning Fix:
- Synchronize desktop camera position to platform before entering XR
- Transfer camera world position and rotation to prevent scene shift
- Reset physics velocity on XR entry to prevent drift
- Add debug logging for position synchronization

Config System Architecture Fix:
- Create singleton appConfigInstance to ensure single source of truth
- Update DiagramObject to use singleton instead of creating instances
- Update DiagramManager to use singleton
- Fix ConfigModal to update AppConfig directly (was only updating legacy config)
- ConfigModal now triggers Observable notifications via appConfigInstance setters
- Maintain legacy config for backward compatibility
- Fixes issue where label rendering mode changes didn't take effect

Files Modified:
- src/diagram/diagramObject.ts - Label positioning, billboard mode, singleton config
- src/diagram/diagramManager.ts - Use singleton config
- src/util/appConfig.ts - Add labelRenderingMode, export singleton
- src/util/appConfigType.ts - Add LabelRenderingMode type
- src/react/pages/configModal.tsx - Update AppConfig directly, add label mode UI
- src/util/functions/groundMeshObserver.ts - Add camera position sync on XR entry
- public/api/user/features - Update test config
- package.json - Version bump

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Michael Mainguy 2025-11-18 08:52:04 -06:00
parent c1503d959e
commit 970f6fc78a
9 changed files with 156 additions and 33 deletions

View File

@ -1,7 +1,7 @@
{
"name": "immersive",
"private": true,
"version": "0.0.8-27",
"version": "0.0.8-28",
"type": "module",
"license": "MIT",
"engines": {

View File

@ -14,7 +14,7 @@
"privateDesigns": false,
"encryptedDesigns": false,
"editData": false,
"config": false,
"config": true,
"enterImmersive": true,
"launchMetaQuest": true
},

View File

@ -2,7 +2,7 @@ import {AbstractActionManager, AbstractMesh, ActionManager, Observable, Scene, W
import {DiagramEntity, DiagramEntityType, DiagramEvent, DiagramEventType} from "./types/diagramEntity";
import log from "loglevel";
import {AppConfig} from "../util/appConfig";
import {appConfigInstance} from "../util/appConfig";
import {buildEntityActionManager} from "./functions/buildEntityActionManager";
import {DefaultScene} from "../defaultScene";
import {DiagramMenuManager} from "./diagramMenuManager";
@ -17,7 +17,6 @@ import {ControllerEvent} from "../controllers/types/controllerEvent";
export class DiagramManager {
private readonly _logger = log.getLogger('DiagramManager');
public readonly _config: AppConfig;
private readonly _controllerObservable: Observable<ControllerEvent>;
private readonly _diagramEntityActionManager: ActionManager;
public readonly onDiagramEventObservable: Observable<DiagramEvent> = new Observable();
@ -40,7 +39,6 @@ export class DiagramManager {
constructor(readyObservable: Observable<boolean>) {
this._me = getMe();
this._scene = DefaultScene.Scene;
this._config = new AppConfig();
this._diagramMenuManager = new DiagramMenuManager(this.onDiagramEventObservable, controllerObservable, readyObservable);
this._diagramEntityActionManager = buildEntityActionManager(controllerObservable);
this.onDiagramEventObservable.add(this.onDiagramEvent, DiagramEventObserverMask.FROM_DB, true, this);
@ -133,8 +131,8 @@ export class DiagramManager {
this._diagramObjects.set(diagramObject.diagramEntity.id, diagramObject);
}
public get config(): AppConfig {
return this._config;
public get config() {
return appConfigInstance;
}

View File

@ -25,6 +25,7 @@ import {xyztovec} from "./functions/vectorConversion";
import {AnimatedLineTexture} from "../util/animatedLineTexture";
import {getToolboxColors} from "../toolbox/toolbox";
import {findClosestColor} from "../util/functions/findClosestColor";
import {appConfigInstance} from "../util/appConfig";
/**
* Converts a Color3 to a hex color string
@ -62,6 +63,7 @@ export class DiagramObject {
private _fromMesh: AbstractMesh;
private _toMesh: AbstractMesh;
private _meshRemovedObserver: Observer<AbstractMesh>;
private _configObserver: Observer<any>;
// Position caching for connection optimization
private _lastFromPosition: Vector3 = null;
private _lastToPosition: Vector3 = null;
@ -70,6 +72,12 @@ export class DiagramObject {
constructor(scene: Scene, eventObservable: Observable<DiagramEvent>, options?: DiagramObjectOptionsType) {
this._eventObservable = eventObservable;
this._scene = scene;
// Subscribe to config changes to update label rendering mode
this._configObserver = appConfigInstance.onConfigChangedObservable.add(() => {
this.updateLabelRenderingMode();
});
if (options) {
this._logger.debug('DiagramObject constructor called with options', options);
if (options.diagramEntity) {
@ -154,37 +162,69 @@ export class DiagramObject {
this._labelBack.parent = this._label;
this._labelBack.metadata = {exportable: true};
this.updateLabelPosition();
this.updateLabelRenderingMode();
}
private updateLabelRenderingMode() {
if (!this._label) {
return;
}
const mode = appConfigInstance.current.labelRenderingMode || 'billboard';
// Reset billboard mode first
this._label.billboardMode = Mesh.BILLBOARDMODE_NONE;
if (this._labelBack) {
this._labelBack.billboardMode = Mesh.BILLBOARDMODE_NONE;
}
switch (mode) {
case 'billboard':
// Billboard mode - labels always face camera (Y-axis only to prevent tilting)
this._label.billboardMode = Mesh.BILLBOARDMODE_Y;
if (this._labelBack) {
this._labelBack.billboardMode = Mesh.BILLBOARDMODE_Y;
}
break;
case 'fixed':
// Fixed mode - no billboard (default state, already set above)
break;
case 'dynamic':
// Dynamic mode - to be implemented in future
// TODO: Implement screen-space positioning
this._logger.warn('Dynamic label rendering mode not yet implemented');
break;
case 'distance':
// Distance-based mode - to be implemented in future
// TODO: Implement distance-based offset
this._logger.warn('Distance-based label rendering mode not yet implemented');
break;
}
}
public updateLabelPosition() {
if (this._label) {
this._mesh.computeWorldMatrix(true);
this._mesh.refreshBoundingInfo({});
this._mesh.refreshBoundingInfo();
if (this._from && this._to) {
//this._label.position.x = .06;
//this._label.position.z = .06;
// Connection labels (arrows/lines)
this._label.position.y = .05;
this._label.rotation.y = Math.PI / 2;
this._labelBack.rotation.y = Math.PI;
this._labelBack.position.z = 0.001
//this._label.billboardMode = Mesh.BILLBOARDMODE_Y;
//this._label.billboardMode = Mesh.BILLBOARDMODE_Y;
this._labelBack.position.z = 0.001;
} else {
const top =
this._mesh.getBoundingInfo().boundingBox.maximumWorld;
// Standard object labels - convert world space to parent's local space
// This accounts for mesh scaling, which is not included in boundingBox.maximum
const top = this._mesh.getBoundingInfo().boundingBox.maximumWorld;
const temp = new TransformNode("temp", this._scene);
temp.position = top;
temp.setParent(this._baseTransform);
const y = temp.position.y;
temp.dispose();
this._label.position.y = y + .06;
//this._labelBack.position.y = y + .06;
this._label.position.y = y + 0.06;
this._labelBack.rotation.y = Math.PI;
this._labelBack.position.z = 0.001
//this._label.billboardMode = Mesh.BILLBOARDMODE_Y;
this._labelBack.position.z = 0.001;
}
}
}
@ -320,6 +360,8 @@ export class DiagramObject {
this._logger.debug('DiagramObject dispose called for ', this._diagramEntity?.id)
this._scene?.onAfterRenderObservable.remove(this._sceneObserver);
this._sceneObserver = null;
appConfigInstance?.onConfigChangedObservable.remove(this._configObserver);
this._configObserver = null;
this._mesh?.setParent(null);
this._mesh?.dispose(true, false);
this._mesh = null;

View File

@ -32,11 +32,12 @@ function createDynamicTexture(text: string, font: string, DTWidth: number, DTHei
function createMaterial(dynamicTexture: DynamicTexture): Material {
const mat = new StandardMaterial("text-mat", DefaultScene.Scene);
//mat.diffuseColor = Color3.Black();
mat.disableLighting = false;
//mat.backFaceCulling = false;
mat.disableLighting = true;
mat.backFaceCulling = true;
mat.emissiveTexture = dynamicTexture;
mat.diffuseTexture = dynamicTexture;
mat.metadata = {exportable: true};
//mat.freeze();
return mat;
}

View File

@ -1,6 +1,7 @@
import {Group, Modal, SegmentedControl, Stack, Switch} from "@mantine/core";
import {Group, Modal, SegmentedControl, Stack, Switch, Select} from "@mantine/core";
import {useEffect, useState} from "react";
import {setAppConfig} from "../../util/appConfig";
import {appConfigInstance} from "../../util/appConfig";
import {LabelRenderingMode} from "../../util/appConfigType";
const locationSnaps = [
{value: ".01", label: '1cm'},
@ -16,6 +17,12 @@ const rotationSnaps = [
{value: "180", label: '180°'},
{value: "360", label: '360°'},
]
const labelRenderingModes = [
{value: 'fixed', label: 'Fixed'},
{value: 'billboard', label: 'Billboard (Always Face Camera)'},
{value: 'dynamic', label: 'Dynamic (Coming Soon)', disabled: true},
{value: 'distance', label: 'Distance-based (Coming Soon)', disabled: true},
]
let defaultConfig =
{
locationSnap: '.1',
@ -24,7 +31,8 @@ let defaultConfig =
rotationSnapEnabled: true,
flyModeEnabled: true,
snapTurnSnap: '45',
snapTurnSnapEnabled: false
snapTurnSnapEnabled: false,
labelRenderingMode: 'billboard' as LabelRenderingMode
}
try {
const newConfig = JSON.parse(localStorage.getItem('config'));
@ -42,19 +50,30 @@ export default function ConfigModal({configOpened, closeConfig}) {
const [rotationSnap, setRotationSnap] = useState(defaultConfig.rotationSnap);
const [rotationSnapEnabled, setRotationSnapEnabled] = useState(defaultConfig.rotationSnapEnabled);
const [flyModeEnabled, setFlyModeEnabled] = useState(defaultConfig.flyModeEnabled);
const [labelRenderingMode, setLabelRenderingMode] = useState<LabelRenderingMode>(defaultConfig.labelRenderingMode);
useEffect(() => {
const config = {
// Update AppConfig singleton instance directly
// This triggers Observable notifications to all DiagramObjects
appConfigInstance.setLabelRenderingMode(labelRenderingMode);
appConfigInstance.setFlyMode(flyModeEnabled);
appConfigInstance.setGridSnap(parseFloat(locationSnap));
appConfigInstance.setRotateSnap(parseFloat(rotationSnap));
appConfigInstance.setTurnSnap(parseFloat(snapTurnSnap));
// Also update legacy config for backward compatibility
const legacyConfig = {
locationSnap: locationSnap,
locationSnapEnabled: locationSnapEnabled,
rotationSnap: rotationSnap,
rotationSnapEnabled: rotationSnapEnabled,
snapTurnSnap: snapTurnSnap,
snapTurnSnapEnabled: snapTurnSnapEnabled,
flyModeEnabled: flyModeEnabled
}
setAppConfig(config);
flyModeEnabled: flyModeEnabled,
labelRenderingMode: labelRenderingMode
};
localStorage.setItem('config', JSON.stringify(legacyConfig));
}, [locationSnap, locationSnapEnabled, rotationSnap, rotationSnapEnabled, snapTurnSnap, snapTurnSnapEnabled, flyModeEnabled]);
}, [locationSnap, locationSnapEnabled, rotationSnap, rotationSnapEnabled, snapTurnSnap, snapTurnSnapEnabled, flyModeEnabled, labelRenderingMode]);
return (
<Modal onClose={closeConfig} opened={configOpened}>
<h1>Configuration</h1>
@ -104,6 +123,16 @@ export default function ConfigModal({configOpened, closeConfig}) {
value={snapTurnSnap}
onChange={setSnapTurnSnap}/>
</Group>
<Group key="labelmode">
<label key="label">Label Rendering Mode</label>
<Select
w={300}
key="select"
data={labelRenderingModes}
value={labelRenderingMode}
onChange={(value) => setLabelRenderingMode(value as LabelRenderingMode)}
/>
</Group>
</Stack>
</Modal>
)

View File

@ -1,5 +1,5 @@
import {Observable} from "@babylonjs/core";
import {AppConfigType} from "./appConfigType";
import {AppConfigType, LabelRenderingMode} from "./appConfigType";
import log from "loglevel";
export class AppConfig {
public readonly onConfigChangedObservable = new Observable<AppConfigType>();
@ -13,7 +13,8 @@ export class AppConfig {
newRelicKey: null,
newRelicAccount: null,
physicsEnabled: false,
flyMode: true
flyMode: true,
labelRenderingMode: 'billboard'
}
constructor() {
@ -74,12 +75,21 @@ export class AppConfig {
this.save();
}
public setLabelRenderingMode(mode: LabelRenderingMode) {
this._currentConfig.labelRenderingMode = mode;
this.save();
}
private save() {
localStorage.setItem('appConfig', JSON.stringify(this._currentConfig));
this.onConfigChangedObservable.notifyObservers(this._currentConfig, -1);
}
}
// Singleton instance for app-wide configuration
// Use this instead of creating new AppConfig() instances
export const appConfigInstance = new AppConfig();
let defaultConfig: ConfigType =
{
locationSnap: '.1',

View File

@ -1,3 +1,5 @@
export type LabelRenderingMode = 'fixed' | 'billboard' | 'dynamic' | 'distance';
export type AppConfigType = {
id?: number,
currentDiagramId?: string,
@ -10,5 +12,6 @@ export type AppConfigType = {
newRelicAccount?: string,
passphrase?: string,
flyMode?: boolean,
labelRenderingMode?: LabelRenderingMode,
}

View File

@ -1,4 +1,4 @@
import {AbstractMesh, Vector3, WebXRDefaultExperience, WebXRMotionControllerManager, WebXRState} from "@babylonjs/core";
import {AbstractMesh, Quaternion, Vector3, WebXRDefaultExperience, WebXRMotionControllerManager, WebXRState} from "@babylonjs/core";
import log from "loglevel";
import {WebController} from "../../controllers/webController";
import {Rigplatform} from "../../controllers/rigplatform";
@ -33,6 +33,46 @@ export async function groundMeshObserver(ground: AbstractMesh,
}
});
window.addEventListener('enterXr', async () => {
// Synchronize desktop camera position with platform before entering XR
// This prevents the jarring scene shift when transitioning to immersive mode
const scene = ground.getScene();
const desktopCamera = scene.activeCamera;
const platform = scene.getMeshByName('platform');
if (desktopCamera && platform) {
// Get desktop camera's world position
const cameraWorldPos = desktopCamera.globalPosition.clone();
// Calculate platform position accounting for camera's local offset
// Camera is at (0, 1.6, 0) in local space relative to cameraTransform
const platformTargetPos = cameraWorldPos.clone();
platformTargetPos.y = 0.01; // Platform stays at floor level
// Subtract camera's local Y offset (1.6m height) from platform position
const cameraLocalOffset = new Vector3(0, 1.59, 0); // 1.6 - 0.01
platformTargetPos.subtractInPlace(cameraLocalOffset);
// Set platform position
platform.setAbsolutePosition(platformTargetPos);
// Match platform rotation to desktop camera's viewing direction
const cameraForward = desktopCamera.getDirection(Vector3.Forward());
const yaw = Math.atan2(cameraForward.x, cameraForward.z);
platform.rotationQuaternion = Quaternion.FromEulerAngles(0, yaw, 0);
// Reset physics velocity to prevent drift on XR entry
if (platform.physicsBody) {
platform.physicsBody.setLinearVelocity(Vector3.Zero());
platform.physicsBody.setAngularVelocity(Vector3.Zero());
}
logger.debug("Synchronized camera position before XR entry:", {
cameraWorldPos: cameraWorldPos.asArray(),
platformPos: platformTargetPos.asArray(),
yaw: yaw
});
}
await xr.baseExperience.enterXRAsync('immersive-vr', 'local-floor');
logger.debug("Entering XR Experience");
})