@konitif/viewer-3d 0.285.3 → 0.285.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/renderer/Viewer3DRenderer.d.ts +30 -0
- package/dist/renderer/Viewer3DRenderer.d.ts.map +1 -1
- package/dist/renderer/Viewer3DRenderer.js +324 -26
- package/dist/renderer/Viewer3DRenderer.js.map +1 -1
- package/dist/renderer/robotViewerMotion.d.ts +10 -0
- package/dist/renderer/robotViewerMotion.d.ts.map +1 -1
- package/dist/renderer/robotViewerMotion.js +19 -0
- package/dist/renderer/robotViewerMotion.js.map +1 -1
- package/package.json +1 -1
|
@@ -6,7 +6,7 @@ import { createClockGraph, createInvalidationController, createTemporalTransport
|
|
|
6
6
|
import { createViewerTemporalScheduler } from './ViewerTemporalScheduler.js';
|
|
7
7
|
import { copyViewerMetadata } from './ViewerMetadata.js';
|
|
8
8
|
import { resolveInteractionInputActions, resolveInteractionInputBindings } from '@konitif/tools/input';
|
|
9
|
-
import { AxesHelper, Box3, BoxGeometry, BufferGeometry, CanvasTexture, Color, ConeGeometry, CylinderGeometry, DirectionalLight, DoubleSide, Float32BufferAttribute, FogExp2, GridHelper, Group, HalfFloatType, HemisphereLight, Line, LineBasicMaterial, Matrix4, MathUtils, Mesh, MeshBasicMaterial, MeshDepthMaterial, NoToneMapping, Object3D, OrthographicCamera, Plane, PlaneGeometry, PerspectiveCamera, VSMShadowMap, Quaternion, Raycaster, Scene, CircleGeometry, SphereGeometry, Sprite, SpriteMaterial, SRGBColorSpace, Vector2, Vector3, WebGLRenderTarget, WebGLRenderer } from 'three';
|
|
9
|
+
import { AxesHelper, Box3, BoxGeometry, BufferGeometry, CanvasTexture, Color, ConeGeometry, CylinderGeometry, DirectionalLight, DoubleSide, Float32BufferAttribute, FogExp2, GridHelper, Group, HalfFloatType, HemisphereLight, Line, LineBasicMaterial, LineSegments, Matrix4, MathUtils, Mesh, MeshBasicMaterial, MeshDepthMaterial, NoToneMapping, Object3D, OrthographicCamera, Plane, PlaneGeometry, PerspectiveCamera, VSMShadowMap, Quaternion, Raycaster, Scene, CircleGeometry, SphereGeometry, Sprite, SpriteMaterial, SRGBColorSpace, Vector2, Vector3, WebGLRenderTarget, WebGLRenderer } from 'three';
|
|
10
10
|
import { ArcballControls } from 'three/examples/jsm/controls/ArcballControls.js';
|
|
11
11
|
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
|
|
12
12
|
import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js';
|
|
@@ -17,7 +17,7 @@ import { ShaderPass } from 'three/examples/jsm/postprocessing/ShaderPass.js';
|
|
|
17
17
|
import { SMAAPass } from 'three/examples/jsm/postprocessing/SMAAPass.js';
|
|
18
18
|
import { HorizontalTiltShiftShader } from 'three/examples/jsm/shaders/HorizontalTiltShiftShader.js';
|
|
19
19
|
import { VerticalTiltShiftShader } from 'three/examples/jsm/shaders/VerticalTiltShiftShader.js';
|
|
20
|
-
import { getRobotMotionAnimationDuration, interpolateRobotMotionTrackValueAtTime, toRobotMotionJointValue, resolveRobotMotionPlaybackDuration } from './robotViewerMotion.js';
|
|
20
|
+
import { getRobotMotionAnimationDuration, interpolateRobotMotionTrackValueAtTime, sampleRobotMotionJointValuesAtTime, toRobotMotionJointValue, resolveRobotMotionPlaybackDuration } from './robotViewerMotion.js';
|
|
21
21
|
import { createViewerProjectileLaunchSolution, VIEWER3D_PROJECTILE_PARTICLE_LIMIT, createViewerProjectileTrajectoryPoints, resolveViewerProjectileChargedLaunchProfile, resolveViewerProjectileImpulse, resolveViewerProjectileLauncherWorldScale, resolveViewerSlingshotReleasePull, stepViewerProjectileAgainstGround } from './ProjectileInteraction.js';
|
|
22
22
|
import { createInteractionKeyboardSignal, DEFAULT_VIEWER_INPUT_BINDINGS, VIEWER_PROJECTILE_CHARGE_ACTION_ID, VIEWER_PROJECTILE_LAUNCH_ACTION_ID } from './ViewerInputBindings.js';
|
|
23
23
|
import { ViewerProjectileParticleRenderer } from './ViewerProjectileParticleRenderer.js';
|
|
@@ -296,6 +296,7 @@ export class Viewer3D {
|
|
|
296
296
|
projectilePointerInside = false;
|
|
297
297
|
trajectoryAnimation = null;
|
|
298
298
|
trajectorySignature = '';
|
|
299
|
+
trajectoryProjectionRevision = 0;
|
|
299
300
|
trajectoryAnchors = new Map();
|
|
300
301
|
themeObserver = null;
|
|
301
302
|
animationLoop;
|
|
@@ -376,6 +377,8 @@ export class Viewer3D {
|
|
|
376
377
|
poseHandleTargetTransitionState = null;
|
|
377
378
|
poseHandleDragStarted = false;
|
|
378
379
|
poseHandleControlsWereEnabled = false;
|
|
380
|
+
ledStateProjection = { visible: false, zones: {} };
|
|
381
|
+
ledMeshBaseColors = new WeakMap();
|
|
379
382
|
suppressPoseHandleClickUntil = 0;
|
|
380
383
|
poseEditBaseline = null;
|
|
381
384
|
dirtyPose = false;
|
|
@@ -1176,6 +1179,7 @@ export class Viewer3D {
|
|
|
1176
1179
|
this.resetTemporalProjection();
|
|
1177
1180
|
if (this.options.physicsEnabled) {
|
|
1178
1181
|
this.physicsVisualRootToRobotMatrix = null;
|
|
1182
|
+
this.invalidateTrajectoryPreviewProjection();
|
|
1179
1183
|
if (!this.syncPhysicsKinematicPoseFromRobotPose({ holdFrames: PHYSICS_RESET_KINEMATIC_HOLD_FRAMES })) {
|
|
1180
1184
|
this.syncPhysicsJointTargetsFromRobotPose();
|
|
1181
1185
|
}
|
|
@@ -1201,6 +1205,7 @@ export class Viewer3D {
|
|
|
1201
1205
|
}
|
|
1202
1206
|
this.options.physicsMotorsCoupled = physicsMotorsCoupled;
|
|
1203
1207
|
this.physicsVisualRootToRobotMatrix = null;
|
|
1208
|
+
this.invalidateTrajectoryPreviewProjection();
|
|
1204
1209
|
this.resetPhysicsPostureCompensation();
|
|
1205
1210
|
if (!physicsMotorsCoupled) {
|
|
1206
1211
|
this.robot?.updateMatrixWorld(true);
|
|
@@ -1233,6 +1238,7 @@ export class Viewer3D {
|
|
|
1233
1238
|
resolvePhysicsSourceString(source.metadata?.visualRootBodyName);
|
|
1234
1239
|
this.physicsVisualBodyObjectNames = visualBodyObjectNames;
|
|
1235
1240
|
this.physicsVisualRootToRobotMatrix = null;
|
|
1241
|
+
this.invalidateTrajectoryPreviewProjection();
|
|
1236
1242
|
this.physicsResetPose = null;
|
|
1237
1243
|
this.physicsResetRobotTransform = null;
|
|
1238
1244
|
this.physicsResetBaselineCaptureFrames = 0;
|
|
@@ -1270,6 +1276,7 @@ export class Viewer3D {
|
|
|
1270
1276
|
this.scrubPreviewState = null;
|
|
1271
1277
|
this.playbackSyncSignature = '';
|
|
1272
1278
|
this.physicsVisualRootToRobotMatrix = null;
|
|
1279
|
+
this.invalidateTrajectoryPreviewProjection();
|
|
1273
1280
|
this.physicsAuthoredJointTargets.clear();
|
|
1274
1281
|
this.resetPhysicsPostureCompensation();
|
|
1275
1282
|
this.comparisonViewerSample = null;
|
|
@@ -1281,6 +1288,7 @@ export class Viewer3D {
|
|
|
1281
1288
|
this.lastPhysicsStepAt = 0;
|
|
1282
1289
|
this.resetTemporalProjection();
|
|
1283
1290
|
this.physicsVisualRootToRobotMatrix = null;
|
|
1291
|
+
this.invalidateTrajectoryPreviewProjection();
|
|
1284
1292
|
if (this.options.physicsMotorsCoupled) {
|
|
1285
1293
|
if (!this.syncPhysicsKinematicPoseFromRobotPose({ holdFrames: PHYSICS_RESET_KINEMATIC_HOLD_FRAMES })) {
|
|
1286
1294
|
this.syncPhysicsJointTargetsFromRobotPose();
|
|
@@ -1960,7 +1968,7 @@ export class Viewer3D {
|
|
|
1960
1968
|
return;
|
|
1961
1969
|
}
|
|
1962
1970
|
this.options.showEyeRingDebug = showEyeRingDebug;
|
|
1963
|
-
if (showEyeRingDebug || this.options.showEyeRingMire) {
|
|
1971
|
+
if (showEyeRingDebug || this.options.showEyeRingMire || this.ledStateProjection.visible) {
|
|
1964
1972
|
this.updateEyeRingDebug();
|
|
1965
1973
|
}
|
|
1966
1974
|
else {
|
|
@@ -1973,7 +1981,7 @@ export class Viewer3D {
|
|
|
1973
1981
|
return;
|
|
1974
1982
|
}
|
|
1975
1983
|
this.options.showEyeRingMire = showEyeRingMire;
|
|
1976
|
-
if (showEyeRingMire || this.options.showEyeRingDebug) {
|
|
1984
|
+
if (showEyeRingMire || this.options.showEyeRingDebug || this.ledStateProjection.visible) {
|
|
1977
1985
|
this.updateEyeRingDebug();
|
|
1978
1986
|
}
|
|
1979
1987
|
else {
|
|
@@ -1983,11 +1991,38 @@ export class Viewer3D {
|
|
|
1983
1991
|
}
|
|
1984
1992
|
setEyeRingCalibration(eyeRingCalibration) {
|
|
1985
1993
|
this.options.eyeRingCalibration = eyeRingCalibration;
|
|
1986
|
-
if (this.options.showEyeRingDebug || this.options.showEyeRingMire) {
|
|
1994
|
+
if (this.options.showEyeRingDebug || this.options.showEyeRingMire || this.ledStateProjection.visible) {
|
|
1987
1995
|
this.updateEyeRingDebug();
|
|
1988
1996
|
}
|
|
1989
1997
|
this.requestRender();
|
|
1990
1998
|
}
|
|
1999
|
+
setLedStateProjection(projection) {
|
|
2000
|
+
this.ledStateProjection = projection
|
|
2001
|
+
? {
|
|
2002
|
+
visible: projection.visible === true,
|
|
2003
|
+
zones: Object.fromEntries(Object.entries(projection.zones).map(([zoneId, zone]) => [
|
|
2004
|
+
zoneId,
|
|
2005
|
+
zone
|
|
2006
|
+
? {
|
|
2007
|
+
color: typeof zone.color === 'string' ? zone.color : undefined,
|
|
2008
|
+
colors: Array.isArray(zone.colors) ? [...zone.colors] : undefined,
|
|
2009
|
+
intensity: Number.isFinite(zone.intensity) ? Math.max(0, Math.min(1, zone.intensity ?? 1)) : 1
|
|
2010
|
+
}
|
|
2011
|
+
: undefined
|
|
2012
|
+
]))
|
|
2013
|
+
}
|
|
2014
|
+
: { visible: false, zones: {} };
|
|
2015
|
+
if (this.ledStateProjection.visible || this.options.showEyeRingDebug || this.options.showEyeRingMire) {
|
|
2016
|
+
this.updateEyeRingDebug();
|
|
2017
|
+
}
|
|
2018
|
+
else {
|
|
2019
|
+
const robot = this.getPoseControlRobot();
|
|
2020
|
+
if (robot)
|
|
2021
|
+
this.updateEarMeshDiffusers(robot, []);
|
|
2022
|
+
this.updateEyeRingDebugVisibility();
|
|
2023
|
+
}
|
|
2024
|
+
this.requestRender();
|
|
2025
|
+
}
|
|
1991
2026
|
setBalanceDebugVisible(showBalanceDebug) {
|
|
1992
2027
|
if (this.options.showBalanceDebug === showBalanceDebug) {
|
|
1993
2028
|
return;
|
|
@@ -2003,7 +2038,12 @@ export class Viewer3D {
|
|
|
2003
2038
|
}
|
|
2004
2039
|
setTrajectoryPreview(animation) {
|
|
2005
2040
|
if (this.trajectoryAnimation === animation) {
|
|
2006
|
-
this.
|
|
2041
|
+
if (this.options.showTrajectories) {
|
|
2042
|
+
this.rebuildTrajectoryPreview();
|
|
2043
|
+
}
|
|
2044
|
+
else {
|
|
2045
|
+
this.updateTrajectoryGroupVisibility();
|
|
2046
|
+
}
|
|
2007
2047
|
return;
|
|
2008
2048
|
}
|
|
2009
2049
|
this.trajectoryAnimation = animation;
|
|
@@ -2027,6 +2067,7 @@ export class Viewer3D {
|
|
|
2027
2067
|
this.captureMaterialBaselines(robot, this.robotMaterialBaselines);
|
|
2028
2068
|
this.resetTemporalProjection();
|
|
2029
2069
|
this.physicsVisualRootToRobotMatrix = null;
|
|
2070
|
+
this.invalidateTrajectoryPreviewProjection();
|
|
2030
2071
|
this.physicsProjectionMinimumStateRevision = null;
|
|
2031
2072
|
this.physicsResetPose = null;
|
|
2032
2073
|
this.physicsResetRobotTransform = null;
|
|
@@ -2356,6 +2397,9 @@ export class Viewer3D {
|
|
|
2356
2397
|
this.playbackState?.isPlaying &&
|
|
2357
2398
|
this.playbackState.animation === animation &&
|
|
2358
2399
|
this.playbackSyncSignature === syncSignature) {
|
|
2400
|
+
if (this.options.showTrajectories && animation) {
|
|
2401
|
+
this.rebuildTrajectoryPreview();
|
|
2402
|
+
}
|
|
2359
2403
|
return;
|
|
2360
2404
|
}
|
|
2361
2405
|
const nextPlaybackState = {
|
|
@@ -2409,6 +2453,7 @@ export class Viewer3D {
|
|
|
2409
2453
|
this.alignUnbufferedPlaybackPoseToSupportFloor();
|
|
2410
2454
|
}
|
|
2411
2455
|
}
|
|
2456
|
+
this.rebuildTrajectoryPreview();
|
|
2412
2457
|
this.updateTrajectoryCurrentMarkers();
|
|
2413
2458
|
if (nextPlaybackState.clockMode === 'realtime') {
|
|
2414
2459
|
this.scheduleLoop();
|
|
@@ -2438,6 +2483,7 @@ export class Viewer3D {
|
|
|
2438
2483
|
this.alignUnbufferedPlaybackPoseToSupportFloor();
|
|
2439
2484
|
}
|
|
2440
2485
|
}
|
|
2486
|
+
this.rebuildTrajectoryPreview();
|
|
2441
2487
|
this.updateTrajectoryCurrentMarkers();
|
|
2442
2488
|
if (shouldStartViewerPlaybackCompletionTransition({
|
|
2443
2489
|
isPlaying,
|
|
@@ -3942,12 +3988,12 @@ export class Viewer3D {
|
|
|
3942
3988
|
if (this.lastFrameAt > 0) {
|
|
3943
3989
|
const frameMs = now - this.lastFrameAt;
|
|
3944
3990
|
this.averageFrameMs = this.averageFrameMs * 0.9 + frameMs * 0.1;
|
|
3945
|
-
this.tunePixelRatio(this.averageFrameMs);
|
|
3946
3991
|
}
|
|
3947
3992
|
this.lastFrameAt = now;
|
|
3948
3993
|
const renderStartedAt = performance.now();
|
|
3949
3994
|
this.render();
|
|
3950
3995
|
this.averageRenderMs = this.updateProfilerAverage(this.averageRenderMs, performance.now() - renderStartedAt);
|
|
3996
|
+
this.tunePixelRatio(this.averageRenderMs);
|
|
3951
3997
|
this.lastRenderCalls = this.renderer.info.render.calls;
|
|
3952
3998
|
this.lastRenderTriangles = this.renderer.info.render.triangles;
|
|
3953
3999
|
this.renderFramesSinceReport += 1;
|
|
@@ -4002,6 +4048,16 @@ export class Viewer3D {
|
|
|
4002
4048
|
if (this.observedRobotGhostMirrorsSimulatedRobot) {
|
|
4003
4049
|
this.syncObservedRobotGhostFromSimulatedRobot();
|
|
4004
4050
|
}
|
|
4051
|
+
if (this.options.showTrajectories &&
|
|
4052
|
+
this.trajectoryAnimation?.motion.hasMotion &&
|
|
4053
|
+
this.trajectorySignature === '') {
|
|
4054
|
+
this.rebuildTrajectoryPreview();
|
|
4055
|
+
}
|
|
4056
|
+
// Every pose authority (authored playback, runtime-history replay and
|
|
4057
|
+
// physics projection) converges here. Refreshing the markers immediately
|
|
4058
|
+
// before drawing prevents a solver step or a root presentation offset
|
|
4059
|
+
// from leaving them one frame behind the rendered robot.
|
|
4060
|
+
this.updateTrajectoryCurrentMarkers();
|
|
4005
4061
|
this.updatePoseHandleScreenScale();
|
|
4006
4062
|
this.updateProjectileCameraProjection();
|
|
4007
4063
|
const ground = this.visualGroundLayer;
|
|
@@ -6000,6 +6056,7 @@ export class Viewer3D {
|
|
|
6000
6056
|
const previousPresentedY = this.robot.position.y + this.simulatedRobotPresentationRoot.position.y;
|
|
6001
6057
|
if (!this.physicsVisualRootToRobotMatrix) {
|
|
6002
6058
|
this.physicsVisualRootToRobotMatrix = this.resolvePhysicsVisualRootToRobotMatrix(physicsRootWorldMatrix, bodyTransforms);
|
|
6059
|
+
this.invalidateTrajectoryPreviewProjection();
|
|
6003
6060
|
}
|
|
6004
6061
|
const nextRobotWorldMatrix = physicsRootWorldMatrix.clone().multiply(this.physicsVisualRootToRobotMatrix);
|
|
6005
6062
|
const parent = this.robot.parent;
|
|
@@ -6285,7 +6342,7 @@ export class Viewer3D {
|
|
|
6285
6342
|
if (this.options.showContactPoints) {
|
|
6286
6343
|
this.updateContactPoints();
|
|
6287
6344
|
}
|
|
6288
|
-
if (this.options.showEyeRingDebug || this.options.showEyeRingMire) {
|
|
6345
|
+
if (this.options.showEyeRingDebug || this.options.showEyeRingMire || this.ledStateProjection.visible) {
|
|
6289
6346
|
this.updateEyeRingDebug();
|
|
6290
6347
|
}
|
|
6291
6348
|
if (this.options.showBalanceDebug) {
|
|
@@ -8391,6 +8448,7 @@ export class Viewer3D {
|
|
|
8391
8448
|
if (!rootTransform)
|
|
8392
8449
|
return;
|
|
8393
8450
|
this.physicsVisualRootToRobotMatrix = this.resolvePhysicsVisualRootToRobotMatrix(this.createPhysicsBodyViewerWorldMatrix(rootTransform), bodyTransforms);
|
|
8451
|
+
this.invalidateTrajectoryPreviewProjection();
|
|
8394
8452
|
}
|
|
8395
8453
|
projectProjectilePhysicsObservation() {
|
|
8396
8454
|
const bodyTransforms = this.physicsService.getBodyTransforms();
|
|
@@ -9236,7 +9294,7 @@ export class Viewer3D {
|
|
|
9236
9294
|
: this.options.showRobot && this.robotComparisonAppearance.mode !== 'observed-only';
|
|
9237
9295
|
this.eyeRingDebugGroup.visible =
|
|
9238
9296
|
subjectVisible &&
|
|
9239
|
-
(this.options.showEyeRingDebug || this.options.showEyeRingMire) &&
|
|
9297
|
+
(this.options.showEyeRingDebug || this.options.showEyeRingMire || this.ledStateProjection.visible) &&
|
|
9240
9298
|
this.eyeRingDebugGroup.children.length > 0;
|
|
9241
9299
|
}
|
|
9242
9300
|
clearEyeRingDebug() {
|
|
@@ -9251,27 +9309,34 @@ export class Viewer3D {
|
|
|
9251
9309
|
}
|
|
9252
9310
|
updateEyeRingDebug() {
|
|
9253
9311
|
const robot = this.getPoseControlRobot();
|
|
9254
|
-
if ((!this.options.showEyeRingDebug && !this.options.showEyeRingMire) || !robot) {
|
|
9312
|
+
if ((!this.options.showEyeRingDebug && !this.options.showEyeRingMire && !this.ledStateProjection.visible) || !robot) {
|
|
9313
|
+
if (robot)
|
|
9314
|
+
this.updateEarMeshDiffusers(robot, []);
|
|
9255
9315
|
this.updateEyeRingDebugVisibility();
|
|
9256
9316
|
return;
|
|
9257
9317
|
}
|
|
9258
9318
|
robot.updateMatrixWorld(true);
|
|
9259
9319
|
const calibration = this.resolveEyeRingCalibration();
|
|
9260
9320
|
const ledRingPoses = this.resolveLedRingPoses(calibration);
|
|
9321
|
+
this.updateEarMeshDiffusers(robot, ledRingPoses.filter((pose) => pose.zoneId === 'ears'));
|
|
9261
9322
|
if (ledRingPoses.length === 0) {
|
|
9262
9323
|
this.clearEyeRingDebug();
|
|
9263
9324
|
return;
|
|
9264
9325
|
}
|
|
9265
9326
|
const group = this.ensureEyeRingDebugGroup();
|
|
9266
9327
|
this.clearEyeRingDebug();
|
|
9267
|
-
if (this.options.showEyeRingDebug) {
|
|
9328
|
+
if (this.options.showEyeRingDebug || this.ledStateProjection.visible) {
|
|
9268
9329
|
for (const ring of ledRingPoses) {
|
|
9269
9330
|
const ledCount = Math.max(ring.calibration.layout === 'strip' ? 1 : 3, Math.min(64, Math.round(ring.calibration.ledCount)));
|
|
9270
|
-
if (ring.calibration.mireVisible) {
|
|
9331
|
+
if (this.options.showEyeRingDebug && ring.calibration.mireVisible) {
|
|
9271
9332
|
for (const [index, dimension] of this.resolveEyeRingDebugDimensions(ring.calibration).entries()) {
|
|
9272
9333
|
group.add(this.createLedLayoutGuide(ring, dimension, index === 0 ? '#22c55e' : '#67e8f9', index === 0 ? 0.84 : 0.42));
|
|
9273
9334
|
}
|
|
9274
9335
|
}
|
|
9336
|
+
if (ring.zoneId === 'ears' && this.ledStateProjection.visible) {
|
|
9337
|
+
group.add(...this.createEarSpeakerGrille(ring));
|
|
9338
|
+
continue;
|
|
9339
|
+
}
|
|
9275
9340
|
for (let index = 0; index < ledCount; index += 1) {
|
|
9276
9341
|
group.add(this.createEyeRingLedMarker(ring, index, ledCount, ring.calibration.ledSize));
|
|
9277
9342
|
}
|
|
@@ -9578,12 +9643,15 @@ export class Viewer3D {
|
|
|
9578
9643
|
return line;
|
|
9579
9644
|
}
|
|
9580
9645
|
createEyeRingLedMarker(eye, index, count, ledSize) {
|
|
9581
|
-
const
|
|
9646
|
+
const zoneState = this.ledStateProjection.visible ? this.ledStateProjection.zones[eye.zoneId] : undefined;
|
|
9647
|
+
const projectedColor = zoneState?.colors?.[index % Math.max(1, zoneState.colors.length)] ?? zoneState?.color;
|
|
9648
|
+
const color = projectedColor ?? (this.ledStateProjection.visible ? '#111827' : index % 2 === 0 ? '#22c55e' : '#86efac');
|
|
9649
|
+
const intensity = zoneState?.intensity ?? (this.ledStateProjection.visible ? 0.3 : 0.92);
|
|
9582
9650
|
const markerSize = Math.max(0.001, Math.min(0.02, ledSize));
|
|
9583
9651
|
const marker = new Mesh(new SphereGeometry(markerSize, 12, 8), new MeshBasicMaterial({
|
|
9584
9652
|
color,
|
|
9585
9653
|
transparent: true,
|
|
9586
|
-
opacity:
|
|
9654
|
+
opacity: intensity,
|
|
9587
9655
|
depthTest: false,
|
|
9588
9656
|
depthWrite: false
|
|
9589
9657
|
}));
|
|
@@ -9596,6 +9664,137 @@ export class Viewer3D {
|
|
|
9596
9664
|
marker.renderOrder = 1000;
|
|
9597
9665
|
return marker;
|
|
9598
9666
|
}
|
|
9667
|
+
/** The speaker grille stays dark; only its peripheral diffuser receives LED light. */
|
|
9668
|
+
createEarSpeakerGrille(ear) {
|
|
9669
|
+
const normal = new Vector3(0, 1, 0).applyQuaternion(ear.orientation).normalize();
|
|
9670
|
+
const surface = new Mesh(new CircleGeometry(ear.radius * 0.76, 72), new MeshBasicMaterial({
|
|
9671
|
+
color: '#070a0d',
|
|
9672
|
+
transparent: true,
|
|
9673
|
+
opacity: 0.96,
|
|
9674
|
+
depthTest: true,
|
|
9675
|
+
depthWrite: false,
|
|
9676
|
+
side: DoubleSide
|
|
9677
|
+
}));
|
|
9678
|
+
surface.geometry.rotateX(-Math.PI * 0.5);
|
|
9679
|
+
surface.position.copy(ear.center).addScaledVector(normal, 0.00035);
|
|
9680
|
+
surface.quaternion.copy(ear.orientation);
|
|
9681
|
+
surface.name = `${ear.id}-speaker-grille-surface`;
|
|
9682
|
+
surface.renderOrder = 996;
|
|
9683
|
+
const grilleRadius = ear.radius * 0.70;
|
|
9684
|
+
const cellRadius = Math.max(0.0012, ear.radius * 0.082);
|
|
9685
|
+
const columnStep = Math.sqrt(3) * cellRadius;
|
|
9686
|
+
const rowStep = cellRadius * 1.5;
|
|
9687
|
+
const points = [];
|
|
9688
|
+
for (let row = -9; row <= 9; row += 1) {
|
|
9689
|
+
for (let column = -9; column <= 9; column += 1) {
|
|
9690
|
+
const centerX = column * columnStep + (Math.abs(row) % 2) * columnStep * 0.5;
|
|
9691
|
+
const centerZ = row * rowStep;
|
|
9692
|
+
if (Math.hypot(centerX, centerZ) + cellRadius > grilleRadius)
|
|
9693
|
+
continue;
|
|
9694
|
+
for (let edge = 0; edge < 6; edge += 1) {
|
|
9695
|
+
const angleA = Math.PI / 6 + edge * Math.PI / 3;
|
|
9696
|
+
const angleB = Math.PI / 6 + ((edge + 1) % 6) * Math.PI / 3;
|
|
9697
|
+
points.push(new Vector3(centerX + Math.cos(angleA) * cellRadius, 0, centerZ + Math.sin(angleA) * cellRadius), new Vector3(centerX + Math.cos(angleB) * cellRadius, 0, centerZ + Math.sin(angleB) * cellRadius));
|
|
9698
|
+
}
|
|
9699
|
+
}
|
|
9700
|
+
}
|
|
9701
|
+
const lattice = new LineSegments(new BufferGeometry().setFromPoints(points), new LineBasicMaterial({
|
|
9702
|
+
color: '#59616b',
|
|
9703
|
+
transparent: true,
|
|
9704
|
+
opacity: 0.9,
|
|
9705
|
+
depthTest: true,
|
|
9706
|
+
depthWrite: false
|
|
9707
|
+
}));
|
|
9708
|
+
lattice.position.copy(ear.center).addScaledVector(normal, 0.00055);
|
|
9709
|
+
lattice.quaternion.copy(ear.orientation);
|
|
9710
|
+
lattice.name = `${ear.id}-speaker-grille-lattice`;
|
|
9711
|
+
lattice.renderOrder = 997;
|
|
9712
|
+
return [surface, lattice];
|
|
9713
|
+
}
|
|
9714
|
+
/** Projects the timeline colors onto the authored outer ear diffuser band. */
|
|
9715
|
+
updateEarMeshDiffusers(robot, ears) {
|
|
9716
|
+
const zoneState = this.ledStateProjection.zones.ears;
|
|
9717
|
+
const intensity = Math.max(0, Math.min(1, zoneState?.intensity ?? 0.25));
|
|
9718
|
+
const sourceColors = zoneState?.colors?.length ? zoneState.colors : null;
|
|
9719
|
+
const earFrames = ears.map((ear) => {
|
|
9720
|
+
const count = Math.max(3, Math.min(32, Math.round(ear.calibration.ledCount)));
|
|
9721
|
+
return {
|
|
9722
|
+
ear,
|
|
9723
|
+
inverseOrientation: ear.orientation.clone().invert(),
|
|
9724
|
+
colors: Array.from({ length: count }, (_, index) => new Color(sourceColors?.[index % sourceColors.length] ?? zoneState?.color ?? '#071018'))
|
|
9725
|
+
};
|
|
9726
|
+
});
|
|
9727
|
+
const worldPosition = new Vector3();
|
|
9728
|
+
const localPosition = new Vector3();
|
|
9729
|
+
let found = false;
|
|
9730
|
+
robot.updateMatrixWorld(true);
|
|
9731
|
+
robot.traverse((object) => {
|
|
9732
|
+
const mesh = object;
|
|
9733
|
+
if (!mesh.isMesh)
|
|
9734
|
+
return;
|
|
9735
|
+
// Older hot-reloaded robot instances may still contain the source
|
|
9736
|
+
// speaker bulb that used to be extracted and incorrectly treated as a
|
|
9737
|
+
// segmented LED surface. Retire that legacy projection immediately;
|
|
9738
|
+
// the authored body and the grille projection own the ear appearance.
|
|
9739
|
+
if (mesh.name === 'viewer-led-diffuser-ears' || mesh.userData.viewerLedZone === 'ears') {
|
|
9740
|
+
mesh.visible = false;
|
|
9741
|
+
return;
|
|
9742
|
+
}
|
|
9743
|
+
const positions = mesh.geometry.getAttribute('position');
|
|
9744
|
+
const colors = mesh.geometry.getAttribute('color');
|
|
9745
|
+
if (!positions || !colors || colors.itemSize < 3)
|
|
9746
|
+
return;
|
|
9747
|
+
let baseline = this.ledMeshBaseColors.get(mesh.geometry);
|
|
9748
|
+
if (!baseline || baseline.length !== colors.array.length) {
|
|
9749
|
+
baseline = new Float32Array(colors.array);
|
|
9750
|
+
this.ledMeshBaseColors.set(mesh.geometry, baseline);
|
|
9751
|
+
}
|
|
9752
|
+
for (let index = 0; index < positions.count; index += 1) {
|
|
9753
|
+
const offset = index * colors.itemSize;
|
|
9754
|
+
colors.setXYZ(index, baseline[offset], baseline[offset + 1], baseline[offset + 2]);
|
|
9755
|
+
if (!this.ledStateProjection.visible || earFrames.length === 0)
|
|
9756
|
+
continue;
|
|
9757
|
+
worldPosition.fromBufferAttribute(positions, index).applyMatrix4(mesh.matrixWorld);
|
|
9758
|
+
let frame = earFrames[0];
|
|
9759
|
+
let distance = worldPosition.distanceToSquared(frame.ear.center);
|
|
9760
|
+
for (let candidateIndex = 1; candidateIndex < earFrames.length; candidateIndex += 1) {
|
|
9761
|
+
const candidate = earFrames[candidateIndex];
|
|
9762
|
+
const candidateDistance = worldPosition.distanceToSquared(candidate.ear.center);
|
|
9763
|
+
if (candidateDistance < distance) {
|
|
9764
|
+
frame = candidate;
|
|
9765
|
+
distance = candidateDistance;
|
|
9766
|
+
}
|
|
9767
|
+
}
|
|
9768
|
+
localPosition.copy(worldPosition).sub(frame.ear.center).applyQuaternion(frame.inverseOrientation);
|
|
9769
|
+
const radialDistance = Math.hypot(localPosition.x, localPosition.z);
|
|
9770
|
+
// Persisted calibrations created before the authored diffuser mapping
|
|
9771
|
+
// used a 16 mm marker radius. Never let that legacy presentation value
|
|
9772
|
+
// select the 25 mm inner speaker bulb again: the light-emitting plastic
|
|
9773
|
+
// is the outer annulus of the authored head shell.
|
|
9774
|
+
const innerRadius = Math.max(0.028, frame.ear.radius * 1.06);
|
|
9775
|
+
const outerRadius = Math.max(0.041, frame.ear.radius * 1.5);
|
|
9776
|
+
const surfaceDepth = Math.max(0.004, frame.ear.radius * 0.48);
|
|
9777
|
+
if (radialDistance < innerRadius ||
|
|
9778
|
+
radialDistance > outerRadius ||
|
|
9779
|
+
Math.abs(localPosition.y) > surfaceDepth) {
|
|
9780
|
+
continue;
|
|
9781
|
+
}
|
|
9782
|
+
found = true;
|
|
9783
|
+
let angle = Math.atan2(localPosition.x, localPosition.z);
|
|
9784
|
+
if (angle < 0)
|
|
9785
|
+
angle += Math.PI * 2;
|
|
9786
|
+
const colorPosition = angle / (Math.PI * 2) * frame.colors.length;
|
|
9787
|
+
const colorIndex = Math.floor(colorPosition) % frame.colors.length;
|
|
9788
|
+
const nextColorIndex = (colorIndex + 1) % frame.colors.length;
|
|
9789
|
+
const color = frame.colors[colorIndex].clone()
|
|
9790
|
+
.lerp(frame.colors[nextColorIndex], colorPosition - Math.floor(colorPosition))
|
|
9791
|
+
.multiplyScalar(intensity);
|
|
9792
|
+
colors.setXYZ(index, color.r, color.g, color.b);
|
|
9793
|
+
}
|
|
9794
|
+
colors.needsUpdate = true;
|
|
9795
|
+
});
|
|
9796
|
+
return found;
|
|
9797
|
+
}
|
|
9599
9798
|
createEyeRingMireLines(eye) {
|
|
9600
9799
|
const axisVertical = new Vector3(1, 0, 0).applyQuaternion(eye.orientation).normalize();
|
|
9601
9800
|
const forward = new Vector3(0, 1, 0).applyQuaternion(eye.orientation).normalize();
|
|
@@ -10145,6 +10344,10 @@ export class Viewer3D {
|
|
|
10145
10344
|
marker.name = 'balance-center-of-mass-ground-projection';
|
|
10146
10345
|
return marker;
|
|
10147
10346
|
}
|
|
10347
|
+
invalidateTrajectoryPreviewProjection() {
|
|
10348
|
+
this.trajectoryProjectionRevision += 1;
|
|
10349
|
+
this.trajectorySignature = '';
|
|
10350
|
+
}
|
|
10148
10351
|
ensureTrajectoryGroup() {
|
|
10149
10352
|
if (this.trajectoryGroup) {
|
|
10150
10353
|
return this.trajectoryGroup;
|
|
@@ -10152,7 +10355,7 @@ export class Viewer3D {
|
|
|
10152
10355
|
this.trajectoryGroup = new Group();
|
|
10153
10356
|
this.trajectoryGroup.name = 'AnimationTrajectoryPreview';
|
|
10154
10357
|
this.trajectoryGroup.visible = false;
|
|
10155
|
-
this.
|
|
10358
|
+
this.simulatedRobotPresentationRoot.add(this.trajectoryGroup);
|
|
10156
10359
|
return this.trajectoryGroup;
|
|
10157
10360
|
}
|
|
10158
10361
|
updateTrajectoryGroupVisibility() {
|
|
@@ -10201,18 +10404,31 @@ export class Viewer3D {
|
|
|
10201
10404
|
this.clearTrajectoryPreview();
|
|
10202
10405
|
this.trajectorySignature = nextSignature;
|
|
10203
10406
|
const originalPose = this.captureJointPose();
|
|
10407
|
+
const originalRobotTransform = this.captureRobotTransform(this.robot);
|
|
10408
|
+
const temporalSampleResolver = this.playbackState?.animation === animation
|
|
10409
|
+
? this.playbackState.resolveTemporalProjectionSample
|
|
10410
|
+
: null;
|
|
10411
|
+
let temporalRootAlignment = this.physicsVisualRootToRobotMatrix?.clone() ?? null;
|
|
10204
10412
|
const previousAnimatedTargets = new Set(this.lastAnimatedJointTargets);
|
|
10205
10413
|
const sampleCount = Math.max(ROBOT_VIEWER_TRAJECTORY_MIN_SAMPLES, Math.min(ROBOT_VIEWER_TRAJECTORY_MAX_SAMPLES, Math.ceil(duration * ROBOT_VIEWER_TRAJECTORY_SAMPLE_RATE)));
|
|
10206
10414
|
const pointsByTarget = new Map(targets.map((target) => [target.id, []]));
|
|
10207
10415
|
for (let sampleIndex = 0; sampleIndex <= sampleCount; sampleIndex += 1) {
|
|
10208
10416
|
const sampleTime = (sampleIndex / sampleCount) * duration;
|
|
10209
|
-
|
|
10417
|
+
const temporalSample = temporalSampleResolver?.(sampleTime) ?? null;
|
|
10418
|
+
this.restoreRobotTransform(this.robot, originalRobotTransform);
|
|
10419
|
+
if (temporalSample?.viewer) {
|
|
10420
|
+
temporalRootAlignment = this.applyTrajectoryTemporalSamplePose(temporalSample, temporalRootAlignment);
|
|
10421
|
+
}
|
|
10422
|
+
else {
|
|
10423
|
+
this.applyTrajectorySamplePose(animation, sampleTime, duration);
|
|
10424
|
+
}
|
|
10210
10425
|
this.robot.updateMatrixWorld(true);
|
|
10211
10426
|
for (const target of targets) {
|
|
10212
|
-
pointsByTarget.get(target.id)?.push(this.
|
|
10427
|
+
pointsByTarget.get(target.id)?.push(this.getTrajectoryPointInProjectionFrame(target.id));
|
|
10213
10428
|
}
|
|
10214
10429
|
}
|
|
10215
|
-
this.
|
|
10430
|
+
this.restoreRobotTransform(this.robot, originalRobotTransform);
|
|
10431
|
+
this.restoreTrajectorySamplePose(originalPose);
|
|
10216
10432
|
this.lastAnimatedJointTargets = previousAnimatedTargets;
|
|
10217
10433
|
this.nextAnimatedJointTargets.clear();
|
|
10218
10434
|
for (const target of targets) {
|
|
@@ -10222,26 +10438,100 @@ export class Viewer3D {
|
|
|
10222
10438
|
}
|
|
10223
10439
|
trajectoryGroup.add(this.createTrajectoryObject(target, points));
|
|
10224
10440
|
}
|
|
10225
|
-
this.updateTrajectoryCurrentMarkers();
|
|
10226
10441
|
this.updateTrajectoryGroupVisibility();
|
|
10442
|
+
this.updateTrajectoryCurrentMarkers();
|
|
10227
10443
|
this.requestRender();
|
|
10228
10444
|
}
|
|
10445
|
+
applyTrajectorySamplePose(animation, time, duration) {
|
|
10446
|
+
const joints = this.getRobotJoints();
|
|
10447
|
+
for (const { target, value } of sampleRobotMotionJointValuesAtTime(animation, time, duration)) {
|
|
10448
|
+
const joint = joints[target];
|
|
10449
|
+
if (joint)
|
|
10450
|
+
this.applyDesiredProjectionJointValue(joint, value);
|
|
10451
|
+
}
|
|
10452
|
+
}
|
|
10453
|
+
applyTrajectoryTemporalSamplePose(sample, rootAlignment) {
|
|
10454
|
+
const viewer = sample.viewer;
|
|
10455
|
+
const robot = this.robot;
|
|
10456
|
+
if (!viewer || !robot) {
|
|
10457
|
+
return rootAlignment;
|
|
10458
|
+
}
|
|
10459
|
+
const joints = this.getRobotJoints();
|
|
10460
|
+
for (const [target, value] of Object.entries(viewer.joints ?? {})) {
|
|
10461
|
+
const joint = joints[target];
|
|
10462
|
+
if (joint && Number.isFinite(value)) {
|
|
10463
|
+
this.applyDesiredProjectionJointValue(joint, value);
|
|
10464
|
+
}
|
|
10465
|
+
}
|
|
10466
|
+
const bodyTransforms = (viewer.physics?.bodyTransforms ?? []).map((transform) => this.deserializeRuntimeHistoryTransform(transform, false));
|
|
10467
|
+
const rootTransform = bodyTransforms.find((transform) => transform.metadata?.visualRoot === true) ??
|
|
10468
|
+
bodyTransforms.find((transform) => transform.bodyName === this.physicsVisualRootBodyName);
|
|
10469
|
+
let nextRootAlignment = rootAlignment;
|
|
10470
|
+
if (rootTransform) {
|
|
10471
|
+
const physicsRootWorldMatrix = this.createPhysicsBodyViewerWorldMatrix(rootTransform);
|
|
10472
|
+
nextRootAlignment ??= this.resolvePhysicsVisualRootToRobotMatrix(physicsRootWorldMatrix, bodyTransforms);
|
|
10473
|
+
const nextRobotLocalMatrix = physicsRootWorldMatrix.clone().multiply(nextRootAlignment);
|
|
10474
|
+
const parent = robot.parent;
|
|
10475
|
+
if (parent && parent !== this.simulatedRobotPresentationRoot) {
|
|
10476
|
+
parent.updateMatrixWorld(true);
|
|
10477
|
+
nextRobotLocalMatrix.premultiply(parent.matrixWorld.clone().invert());
|
|
10478
|
+
}
|
|
10479
|
+
robot.matrix.copy(nextRobotLocalMatrix);
|
|
10480
|
+
robot.matrix.decompose(robot.position, robot.quaternion, robot.scale);
|
|
10481
|
+
}
|
|
10482
|
+
robot.updateMatrixWorld(true);
|
|
10483
|
+
return nextRootAlignment;
|
|
10484
|
+
}
|
|
10485
|
+
restoreTrajectorySamplePose(pose) {
|
|
10486
|
+
const joints = this.getRobotJoints();
|
|
10487
|
+
for (const [target, value] of Object.entries(pose)) {
|
|
10488
|
+
const joint = joints[target];
|
|
10489
|
+
if (joint)
|
|
10490
|
+
this.applyDesiredProjectionJointValue(joint, value);
|
|
10491
|
+
}
|
|
10492
|
+
this.robot?.updateMatrixWorld(true);
|
|
10493
|
+
}
|
|
10229
10494
|
getTrajectorySignature(animation, targets, duration) {
|
|
10230
10495
|
return JSON.stringify({
|
|
10231
10496
|
duration: Number(duration.toFixed(3)),
|
|
10497
|
+
projectionRevision: this.trajectoryProjectionRevision,
|
|
10498
|
+
playbackProjection: this.getTrajectoryPlaybackProjectionSignature(animation),
|
|
10232
10499
|
handsOnly: this.options.showHandTrajectoriesOnly,
|
|
10233
10500
|
targets: targets.map((target) => target.id),
|
|
10501
|
+
temporalProjection: this.getTrajectoryTemporalProjectionSignature(animation, duration),
|
|
10234
10502
|
tracks: animation.motion.tracks.map((track) => ({
|
|
10235
10503
|
target: track.target,
|
|
10236
10504
|
property: track.property,
|
|
10237
|
-
|
|
10238
|
-
|
|
10239
|
-
|
|
10240
|
-
firstValue: track.keys[0]?.value ?? null,
|
|
10241
|
-
lastValue: track.keys[track.keys.length - 1]?.value ?? null
|
|
10505
|
+
interpolation: track.interpolation ?? null,
|
|
10506
|
+
unitKind: track.unitKind ?? null,
|
|
10507
|
+
keys: track.keys.map((key) => [key.time, key.value])
|
|
10242
10508
|
}))
|
|
10243
10509
|
});
|
|
10244
10510
|
}
|
|
10511
|
+
getTrajectoryPlaybackProjectionSignature(animation) {
|
|
10512
|
+
const playback = this.playbackState;
|
|
10513
|
+
if (playback?.animation !== animation) {
|
|
10514
|
+
return null;
|
|
10515
|
+
}
|
|
10516
|
+
return {
|
|
10517
|
+
mutedTargets: [...playback.mutedTrackTargetSet].sort(),
|
|
10518
|
+
startTransitionMinimumDurationSeconds: playback.startTransitionMinimumDurationSeconds,
|
|
10519
|
+
entryPose: this.createPoseSignature(playback.entryPose),
|
|
10520
|
+
endTransitionMinimumDurationSeconds: playback.endTransitionMinimumDurationSeconds,
|
|
10521
|
+
exitPose: this.createPoseSignature(playback.exitPose)
|
|
10522
|
+
};
|
|
10523
|
+
}
|
|
10524
|
+
getTrajectoryTemporalProjectionSignature(animation, duration) {
|
|
10525
|
+
const playback = this.playbackState;
|
|
10526
|
+
const resolver = playback?.animation === animation ? playback.resolveTemporalProjectionSample : null;
|
|
10527
|
+
if (!resolver) {
|
|
10528
|
+
return null;
|
|
10529
|
+
}
|
|
10530
|
+
return [0, duration * 0.5, duration].map((time) => {
|
|
10531
|
+
const sample = resolver(time);
|
|
10532
|
+
return sample ? [sample.id, Number(sample.timeSeconds.toFixed(4))] : null;
|
|
10533
|
+
});
|
|
10534
|
+
}
|
|
10245
10535
|
getTrajectoryPreviewTargets(animation) {
|
|
10246
10536
|
if (this.options.showHandTrajectoriesOnly) {
|
|
10247
10537
|
return (this.options.handTipDefinitions ?? [])
|
|
@@ -10332,9 +10622,12 @@ export class Viewer3D {
|
|
|
10332
10622
|
if (!target || !currentMarker) {
|
|
10333
10623
|
continue;
|
|
10334
10624
|
}
|
|
10335
|
-
currentMarker.position.copy(this.
|
|
10625
|
+
currentMarker.position.copy(this.getTrajectoryPointInProjectionFrame(target));
|
|
10336
10626
|
}
|
|
10337
10627
|
}
|
|
10628
|
+
getTrajectoryPointInProjectionFrame(target) {
|
|
10629
|
+
return projectViewerTrajectoryPointToFrame(this.getTrajectoryPoint(target), this.simulatedRobotPresentationRoot);
|
|
10630
|
+
}
|
|
10338
10631
|
getTrajectoryPoint(target) {
|
|
10339
10632
|
const handTip = (this.options.handTipDefinitions ?? []).find((definition) => definition.id === target);
|
|
10340
10633
|
if (handTip) {
|
|
@@ -10413,10 +10706,10 @@ export class Viewer3D {
|
|
|
10413
10706
|
this.trajectoryAnchors.set(target, anchor);
|
|
10414
10707
|
return anchor;
|
|
10415
10708
|
}
|
|
10709
|
+
joint.updateMatrixWorld?.(true);
|
|
10416
10710
|
const jointInverse = joint.matrixWorld.clone().invert();
|
|
10417
10711
|
const localBounds = new Box3();
|
|
10418
10712
|
let hasBounds = false;
|
|
10419
|
-
joint.updateMatrixWorld?.(true);
|
|
10420
10713
|
joint.traverse((node) => {
|
|
10421
10714
|
if (node !== joint && node.isURDFJoint) {
|
|
10422
10715
|
return;
|
|
@@ -10710,6 +11003,11 @@ export class Viewer3D {
|
|
|
10710
11003
|
function formatNavigationDistance(value) {
|
|
10711
11004
|
return `${value >= 0 ? '+' : ''}${value.toFixed(2)}m`;
|
|
10712
11005
|
}
|
|
11006
|
+
/** Projects a world-space trajectory sample into the robot presentation frame. */
|
|
11007
|
+
export function projectViewerTrajectoryPointToFrame(point, frame) {
|
|
11008
|
+
frame.updateWorldMatrix(true, false);
|
|
11009
|
+
return frame.worldToLocal(new Vector3(point.x, point.y, point.z));
|
|
11010
|
+
}
|
|
10713
11011
|
function formatNavigationAngle(value) {
|
|
10714
11012
|
const degrees = MathUtils.radToDeg(normalizeNavigationRadians(value));
|
|
10715
11013
|
return `${degrees >= 0 ? '+' : ''}${degrees.toFixed(0)}°`;
|