All files / src/components/VirtualWalkthrough boundary-ring.ts

42.62% Statements 26/61
44.44% Branches 8/18
33.33% Functions 2/6
41.66% Lines 25/60

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153                                                                1x               8x 8x 4x     4x 4x 4x 4x 4x 4x   4x 256x 256x 256x     4x 64x 64x 64x   64x 64x 64x 64x 64x     4x                     1x                                                                                                                                              
import {
  CULLFACE_NONE,
  Color,
  LAYERID_IMMEDIATE,
  Mesh,
  MeshInstance,
  Script,
  StandardMaterial,
  Vec3,
} from 'playcanvas';
 
import { computeGroundPlane, yOnPlane } from './groundPlane';
 
export type BoundaryRingGeometryParams = {
  center: Vec3;
  radius: number;
  groundPlane: Vec3[];
  /** Band width in world units (the ring spans radius ± width / 2). */
  width: number;
  dashCount?: number;
  dashRatio?: number;
};
 
/** Flat-array triangle geometry ready for `Mesh.setPositions` / `Mesh.setIndices`. */
export type BoundaryRingGeometry = { positions: number[]; indices: number[] };
 
/**
 * Triangle geometry for a dashed boundary ring laid flat on the ground plane. Each dash is a
 * short band (a quad = two triangles) sweeping from `radius - width / 2` to `radius + width / 2`;
 * every vertex is sampled on the circle in XZ and lifted onto the plane via `yOnPlane`.
 * Returns empty arrays when radius, width, or dashCount is non-positive, or the plane is invalid.
 */
export const boundaryRingMesh = ({
  center,
  radius,
  groundPlane,
  width,
  dashCount = 64,
  dashRatio = 0.5,
}: BoundaryRingGeometryParams): BoundaryRingGeometry => {
  const plane = computeGroundPlane(groundPlane);
  if (radius <= 0 || width <= 0 || dashCount <= 0 || !plane) {
    return { positions: [], indices: [] };
  }
 
  const innerR = radius - width / 2;
  const outerR = radius + width / 2;
  const step = (Math.PI * 2) / dashCount;
  const on = step * dashRatio;
  const positions: number[] = [];
  const indices: number[] = [];
 
  const pushVertex = (r: number, angle: number) => {
    const x = center.x + r * Math.cos(angle);
    const z = center.z + r * Math.sin(angle);
    positions.push(x, yOnPlane(x, z, plane.normal, plane.point, center.y), z);
  };
 
  for (let i = 0; i < dashCount; i++) {
    const a0 = i * step;
    const a1 = a0 + on;
    const base = i * 4;
    // Per dash: v0 inner@a0, v1 outer@a0, v2 inner@a1, v3 outer@a1.
    pushVertex(innerR, a0);
    pushVertex(outerR, a0);
    pushVertex(innerR, a1);
    pushVertex(outerR, a1);
    indices.push(base, base + 1, base + 3, base, base + 3, base + 2);
  }
 
  return { positions, indices };
};
 
/**
 * Draws a dashed boundary ring as a flat band mesh on the ground plane.
 *
 * Rendered as a thin triangle band whose width is a fraction of the radius (`widthRatio`).
 * center / radius / groundPlane are set imperatively by the BoundaryRing component (see BoundaryRing.tsx for why
 * they are not reactive Script props), which calls `rebuild()` after updating them.
 */
export class BoundaryRingScript extends Script {
  static scriptName = 'boundaryRing';
 
  center = new Vec3();
  radius = 0;
  groundPlane: Vec3[] = [];
  color = new Color(1, 1, 1);
  dashCount = 64;
  dashRatio = 0.5;
  /** Band width as a fraction of the radius. */
  widthRatio = 0.05;
 
  private _material?: StandardMaterial;
  private _mesh?: Mesh;
 
  initialize() {
    const material = new StandardMaterial();
    material.useLighting = false;
    material.emissive = this.color;
    material.cull = CULLFACE_NONE;
    material.update();
    this._material = material;
    this.rebuild();
  }
 
  rebuild() {
    this._clearMesh();
 
    const geometry = boundaryRingMesh({
      center: this.center,
      radius: this.radius,
      groundPlane: this.groundPlane,
      width: this.radius * this.widthRatio,
      dashCount: this.dashCount,
      dashRatio: this.dashRatio,
    });
    if (geometry.positions.length === 0 || !this._material) {
      return;
    }
 
    const mesh = new Mesh(this.app.graphicsDevice);
    mesh.setPositions(geometry.positions);
    mesh.setIndices(geometry.indices);
    mesh.update();
    this._mesh = mesh;
 
    const meshInstance = new MeshInstance(mesh, this._material);
    if (this.entity.render) {
      this.entity.render.meshInstances = [meshInstance];
    } else {
      // Render on the Immediate layer (drawn after the World layer where the splats render) so
      // the ring is not composited behind them.
      this.entity.addComponent('render', { meshInstances: [meshInstance], layers: [LAYERID_IMMEDIATE] });
    }
  }
 
  private _clearMesh() {
    if (this.entity.render) {
      this.entity.render.meshInstances = [];
    }
    if (this._mesh) {
      this._mesh.destroy();
      this._mesh = undefined;
    }
  }
 
  destroy() {
    this._clearMesh();
    this._material?.destroy();
    this._material = undefined;
  }
}