@terraware/web-components 4.2.21 → 4.2.22
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/components/VirtualWalkthrough/Annotation.d.ts.map +1 -1
- package/components/VirtualWalkthrough/Annotation.js +10 -0
- package/components/VirtualWalkthrough/BoundaryWall.d.ts +11 -0
- package/components/VirtualWalkthrough/BoundaryWall.d.ts.map +1 -0
- package/components/VirtualWalkthrough/BoundaryWall.js +35 -0
- package/components/VirtualWalkthrough/TfXrNavigation.d.ts +42 -6
- package/components/VirtualWalkthrough/TfXrNavigation.d.ts.map +1 -1
- package/components/VirtualWalkthrough/TfXrNavigation.js +109 -9
- package/components/VirtualWalkthrough/TfXrNavigation.test.js +110 -0
- package/components/VirtualWalkthrough/VirtualWalkthroughViewer.d.ts.map +1 -1
- package/components/VirtualWalkthrough/VirtualWalkthroughViewer.js +50 -3
- package/components/VirtualWalkthrough/VrAnnotationPanel.d.ts +9 -0
- package/components/VirtualWalkthrough/VrAnnotationPanel.d.ts.map +1 -0
- package/components/VirtualWalkthrough/VrAnnotationPanel.js +22 -0
- package/components/VirtualWalkthrough/XrAnnotationInteraction.d.ts +6 -0
- package/components/VirtualWalkthrough/XrAnnotationInteraction.d.ts.map +1 -0
- package/components/VirtualWalkthrough/XrAnnotationInteraction.js +15 -0
- package/components/VirtualWalkthrough/XrGazeDwell.d.ts +6 -0
- package/components/VirtualWalkthrough/XrGazeDwell.d.ts.map +1 -0
- package/components/VirtualWalkthrough/XrGazeDwell.js +15 -0
- package/components/VirtualWalkthrough/XrPointerRay.d.ts +7 -0
- package/components/VirtualWalkthrough/XrPointerRay.d.ts.map +1 -0
- package/components/VirtualWalkthrough/XrPointerRay.js +15 -0
- package/components/VirtualWalkthrough/boundary-wall.d.ts +86 -0
- package/components/VirtualWalkthrough/boundary-wall.d.ts.map +1 -0
- package/components/VirtualWalkthrough/boundary-wall.js +331 -0
- package/components/VirtualWalkthrough/boundary-wall.test.js +203 -0
- package/components/VirtualWalkthrough/vr-annotation-panel-layout.d.ts +6 -0
- package/components/VirtualWalkthrough/vr-annotation-panel-layout.d.ts.map +1 -0
- package/components/VirtualWalkthrough/vr-annotation-panel-layout.js +22 -0
- package/components/VirtualWalkthrough/vr-annotation-panel-layout.test.js +26 -0
- package/components/VirtualWalkthrough/vr-annotation-panel.d.ts +40 -0
- package/components/VirtualWalkthrough/vr-annotation-panel.d.ts.map +1 -0
- package/components/VirtualWalkthrough/vr-annotation-panel.js +312 -0
- package/components/VirtualWalkthrough/xr-annotation-candidates.d.ts +18 -0
- package/components/VirtualWalkthrough/xr-annotation-candidates.d.ts.map +1 -0
- package/components/VirtualWalkthrough/xr-annotation-candidates.js +38 -0
- package/components/VirtualWalkthrough/xr-annotation-interaction.d.ts +13 -0
- package/components/VirtualWalkthrough/xr-annotation-interaction.d.ts.map +1 -0
- package/components/VirtualWalkthrough/xr-annotation-interaction.js +59 -0
- package/components/VirtualWalkthrough/xr-annotation-targeting.d.ts +10 -0
- package/components/VirtualWalkthrough/xr-annotation-targeting.d.ts.map +1 -0
- package/components/VirtualWalkthrough/xr-annotation-targeting.js +36 -0
- package/components/VirtualWalkthrough/xr-annotation-targeting.test.js +66 -0
- package/components/VirtualWalkthrough/xr-button-arming.d.ts +14 -0
- package/components/VirtualWalkthrough/xr-button-arming.d.ts.map +1 -0
- package/components/VirtualWalkthrough/xr-button-arming.js +19 -0
- package/components/VirtualWalkthrough/xr-button-arming.test.js +35 -0
- package/components/VirtualWalkthrough/xr-button-hold.d.ts +20 -0
- package/components/VirtualWalkthrough/xr-button-hold.d.ts.map +1 -0
- package/components/VirtualWalkthrough/xr-button-hold.js +43 -0
- package/components/VirtualWalkthrough/xr-button-hold.test.js +57 -0
- package/components/VirtualWalkthrough/xr-exit-button.d.ts +21 -4
- package/components/VirtualWalkthrough/xr-exit-button.d.ts.map +1 -1
- package/components/VirtualWalkthrough/xr-exit-button.js +98 -37
- package/components/VirtualWalkthrough/xr-face-buttons.d.ts +15 -0
- package/components/VirtualWalkthrough/xr-face-buttons.d.ts.map +1 -0
- package/components/VirtualWalkthrough/xr-face-buttons.js +31 -0
- package/components/VirtualWalkthrough/xr-face-buttons.test.js +82 -0
- package/components/VirtualWalkthrough/xr-gaze-dwell-state.d.ts +17 -0
- package/components/VirtualWalkthrough/xr-gaze-dwell-state.d.ts.map +1 -0
- package/components/VirtualWalkthrough/xr-gaze-dwell-state.js +39 -0
- package/components/VirtualWalkthrough/xr-gaze-dwell-state.test.js +61 -0
- package/components/VirtualWalkthrough/xr-gaze-dwell.d.ts +30 -0
- package/components/VirtualWalkthrough/xr-gaze-dwell.d.ts.map +1 -0
- package/components/VirtualWalkthrough/xr-gaze-dwell.js +173 -0
- package/components/VirtualWalkthrough/xr-interactive-ui.d.ts +13 -0
- package/components/VirtualWalkthrough/xr-interactive-ui.d.ts.map +1 -0
- package/components/VirtualWalkthrough/xr-interactive-ui.js +25 -0
- package/components/VirtualWalkthrough/xr-interactive-ui.test.js +165 -0
- package/components/VirtualWalkthrough/xr-pointer-ray.d.ts +10 -0
- package/components/VirtualWalkthrough/xr-pointer-ray.d.ts.map +1 -0
- package/components/VirtualWalkthrough/xr-pointer-ray.js +24 -0
- package/components/VirtualWalkthrough/xr-progress-pie.d.ts +10 -0
- package/components/VirtualWalkthrough/xr-progress-pie.d.ts.map +1 -0
- package/components/VirtualWalkthrough/xr-progress-pie.js +30 -0
- package/components/VirtualWalkthrough/xr-progress-pie.test.js +79 -0
- package/components/VirtualWalkthrough/xr-ray-quad.d.ts +13 -0
- package/components/VirtualWalkthrough/xr-ray-quad.d.ts.map +1 -0
- package/components/VirtualWalkthrough/xr-ray-quad.js +29 -0
- package/components/VirtualWalkthrough/xr-ray-quad.test.js +37 -0
- package/components/VirtualWalkthrough/xr-scene-bounds.d.ts +17 -0
- package/components/VirtualWalkthrough/xr-scene-bounds.d.ts.map +1 -0
- package/components/VirtualWalkthrough/xr-scene-bounds.js +41 -0
- package/components/VirtualWalkthrough/xr-scene-bounds.test.js +70 -0
- package/components/VirtualWalkthrough/xr-teleport-gesture.d.ts +30 -0
- package/components/VirtualWalkthrough/xr-teleport-gesture.d.ts.map +1 -0
- package/components/VirtualWalkthrough/xr-teleport-gesture.js +45 -0
- package/components/VirtualWalkthrough/xr-teleport-gesture.test.js +90 -0
- package/hooks/useXr.d.ts +2 -0
- package/hooks/useXr.d.ts.map +1 -1
- package/hooks/useXr.js +10 -6
- package/package.json +1 -1
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { Color, Script, Vec3 } from 'playcanvas';
|
|
2
|
+
export type BoundaryWallGeometryParams = {
|
|
3
|
+
center: Vec3;
|
|
4
|
+
radius: number;
|
|
5
|
+
/** Three world-space points defining the ground the wall stands on. Empty or degenerate falls back to baseY. */
|
|
6
|
+
groundPlane: Vec3[];
|
|
7
|
+
/** Flat base height used when no usable ground plane is supplied. */
|
|
8
|
+
baseY: number;
|
|
9
|
+
/** Absolute world Y of the wall's top edge, giving a flat top ring however the ground below tilts. */
|
|
10
|
+
topY: number;
|
|
11
|
+
/** Target grid cell size in world units. Rounded so cells divide the circumference and height evenly. */
|
|
12
|
+
gridSpacing: number;
|
|
13
|
+
segments?: number;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Flat-array geometry ready for `Mesh.setPositions` / `Mesh.setUvs` / `Mesh.setIndices`, plus the
|
|
17
|
+
* grid cell counts the shader needs to know how far the UVs run.
|
|
18
|
+
*/
|
|
19
|
+
export type BoundaryWallGeometry = {
|
|
20
|
+
positions: number[];
|
|
21
|
+
uvs: number[];
|
|
22
|
+
indices: number[];
|
|
23
|
+
columns: number;
|
|
24
|
+
rows: number;
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Triangle geometry for an upright cylinder standing on the boundary circle. Each segment is a quad
|
|
28
|
+
* (two triangles) spanning from the ground up to `topY`; the base is sampled on the ground plane so
|
|
29
|
+
* the wall meets the dashed BoundaryRing exactly, while the top is a flat ring at `topY` — the user
|
|
30
|
+
* stands on the rig floor, not on the splat's ground plane, so the top is positioned relative to the
|
|
31
|
+
* former and only the base follows the latter.
|
|
32
|
+
*
|
|
33
|
+
* UVs are emitted in **grid cell units** rather than metres — u runs 0..columns around the
|
|
34
|
+
* circumference and v runs 0..rows up the wall — so the shader can draw a line at every whole number
|
|
35
|
+
* and have the seam at u = columns line up perfectly with u = 0.
|
|
36
|
+
*/
|
|
37
|
+
export declare const boundaryWallMesh: ({ center, radius, groundPlane, baseY, topY, gridSpacing, segments, }: BoundaryWallGeometryParams) => BoundaryWallGeometry;
|
|
38
|
+
/**
|
|
39
|
+
* Draws a Quest-guardian-style boundary wall: an upright cylinder on the bounds circle whose grid
|
|
40
|
+
* fades in and shifts toward a warning colour as the head approaches, and goes to full intensity
|
|
41
|
+
* while XR navigation is actively holding the user back.
|
|
42
|
+
*
|
|
43
|
+
* center / radius / groundPlane / baseY are set imperatively by the BoundaryWall component (see
|
|
44
|
+
* BoundaryWall.tsx for why they are not reactive Script props), which calls `rebuild()` afterwards.
|
|
45
|
+
* All of them are world-space: this entity is deliberately mounted outside `content-root`, which
|
|
46
|
+
* carries the scene's scaleFactor.
|
|
47
|
+
*/
|
|
48
|
+
export declare class BoundaryWallScript extends Script {
|
|
49
|
+
static scriptName: string;
|
|
50
|
+
center: Vec3;
|
|
51
|
+
radius: number;
|
|
52
|
+
groundPlane: Vec3[];
|
|
53
|
+
/** Flat bottom height used when no usable ground plane is supplied. */
|
|
54
|
+
baseY: number;
|
|
55
|
+
/**
|
|
56
|
+
* Absolute world Y of the wall's top edge. The default puts it 2.5 m above a rig floor at y = 0,
|
|
57
|
+
* which is where the user actually stands - the splat's ground plane can be well below that.
|
|
58
|
+
*/
|
|
59
|
+
topY: number;
|
|
60
|
+
segments: number;
|
|
61
|
+
gridSpacing: number;
|
|
62
|
+
/** Grid line thickness in world metres. */
|
|
63
|
+
lineWidth: number;
|
|
64
|
+
/** Head distance, in world metres, at which the wall starts to appear. */
|
|
65
|
+
fadeDistance: number;
|
|
66
|
+
/** Multiplier applied to fadeDistance while movement is being clamped. */
|
|
67
|
+
blockedFadeScale: number;
|
|
68
|
+
calmColor: Color;
|
|
69
|
+
warnColor: Color;
|
|
70
|
+
private _material?;
|
|
71
|
+
private _mesh?;
|
|
72
|
+
private _camera;
|
|
73
|
+
private _navigation;
|
|
74
|
+
private _headParam;
|
|
75
|
+
initialize(): void;
|
|
76
|
+
/**
|
|
77
|
+
* The camera and the navigation script both live on entities this one does not own. They are
|
|
78
|
+
* resolved lazily rather than once, because script initialize order across sibling entities is not
|
|
79
|
+
* guaranteed and this entity mounts when an XR session starts.
|
|
80
|
+
*/
|
|
81
|
+
private _resolveDependencies;
|
|
82
|
+
rebuild(): void;
|
|
83
|
+
update(): void;
|
|
84
|
+
private _clearMesh;
|
|
85
|
+
}
|
|
86
|
+
//# sourceMappingURL=boundary-wall.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"boundary-wall.d.ts","sourceRoot":"","sources":["../../../src/components/VirtualWalkthrough/boundary-wall.ts"],"names":[],"mappings":"AAAA,OAAO,EAGL,KAAK,EAOL,MAAM,EAEN,IAAI,EACL,MAAM,YAAY,CAAC;AAKpB,MAAM,MAAM,0BAA0B,GAAG;IACvC,MAAM,EAAE,IAAI,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,gHAAgH;IAChH,WAAW,EAAE,IAAI,EAAE,CAAC;IACpB,qEAAqE;IACrE,KAAK,EAAE,MAAM,CAAC;IACd,sGAAsG;IACtG,IAAI,EAAE,MAAM,CAAC;IACb,yGAAyG;IACzG,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB,CAAC;AAEF;;;GAGG;AACH,MAAM,MAAM,oBAAoB,GAAG;IACjC,SAAS,EAAE,MAAM,EAAE,CAAC;IACpB,GAAG,EAAE,MAAM,EAAE,CAAC;IACd,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;CACd,CAAC;AAEF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,gBAAgB,GAAI,sEAQ9B,0BAA0B,KAAG,oBAqD/B,CAAC;AA2EF;;;;;;;;;GASG;AACH,qBAAa,kBAAmB,SAAQ,MAAM;IAC5C,MAAM,CAAC,UAAU,SAAkB;IAEnC,MAAM,OAAc;IACpB,MAAM,SAAK;IACX,WAAW,EAAE,IAAI,EAAE,CAAM;IACzB,uEAAuE;IACvE,KAAK,SAAK;IACV;;;OAGG;IACH,IAAI,SAAO;IACX,QAAQ,SAAM;IACd,WAAW,SAAO;IAClB,2CAA2C;IAC3C,SAAS,SAAS;IAClB,0EAA0E;IAC1E,YAAY,SAAO;IACnB,0EAA0E;IAC1E,gBAAgB,SAAO;IACvB,SAAS,QAA2B;IACpC,SAAS,QAA2B;IAEpC,OAAO,CAAC,SAAS,CAAC,CAAiB;IACnC,OAAO,CAAC,KAAK,CAAC,CAAO;IACrB,OAAO,CAAC,OAAO,CAAuB;IACtC,OAAO,CAAC,WAAW,CAAuC;IAC1D,OAAO,CAAC,UAAU,CAAuB;IAEzC,UAAU;IAwBV;;;;OAIG;IACH,OAAO,CAAC,oBAAoB;IAW5B,OAAO;IA6CP,MAAM;IAgCN,OAAO,CAAC,UAAU;CASnB"}
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import { BLEND_NORMAL, CULLFACE_NONE, Color, LAYERID_IMMEDIATE, Mesh, MeshInstance, SEMANTIC_POSITION, SEMANTIC_TEXCOORD0, Script, ShaderMaterial, Vec3 } from 'playcanvas';
|
|
2
|
+
import { computeGroundPlane, yOnPlane } from './groundPlane';
|
|
3
|
+
import { distanceToCircleEdge } from './xr-scene-bounds';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Flat-array geometry ready for `Mesh.setPositions` / `Mesh.setUvs` / `Mesh.setIndices`, plus the
|
|
7
|
+
* grid cell counts the shader needs to know how far the UVs run.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Triangle geometry for an upright cylinder standing on the boundary circle. Each segment is a quad
|
|
12
|
+
* (two triangles) spanning from the ground up to `topY`; the base is sampled on the ground plane so
|
|
13
|
+
* the wall meets the dashed BoundaryRing exactly, while the top is a flat ring at `topY` — the user
|
|
14
|
+
* stands on the rig floor, not on the splat's ground plane, so the top is positioned relative to the
|
|
15
|
+
* former and only the base follows the latter.
|
|
16
|
+
*
|
|
17
|
+
* UVs are emitted in **grid cell units** rather than metres — u runs 0..columns around the
|
|
18
|
+
* circumference and v runs 0..rows up the wall — so the shader can draw a line at every whole number
|
|
19
|
+
* and have the seam at u = columns line up perfectly with u = 0.
|
|
20
|
+
*/
|
|
21
|
+
export const boundaryWallMesh = ({
|
|
22
|
+
center,
|
|
23
|
+
radius,
|
|
24
|
+
groundPlane,
|
|
25
|
+
baseY,
|
|
26
|
+
topY,
|
|
27
|
+
gridSpacing,
|
|
28
|
+
segments = 96
|
|
29
|
+
}) => {
|
|
30
|
+
if (radius <= 0 || segments <= 0 || gridSpacing <= 0) {
|
|
31
|
+
return {
|
|
32
|
+
positions: [],
|
|
33
|
+
uvs: [],
|
|
34
|
+
indices: [],
|
|
35
|
+
columns: 0,
|
|
36
|
+
rows: 0
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
const plane = computeGroundPlane(groundPlane);
|
|
40
|
+
const step = Math.PI * 2 / segments;
|
|
41
|
+
|
|
42
|
+
// First pass: the bottom of every column, which the row count and the extent guard both need
|
|
43
|
+
// before any vertex can be emitted.
|
|
44
|
+
const bottoms = Array.from({
|
|
45
|
+
length: segments
|
|
46
|
+
}, (_, index) => {
|
|
47
|
+
const angle = index * step;
|
|
48
|
+
const x = center.x + radius * Math.cos(angle);
|
|
49
|
+
const z = center.z + radius * Math.sin(angle);
|
|
50
|
+
return {
|
|
51
|
+
x,
|
|
52
|
+
z,
|
|
53
|
+
y: plane ? yOnPlane(x, z, plane.normal, plane.point, baseY) : baseY
|
|
54
|
+
};
|
|
55
|
+
});
|
|
56
|
+
if (bottoms.some(bottom => bottom.y >= topY)) {
|
|
57
|
+
return {
|
|
58
|
+
positions: [],
|
|
59
|
+
uvs: [],
|
|
60
|
+
indices: [],
|
|
61
|
+
columns: 0,
|
|
62
|
+
rows: 0
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Every column has its own world height once the ground tilts, but v still runs 0..rows uniformly
|
|
67
|
+
// for all of them, so a single row count has to stand for the whole wall. Deriving it from the
|
|
68
|
+
// average bottom is exact for a flat ground plane (the common case) and keeps the cells square on
|
|
69
|
+
// average when the plane is tilted.
|
|
70
|
+
const averageBottomY = bottoms.reduce((total, bottom) => total + bottom.y, 0) / bottoms.length;
|
|
71
|
+
const columns = Math.max(1, Math.round(Math.PI * 2 * radius / gridSpacing));
|
|
72
|
+
const rows = Math.max(1, Math.round((topY - averageBottomY) / gridSpacing));
|
|
73
|
+
const positions = [];
|
|
74
|
+
const uvs = [];
|
|
75
|
+
const indices = [];
|
|
76
|
+
const pushColumn = index => {
|
|
77
|
+
// The closing column (index === segments) reuses column 0's position so the loop shuts exactly,
|
|
78
|
+
// while u keeps counting up to `columns` to place the seam.
|
|
79
|
+
const {
|
|
80
|
+
x,
|
|
81
|
+
z,
|
|
82
|
+
y
|
|
83
|
+
} = bottoms[index % segments];
|
|
84
|
+
const u = index / segments * columns;
|
|
85
|
+
positions.push(x, y, z, x, topY, z);
|
|
86
|
+
uvs.push(u, 0, u, rows);
|
|
87
|
+
};
|
|
88
|
+
for (let i = 0; i < segments; i++) {
|
|
89
|
+
const base = i * 4;
|
|
90
|
+
// Per segment: v0 bottom@a0, v1 top@a0, v2 bottom@a1, v3 top@a1.
|
|
91
|
+
pushColumn(i);
|
|
92
|
+
pushColumn(i + 1);
|
|
93
|
+
indices.push(base, base + 1, base + 3, base, base + 3, base + 2);
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
positions,
|
|
97
|
+
uvs,
|
|
98
|
+
indices,
|
|
99
|
+
columns,
|
|
100
|
+
rows
|
|
101
|
+
};
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
// GLSL only: @playcanvas/react's Application defaults to deviceTypes [DEVICETYPE_WEBGL2], so the
|
|
105
|
+
// WGSL variants the engine's own shaders carry would never be compiled here.
|
|
106
|
+
const wallVertexGLSL = /* glsl */`
|
|
107
|
+
attribute vec3 vertex_position;
|
|
108
|
+
attribute vec2 aUv0;
|
|
109
|
+
|
|
110
|
+
uniform mat4 matrix_model;
|
|
111
|
+
uniform mat4 matrix_viewProjection;
|
|
112
|
+
|
|
113
|
+
varying vec2 uv0;
|
|
114
|
+
varying vec3 vWorld;
|
|
115
|
+
|
|
116
|
+
void main(void) {
|
|
117
|
+
vec4 world = matrix_model * vec4(vertex_position, 1.0);
|
|
118
|
+
vWorld = world.xyz;
|
|
119
|
+
uv0 = aUv0;
|
|
120
|
+
gl_Position = matrix_viewProjection * world;
|
|
121
|
+
}
|
|
122
|
+
`;
|
|
123
|
+
const wallFragmentGLSL = /* glsl */`
|
|
124
|
+
uniform vec3 uHeadPos;
|
|
125
|
+
uniform float uFadeDistance;
|
|
126
|
+
uniform float uBlockedFadeScale;
|
|
127
|
+
uniform float uBlocked;
|
|
128
|
+
uniform float uRows;
|
|
129
|
+
uniform vec3 uCalmColor;
|
|
130
|
+
uniform vec3 uWarnColor;
|
|
131
|
+
uniform float uLineHalfWidth;
|
|
132
|
+
|
|
133
|
+
varying vec2 uv0;
|
|
134
|
+
varying vec3 vWorld;
|
|
135
|
+
|
|
136
|
+
// Antialiased line mask: 1 at whole-number coordinates, 0 between them.
|
|
137
|
+
//
|
|
138
|
+
// The line has a solid core uLineHalfWidth cells wide either side of the coordinate, so its
|
|
139
|
+
// thickness is fixed in world space and holds up close to the wall. The smoothstep then feathers
|
|
140
|
+
// it over exactly one pixel (fwidth), which both antialiases the edge and keeps distant lines
|
|
141
|
+
// from aliasing into moire: once a cell shrinks below a pixel the feather swallows the core and
|
|
142
|
+
// the grid fades out instead of shimmering.
|
|
143
|
+
float gridLine(float coord) {
|
|
144
|
+
float w = fwidth(coord);
|
|
145
|
+
float d = abs(fract(coord + 0.5) - 0.5);
|
|
146
|
+
return 1.0 - smoothstep(uLineHalfWidth, uLineHalfWidth + w, d);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
void main(void) {
|
|
150
|
+
// gridLine takes derivatives, which are undefined in non-uniform control flow, so it has to
|
|
151
|
+
// run before any discard.
|
|
152
|
+
float grid = max(gridLine(uv0.x), gridLine(uv0.y));
|
|
153
|
+
|
|
154
|
+
// Distance from this fragment to the head, so only the arc of wall near the user lights up.
|
|
155
|
+
float fade = uFadeDistance * mix(1.0, uBlockedFadeScale, uBlocked);
|
|
156
|
+
float proximity = 1.0 - clamp(distance(vWorld.xz, uHeadPos.xz) / fade, 0.0, 1.0);
|
|
157
|
+
if (proximity <= 0.0) discard;
|
|
158
|
+
|
|
159
|
+
// Soften the open top edge so the wall does not end in a hard rim.
|
|
160
|
+
float topFade = 1.0 - smoothstep(0.6, 1.0, uv0.y / uRows);
|
|
161
|
+
|
|
162
|
+
float alpha = proximity * topFade * grid;
|
|
163
|
+
if (alpha <= 0.0) discard;
|
|
164
|
+
|
|
165
|
+
gl_FragColor = vec4(mix(uCalmColor, uWarnColor, max(proximity, uBlocked)), alpha);
|
|
166
|
+
}
|
|
167
|
+
`;
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Structural shape of TfXrNavigation this script depends on, kept local so it does not have to
|
|
171
|
+
* import the navigation script. `enabled` is optional because a disabled script (e.g. while edit
|
|
172
|
+
* mode is on) stops receiving postUpdate and would otherwise latch a stale clampDistance.
|
|
173
|
+
*/
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Draws a Quest-guardian-style boundary wall: an upright cylinder on the bounds circle whose grid
|
|
177
|
+
* fades in and shifts toward a warning colour as the head approaches, and goes to full intensity
|
|
178
|
+
* while XR navigation is actively holding the user back.
|
|
179
|
+
*
|
|
180
|
+
* center / radius / groundPlane / baseY are set imperatively by the BoundaryWall component (see
|
|
181
|
+
* BoundaryWall.tsx for why they are not reactive Script props), which calls `rebuild()` afterwards.
|
|
182
|
+
* All of them are world-space: this entity is deliberately mounted outside `content-root`, which
|
|
183
|
+
* carries the scene's scaleFactor.
|
|
184
|
+
*/
|
|
185
|
+
export class BoundaryWallScript extends Script {
|
|
186
|
+
static scriptName = 'boundaryWall';
|
|
187
|
+
center = new Vec3();
|
|
188
|
+
radius = 0;
|
|
189
|
+
groundPlane = [];
|
|
190
|
+
/** Flat bottom height used when no usable ground plane is supplied. */
|
|
191
|
+
baseY = 0;
|
|
192
|
+
/**
|
|
193
|
+
* Absolute world Y of the wall's top edge. The default puts it 2.5 m above a rig floor at y = 0,
|
|
194
|
+
* which is where the user actually stands - the splat's ground plane can be well below that.
|
|
195
|
+
*/
|
|
196
|
+
topY = 2.5;
|
|
197
|
+
segments = 96;
|
|
198
|
+
gridSpacing = 0.5;
|
|
199
|
+
/** Grid line thickness in world metres. */
|
|
200
|
+
lineWidth = 0.025;
|
|
201
|
+
/** Head distance, in world metres, at which the wall starts to appear. */
|
|
202
|
+
fadeDistance = 1.5;
|
|
203
|
+
/** Multiplier applied to fadeDistance while movement is being clamped. */
|
|
204
|
+
blockedFadeScale = 2.5;
|
|
205
|
+
calmColor = new Color(0.35, 0.7, 1);
|
|
206
|
+
warnColor = new Color(1, 0.45, 0.2);
|
|
207
|
+
_camera = null;
|
|
208
|
+
_navigation = null;
|
|
209
|
+
_headParam = new Float32Array(3);
|
|
210
|
+
initialize() {
|
|
211
|
+
this._material = new ShaderMaterial({
|
|
212
|
+
uniqueName: 'boundary-wall',
|
|
213
|
+
vertexGLSL: wallVertexGLSL,
|
|
214
|
+
fragmentGLSL: wallFragmentGLSL,
|
|
215
|
+
attributes: {
|
|
216
|
+
vertex_position: SEMANTIC_POSITION,
|
|
217
|
+
aUv0: SEMANTIC_TEXCOORD0
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
this._material.blendType = BLEND_NORMAL;
|
|
221
|
+
this._material.cull = CULLFACE_NONE;
|
|
222
|
+
this._material.depthWrite = false;
|
|
223
|
+
this._material.update();
|
|
224
|
+
this._resolveDependencies();
|
|
225
|
+
this.rebuild();
|
|
226
|
+
this.once('destroy', () => {
|
|
227
|
+
this._clearMesh();
|
|
228
|
+
this._material?.destroy();
|
|
229
|
+
this._material = undefined;
|
|
230
|
+
this._camera = null;
|
|
231
|
+
this._navigation = null;
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* The camera and the navigation script both live on entities this one does not own. They are
|
|
237
|
+
* resolved lazily rather than once, because script initialize order across sibling entities is not
|
|
238
|
+
* guaranteed and this entity mounts when an XR session starts.
|
|
239
|
+
*/
|
|
240
|
+
_resolveDependencies() {
|
|
241
|
+
if (!this._camera) {
|
|
242
|
+
this._camera = this.app.root.findByName('camera');
|
|
243
|
+
}
|
|
244
|
+
if (!this._navigation) {
|
|
245
|
+
const rig = this.app.root.findByName('camera-root');
|
|
246
|
+
// @ts-expect-error - scripts are added dynamically to the entity
|
|
247
|
+
this._navigation = rig?.script?.tfXrNavigation ?? null;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
rebuild() {
|
|
251
|
+
this._clearMesh();
|
|
252
|
+
const geometry = boundaryWallMesh({
|
|
253
|
+
center: this.center,
|
|
254
|
+
radius: this.radius,
|
|
255
|
+
groundPlane: this.groundPlane,
|
|
256
|
+
baseY: this.baseY,
|
|
257
|
+
topY: this.topY,
|
|
258
|
+
gridSpacing: this.gridSpacing,
|
|
259
|
+
segments: this.segments
|
|
260
|
+
});
|
|
261
|
+
if (geometry.positions.length === 0 || !this._material) {
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// The shader measures distance in grid cells, so convert. A cell is gridSpacing across only
|
|
266
|
+
// approximately - columns and rows are rounded to whole numbers - but that error is well under
|
|
267
|
+
// a line width, so it is not worth carrying the exact cell size through as a second uniform.
|
|
268
|
+
this._material.setParameter('uLineHalfWidth', this.lineWidth / this.gridSpacing * 0.5);
|
|
269
|
+
this._material.setParameter('uFadeDistance', this.fadeDistance);
|
|
270
|
+
this._material.setParameter('uBlockedFadeScale', this.blockedFadeScale);
|
|
271
|
+
this._material.setParameter('uRows', geometry.rows);
|
|
272
|
+
this._material.setParameter('uCalmColor', [this.calmColor.r, this.calmColor.g, this.calmColor.b]);
|
|
273
|
+
this._material.setParameter('uWarnColor', [this.warnColor.r, this.warnColor.g, this.warnColor.b]);
|
|
274
|
+
this._material.setParameter('uBlocked', 0);
|
|
275
|
+
this._material.setParameter('uHeadPos', [0, 0, 0]);
|
|
276
|
+
const mesh = new Mesh(this.app.graphicsDevice);
|
|
277
|
+
mesh.setPositions(geometry.positions);
|
|
278
|
+
mesh.setUvs(0, geometry.uvs);
|
|
279
|
+
mesh.setIndices(geometry.indices);
|
|
280
|
+
mesh.update();
|
|
281
|
+
this._mesh = mesh;
|
|
282
|
+
const meshInstance = new MeshInstance(mesh, this._material);
|
|
283
|
+
if (this.entity.render) {
|
|
284
|
+
this.entity.render.meshInstances = [meshInstance];
|
|
285
|
+
} else {
|
|
286
|
+
// Immediate layer, drawn after the World layer where the splats render, so the wall is not
|
|
287
|
+
// composited behind them - same reason BoundaryRingScript uses it.
|
|
288
|
+
this.entity.addComponent('render', {
|
|
289
|
+
meshInstances: [meshInstance],
|
|
290
|
+
layers: [LAYERID_IMMEDIATE]
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
update() {
|
|
295
|
+
this._resolveDependencies();
|
|
296
|
+
const material = this._material;
|
|
297
|
+
const camera = this._camera;
|
|
298
|
+
if (!material || !camera || !this.entity.render) {
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
const navigation = this._navigation;
|
|
302
|
+
// A disabled navigation script (e.g. edit mode) stops receiving postUpdate, so its
|
|
303
|
+
// clampDistance would otherwise latch whatever value it last held.
|
|
304
|
+
const blocked = navigation?.enabled !== false && (navigation?.clampDistance ?? 0) > 0 ? 1 : 0;
|
|
305
|
+
const reveal = this.fadeDistance * (blocked ? this.blockedFadeScale : 1);
|
|
306
|
+
const head = camera.getPosition();
|
|
307
|
+
|
|
308
|
+
// Skip the transparent draw entirely while the head is deeper inside than the widest reveal
|
|
309
|
+
// radius - every fragment would discard anyway. Toggling render.enabled rather than
|
|
310
|
+
// entity.enabled keeps this update running so the wall can come back.
|
|
311
|
+
const edgeDistance = distanceToCircleEdge(head.x, head.z, this.center.x, this.center.z, this.radius);
|
|
312
|
+
this.entity.render.enabled = this.radius > 0 && edgeDistance > -reveal;
|
|
313
|
+
if (!this.entity.render.enabled) {
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
this._headParam[0] = head.x;
|
|
317
|
+
this._headParam[1] = head.y;
|
|
318
|
+
this._headParam[2] = head.z;
|
|
319
|
+
material.setParameter('uHeadPos', this._headParam);
|
|
320
|
+
material.setParameter('uBlocked', blocked);
|
|
321
|
+
}
|
|
322
|
+
_clearMesh() {
|
|
323
|
+
if (this.entity.render) {
|
|
324
|
+
this.entity.render.meshInstances = [];
|
|
325
|
+
}
|
|
326
|
+
if (this._mesh) {
|
|
327
|
+
this._mesh.destroy();
|
|
328
|
+
this._mesh = undefined;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { Vec3 } from 'playcanvas';
|
|
2
|
+
import { boundaryWallMesh } from './boundary-wall';
|
|
3
|
+
const FLAT = [new Vec3(0, 0, 0), new Vec3(1, 0, 0), new Vec3(0, 0, 1)];
|
|
4
|
+
const BASE = {
|
|
5
|
+
center: new Vec3(0, 0, 0),
|
|
6
|
+
radius: 5,
|
|
7
|
+
groundPlane: FLAT,
|
|
8
|
+
baseY: 0,
|
|
9
|
+
topY: 2.5,
|
|
10
|
+
gridSpacing: 0.5
|
|
11
|
+
};
|
|
12
|
+
const toVertices = positions => {
|
|
13
|
+
const out = [];
|
|
14
|
+
for (let i = 0; i < positions.length; i += 3) {
|
|
15
|
+
out.push([positions[i], positions[i + 1], positions[i + 2]]);
|
|
16
|
+
}
|
|
17
|
+
return out;
|
|
18
|
+
};
|
|
19
|
+
describe('boundaryWallMesh', () => {
|
|
20
|
+
it('produces four vertices, four UVs and six indices per segment', () => {
|
|
21
|
+
const geom = boundaryWallMesh({
|
|
22
|
+
...BASE,
|
|
23
|
+
segments: 16
|
|
24
|
+
});
|
|
25
|
+
expect(geom.positions).toHaveLength(16 * 4 * 3);
|
|
26
|
+
expect(geom.uvs).toHaveLength(16 * 4 * 2);
|
|
27
|
+
expect(geom.indices).toHaveLength(16 * 6);
|
|
28
|
+
});
|
|
29
|
+
it('places every vertex at the boundary radius in XZ', () => {
|
|
30
|
+
const center = new Vec3(2, 0, -3);
|
|
31
|
+
const geom = boundaryWallMesh({
|
|
32
|
+
...BASE,
|
|
33
|
+
center,
|
|
34
|
+
radius: 4,
|
|
35
|
+
segments: 32
|
|
36
|
+
});
|
|
37
|
+
for (const [x,, z] of toVertices(geom.positions)) {
|
|
38
|
+
expect(Math.sqrt((x - center.x) ** 2 + (z - center.z) ** 2)).toBeCloseTo(4);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
it('puts the top ring flat at topY for every column, even over tilted ground', () => {
|
|
42
|
+
// y = z, so the bottom sweeps -3..3 while the top has to stay pinned at 4.
|
|
43
|
+
const tilted = [new Vec3(0, 0, 0), new Vec3(1, 0, 0), new Vec3(0, 1, 1)];
|
|
44
|
+
const geom = boundaryWallMesh({
|
|
45
|
+
...BASE,
|
|
46
|
+
groundPlane: tilted,
|
|
47
|
+
radius: 3,
|
|
48
|
+
topY: 4,
|
|
49
|
+
segments: 8
|
|
50
|
+
});
|
|
51
|
+
const verts = toVertices(geom.positions);
|
|
52
|
+
// Per segment the order is: bottom@a0, top@a0, bottom@a1, top@a1.
|
|
53
|
+
for (let i = 0; i < verts.length; i += 4) {
|
|
54
|
+
expect(verts[i + 1][1]).toBeCloseTo(4);
|
|
55
|
+
expect(verts[i + 3][1]).toBeCloseTo(4);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
it('sits the base on a tilted ground plane (y = z)', () => {
|
|
59
|
+
const tilted = [new Vec3(0, 0, 0), new Vec3(1, 0, 0), new Vec3(0, 1, 1)];
|
|
60
|
+
const geom = boundaryWallMesh({
|
|
61
|
+
...BASE,
|
|
62
|
+
groundPlane: tilted,
|
|
63
|
+
radius: 3,
|
|
64
|
+
topY: 4,
|
|
65
|
+
segments: 8
|
|
66
|
+
});
|
|
67
|
+
const verts = toVertices(geom.positions);
|
|
68
|
+
for (let i = 0; i < verts.length; i += 4) {
|
|
69
|
+
expect(verts[i][1]).toBeCloseTo(verts[i][2]);
|
|
70
|
+
expect(verts[i + 2][1]).toBeCloseTo(verts[i + 2][2]);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
it('falls back to a flat base at baseY when no ground plane is supplied', () => {
|
|
74
|
+
const geom = boundaryWallMesh({
|
|
75
|
+
...BASE,
|
|
76
|
+
groundPlane: [],
|
|
77
|
+
baseY: 1.5,
|
|
78
|
+
topY: 3.5,
|
|
79
|
+
segments: 8
|
|
80
|
+
});
|
|
81
|
+
const verts = toVertices(geom.positions);
|
|
82
|
+
expect(verts).not.toHaveLength(0);
|
|
83
|
+
for (let i = 0; i < verts.length; i += 4) {
|
|
84
|
+
expect(verts[i][1]).toBeCloseTo(1.5);
|
|
85
|
+
expect(verts[i + 1][1]).toBeCloseTo(3.5);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
it('falls back to a flat base for a degenerate ground plane', () => {
|
|
89
|
+
const collinear = [new Vec3(0, 0, 0), new Vec3(1, 0, 0), new Vec3(2, 0, 0)];
|
|
90
|
+
const geom = boundaryWallMesh({
|
|
91
|
+
...BASE,
|
|
92
|
+
groundPlane: collinear,
|
|
93
|
+
baseY: -2,
|
|
94
|
+
segments: 8
|
|
95
|
+
});
|
|
96
|
+
const verts = toVertices(geom.positions);
|
|
97
|
+
expect(verts).not.toHaveLength(0);
|
|
98
|
+
expect(verts[0][1]).toBeCloseTo(-2);
|
|
99
|
+
expect(verts[1][1]).toBeCloseTo(2.5);
|
|
100
|
+
});
|
|
101
|
+
it('reports whole-number grid cell counts derived from gridSpacing', () => {
|
|
102
|
+
// Circumference 2*pi*5 = 31.4159; at 0.5 m spacing that rounds to 63 columns.
|
|
103
|
+
// The ground is flat at y = 0, so rows = round((2.5 - 0) / 0.5) = 5.
|
|
104
|
+
const geom = boundaryWallMesh({
|
|
105
|
+
...BASE,
|
|
106
|
+
radius: 5,
|
|
107
|
+
topY: 2.5,
|
|
108
|
+
gridSpacing: 0.5,
|
|
109
|
+
segments: 8
|
|
110
|
+
});
|
|
111
|
+
expect(geom.columns).toBe(63);
|
|
112
|
+
expect(geom.rows).toBe(5);
|
|
113
|
+
});
|
|
114
|
+
it('derives rows from the average bottom, not from any single column', () => {
|
|
115
|
+
// Plane y = x - 1, sampled on a radius-3 circle at 6 columns (0, 60, 120, 180, 240, 300 deg):
|
|
116
|
+
// bottoms are 2, 0.5, -2.5, -4, -2.5, 0.5 -> average -1, which no column matches.
|
|
117
|
+
// rows = round((2.5 - -1) / 0.5) = round(7) = 7.
|
|
118
|
+
const tilted = [new Vec3(1, 0, 0), new Vec3(2, 1, 0), new Vec3(1, 0, 1)];
|
|
119
|
+
const geom = boundaryWallMesh({
|
|
120
|
+
...BASE,
|
|
121
|
+
groundPlane: tilted,
|
|
122
|
+
radius: 3,
|
|
123
|
+
topY: 2.5,
|
|
124
|
+
segments: 6
|
|
125
|
+
});
|
|
126
|
+
const bottoms = toVertices(geom.positions).filter((_, index) => index % 2 === 0).map(([, y]) => y);
|
|
127
|
+
expect(bottoms).toHaveLength(12);
|
|
128
|
+
for (const y of bottoms) {
|
|
129
|
+
expect(Math.abs(y + 1)).toBeGreaterThan(1);
|
|
130
|
+
}
|
|
131
|
+
expect(geom.rows).toBe(7);
|
|
132
|
+
});
|
|
133
|
+
it('emits u in cell units that wrap exactly at `columns`', () => {
|
|
134
|
+
const geom = boundaryWallMesh({
|
|
135
|
+
...BASE,
|
|
136
|
+
segments: 8
|
|
137
|
+
});
|
|
138
|
+
const us = [];
|
|
139
|
+
for (let i = 0; i < geom.uvs.length; i += 2) {
|
|
140
|
+
us.push(geom.uvs[i]);
|
|
141
|
+
}
|
|
142
|
+
expect(us[0]).toBeCloseTo(0);
|
|
143
|
+
// Last vertex of the last segment closes the loop at exactly `columns`.
|
|
144
|
+
expect(us[us.length - 1]).toBeCloseTo(geom.columns);
|
|
145
|
+
});
|
|
146
|
+
it('emits v as 0 at the base and `rows` at the top', () => {
|
|
147
|
+
const geom = boundaryWallMesh({
|
|
148
|
+
...BASE,
|
|
149
|
+
segments: 8
|
|
150
|
+
});
|
|
151
|
+
for (let i = 0; i < geom.uvs.length; i += 8) {
|
|
152
|
+
expect(geom.uvs[i + 1]).toBeCloseTo(0);
|
|
153
|
+
expect(geom.uvs[i + 3]).toBeCloseTo(geom.rows);
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
it('references only in-range vertices from the index buffer', () => {
|
|
157
|
+
const geom = boundaryWallMesh({
|
|
158
|
+
...BASE,
|
|
159
|
+
segments: 8
|
|
160
|
+
});
|
|
161
|
+
const vertexCount = geom.positions.length / 3;
|
|
162
|
+
for (const idx of geom.indices) {
|
|
163
|
+
expect(idx).toBeGreaterThanOrEqual(0);
|
|
164
|
+
expect(idx).toBeLessThan(vertexCount);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
it('returns empty geometry for a non-positive radius', () => {
|
|
168
|
+
const geom = boundaryWallMesh({
|
|
169
|
+
...BASE,
|
|
170
|
+
radius: 0
|
|
171
|
+
});
|
|
172
|
+
expect(geom.positions).toHaveLength(0);
|
|
173
|
+
expect(geom.indices).toHaveLength(0);
|
|
174
|
+
});
|
|
175
|
+
it('returns empty geometry for a non-positive segment count', () => {
|
|
176
|
+
expect(boundaryWallMesh({
|
|
177
|
+
...BASE,
|
|
178
|
+
segments: 0
|
|
179
|
+
}).positions).toHaveLength(0);
|
|
180
|
+
});
|
|
181
|
+
it('returns empty geometry for a non-positive grid spacing', () => {
|
|
182
|
+
expect(boundaryWallMesh({
|
|
183
|
+
...BASE,
|
|
184
|
+
gridSpacing: 0
|
|
185
|
+
}).positions).toHaveLength(0);
|
|
186
|
+
});
|
|
187
|
+
it('returns empty geometry when topY is level with the base', () => {
|
|
188
|
+
expect(boundaryWallMesh({
|
|
189
|
+
...BASE,
|
|
190
|
+
topY: 0
|
|
191
|
+
}).positions).toHaveLength(0);
|
|
192
|
+
});
|
|
193
|
+
it('returns empty geometry when topY clears only part of a tilted base', () => {
|
|
194
|
+
// y = z on a radius-3 circle reaches 3, so a top at 2 is below the far half of the wall.
|
|
195
|
+
const tilted = [new Vec3(0, 0, 0), new Vec3(1, 0, 0), new Vec3(0, 1, 1)];
|
|
196
|
+
expect(boundaryWallMesh({
|
|
197
|
+
...BASE,
|
|
198
|
+
groundPlane: tilted,
|
|
199
|
+
radius: 3,
|
|
200
|
+
topY: 2
|
|
201
|
+
}).positions).toHaveLength(0);
|
|
202
|
+
});
|
|
203
|
+
});
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Greedily wraps `text` into lines no wider than `maxWidth`, using `measure` to size
|
|
3
|
+
* each candidate line. A single word wider than `maxWidth` still gets its own line.
|
|
4
|
+
*/
|
|
5
|
+
export declare const wrapText: (text: string, maxWidth: number, measure: (segment: string) => number) => string[];
|
|
6
|
+
//# sourceMappingURL=vr-annotation-panel-layout.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"vr-annotation-panel-layout.d.ts","sourceRoot":"","sources":["../../../src/components/VirtualWalkthrough/vr-annotation-panel-layout.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,eAAO,MAAM,QAAQ,GAAI,MAAM,MAAM,EAAE,UAAU,MAAM,EAAE,SAAS,CAAC,OAAO,EAAE,MAAM,KAAK,MAAM,KAAG,MAAM,EAoBrG,CAAC"}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Greedily wraps `text` into lines no wider than `maxWidth`, using `measure` to size
|
|
3
|
+
* each candidate line. A single word wider than `maxWidth` still gets its own line.
|
|
4
|
+
*/
|
|
5
|
+
export const wrapText = (text, maxWidth, measure) => {
|
|
6
|
+
const words = text.split(/\s+/).filter(Boolean);
|
|
7
|
+
const lines = [];
|
|
8
|
+
let current = '';
|
|
9
|
+
words.forEach(word => {
|
|
10
|
+
const candidate = current ? `${current} ${word}` : word;
|
|
11
|
+
if (!current || measure(candidate) <= maxWidth) {
|
|
12
|
+
current = candidate;
|
|
13
|
+
} else {
|
|
14
|
+
lines.push(current);
|
|
15
|
+
current = word;
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
if (current) {
|
|
19
|
+
lines.push(current);
|
|
20
|
+
}
|
|
21
|
+
return lines;
|
|
22
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { wrapText } from './vr-annotation-panel-layout';
|
|
2
|
+
|
|
3
|
+
// Each character is 1 unit wide; a space counts as a character within a line.
|
|
4
|
+
const measure = s => s.length;
|
|
5
|
+
describe('wrapText', () => {
|
|
6
|
+
it('returns an empty array for empty or whitespace-only input', () => {
|
|
7
|
+
expect(wrapText('', 10, measure)).toEqual([]);
|
|
8
|
+
expect(wrapText(' ', 10, measure)).toEqual([]);
|
|
9
|
+
});
|
|
10
|
+
it('keeps text on one line when it fits', () => {
|
|
11
|
+
expect(wrapText('hello world', 11, measure)).toEqual(['hello world']);
|
|
12
|
+
});
|
|
13
|
+
it('wraps to a new line when the next word would exceed the width', () => {
|
|
14
|
+
// 'hello world' is 11 wide; width 10 forces a break before 'world'.
|
|
15
|
+
expect(wrapText('hello world', 10, measure)).toEqual(['hello', 'world']);
|
|
16
|
+
});
|
|
17
|
+
it('packs as many words as fit per line greedily', () => {
|
|
18
|
+
expect(wrapText('a bb cc d', 5, measure)).toEqual(['a bb', 'cc d']);
|
|
19
|
+
});
|
|
20
|
+
it('puts an over-long word on its own line rather than dropping it', () => {
|
|
21
|
+
expect(wrapText('tiny enormouslylongword end', 8, measure)).toEqual(['tiny', 'enormouslylongword', 'end']);
|
|
22
|
+
});
|
|
23
|
+
it('collapses runs of whitespace', () => {
|
|
24
|
+
expect(wrapText('a b', 10, measure)).toEqual(['a b']);
|
|
25
|
+
});
|
|
26
|
+
});
|