@konitif/viewer-3d 0.285.5 → 0.285.7

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.
Files changed (29) hide show
  1. package/README.md +55 -0
  2. package/dist/renderer/IncarnationMaterialProjection.d.ts +16 -0
  3. package/dist/renderer/IncarnationMaterialProjection.d.ts.map +1 -0
  4. package/dist/renderer/IncarnationMaterialProjection.js +45 -0
  5. package/dist/renderer/IncarnationMaterialProjection.js.map +1 -0
  6. package/dist/renderer/ObservedRobotPresentationBuffer.d.ts +15 -4
  7. package/dist/renderer/ObservedRobotPresentationBuffer.d.ts.map +1 -1
  8. package/dist/renderer/ObservedRobotPresentationBuffer.js +136 -8
  9. package/dist/renderer/ObservedRobotPresentationBuffer.js.map +1 -1
  10. package/dist/renderer/ObservedSoleStabilization.d.ts +41 -0
  11. package/dist/renderer/ObservedSoleStabilization.d.ts.map +1 -0
  12. package/dist/renderer/ObservedSoleStabilization.js +140 -0
  13. package/dist/renderer/ObservedSoleStabilization.js.map +1 -0
  14. package/dist/renderer/PerformanceObservation.d.ts +8 -0
  15. package/dist/renderer/PerformanceObservation.d.ts.map +1 -0
  16. package/dist/renderer/PerformanceObservation.js +21 -0
  17. package/dist/renderer/PerformanceObservation.js.map +1 -0
  18. package/dist/renderer/Viewer3DRenderer.d.ts +23 -0
  19. package/dist/renderer/Viewer3DRenderer.d.ts.map +1 -1
  20. package/dist/renderer/Viewer3DRenderer.js +203 -5
  21. package/dist/renderer/Viewer3DRenderer.js.map +1 -1
  22. package/dist/renderer/index.d.ts +2 -0
  23. package/dist/renderer/index.d.ts.map +1 -1
  24. package/dist/renderer/index.js +2 -0
  25. package/dist/renderer/index.js.map +1 -1
  26. package/dist/supportDefinition.d.ts +5 -0
  27. package/dist/supportDefinition.d.ts.map +1 -1
  28. package/package.json +1 -1
  29. package/reference/catalog.json +13 -0
@@ -1,3 +1,4 @@
1
+ import { measureViewerOperation } from './PerformanceObservation.js';
1
2
  import { createViewerPhysicsFrameQuaternion, resolveViewerSceneYaw, projectViewerImpulseByFrame, projectViewerPointByFrame } from './physics/ViewerPhysicsFrame.js';
2
3
  import { resolveViewerJointMapping, selectDirectViewerJointMappings } from './physics/ViewerJointResolution.js';
3
4
  import { createPhysicsJointTargetValue, createVisualJointValueFromPhysics, createVisualJointValueFromSource } from './physics/ViewerPhysicsJointMapping.js';
