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:
parent
c1503d959e
commit
970f6fc78a
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "immersive",
|
||||
"private": true,
|
||||
"version": "0.0.8-27",
|
||||
"version": "0.0.8-28",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
||||
@ -14,7 +14,7 @@
|
||||
"privateDesigns": false,
|
||||
"encryptedDesigns": false,
|
||||
"editData": false,
|
||||
"config": false,
|
||||
"config": true,
|
||||
"enterImmersive": true,
|
||||
"launchMetaQuest": true
|
||||
},
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
|
||||
}
|
||||
@ -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");
|
||||
})
|
||||
|
||||
Loading…
Reference in New Issue
Block a user