pptx-angular-viewer 4.1.6 → 4.2.0
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/CHANGELOG.md +6 -0
- package/fesm2022/pptx-angular-viewer-chart-3d-view-scene-Du6qe5j8.mjs +470 -0
- package/fesm2022/pptx-angular-viewer-chart-3d-view-scene-Du6qe5j8.mjs.map +1 -0
- package/fesm2022/{pptx-angular-viewer-chat-history-idb-thygpDnQ.mjs → pptx-angular-viewer-chat-history-idb-CIvMcUXk.mjs} +2 -2
- package/fesm2022/{pptx-angular-viewer-chat-history-idb-thygpDnQ.mjs.map → pptx-angular-viewer-chat-history-idb-CIvMcUXk.mjs.map} +1 -1
- package/fesm2022/{pptx-angular-viewer-pptx-angular-viewer-D8sOpwA0.mjs → pptx-angular-viewer-pptx-angular-viewer-D25mA21c.mjs} +4635 -3334
- package/fesm2022/{pptx-angular-viewer-pptx-angular-viewer-D8sOpwA0.mjs.map → pptx-angular-viewer-pptx-angular-viewer-D25mA21c.mjs.map} +1 -1
- package/fesm2022/pptx-angular-viewer-view-scene-C5oUG9yl.mjs +235 -0
- package/fesm2022/pptx-angular-viewer-view-scene-C5oUG9yl.mjs.map +1 -0
- package/fesm2022/pptx-angular-viewer.mjs +1 -1
- package/package.json +2 -2
- package/pptx-angular-viewer.css +1 -1
- package/types/pptx-angular-viewer.d.ts +868 -237
- package/types/pptx-angular-viewer.d.ts.map +1 -1
- package/fesm2022/pptx-angular-viewer-index-7b9uo9P2.mjs +0 -655
- package/fesm2022/pptx-angular-viewer-index-7b9uo9P2.mjs.map +0 -1
|
@@ -1,655 +0,0 @@
|
|
|
1
|
-
import { CanvasTexture, SRGBColorSpace, LinearFilter, RepeatWrapping, Shape, ExtrudeGeometry, MeshStandardMaterial, Color, Mesh, EdgesGeometry, LineBasicMaterial, LineSegments, PlaneGeometry, MeshBasicMaterial, DoubleSide, Euler, Vector3, BufferGeometry, Line, Group, WebGLRenderer, Scene, PerspectiveCamera, AmbientLight, DirectionalLight } from 'three';
|
|
2
|
-
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Three.js SmartArt renderer - front-face text textures.
|
|
6
|
-
*
|
|
7
|
-
* Renders a node label onto an offscreen 2D canvas and wraps it as a
|
|
8
|
-
* `THREE.CanvasTexture`, sized to sit on the extruded block's front face. Text
|
|
9
|
-
* is word-wrapped and auto-shrunk to fit the node footprint. Returns `null` in
|
|
10
|
-
* non-DOM environments (SSR / unit tests).
|
|
11
|
-
*/
|
|
12
|
-
/** Supersampling factor for crisp text at oblique camera angles. */
|
|
13
|
-
const SUPERSAMPLE = 4;
|
|
14
|
-
/** Fraction of the footprint the text plane occupies (inset padding). */
|
|
15
|
-
const FILL = 0.86;
|
|
16
|
-
/** Greedily word-wrap `text` to lines that fit `maxWidth` at the given font. */
|
|
17
|
-
function wrapLines(ctx, text, maxWidth) {
|
|
18
|
-
const words = text.split(/\s+/u).filter(Boolean);
|
|
19
|
-
if (words.length === 0) {
|
|
20
|
-
return [];
|
|
21
|
-
}
|
|
22
|
-
const lines = [];
|
|
23
|
-
let line = words[0];
|
|
24
|
-
for (let i = 1; i < words.length; i++) {
|
|
25
|
-
const candidate = `${line} ${words[i]}`;
|
|
26
|
-
if (ctx.measureText(candidate).width <= maxWidth) {
|
|
27
|
-
line = candidate;
|
|
28
|
-
}
|
|
29
|
-
else {
|
|
30
|
-
lines.push(line);
|
|
31
|
-
line = words[i];
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
lines.push(line);
|
|
35
|
-
return lines;
|
|
36
|
-
}
|
|
37
|
-
/** Resolve the canvas `font` string weight/style + effective colour for an optional emphasis override. */
|
|
38
|
-
function resolveEmphasisFont(color, emphasis) {
|
|
39
|
-
const weight = emphasis?.bold === undefined ? '600' : emphasis.bold ? '700' : '400';
|
|
40
|
-
const italic = emphasis?.italic ? 'italic ' : '';
|
|
41
|
-
const scale = typeof emphasis?.fontScale === 'number' &&
|
|
42
|
-
Number.isFinite(emphasis.fontScale) &&
|
|
43
|
-
emphasis.fontScale > 0
|
|
44
|
-
? emphasis.fontScale
|
|
45
|
-
: 1;
|
|
46
|
-
return { weight, italic, color: emphasis?.color ?? color, scale };
|
|
47
|
-
}
|
|
48
|
-
/**
|
|
49
|
-
* Build a label texture for a node's front face.
|
|
50
|
-
*
|
|
51
|
-
* @param text Label text.
|
|
52
|
-
* @param color CSS colour for the text.
|
|
53
|
-
* @param fontSize Requested font size in layout pixels.
|
|
54
|
-
* @param footW Node footprint width (layout pixels).
|
|
55
|
-
* @param footH Node footprint height (layout pixels).
|
|
56
|
-
* @param emphasis Active font-style emphasis override (bold/italic/underline/size/colour), if any.
|
|
57
|
-
* @returns The texture + plane size, or `null` when no DOM / empty text.
|
|
58
|
-
*/
|
|
59
|
-
function makeTextTexture(text, color, fontSize, footW, footH, emphasis) {
|
|
60
|
-
if (typeof document === 'undefined' || !text.trim() || footW <= 0 || footH <= 0) {
|
|
61
|
-
return null;
|
|
62
|
-
}
|
|
63
|
-
const { weight, italic, color: effectiveColor, scale } = resolveEmphasisFont(color, emphasis);
|
|
64
|
-
const worldWidth = footW * FILL;
|
|
65
|
-
const worldHeight = footH * FILL;
|
|
66
|
-
const canvas = document.createElement('canvas');
|
|
67
|
-
canvas.width = Math.max(8, Math.round(worldWidth * SUPERSAMPLE));
|
|
68
|
-
canvas.height = Math.max(8, Math.round(worldHeight * SUPERSAMPLE));
|
|
69
|
-
const ctx = canvas.getContext('2d');
|
|
70
|
-
if (!ctx) {
|
|
71
|
-
return null;
|
|
72
|
-
}
|
|
73
|
-
const family = 'system-ui, -apple-system, Segoe UI, Roboto, sans-serif';
|
|
74
|
-
const maxTextWidth = canvas.width * 0.94;
|
|
75
|
-
const fontString = (px) => `${italic}${weight} ${px}px ${family}`;
|
|
76
|
-
// Shrink the font until the wrapped block fits the canvas height.
|
|
77
|
-
let px = Math.max(6, fontSize) * scale * SUPERSAMPLE;
|
|
78
|
-
let lines = [];
|
|
79
|
-
for (let attempt = 0; attempt < 12; attempt++) {
|
|
80
|
-
ctx.font = fontString(px);
|
|
81
|
-
lines = wrapLines(ctx, text, maxTextWidth);
|
|
82
|
-
const lineHeight = px * 1.2;
|
|
83
|
-
if (lines.length * lineHeight <= canvas.height * 0.96 || px <= 6 * SUPERSAMPLE) {
|
|
84
|
-
break;
|
|
85
|
-
}
|
|
86
|
-
px *= 0.88;
|
|
87
|
-
}
|
|
88
|
-
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
89
|
-
ctx.fillStyle = effectiveColor;
|
|
90
|
-
ctx.textAlign = 'center';
|
|
91
|
-
ctx.textBaseline = 'middle';
|
|
92
|
-
ctx.font = fontString(px);
|
|
93
|
-
const lineHeight = px * 1.2;
|
|
94
|
-
const blockHeight = lines.length * lineHeight;
|
|
95
|
-
const startY = canvas.height / 2 - blockHeight / 2 + lineHeight / 2;
|
|
96
|
-
for (let i = 0; i < lines.length; i++) {
|
|
97
|
-
const y = startY + i * lineHeight;
|
|
98
|
-
ctx.fillText(lines[i], canvas.width / 2, y);
|
|
99
|
-
if (emphasis?.underline) {
|
|
100
|
-
const w = ctx.measureText(lines[i]).width;
|
|
101
|
-
ctx.strokeStyle = effectiveColor;
|
|
102
|
-
ctx.lineWidth = Math.max(1, px * 0.06);
|
|
103
|
-
ctx.beginPath();
|
|
104
|
-
ctx.moveTo(canvas.width / 2 - w / 2, y + px * 0.32);
|
|
105
|
-
ctx.lineTo(canvas.width / 2 + w / 2, y + px * 0.32);
|
|
106
|
-
ctx.stroke();
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
const texture = new CanvasTexture(canvas);
|
|
110
|
-
texture.colorSpace = SRGBColorSpace;
|
|
111
|
-
texture.minFilter = LinearFilter;
|
|
112
|
-
texture.magFilter = LinearFilter;
|
|
113
|
-
// Disable GPU-side flip: WebGL2 does not allow UNPACK_FLIP_Y_WEBGL for
|
|
114
|
-
// texImage3D targets (depth buffers, LUTs, shadow maps). Leaving flipY=true
|
|
115
|
-
// (the Three.js default) pollutes the global pixel-store state and causes
|
|
116
|
-
// "INVALID_OPERATION: texImage3D: FLIP_Y or PREMULTIPLY_ALPHA isn't allowed"
|
|
117
|
-
// errors. Compensate in UV space instead.
|
|
118
|
-
texture.flipY = false;
|
|
119
|
-
texture.premultiplyAlpha = false;
|
|
120
|
-
texture.wrapT = RepeatWrapping;
|
|
121
|
-
texture.repeat.set(1, -1);
|
|
122
|
-
texture.offset.set(0, 1);
|
|
123
|
-
texture.needsUpdate = true;
|
|
124
|
-
return { texture, worldWidth, worldHeight };
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/**
|
|
128
|
-
* Three.js SmartArt renderer - mesh-group construction.
|
|
129
|
-
*
|
|
130
|
-
* Turns a pure {@link SmartArt3DModel} into a `THREE.Group` of extruded blocks
|
|
131
|
-
* (with bevels, edge outlines, and front-face text planes) plus connector
|
|
132
|
-
* lines. All allocated GPU resources are tracked so the caller can dispose them
|
|
133
|
-
* deterministically.
|
|
134
|
-
*/
|
|
135
|
-
/** Build the extruded geometry for one node. */
|
|
136
|
-
function extrudeGeometry(m) {
|
|
137
|
-
const shape = new Shape();
|
|
138
|
-
m.outline.forEach((p, i) => {
|
|
139
|
-
if (i === 0) {
|
|
140
|
-
shape.moveTo(p.x, p.y);
|
|
141
|
-
}
|
|
142
|
-
else {
|
|
143
|
-
shape.lineTo(p.x, p.y);
|
|
144
|
-
}
|
|
145
|
-
});
|
|
146
|
-
shape.closePath();
|
|
147
|
-
const bevelEnabled = m.bevel > 0;
|
|
148
|
-
return new ExtrudeGeometry(shape, {
|
|
149
|
-
depth: m.depth,
|
|
150
|
-
bevelEnabled,
|
|
151
|
-
bevelThickness: m.bevel,
|
|
152
|
-
bevelSize: m.bevel,
|
|
153
|
-
bevelSegments: 2,
|
|
154
|
-
curveSegments: m.rounded ? 24 : 1,
|
|
155
|
-
steps: 1,
|
|
156
|
-
});
|
|
157
|
-
}
|
|
158
|
-
/** Add the extruded block + edge outline for one node to the group. */
|
|
159
|
-
function addBlock(group, disposables, m) {
|
|
160
|
-
const geo = extrudeGeometry(m);
|
|
161
|
-
const material = new MeshStandardMaterial({
|
|
162
|
-
color: new Color(m.fill),
|
|
163
|
-
metalness: 0.12,
|
|
164
|
-
roughness: 0.52,
|
|
165
|
-
transparent: m.opacity < 1,
|
|
166
|
-
opacity: m.opacity,
|
|
167
|
-
});
|
|
168
|
-
const mesh = new Mesh(geo, material);
|
|
169
|
-
mesh.position.set(m.position.x, m.position.y, m.position.z);
|
|
170
|
-
mesh.rotation.set(m.rotation.x, m.rotation.y, m.rotation.z);
|
|
171
|
-
group.add(mesh);
|
|
172
|
-
disposables.push(geo, material);
|
|
173
|
-
if (m.strokeWidth > 0) {
|
|
174
|
-
const edges = new EdgesGeometry(geo, 30);
|
|
175
|
-
const lineMaterial = new LineBasicMaterial({ color: new Color(m.stroke) });
|
|
176
|
-
const line = new LineSegments(edges, lineMaterial);
|
|
177
|
-
line.position.copy(mesh.position);
|
|
178
|
-
line.rotation.copy(mesh.rotation);
|
|
179
|
-
group.add(line);
|
|
180
|
-
disposables.push(edges, lineMaterial);
|
|
181
|
-
}
|
|
182
|
-
return geo;
|
|
183
|
-
}
|
|
184
|
-
/** Build a front-face text plane for one node, or `null` when it has no label / no DOM. */
|
|
185
|
-
function buildLabelPlane(m, textStyle) {
|
|
186
|
-
if (!m.text) {
|
|
187
|
-
return null;
|
|
188
|
-
}
|
|
189
|
-
const tex = makeTextTexture(m.text, m.textColor, m.fontSize, m.halfWidth * 2, m.halfHeight * 2, textStyle);
|
|
190
|
-
if (!tex) {
|
|
191
|
-
return null;
|
|
192
|
-
}
|
|
193
|
-
const planeGeo = new PlaneGeometry(tex.worldWidth, tex.worldHeight);
|
|
194
|
-
// Double-sided: spatial layouts (the cycle/radial carousel ring) rotate
|
|
195
|
-
// roughly half the nodes so their front face points away from the
|
|
196
|
-
// camera's default position. A single-sided (default `FrontSide`)
|
|
197
|
-
// material culls that view entirely, so those nodes render with no
|
|
198
|
-
// caption at all; double-siding keeps the label visible (mirrored, on
|
|
199
|
-
// the far side) instead of vanishing. `scene.ts`'s render loop billboards
|
|
200
|
-
// every plane toward the camera each frame, which overrides this initial
|
|
201
|
-
// rotation once mounted (see `BuiltMeshGroup.forEachLabelPlane`); this
|
|
202
|
-
// still matters for a caller that never renders a frame (tests) and as
|
|
203
|
-
// the visible starting orientation before the first billboard update.
|
|
204
|
-
// `depthTest: false` also matters once billboarded: the plane's offset
|
|
205
|
-
// (below) clears the block along the block's OWN original facing
|
|
206
|
-
// direction, which is not necessarily toward the camera once the plane
|
|
207
|
-
// itself has been rotated to face the camera instead, so a depth-tested
|
|
208
|
-
// plane could end up partially behind its own (or a neighbouring) node's
|
|
209
|
-
// extrusion from the camera's actual angle. Skipping the depth test
|
|
210
|
-
// keeps every caption drawn on top, which is the legible outcome we
|
|
211
|
-
// want for a small, sparse set of node labels.
|
|
212
|
-
const planeMaterial = new MeshBasicMaterial({
|
|
213
|
-
map: tex.texture,
|
|
214
|
-
transparent: true,
|
|
215
|
-
depthWrite: false,
|
|
216
|
-
depthTest: false,
|
|
217
|
-
side: DoubleSide,
|
|
218
|
-
});
|
|
219
|
-
const plane = new Mesh(planeGeo, planeMaterial);
|
|
220
|
-
plane.renderOrder = 1;
|
|
221
|
-
// Float just past the front (+z) face, clearing any bevel, following the
|
|
222
|
-
// mesh's rotation so the label sits flat on the (possibly rotated) face.
|
|
223
|
-
const euler = new Euler(m.rotation.x, m.rotation.y, m.rotation.z);
|
|
224
|
-
const offset = new Vector3(0, 0, m.depth + m.bevel + 0.4).applyEuler(euler);
|
|
225
|
-
plane.position.set(m.position.x + offset.x, m.position.y + offset.y, m.position.z + offset.z);
|
|
226
|
-
plane.rotation.copy(euler);
|
|
227
|
-
return { plane, disposables: [planeGeo, planeMaterial, tex.texture] };
|
|
228
|
-
}
|
|
229
|
-
/** Add a connector poly-line on the base plane. */
|
|
230
|
-
function addConnectors(group, disposables, model) {
|
|
231
|
-
for (const c of model.connectors) {
|
|
232
|
-
if (c.points.length < 2) {
|
|
233
|
-
continue;
|
|
234
|
-
}
|
|
235
|
-
const geo = new BufferGeometry().setFromPoints(c.points.map((p) => new Vector3(p.x, p.y, p.z)));
|
|
236
|
-
const material = new LineBasicMaterial({
|
|
237
|
-
color: new Color(c.color),
|
|
238
|
-
transparent: true,
|
|
239
|
-
opacity: 0.7,
|
|
240
|
-
});
|
|
241
|
-
group.add(new Line(geo, material));
|
|
242
|
-
disposables.push(geo, material);
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
/** Remove and dispose one label entry's current plane, if any. */
|
|
246
|
-
function clearLabel(group, entry) {
|
|
247
|
-
if (entry.plane) {
|
|
248
|
-
group.remove(entry.plane);
|
|
249
|
-
}
|
|
250
|
-
for (const d of entry.disposables) {
|
|
251
|
-
d.dispose();
|
|
252
|
-
}
|
|
253
|
-
entry.plane = null;
|
|
254
|
-
entry.disposables = [];
|
|
255
|
-
}
|
|
256
|
-
/**
|
|
257
|
-
* Build a `THREE.Group` for a SmartArt 3D model. The group is centred on the
|
|
258
|
-
* origin (the model positions already are); callers add it to the scene.
|
|
259
|
-
* `textStyle` is a font-style emphasis override applied to every node's
|
|
260
|
-
* label (see {@link BuiltMeshGroup.setTextStyle}); emphasis is authored per
|
|
261
|
-
* shape/animation-target, not per SmartArt node, so it applies uniformly.
|
|
262
|
-
*
|
|
263
|
-
* A label plane's *position* floats just off its node's own front (radially
|
|
264
|
-
* outward, for a spatial/carousel arrangement) face, following the node's
|
|
265
|
-
* rotation - but its *rotation* is left at that same face-following value
|
|
266
|
-
* only as an initial default. `mountSmartArt3D`'s render loop re-orients
|
|
267
|
-
* every plane toward the camera each frame via {@link BuiltMeshGroup.forEachLabelPlane}
|
|
268
|
-
* (a Y-axis-only billboard, so captions stay upright and legible). Without
|
|
269
|
-
* that, a node whose front face happens to point away from or across the
|
|
270
|
-
* camera - unavoidable for roughly half of any ring/carousel arrangement -
|
|
271
|
-
* would have an edge-on or backward-facing caption: `DoubleSide` (see
|
|
272
|
-
* `buildLabelPlane`) fixes the "backward" case, but nothing short of facing
|
|
273
|
-
* the plane at the camera fixes "edge-on".
|
|
274
|
-
*/
|
|
275
|
-
function buildMeshGroup(model, textStyle) {
|
|
276
|
-
const group = new Group();
|
|
277
|
-
const blockDisposables = [];
|
|
278
|
-
const labelEntries = model.meshes.map((m) => {
|
|
279
|
-
addBlock(group, blockDisposables, m);
|
|
280
|
-
const built = buildLabelPlane(m, textStyle);
|
|
281
|
-
if (built) {
|
|
282
|
-
group.add(built.plane);
|
|
283
|
-
}
|
|
284
|
-
return { node: m, plane: built?.plane ?? null, disposables: built?.disposables ?? [] };
|
|
285
|
-
});
|
|
286
|
-
addConnectors(group, blockDisposables, model);
|
|
287
|
-
return {
|
|
288
|
-
group,
|
|
289
|
-
setTextStyle(style) {
|
|
290
|
-
for (const entry of labelEntries) {
|
|
291
|
-
clearLabel(group, entry);
|
|
292
|
-
const built = buildLabelPlane(entry.node, style);
|
|
293
|
-
if (built) {
|
|
294
|
-
group.add(built.plane);
|
|
295
|
-
entry.plane = built.plane;
|
|
296
|
-
entry.disposables = built.disposables;
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
},
|
|
300
|
-
forEachLabelPlane(fn) {
|
|
301
|
-
for (const entry of labelEntries) {
|
|
302
|
-
if (entry.plane) {
|
|
303
|
-
fn(entry.plane);
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
},
|
|
307
|
-
dispose() {
|
|
308
|
-
for (const d of blockDisposables) {
|
|
309
|
-
d.dispose();
|
|
310
|
-
}
|
|
311
|
-
for (const entry of labelEntries) {
|
|
312
|
-
clearLabel(group, entry);
|
|
313
|
-
}
|
|
314
|
-
},
|
|
315
|
-
};
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
/**
|
|
319
|
-
* Three.js SmartArt renderer - camera framing + label billboarding math.
|
|
320
|
-
*
|
|
321
|
-
* Pure geometry (no `three` rendering calls beyond the `Mesh`/`PerspectiveCamera`
|
|
322
|
-
* types billboarding reads from) split out of `scene.ts` to keep that module
|
|
323
|
-
* focused on mount/render-loop orchestration. Exported for unit tests
|
|
324
|
-
* (`scene-camera.test.ts`); not part of the `pptx-viewer-shared/smartart-3d`
|
|
325
|
-
* public subpath barrel (`index.ts`), which only re-exports `mountSmartArt3D`.
|
|
326
|
-
*/
|
|
327
|
-
/** Vertical field of view (degrees) the mounted camera uses. */
|
|
328
|
-
const FOV = 42;
|
|
329
|
-
/**
|
|
330
|
-
* Expand `bounds` by one mesh's true world-space footprint.
|
|
331
|
-
*
|
|
332
|
-
* Every mesh here only ever rotates about Y (see `applySpatialLayout`), so a
|
|
333
|
-
* mesh's local box - footprint half-extents `(halfWidth, halfHeight)` in its
|
|
334
|
-
* own XY plane, and an extrusion that spans local z from `0` to
|
|
335
|
-
* `depth` (plus a small bevel overshoot on both ends) - can be exactly
|
|
336
|
-
* reprojected into world X/Z with the standard rotated-box formula
|
|
337
|
-
* (`|halfExtent * cos| + |otherHalfExtent * sin|` per axis). Using a single
|
|
338
|
-
* scalar radius applied equally to every world axis (the previous approach)
|
|
339
|
-
* conflated the footprint size with the much smaller extrusion depth,
|
|
340
|
-
* wildly overstating a wide/flat node's extent along its through-face axis
|
|
341
|
-
* and pushing the fit-to-frame camera far past where the content actually
|
|
342
|
-
* needed it, so the whole diagram rendered tiny inside its frame.
|
|
343
|
-
*/
|
|
344
|
-
function expandForMesh(expand, m) {
|
|
345
|
-
const hx = m.halfWidth + m.bevel;
|
|
346
|
-
const hy = m.halfHeight + m.bevel;
|
|
347
|
-
const hz = m.depth / 2 + m.bevel;
|
|
348
|
-
const midZ = m.position.z + m.depth / 2;
|
|
349
|
-
const cosY = Math.cos(m.rotation.y);
|
|
350
|
-
const sinY = Math.sin(m.rotation.y);
|
|
351
|
-
const worldHalfX = Math.abs(hx * cosY) + Math.abs(hz * sinY);
|
|
352
|
-
const worldHalfZ = Math.abs(hx * sinY) + Math.abs(hz * cosY);
|
|
353
|
-
expand(m.position.x, m.position.y, midZ, worldHalfX, hy, worldHalfZ);
|
|
354
|
-
}
|
|
355
|
-
/** Bounding sphere of all meshes (rotation-aware) + connectors. */
|
|
356
|
-
function contentSphere(model) {
|
|
357
|
-
let minX = Infinity;
|
|
358
|
-
let minY = Infinity;
|
|
359
|
-
let minZ = Infinity;
|
|
360
|
-
let maxX = -Infinity;
|
|
361
|
-
let maxY = -Infinity;
|
|
362
|
-
let maxZ = -Infinity;
|
|
363
|
-
const expand = (x, y, z, rx, ry, rz) => {
|
|
364
|
-
minX = Math.min(minX, x - rx);
|
|
365
|
-
minY = Math.min(minY, y - ry);
|
|
366
|
-
minZ = Math.min(minZ, z - rz);
|
|
367
|
-
maxX = Math.max(maxX, x + rx);
|
|
368
|
-
maxY = Math.max(maxY, y + ry);
|
|
369
|
-
maxZ = Math.max(maxZ, z + rz);
|
|
370
|
-
};
|
|
371
|
-
for (const m of model.meshes) {
|
|
372
|
-
expandForMesh(expand, m);
|
|
373
|
-
}
|
|
374
|
-
for (const c of model.connectors) {
|
|
375
|
-
for (const p of c.points) {
|
|
376
|
-
expand(p.x, p.y, p.z, 1, 1, 1);
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
if (!Number.isFinite(minX)) {
|
|
380
|
-
const halfW = model.bounds.width / 2 || 1;
|
|
381
|
-
const halfH = model.bounds.height / 2 || 1;
|
|
382
|
-
const fallback = Math.max(halfW, halfH);
|
|
383
|
-
return { cx: 0, cy: 0, cz: 0, radius: fallback, halfW, halfH, halfD: 1 };
|
|
384
|
-
}
|
|
385
|
-
return {
|
|
386
|
-
cx: (minX + maxX) / 2,
|
|
387
|
-
cy: (minY + maxY) / 2,
|
|
388
|
-
cz: (minZ + maxZ) / 2,
|
|
389
|
-
radius: 0.5 * Math.hypot(maxX - minX, maxY - minY, maxZ - minZ) || 1,
|
|
390
|
-
halfW: (maxX - minX) / 2,
|
|
391
|
-
halfH: (maxY - minY) / 2,
|
|
392
|
-
halfD: (maxZ - minZ) / 2,
|
|
393
|
-
};
|
|
394
|
-
}
|
|
395
|
-
/** Camera distance that frames a bounding sphere of `radius` at the given FOV. */
|
|
396
|
-
function frameDistance(radius, aspect) {
|
|
397
|
-
const vFov = (FOV * Math.PI) / 180;
|
|
398
|
-
const hFov = 2 * Math.atan(Math.tan(vFov / 2) * aspect);
|
|
399
|
-
const minFov = Math.min(vFov, hFov);
|
|
400
|
-
return (radius / Math.sin(minFov / 2)) * 1.1;
|
|
401
|
-
}
|
|
402
|
-
/** Fraction of the frame the content's projected box may occupy on its tightest axis. */
|
|
403
|
-
const FRAME_FILL = 0.92;
|
|
404
|
-
/**
|
|
405
|
-
* Place the camera in front of the content: a slight x offset + elevation
|
|
406
|
-
* gives the extrusion/spatial depth a readable three-quarter presence while
|
|
407
|
-
* framing the content's own centroid (`cameraElevation` picks the tilt).
|
|
408
|
-
*/
|
|
409
|
-
function placeCamera(bounds, family, dist) {
|
|
410
|
-
const { cx, cy, cz, radius } = bounds;
|
|
411
|
-
const elevation = cameraElevation(family, radius, dist);
|
|
412
|
-
return {
|
|
413
|
-
position: [cx + radius * 0.25, cy + elevation, cz + dist],
|
|
414
|
-
target: [cx, cy, cz],
|
|
415
|
-
dist,
|
|
416
|
-
};
|
|
417
|
-
}
|
|
418
|
-
/**
|
|
419
|
-
* Largest ratio of a content-box corner's projected offset to the frame's
|
|
420
|
-
* half-extent, seen from `placement`: `1` means the box exactly touches the
|
|
421
|
-
* frame edge on its tightest axis, `>1` means it overflows. Corners behind
|
|
422
|
-
* the camera count as infinitely overflowing.
|
|
423
|
-
*/
|
|
424
|
-
function projectedFill(bounds, placement, aspect) {
|
|
425
|
-
const vFov = (FOV * Math.PI) / 180;
|
|
426
|
-
const tanV = Math.tan(vFov / 2);
|
|
427
|
-
const tanH = tanV * aspect;
|
|
428
|
-
const [px, py, pz] = placement.position;
|
|
429
|
-
const [tx, ty, tz] = placement.target;
|
|
430
|
-
// Camera basis: forward toward the target, right = forward x worldUp, up = right x forward.
|
|
431
|
-
let fx = tx - px, fy = ty - py, fz = tz - pz;
|
|
432
|
-
const fl = Math.hypot(fx, fy, fz) || 1;
|
|
433
|
-
fx /= fl;
|
|
434
|
-
fy /= fl;
|
|
435
|
-
fz /= fl;
|
|
436
|
-
let rx = -fz, rz = fx;
|
|
437
|
-
const rl = Math.hypot(rx, rz) || 1;
|
|
438
|
-
rx /= rl;
|
|
439
|
-
rz /= rl;
|
|
440
|
-
const ux = -rz * fy, uy = rz * fx - rx * fz, uz = rx * fy;
|
|
441
|
-
let worst = 0;
|
|
442
|
-
for (const sx of [-1, 1]) {
|
|
443
|
-
for (const sy of [-1, 1]) {
|
|
444
|
-
for (const sz of [-1, 1]) {
|
|
445
|
-
const dx = bounds.cx + sx * bounds.halfW - px;
|
|
446
|
-
const dy = bounds.cy + sy * bounds.halfH - py;
|
|
447
|
-
const dz = bounds.cz + sz * bounds.halfD - pz;
|
|
448
|
-
const depth = dx * fx + dy * fy + dz * fz;
|
|
449
|
-
if (depth <= 1e-6) {
|
|
450
|
-
return Infinity;
|
|
451
|
-
}
|
|
452
|
-
const x = Math.abs(dx * rx + dz * rz) / (depth * tanH);
|
|
453
|
-
const y = Math.abs(dx * ux + dy * uy + dz * uz) / (depth * tanV);
|
|
454
|
-
worst = Math.max(worst, x, y);
|
|
455
|
-
}
|
|
456
|
-
}
|
|
457
|
-
}
|
|
458
|
-
return worst;
|
|
459
|
-
}
|
|
460
|
-
/**
|
|
461
|
-
* Camera placement whose view fits the content's projected bounding BOX to
|
|
462
|
-
* the frame (`FRAME_FILL` of the tightest axis).
|
|
463
|
-
*
|
|
464
|
-
* `frameDistance` fits the bounding SPHERE to the smaller field of view,
|
|
465
|
-
* which is only tight for roughly cubic content: a wide, flat list diagram
|
|
466
|
-
* (600 x 340 x 40) has a sphere nearly as wide as the diagram but the frame
|
|
467
|
-
* is 1.76x wider than it is tall, so the sphere fit left the diagram at ~45%
|
|
468
|
-
* of the frame width with its captions unreadably small. Starting from the
|
|
469
|
-
* sphere fit, this re-projects the box's eight corners through the actual
|
|
470
|
-
* (elevated, x-offset) camera and rescales the distance until the box just
|
|
471
|
-
* fills the frame; the ratio is close to linear in `dist`, so a handful of
|
|
472
|
-
* fixed-point steps converge.
|
|
473
|
-
*/
|
|
474
|
-
function fitCamera(bounds, family, aspect) {
|
|
475
|
-
let dist = frameDistance(bounds.radius, aspect);
|
|
476
|
-
let placement = placeCamera(bounds, family, dist);
|
|
477
|
-
for (let step = 0; step < 8; step++) {
|
|
478
|
-
const fill = projectedFill(bounds, placement, aspect);
|
|
479
|
-
if (!Number.isFinite(fill)) {
|
|
480
|
-
dist *= 2;
|
|
481
|
-
}
|
|
482
|
-
else {
|
|
483
|
-
const ratio = fill / FRAME_FILL;
|
|
484
|
-
if (Math.abs(ratio - 1) < 0.01) {
|
|
485
|
-
break;
|
|
486
|
-
}
|
|
487
|
-
dist *= ratio;
|
|
488
|
-
}
|
|
489
|
-
placement = placeCamera(bounds, family, dist);
|
|
490
|
-
}
|
|
491
|
-
return placement;
|
|
492
|
-
}
|
|
493
|
-
/** Elevation angle (degrees, above the content's own horizontal plane) for a near-overhead ring view. */
|
|
494
|
-
const CAROUSEL_ELEVATION_DEG = 34;
|
|
495
|
-
/**
|
|
496
|
-
* World-space Y offset for the camera above the content's own centroid.
|
|
497
|
-
*
|
|
498
|
-
* `cycleSpatial` (see `smartart-3d-spatial.ts`) flattens every node onto
|
|
499
|
-
* `y = 0`, arranging them on a ring in the XZ plane. The default modest
|
|
500
|
-
* elevation (a good three-quarter tilt for extruded-flat and receding-tree
|
|
501
|
-
* content, which both keep real vertical extent) views that flat ring
|
|
502
|
-
* almost edge-on: nodes on the far side of the ring sit almost directly
|
|
503
|
-
* behind the near ones from the camera's angle, so the diagram reads as a
|
|
504
|
-
* couple of overlapping shapes instead of the circle the 2D layout shows.
|
|
505
|
-
* A steep, near-overhead elevation (expressed as a fixed angle, not a
|
|
506
|
-
* fraction of `radius`, so it holds regardless of the radius/distance
|
|
507
|
-
* ratio) opens the ring up into a readable circle, matching the 2D
|
|
508
|
-
* cycle/radial layout's own top-down gestalt.
|
|
509
|
-
*/
|
|
510
|
-
function cameraElevation(family, radius, dist) {
|
|
511
|
-
if (family === 'cycle' || family === 'radial') {
|
|
512
|
-
return dist * Math.tan((CAROUSEL_ELEVATION_DEG * Math.PI) / 180);
|
|
513
|
-
}
|
|
514
|
-
return radius * 0.3;
|
|
515
|
-
}
|
|
516
|
-
/**
|
|
517
|
-
* Y-axis-only billboard: rotate every label plane to face the camera's
|
|
518
|
-
* current azimuth, keeping the plane vertical (so captions never tilt or
|
|
519
|
-
* flip upside down).
|
|
520
|
-
*
|
|
521
|
-
* A label's position floats just off its node's own (possibly rotated,
|
|
522
|
-
* possibly radially-outward-facing) front face, but a plane rotated to
|
|
523
|
-
* match that face is only readable when the camera happens to be roughly
|
|
524
|
-
* in front of it. For a spatial/carousel arrangement (`cycleSpatial`),
|
|
525
|
-
* that is true for at most the ~180 degrees of the ring facing the
|
|
526
|
-
* camera, and only good for reading text within roughly +/-45 degrees of
|
|
527
|
-
* dead-on; nodes further round the ring go edge-on (near-zero projected
|
|
528
|
-
* width; `DoubleSide` on the material does not help, since that only
|
|
529
|
-
* fixes a plane facing directly away, not sideways) or fully backward.
|
|
530
|
-
* Re-orienting the plane itself toward the camera, every frame (so this
|
|
531
|
-
* keeps working through an interactive orbit), makes every node's caption
|
|
532
|
-
* legible regardless of which way its underlying block happens to face.
|
|
533
|
-
*/
|
|
534
|
-
function billboardLabels(built, camera) {
|
|
535
|
-
built.forEachLabelPlane((plane) => {
|
|
536
|
-
const dx = camera.position.x - plane.position.x;
|
|
537
|
-
const dz = camera.position.z - plane.position.z;
|
|
538
|
-
plane.rotation.set(0, Math.atan2(dx, dz), 0);
|
|
539
|
-
});
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
/**
|
|
543
|
-
* Three.js SmartArt renderer - vanilla scene runtime.
|
|
544
|
-
*
|
|
545
|
-
* Frames a {@link SmartArt3DModel} in a WebGL scene (lights, perspective
|
|
546
|
-
* camera, optional OrbitControls, render loop) on a caller-provided canvas.
|
|
547
|
-
* Pure vanilla three.js - no framework code - so the React, Vue, and Angular
|
|
548
|
-
* bindings all mount it through a thin canvas wrapper. `three` is imported here
|
|
549
|
-
* only; this module lives behind the `pptx-viewer-shared/smartart-3d` subpath so
|
|
550
|
-
* it is lazily loaded and `three` stays an optional dependency.
|
|
551
|
-
*
|
|
552
|
-
* Camera framing (`contentSphere`/`fitCamera`/`cameraElevation`) and label
|
|
553
|
-
* billboarding (`billboardLabels`) are pure geometry, split out to
|
|
554
|
-
* `scene-camera.ts` to keep this module focused on mount/render-loop
|
|
555
|
-
* orchestration.
|
|
556
|
-
*/
|
|
557
|
-
/**
|
|
558
|
-
* Mount a SmartArt 3D model onto a canvas and start rendering.
|
|
559
|
-
*
|
|
560
|
-
* @returns a handle for resizing, toggling interactivity, and disposal.
|
|
561
|
-
*/
|
|
562
|
-
function mountSmartArt3D(canvas, model, width, height, options = {}) {
|
|
563
|
-
const renderer = new WebGLRenderer({ canvas, antialias: true, alpha: !options.background });
|
|
564
|
-
renderer.setPixelRatio(Math.min(typeof window === 'undefined' ? 1 : window.devicePixelRatio || 1, options.maxPixelRatio ?? 2));
|
|
565
|
-
renderer.setSize(width, height, false);
|
|
566
|
-
const scene = new Scene();
|
|
567
|
-
if (options.background) {
|
|
568
|
-
scene.background = new Color(options.background);
|
|
569
|
-
}
|
|
570
|
-
const bounds = contentSphere(model);
|
|
571
|
-
const { cx, cy, cz, radius } = bounds;
|
|
572
|
-
const aspect = width / Math.max(1, height);
|
|
573
|
-
// Fit the content's projected bounding box (not just its sphere) to the
|
|
574
|
-
// frame; see `fitCamera` for why the sphere fit alone left wide, flat
|
|
575
|
-
// diagrams tiny.
|
|
576
|
-
const placement = fitCamera(bounds, model.family, aspect);
|
|
577
|
-
const { dist } = placement;
|
|
578
|
-
const camera = new PerspectiveCamera(FOV, aspect, 0.1, dist * 8 + radius * 4);
|
|
579
|
-
camera.position.set(...placement.position);
|
|
580
|
-
camera.lookAt(...placement.target);
|
|
581
|
-
scene.add(new AmbientLight(0xffffff, 0.62));
|
|
582
|
-
const key = new DirectionalLight(0xffffff, 0.95);
|
|
583
|
-
key.position.set(cx + radius, cy + radius * 1.4, cz + dist);
|
|
584
|
-
scene.add(key);
|
|
585
|
-
const fill = new DirectionalLight(0xffffff, 0.3);
|
|
586
|
-
fill.position.set(cx - radius, cy - radius * 0.6, cz + dist * 0.6);
|
|
587
|
-
scene.add(fill);
|
|
588
|
-
const built = buildMeshGroup(model, options.textStyle);
|
|
589
|
-
scene.add(built.group);
|
|
590
|
-
let controls = null;
|
|
591
|
-
const enableControls = (on) => {
|
|
592
|
-
if (on && !controls) {
|
|
593
|
-
controls = new OrbitControls(camera, canvas);
|
|
594
|
-
controls.enablePan = false;
|
|
595
|
-
controls.target.set(cx, cy, cz);
|
|
596
|
-
controls.minDistance = dist * 0.4;
|
|
597
|
-
controls.maxDistance = dist * 3;
|
|
598
|
-
controls.update();
|
|
599
|
-
}
|
|
600
|
-
else if (!on && controls) {
|
|
601
|
-
controls.dispose();
|
|
602
|
-
controls = null;
|
|
603
|
-
}
|
|
604
|
-
if (controls) {
|
|
605
|
-
controls.autoRotate = Boolean(options.autoRotate);
|
|
606
|
-
controls.autoRotateSpeed = 1.2;
|
|
607
|
-
}
|
|
608
|
-
};
|
|
609
|
-
enableControls(Boolean(options.interactive));
|
|
610
|
-
let frame = 0;
|
|
611
|
-
let disposed = false;
|
|
612
|
-
const renderLoop = () => {
|
|
613
|
-
if (disposed) {
|
|
614
|
-
return;
|
|
615
|
-
}
|
|
616
|
-
frame = requestAnimationFrame(renderLoop);
|
|
617
|
-
billboardLabels(built, camera);
|
|
618
|
-
controls?.update();
|
|
619
|
-
renderer.render(scene, camera);
|
|
620
|
-
};
|
|
621
|
-
frame = requestAnimationFrame(renderLoop);
|
|
622
|
-
return {
|
|
623
|
-
resize(w, h) {
|
|
624
|
-
camera.aspect = w / Math.max(1, h);
|
|
625
|
-
camera.updateProjectionMatrix();
|
|
626
|
-
renderer.setSize(w, h, false);
|
|
627
|
-
},
|
|
628
|
-
setInteractive(on) {
|
|
629
|
-
enableControls(on);
|
|
630
|
-
},
|
|
631
|
-
setTextStyle(style) {
|
|
632
|
-
built.setTextStyle(style);
|
|
633
|
-
},
|
|
634
|
-
dispose() {
|
|
635
|
-
disposed = true;
|
|
636
|
-
cancelAnimationFrame(frame);
|
|
637
|
-
controls?.dispose();
|
|
638
|
-
built.dispose();
|
|
639
|
-
renderer.dispose();
|
|
640
|
-
},
|
|
641
|
-
};
|
|
642
|
-
}
|
|
643
|
-
|
|
644
|
-
/**
|
|
645
|
-
* `pptx-viewer-shared/smartart-3d` - vanilla three.js SmartArt scene runtime.
|
|
646
|
-
*
|
|
647
|
-
* Lazily imported by each binding's SmartArt 3D wrapper so `three` stays an
|
|
648
|
-
* optional dependency: when it is not installed the dynamic import rejects and
|
|
649
|
-
* the binding falls back to the SVG `SmartArtRenderer`. The pure model builder
|
|
650
|
-
* (`buildSmartArt3DModel`) and its types live in the main barrel
|
|
651
|
-
* (`pptx-viewer-shared`) and should be imported from there directly.
|
|
652
|
-
*/
|
|
653
|
-
|
|
654
|
-
export { mountSmartArt3D };
|
|
655
|
-
//# sourceMappingURL=pptx-angular-viewer-index-7b9uo9P2.mjs.map
|