@@ -5,6 +6,7 @@ import { resolvePhysicsSourceString, resolvePhysicsSourceStringListMap, resolveP
5
6
  import { createClockGraph, createInvalidationController, createTemporalTransport } from '@konitif/temporal';
6
7
  import { createViewerTemporalScheduler } from './ViewerTemporalScheduler.js';
7
8
  import { copyViewerMetadata } from './ViewerMetadata.js';
9
+ import { IncarnationMaterialProjector, normalizeIncarnationMaterialProjection } from './IncarnationMaterialProjection.js';
8
10
  import { resolveInteractionInputActions, resolveInteractionInputBindings } from '@konitif/tools/input';
9
11
  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
12
  import { ArcballControls } from 'three/examples/jsm/controls/ArcballControls.js';
@@ -30,6 +32,8 @@ import { resolveViewerIncarnationHealth } from './ViewerIncarnationHealth.js';
30
32
  import { PerformanceMonitor } from './PerformanceMonitor.js';
31
33
  import { ViewerTemporalProjectionBuffer } from './TemporalProjectionModel.js';
32
34
  import { ObservedRobotPresentationBuffer } from './ObservedRobotPresentationBuffer.js';
35
+ import { ObservedSoleAnchors, projectBoundedSoleCorrection } from './ObservedSoleStabilization.js';
36
+ import { ConvexHull } from 'three/examples/jsm/math/ConvexHull.js';
33
37
  import { resolveViewerFrameSignalIntervalMs, VIEWER_FRAME_SIGNAL_CATALOG } from './ViewerFrameSignals.js';
34
38
  import { DEFAULT_DISPLAY_FRAME_RATE_HZ, normalizeConfiguredDisplayFrameRate, observeDisplayFrameRateCeiling, resolveDisplayFrameRateTarget } from './displayFrameRate.js';
35
39
  import { ViewerTemporalProjectionExperimentAdapter } from './ViewerTemporalProjectionExperimentAdapter.js';
@@ -219,6 +223,8 @@ export class Viewer3D {
219
223
  poseControlTarget = 'simulated';
220
224
  observedPoseLiveControlEnabled = false;
221
225
  robotMaterialBaselines = new Map();
226
+ incarnationMaterialProjector = new IncarnationMaterialProjector();
227
+ incarnationMaterialProjections = {};
222
228
  observedRobotGhostMaterialBaselines = new Map();
223
229
  observedRobotGhostRenderOrderBaselines = new Map();
224
230
  observedRobotGhostShadowMaterials = new Map();
@@ -229,6 +235,11 @@ export class Viewer3D {
229
235
  observedRobotGhostBaseQuaternion = new Quaternion();
230
236
  observedRobotGhostBaseScale = new Vector3(1, 1, 1);
231
237
  observedRobotTemporalSupportAnchorState = null;
238
+ observedSoleAnchors = new ObservedSoleAnchors();
239
+ observedSoleConnectionId = '';
240
+ observedSoleStrength = 0;
241
+ observedSoleBaseline = new Map();
242
+ observedSoleSurfaceVertices = new WeakMap();
232
243
  observedRobotGrounding = resolveObservedRobotGroundingProjection({
233
244
  enabled: false,
234
245
  floorY: ROBOT_VIEWER_SUPPORT_FLOOR_Y,
@@ -1051,11 +1062,17 @@ export class Viewer3D {
1051
1062
  sampleId: input.frameId,
1052
1063
  connectionId: input.connectionId,
1053
1064
  sourceObservedAtSeconds: input.observedAtSeconds,
1065
+ sourceClock: input.sourceClock,
1054
1066
  receivedAtMs,
1055
1067
  jointValues: input.jointValues
1056
1068
  });
1057
1069
  if (!accepted)
1058
1070
  return false;
1071
+ if (this.observedSoleConnectionId !== input.connectionId) {
1072
+ this.restoreObservedSoleBaseline();
1073
+ this.observedSoleAnchors.reset();
1074
+ this.observedSoleConnectionId = input.connectionId;
1075
+ }
1059
1076
  const frame = this.observedRobotPresentation.project(receivedAtMs);
1060
1077
  if (frame)
1061
1078
  this.applyObservedRobotGhostJointValues(frame.jointValues);
@@ -1063,14 +1080,35 @@ export class Viewer3D {
1063
1080
  return true;
1064
1081
  }
1065
1082
  resetObservedRobotGhostJointObservation() {
1083
+ this.restoreObservedSoleBaseline();
1084
+ this.observedSoleAnchors.reset();
1066
1085
  this.observedRobotPresentation.reset('discontinuity');
1067
1086
  }
1087
+ setObservedRobotSoleStabilizationStrength(value) {
1088
+ this.observedSoleAnchors ??= new ObservedSoleAnchors();
1089
+ const strength = Number.isFinite(value) ? Math.max(0, Math.min(1, value)) : 0;
1090
+ if (strength === this.observedSoleStrength)
1091
+ return;
1092
+ this.restoreObservedSoleBaseline();
1093
+ this.observedSoleAnchors.reset();
1094
+ this.observedSoleStrength = strength;
1095
+ this.applyObservedRobotGhostGrounding();
1096
+ this.requestRender();
1097
+ }
1098
+ restoreObservedSoleBaseline() {
1099
+ this.observedSoleBaseline ??= new Map();
1100
+ for (const [joint, value] of this.observedSoleBaseline)
1101
+ joint.setJointValue(value);
1102
+ this.observedSoleBaseline.clear();
1103
+ }
1068
1104
  getObservedRobotPresentationStatus() {
1069
1105
  return this.observedRobotPresentation.getStatus();
1070
1106
  }
1071
1107
  applyObservedRobotGhostJointValues(values) {
1072
1108
  if (!this.observedRobotGhost)
1073
1109
  return false;
1110
+ const hadSoleCorrection = (this.observedSoleBaseline?.size ?? 0) > 0;
1111
+ this.restoreObservedSoleBaseline();
1074
1112
  const joints = this.observedRobotGhost.joints ?? {};
1075
1113
  let changed = false;
1076
1114
  for (const [target, value] of Object.entries(values)) {
@@ -1082,7 +1120,7 @@ export class Viewer3D {
1082
1120
  continue;
1083
1121
  changed = joint.setJointValue(value) !== false || changed;
1084
1122
  }
1085
- if (changed) {
1123
+ if (changed || hadSoleCorrection) {
1086
1124
  this.applyObservedRobotGhostGrounding();
1087
1125
  if (this.poseControlTarget === 'observed') {
1088
1126
  this.refreshPoseHandleProjection();
@@ -1145,10 +1183,28 @@ export class Viewer3D {
1145
1183
  // invalidate that anchor.
1146
1184
  if (spatialLayoutChanged) {
1147
1185
  this.observedRobotTemporalSupportAnchorState = null;
1186
+ this.observedSoleAnchors?.reset();
1148
1187
  }
1149
1188
  this.applyRobotComparisonAppearance();
1150
1189
  this.requestRender();
1151
1190
  }
1191
+ /** Tune one visual incarnation independently; source meshes and telemetry remain unchanged. */
1192
+ setIncarnationMaterialProjection(target, projection) {
1193
+ if (target !== 'simulated' && target !== 'observed')
1194
+ throw new Error('Invalid material incarnation target');
1195
+ this.incarnationMaterialProjections[target] = normalizeIncarnationMaterialProjection(projection);
1196
+ this.applyIncarnationMaterialProjections();
1197
+ this.requestRender();
1198
+ }
1199
+ applyIncarnationMaterialProjections() {
1200
+ for (const target of ['simulated', 'observed']) {
1201
+ const projection = this.incarnationMaterialProjections[target];
1202
+ if (!projection)
1203
+ continue;
1204
+ const materials = target === 'simulated' ? this.robotMaterialBaselines : this.observedRobotGhostMaterialBaselines;
1205
+ this.incarnationMaterialProjector.apply(materials.keys(), projection);
1206
+ }
1207
+ }
1152
1208
  async setPhysicsEngine(physicsEngine, physicsEnabled = physicsEngine !== 'none') {
1153
1209
  if (this.destroyed)
1154
1210
  return;
@@ -1398,6 +1454,11 @@ export class Viewer3D {
1398
1454
  getPerformanceSummary() {
1399
1455
  return this.performanceMonitor.getSummary();
1400
1456
  }
1457
+ performanceObserver = null;
1458
+ /** Borrowed, optional sink. Does not allocate a loop, timer or browser observer. */
1459
+ setPerformanceObserver(observer) {
1460
+ this.performanceObserver = observer;
1461
+ }
1401
1462
  setTemporalProjectionMode(mode) {
1402
1463
  if (this.temporalProjection.getMode() === mode) {
1403
1464
  return;
@@ -3089,6 +3150,7 @@ export class Viewer3D {
3089
3150
  this.requestRender();
3090
3151
  }
3091
3152
  destroy(options = {}) {
3153
+ this.performanceObserver = null;
3092
3154
  if (this.destroyed) {
3093
3155
  return;
3094
3156
  }
@@ -3881,6 +3943,9 @@ export class Viewer3D {
3881
3943
  if (!this.animationLoop.beginFrame()) {
3882
3944
  return;
3883
3945
  }
3946
+ measureViewerOperation(this.performanceObserver, 'viewer.loop.cpu', () => this.runFrame());
3947
+ };
3948
+ runFrame() {
3884
3949
  const loopStartedAt = performance.now();
3885
3950
  const rafNow = loopStartedAt;
3886
3951
  this.temporalScheduler.frame(rafNow / 1000);
@@ -3957,7 +4022,7 @@ export class Viewer3D {
3957
4022
  const controlsStartedAt = performance.now();
3958
4023
  const shouldUpdateControls = this.controls.enabled && (this.controlsActive || this.controlsSettlingFrames > 0);
3959
4024
  if (shouldUpdateControls) {
3960
- this.controls.update();
4025
+ measureViewerOperation(this.performanceObserver, 'viewer.controls.cpu', () => this.controls.update());
3961
4026
  }
3962
4027
  if (shouldUpdateControls) {
3963
4028
  this.markFrameSignal('controls');
@@ -4042,8 +4107,11 @@ export class Viewer3D {
4042
4107
  if (shouldContinueLoop && this.animationLoop.isIdle()) {
4043
4108
  this.scheduleLoop();
4044
4109
  }
4045
- };
4110
+ }
4046
4111
  render() {
4112
+ measureViewerOperation(this.performanceObserver, 'viewer.draw.cpu', () => this.renderScene());
4113
+ }
4114
+ renderScene() {
4047
4115
  this.projectComparisonSimulation();
4048
4116
  if (this.observedRobotGhostMirrorsSimulatedRobot) {
4049
4117
  this.syncObservedRobotGhostFromSimulatedRobot();
@@ -5150,6 +5218,10 @@ export class Viewer3D {
5150
5218
  return;
5151
5219
  const ghostMaterials = sourceMaterials.map((source) => {
5152
5220
  const material = source.clone();
5221
+ // Three material cloning does not retain consumer-owned shader hooks.
5222
+ // Preserve the observed model's own hooks, never the simulated state.
5223
+ material.onBeforeCompile = source.onBeforeCompile;
5224
+ material.customProgramCacheKey = source.customProgramCacheKey;
5153
5225
  this.captureMaterialBaseline(material, this.observedRobotGhostMaterialBaselines);
5154
5226
  return material;
5155
5227
  });
@@ -5189,6 +5261,8 @@ export class Viewer3D {
5189
5261
  }
5190
5262
  }
5191
5263
  clearObservedRobotGhost() {
5264
+ this.restoreObservedSoleBaseline();
5265
+ this.observedSoleAnchors?.reset();
5192
5266
  this.cancelRobotComparisonVisibilityTransition();
5193
5267
  this.observedRobotPresentation.reset('discontinuity');
5194
5268
  this.simulatedRobotPresentationRoot.position.x = 0;
@@ -5232,6 +5306,7 @@ export class Viewer3D {
5232
5306
  this.applyBalanceDebugPresentationOffset();
5233
5307
  }
5234
5308
  applyRobotComparisonAppearance() {
5309
+ this.applyIncarnationMaterialProjections();
5235
5310
  this.applyMaterialAppearance(this.robotMaterialBaselines, this.robotComparisonAppearance.simulated);
5236
5311
  this.applyMaterialAppearance(this.observedRobotGhostMaterialBaselines, this.robotComparisonAppearance.observed);
5237
5312
  this.applyObservedRobotGhostRenderOrder(this.robotComparisonAppearance.observed.opacity < 0.999);
@@ -5268,6 +5343,7 @@ export class Viewer3D {
5268
5343
  }
5269
5344
  }
5270
5345
  applyObservedRobotGhostGrounding() {
5346
+ this.restoreObservedSoleBaseline();
5271
5347
  if (this.comparisonProfileId) {
5272
5348
  this.projectComparisonSimulation();
5273
5349
  return;
@@ -5295,6 +5371,12 @@ export class Viewer3D {
5295
5371
  robot.updateMatrixWorld(true);
5296
5372
  const supportContacts = this.resolveObservedRobotSupportContacts(robot);
5297
5373
  const supportPoints = supportContacts.supportPoints;
5374
+ const lowestRawSupport = Math.min(...supportPoints.map(point => point.y));
5375
+ const soleEligible = new Map();
5376
+ for (const group of new Set(supportContacts.supportGroupIds)) {
5377
+ const minimum = Math.min(...supportPoints.filter((_, index) => supportContacts.supportGroupIds[index] === group).map(point => point.y));
5378
+ soleEligible.set(group, minimum - lowestRawSupport <= 0.008);
5379
+ }
5298
5380
  const activeSupport = resolveObservedRobotActiveSupportProjection({
5299
5381
  supportPoints,
5300
5382
  contactIds: supportContacts.contactIds,
@@ -5343,18 +5425,134 @@ export class Viewer3D {
5343
5425
  robot.position.x += temporalSupportAnchor.offsetX;
5344
5426
  robot.position.z += temporalSupportAnchor.offsetZ;
5345
5427
  robot.updateMatrixWorld(true);
5428
+ const stabilizedSurfaceMinimumY = this.applyObservedSoleStabilization(robot, soleEligible, footSupportEligibility.eligible);
5346
5429
  const observedSupport = this.resolveObservedRobotSupport(robot, activeSupport.activeContactCount);
5347
5430
  this.observedRobotGrounding = resolveObservedRobotGroundingProjection({
5348
5431
  enabled: this.options.groundObservedRobotGhost,
5349
5432
  floorY: ROBOT_VIEWER_SUPPORT_FLOOR_Y,
5350
- supportMinimumY: observedSupport.supportMinimumY,
5433
+ supportMinimumY: stabilizedSurfaceMinimumY ?? observedSupport.supportMinimumY,
5351
5434
  supportContactCount: observedSupport.supportContactCount,
5352
- source: observedSupport.source
5435
+ source: stabilizedSurfaceMinimumY === null || stabilizedSurfaceMinimumY === undefined ? observedSupport.source : 'foot-geometry'
5353
5436
  });
5354
5437
  robot.position.y += this.observedRobotGrounding.offsetY;
5355
5438
  robot.updateMatrixWorld(true);
5356
5439
  this.applyNavigationPreviewPresentationTransform();
5357
5440
  }
5441
+ applyObservedSoleStabilization(robot, eligible, supportEligible) {
5442
+ if (!(this.observedSoleStrength > 0) || !supportEligible)
5443
+ return null;
5444
+ const status = this.observedRobotPresentation?.getStatus();
5445
+ if (status?.mode === 'raw' || status?.fallbackReason === 'sample_gap') {
5446
+ this.observedSoleAnchors.reset();
5447
+ return null;
5448
+ }
5449
+ const chains = this.options.supportDefinition?.soleStabilizationChains ?? [];
5450
+ const joints = robot.joints ?? {};
5451
+ const pose = (groupId, foot) => {
5452
+ const contacts = this.resolveObservedRobotSupportContacts(robot);
5453
+ const points = contacts.supportPoints.filter((_, index) => contacts.supportGroupIds[index] === groupId);
5454
+ const center = points.reduce((sum, point) => sum.add(point), new Vector3()).multiplyScalar(1 / Math.max(1, points.length));
5455
+ const rotation = foot.getWorldQuaternion(new Quaternion());
5456
+ const supportMinimumY = this.resolvePreciseObservedSoleMinimumY(foot) ?? Math.min(...points.map(point => point.y));
5457
+ return { position: [center.x, center.y, center.z], orientation: [rotation.x, rotation.y, rotation.z, rotation.w], supportMinimumY };
5458
+ };
5459
+ const candidates = chains.flatMap(chain => {
5460
+ const selected = chain.jointNames.map(name => joints[name]);
5461
+ const foot = selected[selected.length - 1];
5462
+ if (!foot || typeof foot.getWorldQuaternion !== 'function' || selected.some(joint => !joint || joint.mimicJoint))
5463
+ return [];
5464
+ const baseline = selected.map(joint => this.readJointValue(joint));
5465
+ if (baseline.some(value => typeof value !== 'number' || !Number.isFinite(value)))
5466
+ return [];
5467
+ return [{ chain, selected: selected, foot, baseline: baseline, raw: pose(chain.groupId, foot) }];
5468
+ });
5469
+ const targets = this.observedSoleAnchors.resolve(this.observedSoleConnectionId, candidates.map(candidate => ({
5470
+ id: candidate.chain.groupId, position: candidate.raw.position,
5471
+ eligible: this.options.groundObservedRobotGhost && supportEligible && eligible.get(candidate.chain.groupId) === true
5472
+ })), this.observedSoleStrength);
5473
+ // Both eligible soles share a surface before the final root grounding.
5474
+ // Preserve lifted/released feet rather than treating every chain as contact.
5475
+ const supportPlaneY = Math.min(...candidates.filter(candidate => targets.some(target => target.id === candidate.chain.groupId)).map(candidate => candidate.raw.supportMinimumY));
5476
+ for (const target of targets) {
5477
+ const candidate = candidates.find(entry => entry.chain.groupId === target.id);
5478
+ const verticalOffset = supportPlaneY - candidate.raw.supportMinimumY;
5479
+ const targetPosition = [target.target[0], candidate.raw.position[1] + verticalOffset, target.target[2]];
5480
+ if (Math.hypot(...targetPosition.map((value, index) => value - candidate.raw.position[index])) < 0.00001)
5481
+ continue;
5482
+ const apply = (values) => {
5483
+ candidate.selected.forEach((joint, index) => joint.setJointValue(values[index]));
5484
+ robot.updateMatrixWorld(true);
5485
+ };
5486
+ try {
5487
+ const correction = projectBoundedSoleCorrection({
5488
+ values: candidate.baseline,
5489
+ limits: candidate.selected.map(joint => [joint.limit?.lower ?? -Math.PI, joint.limit?.upper ?? Math.PI]),
5490
+ targetPosition, targetOrientation: candidate.raw.orientation,
5491
+ targetSupportMinimumY: supportPlaneY,
5492
+ evaluate: values => { apply(values); return pose(target.id, candidate.foot); }
5493
+ });
5494
+ apply(correction.values);
5495
+ candidate.selected.forEach((joint, index) => this.observedSoleBaseline.set(joint, candidate.baseline[index]));
5496
+ }
5497
+ catch {
5498
+ // Invalid/unreachable authored chains must leave the observed copy intact.
5499
+ apply(candidate.baseline);
5500
+ }
5501
+ }
5502
+ if (!targets.length)
5503
+ return null;
5504
+ // An unanchored foot must still not be pushed through the floor by root grounding.
5505
+ const visibleMinima = candidates.map(candidate => this.resolvePreciseObservedSoleMinimumY(candidate.foot))
5506
+ .filter((value) => value !== null);
5507
+ const minimum = Math.min(...visibleMinima);
5508
+ return Number.isFinite(minimum) ? minimum : null;
5509
+ }
5510
+ /** Exact directional support of rigid visible meshes, with cached convex vertices. */
5511
+ resolvePreciseObservedSoleMinimumY(foot) {
5512
+ this.observedSoleSurfaceVertices ??= new WeakMap();
5513
+ let minimum = Infinity;
5514
+ foot.traverse(node => {
5515
+ const mesh = node;
5516
+ if (!mesh.isMesh || mesh.isURDFCollider || !mesh.geometry)
5517
+ return;
5518
+ const attribute = mesh.geometry.getAttribute('position');
5519
+ if (!attribute)
5520
+ return;
5521
+ const version = 'version' in attribute ? Number(attribute.version) : attribute.data.version;
5522
+ let cached = this.observedSoleSurfaceVertices.get(mesh.geometry);
5523
+ if (!cached || cached.attribute !== attribute || cached.version !== version) {
5524
+ const points = [];
5525
+ for (let index = 0; index < attribute.count; index++) {
5526
+ const point = new Vector3().fromBufferAttribute(attribute, index);
5527
+ if ([point.x, point.y, point.z].every(Number.isFinite))
5528
+ points.push(point);
5529
+ }
5530
+ let vertices = points;
5531
+ if (points.length > 4) {
5532
+ try {
5533
+ const hull = new ConvexHull().setFromPoints(points);
5534
+ const unique = new Set();
5535
+ for (const face of hull.faces) {
5536
+ let edge = face.edge;
5537
+ do {
5538
+ unique.add(edge.vertex.point);
5539
+ edge = edge.next;
5540
+ } while (edge !== face.edge);
5541
+ }
5542
+ if (unique.size)
5543
+ vertices = [...unique];
5544
+ }
5545
+ catch { /* Degenerate meshes retain their exact original vertices. */ }
5546
+ }
5547
+ cached = { attribute, version, points: vertices };
5548
+ this.observedSoleSurfaceVertices.set(mesh.geometry, cached);
5549
+ }
5550
+ const matrix = mesh.matrixWorld.elements;
5551
+ for (const point of cached.points)
5552
+ minimum = Math.min(minimum, matrix[1] * point.x + matrix[5] * point.y + matrix[9] * point.z + matrix[13]);
5553
+ });
5554
+ return Number.isFinite(minimum) ? minimum : null;
5555
+ }
5358
5556
  resolveObservedRobotSupportContacts(robot) {
5359
5557
  const supportPoints = [];
5360
5558
  const contactIds = [];