@pascal-app/viewer 0.9.2 → 1.0.0-beta.2
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/README.md +20 -5
- package/dist/components/viewer/frame-limiter.d.ts.map +1 -1
- package/dist/components/viewer/frame-limiter.js +25 -5
- package/dist/components/viewer/glb-scene.js +2 -2
- package/dist/components/viewer/glb-walkthrough-controller.d.ts +10 -0
- package/dist/components/viewer/glb-walkthrough-controller.d.ts.map +1 -1
- package/dist/components/viewer/glb-walkthrough-controller.js +96 -8
- package/dist/components/viewer/index.d.ts.map +1 -1
- package/dist/components/viewer/index.js +9 -0
- package/dist/components/viewer/post-processing.d.ts.map +1 -1
- package/dist/components/viewer/post-processing.js +1 -0
- package/dist/components/viewer/selection-manager.js +5 -2
- package/dist/hooks/use-gltf-ktx2.d.ts.map +1 -1
- package/dist/hooks/use-gltf-ktx2.js +2 -5
- package/dist/index.d.ts +8 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -8
- package/dist/lib/hero-pose.d.ts +47 -0
- package/dist/lib/hero-pose.d.ts.map +1 -0
- package/dist/lib/hero-pose.js +227 -0
- package/dist/lib/ktx2-loader.d.ts +3 -0
- package/dist/lib/ktx2-loader.d.ts.map +1 -1
- package/dist/lib/ktx2-loader.js +6 -0
- package/dist/lib/materials.d.ts +2 -0
- package/dist/lib/materials.d.ts.map +1 -1
- package/dist/lib/materials.js +56 -9
- package/dist/lib/materials.test.d.ts +2 -0
- package/dist/lib/materials.test.d.ts.map +1 -0
- package/dist/lib/materials.test.js +25 -0
- package/dist/lib/merged-outline-node.test.d.ts +2 -0
- package/dist/lib/merged-outline-node.test.d.ts.map +1 -0
- package/dist/lib/merged-outline-node.test.js +22 -0
- package/dist/lib/snapshot-pipeline.d.ts +42 -0
- package/dist/lib/snapshot-pipeline.d.ts.map +1 -0
- package/dist/lib/snapshot-pipeline.js +254 -0
- package/dist/lib/terrain-perimeter-fill.d.ts +7 -0
- package/dist/lib/terrain-perimeter-fill.d.ts.map +1 -0
- package/dist/lib/terrain-perimeter-fill.js +75 -0
- package/dist/lib/texture-node-guard.d.ts +13 -0
- package/dist/lib/texture-node-guard.d.ts.map +1 -0
- package/dist/lib/texture-node-guard.js +39 -0
- package/dist/lib/texture-reference.d.ts +31 -0
- package/dist/lib/texture-reference.d.ts.map +1 -0
- package/dist/lib/texture-reference.js +139 -0
- package/dist/lib/texture-reference.test.d.ts +2 -0
- package/dist/lib/texture-reference.test.d.ts.map +1 -0
- package/dist/lib/texture-reference.test.js +86 -0
- package/dist/store/use-viewer.d.ts +13 -0
- package/dist/store/use-viewer.d.ts.map +1 -1
- package/dist/store/use-viewer.js +16 -1
- package/dist/store/use-viewer.test.js +19 -0
- package/dist/systems/ceiling/ceiling-system.d.ts.map +1 -1
- package/dist/systems/ceiling/ceiling-system.js +8 -4
- package/dist/systems/floor-elevation/floor-elevation-system.d.ts.map +1 -1
- package/dist/systems/floor-elevation/floor-elevation-system.js +30 -7
- package/dist/systems/geometry/geometry-system.d.ts.map +1 -1
- package/dist/systems/geometry/geometry-system.js +18 -2
- package/dist/systems/level/level-system.d.ts.map +1 -1
- package/dist/systems/level/level-system.js +8 -7
- package/dist/systems/level/level-utils.d.ts.map +1 -1
- package/dist/systems/level/level-utils.js +7 -6
- package/dist/systems/slab/slab-system.d.ts +5 -4
- package/dist/systems/slab/slab-system.d.ts.map +1 -1
- package/dist/systems/slab/slab-system.js +24 -94
- package/dist/systems/slab/slab-system.test.js +51 -12
- package/dist/systems/stair/stair-system.js +1 -1
- package/dist/systems/wall/wall-cutout.d.ts.map +1 -1
- package/dist/systems/wall/wall-cutout.js +5 -2
- package/dist/systems/wall/wall-support-extension.test.js +69 -1
- package/dist/systems/wall/wall-system.d.ts +3 -7
- package/dist/systems/wall/wall-system.d.ts.map +1 -1
- package/dist/systems/wall/wall-system.js +87 -22
- package/dist/systems/wall/wall-terrain-bottom.test.d.ts +2 -0
- package/dist/systems/wall/wall-terrain-bottom.test.d.ts.map +1 -0
- package/dist/systems/wall/wall-terrain-bottom.test.js +34 -0
- package/package.json +2 -2
- package/dist/components/viewer/walkthrough-controls.d.ts +0 -3
- package/dist/components/viewer/walkthrough-controls.d.ts.map +0 -1
- package/dist/components/viewer/walkthrough-controls.js +0 -151
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { Color, Matrix4, UnsignedByteType } from 'three';
|
|
2
|
+
import { ssgi } from 'three/addons/tsl/display/SSGINode.js';
|
|
3
|
+
import { denoise } from 'three/examples/jsm/tsl/display/DenoiseNode.js';
|
|
4
|
+
import { fxaa } from 'three/examples/jsm/tsl/display/FXAANode.js';
|
|
5
|
+
import { convertToTexture, diffuseColor, float, mix, mrt, normalView, output, pass, sample, saturation, screenUV, smoothstep, uniform, vec3, vec4, } from 'three/tsl';
|
|
6
|
+
import { RenderPipeline, RenderTarget } from 'three/webgpu';
|
|
7
|
+
import { GRADE_PARAMS, SSGI_PARAMS } from '../components/viewer/post-processing';
|
|
8
|
+
import { backdropGradient, deepSkyColor, horizonHazeColor } from './backdrop';
|
|
9
|
+
import { edgeColorFor, edgeOpacityScaleFor } from './edge-style';
|
|
10
|
+
import { inkedEdges } from './ink-edges';
|
|
11
|
+
import { getSceneTheme } from './scene-themes';
|
|
12
|
+
import { packNormalToRGB, unpackRGBToNormal } from './tsl-compat';
|
|
13
|
+
export const THUMBNAIL_WIDTH = 1920;
|
|
14
|
+
export const THUMBNAIL_HEIGHT = 1080;
|
|
15
|
+
export async function createSnapshotPipeline({ renderer, scene, camera, }) {
|
|
16
|
+
try {
|
|
17
|
+
if (renderer.init)
|
|
18
|
+
await renderer.init();
|
|
19
|
+
// Backdrop compositing for scene snapshots (studio renders, project
|
|
20
|
+
// thumbnails): theme background + sky gradient, same world-ray math as the
|
|
21
|
+
// viewport backdrop in viewer's post-processing. Uniform-driven so the one
|
|
22
|
+
// cached pipeline serves both opaque and transparent (preset/item) captures.
|
|
23
|
+
const bgColorUniform = uniform(new Color('#ffffff'));
|
|
24
|
+
const bgSkyUniform = uniform(new Color('#ffffff'));
|
|
25
|
+
const bgSkyDeepUniform = uniform(new Color('#ffffff'));
|
|
26
|
+
const bgHazeUniform = uniform(new Color('#ffffff'));
|
|
27
|
+
const bgProjInvUniform = uniform(new Matrix4());
|
|
28
|
+
const bgCamWorldUniform = uniform(new Matrix4());
|
|
29
|
+
const bgMixUniform = uniform(1);
|
|
30
|
+
const gradeMixUniform = uniform(0);
|
|
31
|
+
const inkMixUniform = uniform(0);
|
|
32
|
+
const inkColorUniform = uniform(new Color('#1a1d24'));
|
|
33
|
+
const inkOpacityUniform = uniform(0.5);
|
|
34
|
+
const inkOpacityScaleUniform = uniform(1);
|
|
35
|
+
// pass() handles MRT internally for all material types, including custom
|
|
36
|
+
// shaders — unlike renderer.setMRT() which crashes on non-NodeMaterials.
|
|
37
|
+
// pass() also respects camera.layers, so caller-disabled objects are filtered.
|
|
38
|
+
const scenePass = pass(scene, camera);
|
|
39
|
+
scenePass.setMRT(mrt({
|
|
40
|
+
output,
|
|
41
|
+
diffuseColor,
|
|
42
|
+
normal: packNormalToRGB(normalView),
|
|
43
|
+
}));
|
|
44
|
+
const scenePassColor = scenePass.getTextureNode('output');
|
|
45
|
+
const scenePassDepth = scenePass.getTextureNode('depth');
|
|
46
|
+
const scenePassNormal = scenePass.getTextureNode('normal');
|
|
47
|
+
scenePass.getTexture('diffuseColor').type = UnsignedByteType;
|
|
48
|
+
scenePass.getTexture('normal').type = UnsignedByteType;
|
|
49
|
+
const sceneNormal = sample((uv) => unpackRGBToNormal(scenePassNormal.sample(uv)));
|
|
50
|
+
const giPass = ssgi(scenePassColor, scenePassDepth, sceneNormal, camera);
|
|
51
|
+
giPass.sliceCount.value = SSGI_PARAMS.sliceCount;
|
|
52
|
+
giPass.stepCount.value = SSGI_PARAMS.stepCount;
|
|
53
|
+
giPass.radius.value = SSGI_PARAMS.radius;
|
|
54
|
+
giPass.expFactor.value = SSGI_PARAMS.expFactor;
|
|
55
|
+
giPass.thickness.value = SSGI_PARAMS.thickness;
|
|
56
|
+
giPass.backfaceLighting.value = SSGI_PARAMS.backfaceLighting;
|
|
57
|
+
giPass.aoIntensity.value = SSGI_PARAMS.aoIntensity;
|
|
58
|
+
giPass.giIntensity.value = SSGI_PARAMS.giIntensity;
|
|
59
|
+
giPass.useLinearThickness.value = SSGI_PARAMS.useLinearThickness;
|
|
60
|
+
giPass.useScreenSpaceSampling.value = SSGI_PARAMS.useScreenSpaceSampling;
|
|
61
|
+
giPass.useTemporalFiltering = SSGI_PARAMS.useTemporalFiltering;
|
|
62
|
+
// r185: SSGI's AO lives in its own single-channel texture (getAONode)
|
|
63
|
+
// rather than the alpha of one packed rgba texture.
|
|
64
|
+
const aoTexture = giPass.getAONode();
|
|
65
|
+
const aoAsRgb = vec4(aoTexture.r, aoTexture.r, aoTexture.r, float(1));
|
|
66
|
+
const denoisePass = denoise(aoAsRgb, scenePassDepth, sceneNormal, camera);
|
|
67
|
+
denoisePass.index.value = 0;
|
|
68
|
+
denoisePass.radius.value = 4;
|
|
69
|
+
// Same far-field AO fade as the viewport pipeline — without it the
|
|
70
|
+
// horizon picks up a visible AO line in captures.
|
|
71
|
+
const aoFarFade = smoothstep(float(0.9994), float(0.9998), scenePassDepth.sample(screenUV).r);
|
|
72
|
+
const ao = mix(denoisePass.r, float(1), aoFarFade);
|
|
73
|
+
const aoRgb = scenePassColor.rgb.mul(ao);
|
|
74
|
+
// Ink edges, mirroring the viewport pipeline (AO → ink → grade) so
|
|
75
|
+
// captures carry the same soft/strong edge look the canvas shows.
|
|
76
|
+
// Uniform-gated like grade/backdrop: one cached pipeline serves all modes.
|
|
77
|
+
// Radius scales with render height: a supersampled capture (4K → 1080p)
|
|
78
|
+
// would otherwise halve the apparent line weight vs the viewport's 1px.
|
|
79
|
+
const inkRadius = Math.max(1, Math.round(renderer.domElement.height / 1080));
|
|
80
|
+
const inkedRgb = inkedEdges({
|
|
81
|
+
sceneRgb: aoRgb,
|
|
82
|
+
depthTex: scenePassDepth,
|
|
83
|
+
normalTex: scenePassNormal,
|
|
84
|
+
inkColor: inkColorUniform,
|
|
85
|
+
radius: inkRadius,
|
|
86
|
+
opacity: float(inkOpacityUniform).mul(inkOpacityScaleUniform),
|
|
87
|
+
});
|
|
88
|
+
const ungradedSceneRgb = mix(aoRgb, inkedRgb, inkMixUniform);
|
|
89
|
+
const gradeRgb = (rgb) => saturation(rgb.div(0.18).pow(vec3(GRADE_PARAMS.contrast)).mul(0.18), GRADE_PARAMS.saturation);
|
|
90
|
+
const sceneRgb = mix(ungradedSceneRgb, gradeRgb(ungradedSceneRgb), gradeMixUniform);
|
|
91
|
+
// Per-pixel world ray from the capture camera → sky gradient above the
|
|
92
|
+
// horizon (dir.y = 0), flat background below — mirrors the viewport
|
|
93
|
+
// backdrop. bgMix 0 bypasses it and keeps the capture transparent.
|
|
94
|
+
const ndc = vec4(screenUV.x.mul(2).sub(1), float(1).sub(screenUV.y).mul(2).sub(1), 1, 1);
|
|
95
|
+
const viewRay = bgProjInvUniform.mul(ndc);
|
|
96
|
+
const worldDir = bgCamWorldUniform.mul(vec4(viewRay.xyz, 0)).xyz.normalize();
|
|
97
|
+
const ungradedBgGradient = backdropGradient({
|
|
98
|
+
dirY: worldDir.y,
|
|
99
|
+
background: bgColorUniform,
|
|
100
|
+
haze: bgHazeUniform,
|
|
101
|
+
sky: bgSkyUniform,
|
|
102
|
+
skyDeep: bgSkyDeepUniform,
|
|
103
|
+
});
|
|
104
|
+
const bgGradient = mix(ungradedBgGradient, gradeRgb(ungradedBgGradient), gradeMixUniform);
|
|
105
|
+
const alpha = scenePassColor.a;
|
|
106
|
+
const finalOutput = vec4(mix(sceneRgb, mix(bgGradient, sceneRgb, alpha), bgMixUniform), mix(alpha, float(1), bgMixUniform));
|
|
107
|
+
// FXAA requires a texture node as input; convertToTexture renders finalOutput
|
|
108
|
+
// into an intermediate RT so FXAA can sample it with neighbour UV offsets.
|
|
109
|
+
const aaOutput = fxaa(convertToTexture(finalOutput));
|
|
110
|
+
const pipeline = new RenderPipeline(renderer);
|
|
111
|
+
pipeline.outputNode = aaOutput;
|
|
112
|
+
// Dedicated render target — pipeline outputs here instead of the canvas,
|
|
113
|
+
// so R3F's main render loop can never overwrite our capture.
|
|
114
|
+
const { width, height } = renderer.domElement;
|
|
115
|
+
const renderTarget = new RenderTarget(width, height, { depthBuffer: true });
|
|
116
|
+
return {
|
|
117
|
+
applyEnvironment: ({ theme, transparent, grade, edges, camera: captureCamera }) => {
|
|
118
|
+
const sceneTheme = getSceneTheme(theme);
|
|
119
|
+
inkMixUniform.value = edges === 'off' ? 0 : 1;
|
|
120
|
+
inkOpacityUniform.value = edges === 'strong' ? 1 : 0.5;
|
|
121
|
+
inkColorUniform.value.set(edgeColorFor(sceneTheme.background));
|
|
122
|
+
inkOpacityScaleUniform.value = edgeOpacityScaleFor(sceneTheme.background);
|
|
123
|
+
bgColorUniform.value.set(sceneTheme.background);
|
|
124
|
+
bgSkyUniform.value.set(sceneTheme.backgroundSky ?? sceneTheme.background);
|
|
125
|
+
bgSkyDeepUniform.value.set(deepSkyColor(sceneTheme.backgroundSky ?? sceneTheme.background));
|
|
126
|
+
bgHazeUniform.value.set(horizonHazeColor(sceneTheme.backgroundSky ?? sceneTheme.background, sceneTheme.appearance));
|
|
127
|
+
bgMixUniform.value = transparent ? 0 : 1;
|
|
128
|
+
gradeMixUniform.value = grade ? 1 : 0;
|
|
129
|
+
// The capture camera never joins the scene graph, so its matrixWorld
|
|
130
|
+
// is only refreshed by the render itself — too late for the backdrop
|
|
131
|
+
// uniforms below.
|
|
132
|
+
captureCamera.updateMatrixWorld();
|
|
133
|
+
bgProjInvUniform.value.copy(captureCamera.projectionMatrixInverse);
|
|
134
|
+
bgCamWorldUniform.value.copy(captureCamera.matrixWorld);
|
|
135
|
+
},
|
|
136
|
+
capture: async ({ captureMode, cropRegion, standardSize }) => {
|
|
137
|
+
const standardW = standardSize?.w ?? THUMBNAIL_WIDTH;
|
|
138
|
+
const standardH = standardSize?.h ?? THUMBNAIL_HEIGHT;
|
|
139
|
+
const { width: captureWidth, height: captureHeight } = renderer.domElement;
|
|
140
|
+
// Resize RT if the canvas dimensions changed
|
|
141
|
+
if (renderTarget.width !== captureWidth || renderTarget.height !== captureHeight) {
|
|
142
|
+
renderTarget.setSize(captureWidth, captureHeight);
|
|
143
|
+
}
|
|
144
|
+
try {
|
|
145
|
+
;
|
|
146
|
+
renderer.setClearAlpha(0);
|
|
147
|
+
renderer.setRenderTarget(renderTarget);
|
|
148
|
+
pipeline.render();
|
|
149
|
+
}
|
|
150
|
+
finally {
|
|
151
|
+
renderer.setRenderTarget(null);
|
|
152
|
+
}
|
|
153
|
+
// Let callers restore visibility and other capture policy immediately
|
|
154
|
+
// after the render, before the asynchronous GPU readback begins.
|
|
155
|
+
await Promise.resolve();
|
|
156
|
+
// Read pixels from the RT asynchronously.
|
|
157
|
+
// WebGPU copyTextureToBuffer aligns each row to 256 bytes, so we must
|
|
158
|
+
// depad the rows before constructing ImageData.
|
|
159
|
+
const pixels = (await renderer.readRenderTargetPixelsAsync(renderTarget, 0, 0, captureWidth, captureHeight));
|
|
160
|
+
const actualBytesPerRow = captureWidth * 4;
|
|
161
|
+
const tightTotal = actualBytesPerRow * captureHeight;
|
|
162
|
+
const paddedBytesPerRow = Math.ceil(actualBytesPerRow / 256) * 256;
|
|
163
|
+
// Two readback shapes to handle:
|
|
164
|
+
// - WebGPU (`copyTextureToBuffer`): top-down + 256-byte row padding
|
|
165
|
+
// when width*4 isn't already a multiple of 256.
|
|
166
|
+
// - WebGL2 fallback (iOS Chrome, etc.): tightly-packed but bottom-up
|
|
167
|
+
// (OpenGL framebuffer convention).
|
|
168
|
+
// `isWebGPURenderer` lies — it stays true even when the renderer
|
|
169
|
+
// falls back to the WebGL backend. Inspect the actual backend
|
|
170
|
+
// instead (presence of a GPU device, or backend constructor name).
|
|
171
|
+
const backend = renderer.backend;
|
|
172
|
+
const isWebGPU = !!backend?.device ||
|
|
173
|
+
backend?.isWebGPUBackend === true ||
|
|
174
|
+
backend?.constructor?.name === 'WebGPUBackend';
|
|
175
|
+
let tightPixels;
|
|
176
|
+
if (isWebGPU) {
|
|
177
|
+
// WebGPU: depad rows if needed; orientation is already top-down.
|
|
178
|
+
if (paddedBytesPerRow === actualBytesPerRow) {
|
|
179
|
+
tightPixels = new Uint8ClampedArray(pixels.buffer, pixels.byteOffset, Math.min(pixels.byteLength, tightTotal));
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
tightPixels = new Uint8ClampedArray(tightTotal);
|
|
183
|
+
for (let row = 0; row < captureHeight; row++) {
|
|
184
|
+
tightPixels.set(pixels.subarray(row * paddedBytesPerRow, row * paddedBytesPerRow + actualBytesPerRow), row * actualBytesPerRow);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
// WebGL2: tight buffer in bottom-up order — flip rows.
|
|
190
|
+
tightPixels = new Uint8ClampedArray(tightTotal);
|
|
191
|
+
for (let row = 0; row < captureHeight; row++) {
|
|
192
|
+
const srcStart = (captureHeight - 1 - row) * actualBytesPerRow;
|
|
193
|
+
tightPixels.set(pixels.subarray(srcStart, srcStart + actualBytesPerRow), row * actualBytesPerRow);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const imageData = new ImageData(tightPixels, captureWidth, captureHeight);
|
|
197
|
+
const srcCanvas = new OffscreenCanvas(captureWidth, captureHeight);
|
|
198
|
+
srcCanvas.getContext('2d').putImageData(imageData, 0, 0);
|
|
199
|
+
let outW;
|
|
200
|
+
let outH;
|
|
201
|
+
let blob;
|
|
202
|
+
if (captureMode === 'viewport') {
|
|
203
|
+
outW = captureWidth;
|
|
204
|
+
outH = captureHeight;
|
|
205
|
+
const offscreen = new OffscreenCanvas(outW, outH);
|
|
206
|
+
offscreen.getContext('2d').drawImage(srcCanvas, 0, 0);
|
|
207
|
+
blob = await offscreen.convertToBlob({ type: 'image/png' });
|
|
208
|
+
}
|
|
209
|
+
else if (captureMode === 'area' && cropRegion) {
|
|
210
|
+
const sx = Math.round(cropRegion.x * captureWidth);
|
|
211
|
+
const sy = Math.round(cropRegion.y * captureHeight);
|
|
212
|
+
outW = Math.round(cropRegion.width * captureWidth);
|
|
213
|
+
outH = Math.round(cropRegion.height * captureHeight);
|
|
214
|
+
const offscreen = new OffscreenCanvas(outW, outH);
|
|
215
|
+
offscreen.getContext('2d').drawImage(srcCanvas, sx, sy, outW, outH, 0, 0, outW, outH);
|
|
216
|
+
blob = await offscreen.convertToBlob({ type: 'image/png' });
|
|
217
|
+
}
|
|
218
|
+
else {
|
|
219
|
+
// Standard: center-crop to the requested aspect (default 1920×1080)
|
|
220
|
+
const srcAspect = captureWidth / captureHeight;
|
|
221
|
+
const dstAspect = standardW / standardH;
|
|
222
|
+
let sx = 0;
|
|
223
|
+
let sy = 0;
|
|
224
|
+
let sWidth = captureWidth;
|
|
225
|
+
let sHeight = captureHeight;
|
|
226
|
+
if (srcAspect > dstAspect) {
|
|
227
|
+
sWidth = Math.round(captureHeight * dstAspect);
|
|
228
|
+
sx = Math.round((captureWidth - sWidth) / 2);
|
|
229
|
+
}
|
|
230
|
+
else if (srcAspect < dstAspect) {
|
|
231
|
+
sHeight = Math.round(captureWidth / dstAspect);
|
|
232
|
+
sy = Math.round((captureHeight - sHeight) / 2);
|
|
233
|
+
}
|
|
234
|
+
outW = standardW;
|
|
235
|
+
outH = standardH;
|
|
236
|
+
const offscreen = new OffscreenCanvas(outW, outH);
|
|
237
|
+
offscreen
|
|
238
|
+
.getContext('2d')
|
|
239
|
+
.drawImage(srcCanvas, sx, sy, sWidth, sHeight, 0, 0, outW, outH);
|
|
240
|
+
blob = await offscreen.convertToBlob({ type: 'image/png' });
|
|
241
|
+
}
|
|
242
|
+
return { blob, outW, outH };
|
|
243
|
+
},
|
|
244
|
+
dispose: () => {
|
|
245
|
+
pipeline.dispose();
|
|
246
|
+
renderTarget.dispose();
|
|
247
|
+
},
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
catch (error) {
|
|
251
|
+
console.error('[thumbnail] Failed to build post-processing pipeline, will use fallback render.', error);
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { BufferGeometry } from 'three';
|
|
2
|
+
export type TerrainPerimeterPoint = {
|
|
3
|
+
x: number;
|
|
4
|
+
z: number;
|
|
5
|
+
};
|
|
6
|
+
export declare function buildTerrainPerimeterFillGeometry(points: readonly TerrainPerimeterPoint[], bottomY: readonly number[], topY: number, epsilon?: number): BufferGeometry | null;
|
|
7
|
+
//# sourceMappingURL=terrain-perimeter-fill.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"terrain-perimeter-fill.d.ts","sourceRoot":"","sources":["../../src/lib/terrain-perimeter-fill.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAA+C,MAAM,OAAO,CAAA;AAGnF,MAAM,MAAM,qBAAqB,GAAG;IAAE,CAAC,EAAE,MAAM,CAAC;IAAC,CAAC,EAAE,MAAM,CAAA;CAAE,CAAA;AAE5D,wBAAgB,iCAAiC,CAC/C,MAAM,EAAE,SAAS,qBAAqB,EAAE,EACxC,OAAO,EAAE,SAAS,MAAM,EAAE,EAC1B,IAAI,EAAE,MAAM,EACZ,OAAO,SAAO,GACb,cAAc,GAAG,IAAI,CA2EvB"}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { BufferGeometry, Float32BufferAttribute, ShapeUtils, Vector2 } from 'three';
|
|
2
|
+
import { ensureRenderableGeometryAttributes } from './csg-utils';
|
|
3
|
+
export function buildTerrainPerimeterFillGeometry(points, bottomY, topY, epsilon = 1e-6) {
|
|
4
|
+
if (points.length < 3 || bottomY.length !== points.length)
|
|
5
|
+
return null;
|
|
6
|
+
if (bottomY.every((y) => y >= topY - epsilon))
|
|
7
|
+
return null;
|
|
8
|
+
let signedArea = 0;
|
|
9
|
+
for (let index = 0; index < points.length; index += 1) {
|
|
10
|
+
const a = points[index];
|
|
11
|
+
const b = points[(index + 1) % points.length];
|
|
12
|
+
signedArea += a.x * b.z - b.x * a.z;
|
|
13
|
+
}
|
|
14
|
+
const counterClockwise = signedArea > 0;
|
|
15
|
+
const positions = [];
|
|
16
|
+
const push = (point, y) => {
|
|
17
|
+
positions.push(point.x, y, point.z);
|
|
18
|
+
};
|
|
19
|
+
for (let index = 0; index < points.length; index += 1) {
|
|
20
|
+
const next = (index + 1) % points.length;
|
|
21
|
+
const a = points[index];
|
|
22
|
+
const b = points[next];
|
|
23
|
+
const ay = bottomY[index];
|
|
24
|
+
const by = bottomY[next];
|
|
25
|
+
if (ay >= topY - epsilon && by >= topY - epsilon)
|
|
26
|
+
continue;
|
|
27
|
+
if (counterClockwise) {
|
|
28
|
+
push(a, topY);
|
|
29
|
+
push(b, by);
|
|
30
|
+
push(a, ay);
|
|
31
|
+
push(a, topY);
|
|
32
|
+
push(b, topY);
|
|
33
|
+
push(b, by);
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
push(a, topY);
|
|
37
|
+
push(a, ay);
|
|
38
|
+
push(b, by);
|
|
39
|
+
push(a, topY);
|
|
40
|
+
push(b, by);
|
|
41
|
+
push(b, topY);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const faces = ShapeUtils.triangulateShape(points.map((point) => new Vector2(point.x, point.z)), []);
|
|
45
|
+
for (const face of faces) {
|
|
46
|
+
const [ia, ib, ic] = face;
|
|
47
|
+
if (ia == null || ib == null || ic == null)
|
|
48
|
+
continue;
|
|
49
|
+
if (bottomY[ia] >= topY - epsilon &&
|
|
50
|
+
bottomY[ib] >= topY - epsilon &&
|
|
51
|
+
bottomY[ic] >= topY - epsilon) {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
const a = points[ia];
|
|
55
|
+
const b = points[ib];
|
|
56
|
+
const c = points[ic];
|
|
57
|
+
const cross = (b.x - a.x) * (c.z - a.z) - (b.z - a.z) * (c.x - a.x);
|
|
58
|
+
push(a, bottomY[ia]);
|
|
59
|
+
if (cross >= 0) {
|
|
60
|
+
push(b, bottomY[ib]);
|
|
61
|
+
push(c, bottomY[ic]);
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
push(c, bottomY[ic]);
|
|
65
|
+
push(b, bottomY[ib]);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (positions.length === 0)
|
|
69
|
+
return null;
|
|
70
|
+
const geometry = new BufferGeometry();
|
|
71
|
+
geometry.setAttribute('position', new Float32BufferAttribute(positions, 3));
|
|
72
|
+
geometry.computeVertexNormals();
|
|
73
|
+
ensureRenderableGeometryAttributes(geometry);
|
|
74
|
+
return geometry;
|
|
75
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* three's node system pulls texture uniforms from materials each frame via
|
|
3
|
+
* reference nodes, and several override-material passes (shadow, depth/normal
|
|
4
|
+
* prepasses) copy per-object texture slots onto shared materials whose cached
|
|
5
|
+
* per-mesh node graphs can disagree about a slot's presence. When they do,
|
|
6
|
+
* `TextureNode.update` dereferences a null texture and the exception kills the
|
|
7
|
+
* whole render pass — the scene goes black. Substitute a 1×1 black fallback
|
|
8
|
+
* instead (skipping is not enough: the null would still reach the backend's
|
|
9
|
+
* texture-binding WeakMap). The slot renders black for a frame and recovers
|
|
10
|
+
* as soon as the reference pulls a real value again.
|
|
11
|
+
*/
|
|
12
|
+
export declare function installTextureNodeNullGuard(): void;
|
|
13
|
+
//# sourceMappingURL=texture-node-guard.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"texture-node-guard.d.ts","sourceRoot":"","sources":["../../src/lib/texture-node-guard.ts"],"names":[],"mappings":"AAeA;;;;;;;;;;GAUG;AACH,wBAAgB,2BAA2B,IAAI,IAAI,CAiBlD"}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { DataTexture, TextureNode } from 'three/webgpu';
|
|
2
|
+
let installed = false;
|
|
3
|
+
let reported = 0;
|
|
4
|
+
let fallbackTexture = null;
|
|
5
|
+
function getFallbackTexture() {
|
|
6
|
+
if (!fallbackTexture) {
|
|
7
|
+
fallbackTexture = new DataTexture(new Uint8Array([0, 0, 0, 255]), 1, 1);
|
|
8
|
+
fallbackTexture.needsUpdate = true;
|
|
9
|
+
}
|
|
10
|
+
return fallbackTexture;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* three's node system pulls texture uniforms from materials each frame via
|
|
14
|
+
* reference nodes, and several override-material passes (shadow, depth/normal
|
|
15
|
+
* prepasses) copy per-object texture slots onto shared materials whose cached
|
|
16
|
+
* per-mesh node graphs can disagree about a slot's presence. When they do,
|
|
17
|
+
* `TextureNode.update` dereferences a null texture and the exception kills the
|
|
18
|
+
* whole render pass — the scene goes black. Substitute a 1×1 black fallback
|
|
19
|
+
* instead (skipping is not enough: the null would still reach the backend's
|
|
20
|
+
* texture-binding WeakMap). The slot renders black for a frame and recovers
|
|
21
|
+
* as soon as the reference pulls a real value again.
|
|
22
|
+
*/
|
|
23
|
+
export function installTextureNodeNullGuard() {
|
|
24
|
+
if (installed)
|
|
25
|
+
return;
|
|
26
|
+
installed = true;
|
|
27
|
+
const prototype = TextureNode.prototype;
|
|
28
|
+
const originalUpdate = prototype.update;
|
|
29
|
+
prototype.update = function update() {
|
|
30
|
+
if (this.value == null) {
|
|
31
|
+
if (reported < 5) {
|
|
32
|
+
reported += 1;
|
|
33
|
+
console.warn(`[viewer] TextureNode ${this.uuid} has no texture — using fallback`);
|
|
34
|
+
}
|
|
35
|
+
this.value = getFallbackTexture();
|
|
36
|
+
}
|
|
37
|
+
originalUpdate.call(this);
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type * as THREE from 'three';
|
|
2
|
+
export type PascalTextureMap = 'basecolor' | 'normal' | 'roughness' | 'metalness' | 'height' | 'other';
|
|
3
|
+
export type PascalTextureColorSpace = 'srgb' | 'linear';
|
|
4
|
+
type PascalTextureRefBase = {
|
|
5
|
+
v: 1;
|
|
6
|
+
src: string;
|
|
7
|
+
map: PascalTextureMap;
|
|
8
|
+
colorSpace: PascalTextureColorSpace;
|
|
9
|
+
};
|
|
10
|
+
export type PascalTextureRef = (PascalTextureRefBase & {
|
|
11
|
+
kind: 'library-material' | 'app-material' | 'project-asset';
|
|
12
|
+
}) | (PascalTextureRefBase & {
|
|
13
|
+
kind: 'item-glb';
|
|
14
|
+
imageIndex: number;
|
|
15
|
+
});
|
|
16
|
+
export declare function textureMapForSlot(slot: string): PascalTextureMap;
|
|
17
|
+
export declare function stampPascalTextureRef(texture: THREE.Texture, input: {
|
|
18
|
+
/** 'material' resolves to library-material (storage bucket) or
|
|
19
|
+
* app-material (static catalog on the assets CDN) by URL shape. */
|
|
20
|
+
kind: 'material' | 'project-asset';
|
|
21
|
+
src: string;
|
|
22
|
+
slot: string;
|
|
23
|
+
} | {
|
|
24
|
+
kind: 'item-glb';
|
|
25
|
+
src: string;
|
|
26
|
+
slot: string;
|
|
27
|
+
imageIndex: number;
|
|
28
|
+
}): PascalTextureRef | null;
|
|
29
|
+
export declare function getPascalTextureRef(texture: THREE.Texture): PascalTextureRef | null;
|
|
30
|
+
export {};
|
|
31
|
+
//# sourceMappingURL=texture-reference.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"texture-reference.d.ts","sourceRoot":"","sources":["../../src/lib/texture-reference.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,KAAK,MAAM,OAAO,CAAA;AAGnC,MAAM,MAAM,gBAAgB,GACxB,WAAW,GACX,QAAQ,GACR,WAAW,GACX,WAAW,GACX,QAAQ,GACR,OAAO,CAAA;AAEX,MAAM,MAAM,uBAAuB,GAAG,MAAM,GAAG,QAAQ,CAAA;AAEvD,KAAK,oBAAoB,GAAG;IAC1B,CAAC,EAAE,CAAC,CAAA;IACJ,GAAG,EAAE,MAAM,CAAA;IACX,GAAG,EAAE,gBAAgB,CAAA;IACrB,UAAU,EAAE,uBAAuB,CAAA;CACpC,CAAA;AAED,MAAM,MAAM,gBAAgB,GACxB,CAAC,oBAAoB,GAAG;IACtB,IAAI,EAAE,kBAAkB,GAAG,cAAc,GAAG,eAAe,CAAA;CAC5D,CAAC,GACF,CAAC,oBAAoB,GAAG;IACtB,IAAI,EAAE,UAAU,CAAA;IAChB,UAAU,EAAE,MAAM,CAAA;CACnB,CAAC,CAAA;AAqDN,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,gBAAgB,CAgBhE;AAMD,wBAAgB,qBAAqB,CACnC,OAAO,EAAE,KAAK,CAAC,OAAO,EACtB,KAAK,EACD;IACE;wEACoE;IACpE,IAAI,EAAE,UAAU,GAAG,eAAe,CAAA;IAClC,GAAG,EAAE,MAAM,CAAA;IACX,IAAI,EAAE,MAAM,CAAA;CACb,GACD;IACE,IAAI,EAAE,UAAU,CAAA;IAChB,GAAG,EAAE,MAAM,CAAA;IACX,IAAI,EAAE,MAAM,CAAA;IACZ,UAAU,EAAE,MAAM,CAAA;CACnB,GACJ,gBAAgB,GAAG,IAAI,CA6BzB;AAED,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,KAAK,CAAC,OAAO,GAAG,gBAAgB,GAAG,IAAI,CAmCnF"}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { ASSETS_CDN_URL } from './asset-url';
|
|
2
|
+
const STORAGE_BUCKET_BY_KIND = {
|
|
3
|
+
'library-material': 'materials',
|
|
4
|
+
'item-glb': 'items',
|
|
5
|
+
'project-asset': 'project-assets',
|
|
6
|
+
};
|
|
7
|
+
let cachedStorageOrigin;
|
|
8
|
+
function pascalStorageOrigin() {
|
|
9
|
+
if (cachedStorageOrigin !== undefined)
|
|
10
|
+
return cachedStorageOrigin;
|
|
11
|
+
try {
|
|
12
|
+
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
|
13
|
+
cachedStorageOrigin = url ? new URL(url).origin : null;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
cachedStorageOrigin = null;
|
|
17
|
+
}
|
|
18
|
+
return cachedStorageOrigin;
|
|
19
|
+
}
|
|
20
|
+
/** Static catalog materials ship in the app's public dir and resolve through
|
|
21
|
+
* the assets CDN (`/material/{category}/{slug}/{slug}_{map}_{size}.ktx2`) —
|
|
22
|
+
* a server-known KTX2 source like the storage buckets, just app-hosted. */
|
|
23
|
+
function isAppMaterialUrl(src) {
|
|
24
|
+
try {
|
|
25
|
+
const url = new URL(src);
|
|
26
|
+
return url.origin === new URL(ASSETS_CDN_URL).origin && url.pathname.startsWith('/material/');
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const TEXTURE_MAPS = new Set([
|
|
33
|
+
'basecolor',
|
|
34
|
+
'normal',
|
|
35
|
+
'roughness',
|
|
36
|
+
'metalness',
|
|
37
|
+
'height',
|
|
38
|
+
'other',
|
|
39
|
+
]);
|
|
40
|
+
function isPascalStorageUrl(src, kind) {
|
|
41
|
+
const origin = pascalStorageOrigin();
|
|
42
|
+
if (!origin)
|
|
43
|
+
return false;
|
|
44
|
+
try {
|
|
45
|
+
const url = new URL(src);
|
|
46
|
+
const bucket = STORAGE_BUCKET_BY_KIND[kind];
|
|
47
|
+
return url.origin === origin && url.pathname.startsWith(`/storage/v1/object/public/${bucket}/`);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
export function textureMapForSlot(slot) {
|
|
54
|
+
switch (slot) {
|
|
55
|
+
case 'map':
|
|
56
|
+
return 'basecolor';
|
|
57
|
+
case 'normalMap':
|
|
58
|
+
return 'normal';
|
|
59
|
+
case 'roughnessMap':
|
|
60
|
+
return 'roughness';
|
|
61
|
+
case 'metalnessMap':
|
|
62
|
+
return 'metalness';
|
|
63
|
+
case 'displacementMap':
|
|
64
|
+
case 'bumpMap':
|
|
65
|
+
return 'height';
|
|
66
|
+
default:
|
|
67
|
+
return 'other';
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
function textureColorSpace(texture) {
|
|
71
|
+
return texture.colorSpace === 'srgb' ? 'srgb' : 'linear';
|
|
72
|
+
}
|
|
73
|
+
export function stampPascalTextureRef(texture, input) {
|
|
74
|
+
const base = {
|
|
75
|
+
v: 1,
|
|
76
|
+
src: input.src,
|
|
77
|
+
map: textureMapForSlot(input.slot),
|
|
78
|
+
colorSpace: textureColorSpace(texture),
|
|
79
|
+
};
|
|
80
|
+
let ref;
|
|
81
|
+
if (input.kind === 'item-glb') {
|
|
82
|
+
if (!isPascalStorageUrl(input.src, 'item-glb'))
|
|
83
|
+
return null;
|
|
84
|
+
if (!Number.isInteger(input.imageIndex) || input.imageIndex < 0)
|
|
85
|
+
return null;
|
|
86
|
+
ref = { ...base, kind: 'item-glb', imageIndex: input.imageIndex };
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
const kind = input.kind === 'material'
|
|
90
|
+
? isPascalStorageUrl(input.src, 'library-material')
|
|
91
|
+
? 'library-material'
|
|
92
|
+
: isAppMaterialUrl(input.src)
|
|
93
|
+
? 'app-material'
|
|
94
|
+
: null
|
|
95
|
+
: isPascalStorageUrl(input.src, 'project-asset')
|
|
96
|
+
? 'project-asset'
|
|
97
|
+
: null;
|
|
98
|
+
if (!kind)
|
|
99
|
+
return null;
|
|
100
|
+
ref = { ...base, kind };
|
|
101
|
+
}
|
|
102
|
+
texture.userData.pascalTextureRef = ref;
|
|
103
|
+
return ref;
|
|
104
|
+
}
|
|
105
|
+
export function getPascalTextureRef(texture) {
|
|
106
|
+
const raw = texture.userData.pascalTextureRef;
|
|
107
|
+
if (!raw || typeof raw !== 'object')
|
|
108
|
+
return null;
|
|
109
|
+
const candidate = raw;
|
|
110
|
+
const kind = candidate.kind;
|
|
111
|
+
if (candidate.v !== 1 ||
|
|
112
|
+
(kind !== 'library-material' &&
|
|
113
|
+
kind !== 'app-material' &&
|
|
114
|
+
kind !== 'item-glb' &&
|
|
115
|
+
kind !== 'project-asset') ||
|
|
116
|
+
typeof candidate.src !== 'string' ||
|
|
117
|
+
!(kind === 'app-material'
|
|
118
|
+
? isAppMaterialUrl(candidate.src)
|
|
119
|
+
: isPascalStorageUrl(candidate.src, kind)) ||
|
|
120
|
+
typeof candidate.map !== 'string' ||
|
|
121
|
+
!TEXTURE_MAPS.has(candidate.map) ||
|
|
122
|
+
(candidate.colorSpace !== 'srgb' && candidate.colorSpace !== 'linear')) {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
const base = {
|
|
126
|
+
v: 1,
|
|
127
|
+
src: candidate.src,
|
|
128
|
+
map: candidate.map,
|
|
129
|
+
colorSpace: candidate.colorSpace,
|
|
130
|
+
};
|
|
131
|
+
if (kind === 'item-glb') {
|
|
132
|
+
if (!Number.isInteger(candidate.imageIndex) || candidate.imageIndex < 0)
|
|
133
|
+
return null;
|
|
134
|
+
return { ...base, kind, imageIndex: candidate.imageIndex };
|
|
135
|
+
}
|
|
136
|
+
if (candidate.imageIndex !== undefined)
|
|
137
|
+
return null;
|
|
138
|
+
return { ...base, kind };
|
|
139
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"texture-reference.test.d.ts","sourceRoot":"","sources":["../../src/lib/texture-reference.test.ts"],"names":[],"mappings":""}
|