diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 47541a9..5a8c43e 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -5,4 +5,4 @@ - [ ] `dotnet build IOViewer.slnx -c Release` has no warnings - [ ] `dotnet test IOViewer.slnx -c Release` passes - [ ] Generated HTML and JSON for the same inputs are unchanged, or the differences are listed and intended -- [ ] No customer models, personal data, vendor code or third-party JavaScript is added +- [ ] No customer models, personal data or vendor code is added; new third-party code is listed in `THIRD-PARTY-NOTICES.md` diff --git a/.github/dependabot.yml b/.github/dependabot.yml index a846233..39f7291 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -8,3 +8,7 @@ updates: directory: / schedule: interval: weekly + - package-ecosystem: npm + directory: /src/IOViewer/Viewer + schedule: + interval: weekly diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cb23daf..799a077 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,3 +42,29 @@ jobs: dotnet-version: | 8.0.x - run: dotnet build src/IOViewer.Cli/IOViewer.Cli.csproj -c Release + + viewer: + runs-on: ubuntu-latest + defaults: + run: + working-directory: src/IOViewer/Viewer + steps: + - uses: actions/checkout@v7 + # The e2e global setup exports the fixtures with the CLI (`dotnet run`). + - uses: actions/setup-dotnet@v6 + with: + global-json-file: global.json + dotnet-version: | + 8.0.x + - uses: actions/setup-node@v7 + with: + node-version: 24 + cache: npm + cache-dependency-path: src/IOViewer/Viewer/package-lock.json + - run: npm ci + - run: npm run build + # dist/viewer.js is committed and embedded in the library: it must match the sources. + - run: git diff --exit-code dist/ + - run: npm test + - run: npx playwright install --with-deps chromium + - run: npm run e2e diff --git a/.gitignore b/.gitignore index 5f98066..ce40f62 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,8 @@ __pycache__/ # Documentation site (DocFX output and generated API pages) _site/ docs/api/ + +# Viewer toolchain (npm, Playwright) +node_modules/ +test-results/ +playwright-report/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 2752498..cc1f7cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,21 +7,86 @@ API between minor releases. ## [Unreleased] ### Fixed +- `JsonViewerImporter` keeps grid lines, 4D tasks with their attached element ids and child tasks, clash results, + element properties and element mesh indices; they were silently dropped on load. - The `IOViewer` package now includes the XML documentation file, so existing API comments show in IntelliSense. - The release workflow runs only for version tags again, fails on any `gh release` error and no longer overwrites assets of an existing release. - `tools/pack-release.ps1` fails when creating the source archive fails. - Documentation no longer claims `.mtl` material support or mesh simplification, neither of which exists. - The logo on the documentation site no longer covers half of the page; it is sized to the navigation bar. +- `GlbViewerImporter` and `ObjViewerImporter` create elements: one `ViewerElement` per GLB node that draws a mesh + (category `GLB`) and per OBJ object or group (category `OBJ`), so converted models have a model tree and can be + selected, hidden and isolated. Each OBJ mesh keeps only the vertices its faces use instead of every vertex read so + far, and a face that references a vertex not yet read throws `InvalidDataException` on load. +- A document without elements (built through the API or loaded from older JSON) can still be listed, selected, hidden + and isolated in the HTML viewer: meshes that share an `ElementId` appear as one element with that id, which 4D tasks + and clashes match, and each other mesh appears on its own with its mesh id and name, as in 0.1.0. The document itself + is not changed. ### Added +- A new HTML viewer built on three.js 0.186.1, embedded in the page, which still works offline without network requests: + - floating panels (model tree with search, properties, views, sections, clashes, 4D, linked files), a toolbar with + labelled groups, a status bar with selected and total counts, and a context menu; + - Navisworks-style mouse: orbit around the point under the cursor, pan with the middle or right button, zoom to the + cursor, Ctrl+click, window (left to right) and crossing (right to left) box selection; hotkeys H, I, Shift+H, F, Esc; + - a view cube drawn from the camera orientation, with face clicks, 90° arrows and a home button; + - up to six section planes moved and rotated with an in-scene gizmo, or a section box that keeps or cuts its inside, + with face handles, a centre handle and a box around the selection; + - distance measurement, display modes (materials, textures, white, wireframe), grid lines, global opacity, + perspective or orthographic camera and fullscreen; + - saved viewpoints with a tour, a clash list that flies to the clash and selects both elements, a 4D schedule player + and linked-file visibility; + - Russian or English interface, chosen from the browser language; + - a message instead of a blank page when the browser has no WebGL 2, the model data cannot be read, the graphics + context is lost or the model has more objects than the GPU can address. +- Shared geometry: `ViewerDocument.Geometries`, `ViewerGeometry`, `ViewerMesh.GeometryId`, `ViewerMesh.Transform` and + the `Transform3d` affine transform. The HTML exporter writes each shared geometry once and also writes meshes that + are exact translated copies of each other once. +- `ViewerClashResult.Element1Id` and `Element2Id`. The viewer selects clashing elements by Id, and by name only when the + name is unique. +- The HTML exporter writes the projection of saved viewpoints, so orthographic viewpoints open orthographic. +- `THIRD-PARTY-NOTICES.md` with the three.js license, included in the NuGet packages and the release archives. +- A CI job for the viewer: it rebuilds the bundle, checks that the committed `dist/viewer.js` matches the sources and + runs the unit and Playwright end-to-end tests. Dependabot also updates the viewer's npm packages. - Links to the DeepWiki overview of the repository in the README and on the documentation site. ### Changed +- `GlbViewerImporter` keeps node transforms and shared meshes (one `ViewerGeometry` per mesh primitive, one + `ViewerMesh` per node that uses it) and converts glTF's Y-up to the viewer's Z-up, so GLB models no longer lie on + their side. A node hierarchy that is not a tree (a node referenced twice, or a cycle) now throws + `InvalidDataException`. +- `HtmlViewerExporter` throws `InvalidDataException` for indices outside a mesh's or geometry's vertices, a + `GeometryId` that is not in the document, a duplicate geometry id or a mesh with both a `GeometryId` and its own + vertices, instead of writing broken geometry. `Save` checks the data before it opens the file, so a failed export + leaves an existing file as it was, and the CLI reports such input with exit code 3. +- `HtmlViewerOptions.MaxMeshVertices` defaults to no limit (was 5,000,000) and no longer cuts the model silently: when a + lower limit leaves meshes out, the viewer shows a banner with the number of meshes written. +- The HTML payload uses format 3: one base64 binary block, positions quantized to 16-bit steps of 2⁻¹⁰ m (error at most + 0.49 mm, 0.85 mm for rotated shared geometry) and octahedral 16-bit normals. Geometry larger than about 64 m, and + shared geometry that any instance scales up, stays 32-bit float. For a real 746 MB IFC water-supply model (via GLB) + the page is 44.4 MB, about the size 0.1.0 wrote (44.1 MB) although it now also carries normals; with + 32-bit float positions and normals it would be 57.5 MB. The HTML is written to the output stream as it is produced. +- The viewer merges geometry into spatial chunks per material and keeps per-object state (hidden, selected, tint) in a + texture, so hide, isolate, selection and 4D colouring never rebuild geometry. When merged data would exceed + 384 MiB, repeated shapes are drawn as instanced copies in spatially bounded cells instead. +- Measured on an integrated 2-CU AMD GPU: a model of 200,000 elements and 19.2 million triangles opens in about 1.3 s, + orbits at 30 fps at full detail (60 fps on an RTX 4080), draws a full frame in 30.3 ms and shows a click, hide or + show all in about 52 ms, using about 327 MB of GPU memory. A model of 1,000,000 elements opens in about 2.2 s, draws + a full frame in 0.21 s and uses about 88 MB of GPU memory; it orbits at 20 fps (95th percentile), picking an object + takes about 22 ms and a still frame costs about 23 ms of CPU time. +- Transparent objects are sorted back to front per chunk or cell, not per object. +- Package descriptions and documentation say "no external dependencies, works offline" instead of "zero-dependency". - Tests run on both .NET 8 and .NET 10. - `global.json`, CI workflows and repository settings follow the shared IOExtend conventions: SDK 10.0.100 without prerelease, NuGet vulnerability audit, Dependabot, LF line endings, issue and pull request templates. - Copyright holder is `mahach666` in the license, package metadata and README. - README and documentation state that IOViewer is not affiliated with Autodesk. - `SECURITY.md` and `CONTRIBUTING.md` describe supported versions, security scope, development setup and the clean-room policy. +- `CONTRIBUTING.md` describes the viewer toolchain (Node.js 24, `npm ci`, `npm run build`, `npm test`, `npm run e2e`, + manual `npm run perf`). + +### Removed +- The turntable (automatic 360° rotation) of the old viewer. +- The previous hand-written WebGL viewer template, replaced by the three.js viewer. ## [0.1.0] - 2026-11-11 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4c2fb99..2055bc3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -37,12 +37,42 @@ The library is in `src/IOViewer`, the CLI in `src/IOViewer.Cli`, and tests in `tests/IOViewer.Tests`. Tests use small synthetic models built in code. The repository ships no customer models. +### Viewer + +The HTML viewer is a JavaScript project in `src/IOViewer/Viewer` (three.js, +bundled with esbuild). Its built script `dist/viewer.js` is committed and +embedded in the library, so .NET work does not need Node.js. To change the +viewer, install Node.js 24 and run from `src/IOViewer/Viewer`: + +```sh +npm ci # exact versions from package-lock.json +npm run build # rebuilds dist/viewer.js +npm test # unit tests (node --test) +npm run e2e # builds, exports the fixtures with the CLI and runs Playwright (Chromium) +``` + +Commit `dist/viewer.js` together with the source change: CI rebuilds it and +fails when it differs from the committed file. The end-to-end tests need the +.NET SDK and a Chromium for Playwright (`npx playwright install chromium`). +Fixtures are in `tests/fixtures/viewer`. + +`npm run perf` runs the manual performance harness in a visible Chromium +window (for example `PERF_LADDER=25000,200000` measures synthetic models made +by `tools/SyntheticModel`); its settings are described at the top of +`test/perf/orbit.perf.js`. It is not part of CI; include its numbers in the +pull request when a change can affect rendering speed. npm dependencies are +pinned to exact versions. When updating three.js (for example a Dependabot pull +request), also rebuild and commit `dist/viewer.js` and update the version in +`THIRD-PARTY-NOTICES.md`. + ## Clean-room policy - Write all code independently. Do not use decompiled or leaked source code, proprietary headers, vendor SDKs or code copied from other viewers. -- Do not add third-party JavaScript to the generated HTML. The viewer runs on - plain WebGL and must stay self-contained and offline. +- The only third-party JavaScript in the generated HTML is three.js (MIT), + listed in `THIRD-PARTY-NOTICES.md`. Discuss any other runtime dependency + first and add its license there. The page must stay self-contained and + work offline: no CDN, web fonts or network requests. - Do not add customer models, confidential data, vendor files or binaries to the repository. Keep private test files outside version control. - Do not put personal data in code, documentation, test data or commit diff --git a/Directory.Build.props b/Directory.Build.props index a543d01..e761230 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -18,7 +18,7 @@ mahach666 Copyright (c) 2026 mahach666 IOViewer - Fast, pure managed .NET library and CLI tool for generating standalone, zero-dependency interactive 3D WebGL/HTML BIM & CAD model viewers. + Fast, pure managed .NET library and CLI tool for generating standalone interactive 3D WebGL/HTML BIM & CAD model viewers with no external dependencies that work offline. en MIT https://github.com/IOExtend/IOViewer @@ -32,6 +32,7 @@ + diff --git a/README.md b/README.md index 67a6a3d..61b709b 100644 --- a/README.md +++ b/README.md @@ -10,24 +10,45 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg?style=flat-square)](https://opensource.org/licenses/MIT) [![Platform](https://img.shields.io/badge/.NET-8.0%20%7C%2010.0-512BD4?style=flat-square&logo=dotnet)](https://dotnet.microsoft.com/) -IOViewer is a fast, 100% managed .NET library and command line tool that generates standalone, zero-dependency interactive 3D WebGL / HTML BIM & CAD model viewers from code, glTF/GLB, Wavefront OBJ, or JSON. Geometry read by sibling libraries ([RvtIO](https://github.com/IOExtend/RvtIO), [IfcIO](https://github.com/IOExtend/IfcIO), and [NwcIO](https://github.com/IOExtend/NwcIO)) can be passed in by building a `ViewerDocument` in code; IOViewer has no direct dependency on them. +IOViewer is a fast, 100% managed .NET library and command line tool that generates standalone interactive 3D WebGL / HTML BIM & CAD model viewers from code, glTF/GLB, Wavefront OBJ, or JSON. Geometry read by sibling libraries ([RvtIO](https://github.com/IOExtend/RvtIO), [IfcIO](https://github.com/IOExtend/IfcIO), and [NwcIO](https://github.com/IOExtend/NwcIO)) can be passed in by building a `ViewerDocument` in code; IOViewer has no direct dependency on them. -The generated HTML file is completely self-contained, offline-ready, and works in any modern browser without external servers or cloud dependencies. +The generated HTML is a single file with no external dependencies that works offline: it opens from disk (`file://`) in a current browser with WebGL 2 and makes no network requests. The viewer is built on [three.js](https://threejs.org/), which is embedded in the file (see [THIRD-PARTY-NOTICES.md](THIRD-PARTY-NOTICES.md)). Built clean-room under the [IOExtend](https://github.com/IOExtend) organization. ## What it does -- Generates standalone, zero-dependency single-file interactive 3D WebGL HTML viewers (`.html`). -- Packs binary geometry into Base64 little-endian `Float32Array` positions and `Uint32Array`/`Uint16Array` indices for ~3x smaller payload than JSON. -- Shifts model coordinates Relative-To-Center (RTC) to eliminate WebGL 32-bit floating point precision jitter on large georeferenced coordinates. -- Features an interactive 3D **ViewCube** with orientation faces and directional navigation arrows for instant orthographic alignment. -- Provides comprehensive sectioning: arbitrary cutting planes with in-scene 3D normal gizmo manipulators, plus 6-sided Section Box volumes with fit-to-selection. -- Supports multiple display modes: Material colors & opacity, Diffuse Textures with real-world UV box projection, White/Clay architectural model, Wireframe, and global scene opacity. -- Manages BIM hierarchies with Category trees, element counters, linked files, instant search, and instance/type property inspection. -- Includes coordination tools: 3D spatial grids projection, interactive 3D point-to-point measurement, 4D TimeLiner schedule simulation player, and clash detection inspection. -- Drives smooth camera animations: cubic Ease-In-Out transitions, 360° Turntable presentation orbit, and Saved Viewpoints Walkthrough Tours. -- Imports standard 3D formats out of the box: binary glTF 2.0 (`.glb`), Wavefront OBJ (`.obj`), and JSON snapshots. +- Writes one self-contained HTML file per model; the viewer, its styles and the model data are all inlined. +- Stores geometry once per shape: meshes can share a `ViewerGeometry` placed by a `Transform3d`, and the exporter also finds meshes that are exact translated copies of each other. +- Packs geometry compactly: positions quantized to 16-bit steps of 2⁻¹⁰ m (error at most 0.49 mm, 0.85 mm for rotated shared geometry; geometry larger than about 64 m, or shared geometry that an instance scales up, stays 32-bit float), octahedral 16-bit normals, 16- or 32-bit indices, all in one base64 block. +- Shifts coordinates relative to the model centre (RTC), so georeferenced models with coordinates around 10⁶ m do not jitter. +- Draws large models with few draw calls: geometry is merged into spatial chunks per material (very large models draw their repeated shapes as instanced copies instead), and hiding, selection and colouring are per-object data, so they never rebuild geometry. +- Viewer interface in Russian or English, chosen from the browser language: + - model tree by category with search, a properties panel and a selected/total counter; + - toolbar with labelled groups: navigation, sections, measure, display and view; + - context menu: properties, hide, isolate, show all, zoom to object, section box around the object, copy Id; + - view cube that follows the camera exactly, with face clicks, 90° arrows and a home button; + - section planes (up to six, moved and rotated with an in-scene gizmo) or a section box (keep inside or cut inside, face handles, move by the centre, box around the selection); + - point-to-point distance measurement, display modes (materials, textures, white, wireframe), grid lines and a global opacity slider; + - saved viewpoints with a viewpoint tour, clash results, a 4D schedule player and linked-file visibility, each shown only when the document has that data. +- Imports binary glTF 2.0 (`.glb`), Wavefront OBJ (`.obj`), and JSON snapshots. + +### Mouse and keyboard + +| Input | Action | +| --- | --- | +| Left click | Select the object under the cursor; click on empty space clears the selection | +| Ctrl + left click | Add or remove an object from the selection | +| Left drag | Orbit around the point under the cursor (the centre of the visible model over empty space) | +| Shift + left drag | Box select: left to right selects objects fully inside, right to left selects objects the box touches | +| Middle or right drag | Pan | +| Wheel | Zoom towards the cursor | +| Right click | Context menu | +| H / I / Shift+H | Hide selected / isolate selected / show all | +| F | Zoom to the selection, or fit the whole model when nothing is selected | +| Esc | Close the open menu or popup, or clear the measurement; otherwise clear the selection | + +The model's up axis is +Z. ## Install @@ -129,6 +150,11 @@ The documentation site with API references and guides is at +- License: MIT + +```text +The MIT License + +Copyright © 2010-2026 three.js authors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +``` diff --git a/docs/getting-started.md b/docs/getting-started.md index 90b45b8..f1e9647 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -55,3 +55,42 @@ doc.Meshes.Add(mesh); // Export to self-contained HTML HtmlViewerExporter.Save(doc, "tower_viewer.html"); ``` + +## Sharing geometry between meshes + +Repeated shapes, such as identical fittings or columns, can be stored once in `ViewerDocument.Geometries` and placed by +each mesh with `GeometryId` and `Transform`. The HTML file then holds the vertices once and the viewer draws the copies. + +```csharp +doc.Geometries.Add(new ViewerGeometry +{ + Id = 7, + Vertices = [new(0, 0, 0), new(0.3, 0, 0), new(0, 0.3, 0)], + Indices = [0, 1, 2] +}); + +for (int i = 0; i < 100; i++) +{ + doc.Meshes.Add(new ViewerMesh + { + Id = 100 + i, + GeometryId = 7, + Transform = Transform3d.Translate(new(i * 6.0, 0, 0)), + Color = new RgbaColor(120, 120, 120) + }); +} +``` + +`GlbViewerImporter` fills these properties from glTF nodes and meshes. Meshes with their own `Vertices` keep working as +before; the exporter also writes exact translated copies of such meshes once. + +## Using the viewer + +The model's up axis is +Z. The interface is in Russian when the browser language is Russian and in English otherwise. + +- Left click selects, Ctrl+click adds or removes, a click on empty space clears the selection. +- Left drag orbits around the point under the cursor; middle or right drag pans; the wheel zooms towards the cursor. +- Shift+left drag selects with a box: left to right selects objects fully inside, right to left selects objects it touches. +- A right click without moving opens the context menu. +- H hides the selection, I isolates it, Shift+H shows everything, F zooms to the selection (or fits the model), Esc + closes menus or clears the selection. diff --git a/docs/index.md b/docs/index.md index e0e2ab9..2a616b0 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,27 +4,27 @@ title: IOViewer # IOViewer -**IOViewer** is a fast, 100% managed .NET library and CLI tool for generating standalone, zero-dependency interactive 3D WebGL / HTML BIM & CAD model viewers directly from code or file formats (glTF/GLB, Wavefront OBJ, and JSON). +**IOViewer** is a fast, 100% managed .NET library and CLI tool for generating standalone interactive 3D WebGL / HTML BIM & CAD model viewers directly from code or file formats (glTF/GLB, Wavefront OBJ, and JSON). -The generated HTML file is completely self-contained, offline-ready, and works in any modern desktop or mobile browser without requiring external servers, WebGL plug-ins, or cloud connectivity. +The generated HTML is a single file with no external dependencies that works offline: it opens from disk in a current browser with WebGL 2, without servers, plug-ins or network requests. The viewer is built on three.js, embedded in the file. ## Features -- **Packed Binary Base64 Geometry**: Little-endian `Float32Array` positions and `Uint32Array`/`Uint16Array` indices encoded in base64, delivering ~3x smaller HTML payload and instant GPU buffer uploads. -- **Relative-To-Center (RTC) Coordinates**: Model coordinates centered around $(0, 0, 0)$ with world offset, eliminating 32-bit WebGL floating point precision jitter on large georeferenced coordinates. -- **3D ViewCube**: Interactive orientation cube with directional navigation arrows for instant orthographic alignment. -- **Sectioning & Clipping**: - - Arbitrary section planes with interactive in-scene 3D normal gizmo manipulator. - - 6-sided Section Box volume with bounding wireframe, scaling slider, invert mode, and fit-to-selection. -- **Display Modes**: Material colors with transparency, Diffuse Textures with real-world UV box projection, White/Clay architectural model, Wireframe, and global scene opacity slider. -- **BIM Data Hierarchy & Navigation**: Category tree with element counts, Linked files panel, instant search with autocomplete and camera focus, properties inspector panel, and selection actions (Isolate, Hide, Show All). -- **Coordination & Spatial Tools**: 3D spatial grids projection, interactive 3D point-to-point measurement, 4D TimeLiner schedule simulation player with status coloring, and clash detection inspection. -- **Camera Animations**: Cubic Ease-In-Out transitions, Turntable 360° presentation orbit, and Saved Viewpoints Walkthrough Tour player. +- **Compact geometry**: shared geometry stored once and placed by transforms; 16-bit quantized positions (error at most 0.49 mm), octahedral normals and 16- or 32-bit indices in one base64 block. +- **Relative-To-Center (RTC) coordinates**: no precision jitter on georeferenced models. +- **Large models**: geometry merged into spatial chunks per material with few draw calls; hiding, selection and colouring are per-object data, not geometry rebuilds. +- **Navigation**: orbit around the point under the cursor, pan with the middle or right button, zoom to the cursor, perspective or orthographic camera, and a view cube that follows the camera. +- **Selection**: click, Ctrl+click, window and crossing box selection, context menu, hotkeys (H, I, Shift+H, F, Esc), model tree by category with search and a properties panel. +- **Sections**: up to six section planes moved and rotated with an in-scene gizmo, or a section box that keeps or cuts its inside, with face handles and a box around the selection. +- **Tools**: point-to-point distance, display modes (materials, textures, white, wireframe), grid lines and global opacity. +- **Coordination data**: saved viewpoints and a viewpoint tour, clash results, a 4D schedule player and linked-file visibility. +- **Russian and English interface**, chosen from the browser language. ## Where to go next - [Getting started](getting-started.md): install the package or CLI tool and generate your first 3D viewer. - [Public API](public-api.md): supported surface and key entry points of the library. +- [Third-party notices](https://github.com/IOExtend/IOViewer/blob/main/THIRD-PARTY-NOTICES.md): the three.js license. - [Source on GitHub](https://github.com/IOExtend/IOViewer) and [DeepWiki](https://deepwiki.com/IOExtend/IOViewer) for an automatically generated overview of the repository. IOViewer is not affiliated with, endorsed by or sponsored by Autodesk. Autodesk, Revit, Navisworks, ViewCube and TimeLiner are trademarks of Autodesk, Inc. diff --git a/docs/public-api.md b/docs/public-api.md index e2291a0..720d85a 100644 --- a/docs/public-api.md +++ b/docs/public-api.md @@ -6,24 +6,27 @@ ### Document & Model (`IOViewer.Model`) -- `ViewerDocument`: The root container holding elements, meshes, materials, textures, viewpoints, grids, section box, and 4D schedules. +- `ViewerDocument`: The root container holding elements, meshes, shared geometries, materials, textures, viewpoints, grids, section planes and box, clash tests, linked files, and 4D schedules. - `ViewerElement`: Represents a BIM element (Revit element, IFC product, Navisworks node) with properties and associated mesh parts. -- `ViewerMesh`: Triangulated 3D mesh geometry with vertex coordinates, triangle indices, normals, colors, and bounding box. -- `ViewerMaterial`: Visual appearance definition including diffuse color, opacity, metalness, and roughness. -- `ViewerTexture`: Embedded raster texture data with real-world meter scaling for UV box projection. -- `ViewerViewpoint`: Saved camera orientation, target, field of view, and projection mode. +- `ViewerMesh`: Triangulated 3D mesh geometry with vertex coordinates, triangle indices, normals, colors, and bounding box. A mesh can instead draw a shared `ViewerGeometry` (`GeometryId`) and place it with `Transform`. +- `ViewerGeometry`: Triangle geometry stored once in `ViewerDocument.Geometries` and drawn by every mesh that references its `Id`. +- `Transform3d`: Affine transform stored by columns (`XAxis`, `YAxis`, `ZAxis`, `Translation`), with `Identity`, `Translate`, `Apply` and `Then`. +- `ViewerMaterial`: Visual appearance definition including diffuse color, opacity, metalness, and roughness. The HTML viewer uses the color, opacity and texture. +- `ViewerTexture`: Embedded raster texture data with its real-world size in meters, which scales the mesh UVs. +- `ViewerViewpoint`: Saved camera position, target, field of view, and projection mode (perspective or orthographic). - `ViewerSectionPlane`: Cutting plane defined by axis or normal vector with offset and flip direction. -- `ViewerSectionBox`: 6-sided 3D clipping volume with fit-to-selection capability. +- `ViewerSectionBox`: 6-sided 3D clipping volume. Section planes and boxes are kept in JSON snapshots; the HTML viewer does not apply them when it opens. - `ViewerTimeLinerSchedule`: 4D construction schedule tasks with planned and actual dates, costs, and progress. -- `ViewerClashTest`: Clash detection test conflict items with 3D intersection points and status. +- `ViewerClashTest`: Clash detection test conflict items with 3D intersection points and status. `ViewerClashResult.Element1Id` and `Element2Id` optionally identify the clashing elements; without them the viewer matches an element by name only when the name is unique. ### Exporters (`IOViewer.Exporters`) -- `HtmlViewerExporter`: Generates self-contained, offline-ready 3D HTML viewers using packed Base64 binary buffers. +- `HtmlViewerExporter`: Generates self-contained, offline-ready 3D HTML viewers using packed Base64 binary buffers. The page is written to the output stream as it is produced. A document without elements is listed by mesh `ElementId`, or per mesh without one, so its meshes can still be selected, hidden and isolated. +- `HtmlViewerOptions`: Title, which data to include (properties, viewpoints, grids, 4D schedule, clashes), the initial display mode (`InitialWireframe`, `InitialWhiteMode`), and `MaxMeshVertices`, which is unlimited by default; when a lower limit leaves meshes out, the viewer shows how many were written. - `JsonViewerExporter`: Exports `ViewerDocument` snapshots into structured JSON. ### Importers (`IOViewer.Importers`) -- `GlbViewerImporter`: Reads binary glTF 2.0 (`.glb`) files into `ViewerDocument`. -- `ObjViewerImporter`: Reads Wavefront OBJ geometry into `ViewerDocument`. Material (`.mtl`) files are not read. +- `GlbViewerImporter`: Reads binary glTF 2.0 (`.glb`) files into `ViewerDocument`. Each glTF mesh primitive becomes one `ViewerGeometry` and each node that uses it a `ViewerMesh` with the node's world transform, converted from glTF's Y-up to the viewer's Z-up. Each such node also becomes a `ViewerElement` (category `GLB`, named after the node, else the mesh) that all its primitives belong to. A node hierarchy that is not a tree (a node referenced twice or a cycle) is rejected with `InvalidDataException`. +- `ObjViewerImporter`: Reads Wavefront OBJ geometry into `ViewerDocument` as is, without changing the up axis. Each object or group (`o`/`g`) becomes one `ViewerMesh` holding only the vertices its faces use and one `ViewerElement` (category `OBJ`) with its name. Material (`.mtl`) files are not read. - `JsonViewerImporter`: Reads JSON document snapshots into `ViewerDocument`. diff --git a/src/IOViewer.Cli/IOViewer.Cli.csproj b/src/IOViewer.Cli/IOViewer.Cli.csproj index 6eba71f..c13d185 100644 --- a/src/IOViewer.Cli/IOViewer.Cli.csproj +++ b/src/IOViewer.Cli/IOViewer.Cli.csproj @@ -2,7 +2,7 @@ IOViewer.Cli - Command line tool for generating standalone, zero-dependency interactive 3D WebGL/HTML BIM & CAD model viewers. + Command line tool for generating standalone interactive 3D WebGL/HTML BIM & CAD model viewers with no external dependencies that work offline. Exe true ioviewer diff --git a/src/IOViewer.Cli/Program.cs b/src/IOViewer.Cli/Program.cs index 600ffaa..cf3cfab 100644 --- a/src/IOViewer.Cli/Program.cs +++ b/src/IOViewer.Cli/Program.cs @@ -13,8 +13,9 @@ internal static class Program Usage: ioviewer [output.html] [options] ioviewer --help | --version - Generates standalone, zero-dependency interactive 3D WebGL / HTML BIM & CAD - model viewers from 3D models (glTF/GLB, Wavefront OBJ, JSON snapshots). + Generates standalone interactive 3D WebGL / HTML BIM & CAD model viewers + from 3D models (glTF/GLB, Wavefront OBJ, JSON snapshots). The HTML file has + no external dependencies and works offline. Arguments: Path to input 3D model (.glb, .obj, or .json). @@ -23,10 +24,10 @@ model viewers from 3D models (glTF/GLB, Wavefront OBJ, JSON snapshots). Options: -o, --output Destination path for exported HTML or JSON file. - --title Title displayed in viewer header and HUD. + --title <title> Page title shown in the browser tab. --wireframe Enable wireframe display mode by default. --white-mode Enable white/clay architectural rendering mode by default. - --no-properties Exclude BIM metadata and element properties to minimize payload. + --no-properties Exclude element properties to reduce the file size. --no-grids Exclude spatial grid lines. --no-timeliner Exclude 4D TimeLiner schedule simulation. --json <file.json> Export structured model snapshot to JSON. @@ -197,7 +198,15 @@ public static int Main(string[] args) } else { - HtmlViewerExporter.Save(doc, outputPath, options); + try + { + HtmlViewerExporter.Save(doc, outputPath, options); + } + catch (Exception ex) when (ex is InvalidDataException or ArgumentException) + { + Console.Error.WriteLine($"Error: Failed to export '{inputPath}': {ex.Message}"); + return 3; + } var fi = new FileInfo(outputPath); Console.WriteLine($"Exported standalone 3D HTML viewer to '{outputPath}' ({fi.Length / 1024:N0} KB, {doc.Meshes.Count} meshes) in {sw.ElapsedMilliseconds} ms."); } diff --git a/src/IOViewer/Exporters/BinaryBlobBuilder.cs b/src/IOViewer/Exporters/BinaryBlobBuilder.cs new file mode 100644 index 0000000..82aabaa --- /dev/null +++ b/src/IOViewer/Exporters/BinaryBlobBuilder.cs @@ -0,0 +1,84 @@ +using System.Buffers; +using System.Buffers.Text; +using System.Runtime.InteropServices; +using System.Text.Json; + +namespace IOViewer.Exporters; + +/// <summary>A typed array inside the binary blob: byte offset, element count and element type.</summary> +internal readonly record struct BlobView(long Offset, int Count, string Type) +{ + public void WriteTo(Utf8JsonWriter writer) + { + writer.WriteStartObject(); + writer.WriteNumber("o", Offset); + writer.WriteNumber("n", Count); + writer.WriteString("t", Type); + writer.WriteEndObject(); + } +} + +/// <summary> +/// Accumulates little-endian typed arrays, each aligned to 4 bytes, and streams them out as standard base64. +/// </summary> +internal sealed class BinaryBlobBuilder +{ + private readonly ArrayBufferWriter<byte> _buffer = new(); + + public BinaryBlobBuilder() + { + if (!BitConverter.IsLittleEndian) + { + throw new PlatformNotSupportedException("The viewer payload is written on little-endian platforms only."); + } + } + + public BlobView Add(ReadOnlySpan<float> values) => Add(MemoryMarshal.AsBytes(values), values.Length, "f32"); + + public BlobView Add(ReadOnlySpan<ushort> values) => Add(MemoryMarshal.AsBytes(values), values.Length, "u16"); + + public BlobView Add(ReadOnlySpan<short> values) => Add(MemoryMarshal.AsBytes(values), values.Length, "i16"); + + public BlobView Add(ReadOnlySpan<uint> values) => Add(MemoryMarshal.AsBytes(values), values.Length, "u32"); + + public BlobView Add(ReadOnlySpan<int> values) => Add(MemoryMarshal.AsBytes(values), values.Length, "i32"); + + /// <summary>Returns the bytes of a previously added array.</summary> + public ReadOnlySpan<byte> Bytes(BlobView view) + { + int size = view.Type is "u16" or "i16" ? 2 : 4; + return _buffer.WrittenSpan.Slice(checked((int)view.Offset), view.Count * size); + } + + /// <summary>Writes the whole blob to <paramref name="output"/> as base64 in chunks.</summary> + public void WriteBase64(Stream output) + { + const int chunk = 3 * 16 * 1024; + var source = _buffer.WrittenSpan; + var encoded = ArrayPool<byte>.Shared.Rent(Base64.GetMaxEncodedToUtf8Length(chunk)); + try + { + while (!source.IsEmpty) + { + var part = source[..Math.Min(chunk, source.Length)]; + Base64.EncodeToUtf8(part, encoded, out _, out int written, isFinalBlock: part.Length == source.Length); + output.Write(encoded, 0, written); + source = source[part.Length..]; + } + } + finally + { + ArrayPool<byte>.Shared.Return(encoded); + } + } + + private BlobView Add(ReadOnlySpan<byte> bytes, int count, string type) + { + int padding = (4 - _buffer.WrittenCount % 4) % 4; + _buffer.GetSpan(padding)[..padding].Clear(); + _buffer.Advance(padding); + var view = new BlobView(_buffer.WrittenCount, count, type); + _buffer.Write(bytes); + return view; + } +} diff --git a/src/IOViewer/Exporters/GeometryQuantizer.cs b/src/IOViewer/Exporters/GeometryQuantizer.cs new file mode 100644 index 0000000..74de381 --- /dev/null +++ b/src/IOViewer/Exporters/GeometryQuantizer.cs @@ -0,0 +1,81 @@ +using IOViewer.Model; + +namespace IOViewer.Exporters; + +/// <summary> +/// Geometry encodings of the viewer payload format 3: positions as unsigned 16-bit steps of <see cref="Step"/> metres +/// from a grid-aligned origin, normals as octahedral signed-normalized 16-bit pairs. +/// </summary> +internal static class GeometryQuantizer +{ + /// <summary>Grid step in metres (2^-10): exact in float32 and double, at most 0.49 mm of rounding.</summary> + public const double Step = 1.0 / 1024; + + /// <summary>The grid point at or below <paramref name="v"/>.</summary> + public static double Snap(double v) => Math.Floor(v / Step) * Step; + + /// <summary> + /// Quantizes <paramref name="v"/> to <c>q = round((v - origin) / Step)</c> per coordinate, with + /// <c>origin = Snap(min)</c>, so <c>origin + q·Step</c> is within <see cref="Step"/>/2 of each input coordinate. + /// Returns false (and an empty <paramref name="q"/>) when an offset needs more than 65535 steps (about 64 m) or a + /// coordinate is not finite. + /// </summary> + public static bool TryQuantize(ReadOnlySpan<Vector3d> v, out Vector3d origin, out ushort[] q) + { + double minX = double.PositiveInfinity, minY = double.PositiveInfinity, minZ = double.PositiveInfinity; + foreach (var p in v) + { + minX = Math.Min(minX, p.X); + minY = Math.Min(minY, p.Y); + minZ = Math.Min(minZ, p.Z); + } + origin = v.IsEmpty ? Vector3d.Zero : new Vector3d(Snap(minX), Snap(minY), Snap(minZ)); + q = []; + var steps = new ushort[v.Length * 3]; + for (int i = 0; i < v.Length; i++) + { + if (!TryStep(v[i].X - origin.X, out steps[i * 3]) + || !TryStep(v[i].Y - origin.Y, out steps[i * 3 + 1]) + || !TryStep(v[i].Z - origin.Z, out steps[i * 3 + 2])) + { + return false; + } + } + q = steps; + return true; + } + + private static bool TryStep(double offset, out ushort step) + { + double r = Math.Round(offset / Step, MidpointRounding.AwayFromZero); + bool fits = r >= 0 && r <= ushort.MaxValue; // false for NaN as well + step = fits ? (ushort)r : (ushort)0; + return fits; + } + + /// <summary> + /// Octahedral encoding, two values per normal: <c>n /= |x|+|y|+|z|</c>; when z < 0, + /// <c>(x, y) = ((1-|y|)·sgn x, (1-|x|)·sgn y)</c> with sgn(0) = +1; then <c>round(clamp(·, -1, 1)·32767)</c>, + /// halves away from zero. A zero or non-finite normal has no direction and is written as (0, 0), i.e. +Z. + /// </summary> + public static short[] OctEncode(IReadOnlyList<Vector3d> n) + { + var result = new short[n.Count * 2]; + for (int i = 0; i < n.Count; i++) + { + var v = n[i]; + double sum = Math.Abs(v.X) + Math.Abs(v.Y) + Math.Abs(v.Z); + if (!(sum > 0) || double.IsInfinity(sum)) continue; + double x = v.X / sum, y = v.Y / sum; + if (v.Z < 0) (x, y) = ((1 - Math.Abs(y)) * Sign(x), (1 - Math.Abs(x)) * Sign(y)); + result[i * 2] = Snorm(x); + result[i * 2 + 1] = Snorm(y); + } + return result; + } + + private static double Sign(double v) => v < 0 ? -1 : 1; + + private static short Snorm(double v) => + (short)Math.Round(Math.Clamp(v, -1, 1) * short.MaxValue, MidpointRounding.AwayFromZero); +} diff --git a/src/IOViewer/Exporters/HtmlViewerExporter.cs b/src/IOViewer/Exporters/HtmlViewerExporter.cs index 8b71f7f..79e7368 100644 --- a/src/IOViewer/Exporters/HtmlViewerExporter.cs +++ b/src/IOViewer/Exporters/HtmlViewerExporter.cs @@ -1,6 +1,5 @@ -using System.Globalization; using System.Text; -using System.Text.Json; +using System.Text.RegularExpressions; using IOViewer.Model; namespace IOViewer.Exporters; @@ -15,8 +14,10 @@ public static void Save(ViewerDocument doc, string filePath, HtmlViewerOptions? ArgumentNullException.ThrowIfNull(doc); ArgumentNullException.ThrowIfNull(filePath); + // Validated before the file is opened, so invalid data never truncates an existing page. + var payload = ViewerPayloadWriter.Create(doc, options ?? new HtmlViewerOptions()); using var fs = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.None); - Export(doc, fs, options); + Write(payload, fs); } public static void Export(ViewerDocument doc, Stream stream, HtmlViewerOptions? options = null) @@ -24,1508 +25,62 @@ public static void Export(ViewerDocument doc, Stream stream, HtmlViewerOptions? ArgumentNullException.ThrowIfNull(doc); ArgumentNullException.ThrowIfNull(stream); - options ??= new HtmlViewerOptions(); - var jsonPayload = BuildJsonPayload(doc, options); - var html = Template.Replace("<!--__MODEL_DATA_JSON__-->", jsonPayload); - - using var writer = new StreamWriter(stream, Encoding.UTF8, bufferSize: 8192, leaveOpen: true); - writer.Write(html); - writer.Flush(); - } - - public static string ExportToString(ViewerDocument doc, HtmlViewerOptions? options = null) - { - using var ms = new MemoryStream(); - Export(doc, ms, options); - return Encoding.UTF8.GetString(ms.ToArray()); + Write(ViewerPayloadWriter.Create(doc, options ?? new HtmlViewerOptions()), stream); } - private static string BuildJsonPayload(ViewerDocument doc, HtmlViewerOptions options) + private static void Write(ViewerPayloadWriter payload, Stream stream) { - var bounds = doc.ComputeBoundingBox(); - var rtcOffset = bounds.IsEmpty ? Vector3d.Zero : bounds.Center; - - using var stream = new MemoryStream(); - using var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = false }); - - writer.WriteStartObject(); - writer.WriteString("title", options.Title); - writer.WriteString("author", doc.Author ?? "IOViewer"); - writer.WriteString("createdAt", doc.CreatedAt ?? DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture)); - - // Relative To Center (RTC) offset - writer.WriteStartArray("rtcOffset"); - writer.WriteNumberValue(rtcOffset.X); - writer.WriteNumberValue(rtcOffset.Y); - writer.WriteNumberValue(rtcOffset.Z); - writer.WriteEndArray(); - - // Bounding Box (in local RTC space) - writer.WriteStartObject("boundingBox"); - writer.WriteStartArray("min"); - writer.WriteNumberValue(bounds.Min.X - rtcOffset.X); - writer.WriteNumberValue(bounds.Min.Y - rtcOffset.Y); - writer.WriteNumberValue(bounds.Min.Z - rtcOffset.Z); - writer.WriteEndArray(); - writer.WriteStartArray("max"); - writer.WriteNumberValue(bounds.Max.X - rtcOffset.X); - writer.WriteNumberValue(bounds.Max.Y - rtcOffset.Y); - writer.WriteNumberValue(bounds.Max.Z - rtcOffset.Z); - writer.WriteEndArray(); - writer.WriteEndObject(); - - // Textures - writer.WriteStartArray("textures"); - foreach (var tex in doc.Textures) - { - writer.WriteStartObject(); - writer.WriteNumber("id", tex.Id); - writer.WriteString("mime", tex.MimeType); - writer.WriteString("data", tex.Base64Data); - writer.WriteNumber("sx", tex.ScaleX); - writer.WriteNumber("sy", tex.ScaleY); - writer.WriteEndObject(); - } - writer.WriteEndArray(); - - // Meshes (packed with Base64 Float32Array positions and Uint16/32Array indices) - writer.WriteStartArray("meshes"); - int totalVerts = 0; - foreach (var mesh in doc.Meshes) - { - if (totalVerts >= options.MaxMeshVertices) break; - - writer.WriteStartObject(); - writer.WriteNumber("id", mesh.Id); - writer.WriteString("name", string.IsNullOrEmpty(mesh.Name) ? $"Mesh {mesh.Id}" : mesh.Name); - if (mesh.ElementId.HasValue) - { - writer.WriteNumber("elementId", mesh.ElementId.Value); - } - - var col = mesh.Color ?? new RgbaColor(200, 200, 200); - writer.WriteStartArray("color"); - writer.WriteNumberValue(col.Rf); - writer.WriteNumberValue(col.Gf); - writer.WriteNumberValue(col.Bf); - writer.WriteNumberValue(col.Af); - writer.WriteEndArray(); - - writer.WriteNumber("opacity", mesh.Opacity); - if (mesh.TextureIndex.HasValue) - { - writer.WriteNumber("texIndex", mesh.TextureIndex.Value); - } - - // Packed Binary Positions (Float32Array base64) - writer.WriteString("positions", PositionsToBase64(mesh.Vertices, rtcOffset)); - - // Packed Binary Indices (Uint16Array or Uint32Array base64) - bool is16Bit = mesh.Vertices.Count <= 65535; - writer.WriteBoolean("idx16", is16Bit); - writer.WriteString("indices", is16Bit ? Indices16ToBase64(mesh.Indices) : Indices32ToBase64(mesh.Indices)); - - writer.WriteEndObject(); - totalVerts += mesh.Vertices.Count; - } - writer.WriteEndArray(); // meshes - - // Elements (BIM Metadata) - if (options.IncludeProperties && doc.Elements.Count > 0) + string shell = ReadResourceText("IOViewer.Viewer.index.html"); + + (string Token, Action<Stream> Write)[] placeholders = + [ + ("/*IOVIEWER:CSS*/", s => WriteText(s, EscapeEndTag(ReadResourceText("IOViewer.Viewer.viewer.css"), "style"))), + ("/*IOVIEWER:JS*/", s => WriteText(s, EscapeEndTag(ReadResourceText("IOViewer.Viewer.viewer.js"), "script"))), + ("<!--IOVIEWER:META-->", payload.WriteMeta), + ("<!--IOVIEWER:BIN-->", payload.WriteBin), + ("<!--IOVIEWER:PROPS-->", payload.WriteProps), + ]; + + var ordered = placeholders + .Select(p => (Index: shell.IndexOf(p.Token, StringComparison.Ordinal), p.Token, p.Write)) + .OrderBy(p => p.Index) + .ToList(); + if (ordered[0].Index < 0) { - writer.WriteStartArray("elements"); - foreach (var el in doc.Elements) - { - writer.WriteStartObject(); - writer.WriteNumber("id", el.Id); - writer.WriteString("name", el.Name); - writer.WriteString("category", string.IsNullOrEmpty(el.Category) ? "General" : el.Category); - writer.WriteString("type", el.TypeName); - writer.WriteString("level", el.Level); - if (!string.IsNullOrEmpty(el.LinkName)) - { - writer.WriteString("link", el.LinkName); - } - - writer.WriteStartArray("meshIndices"); - foreach (var mi in el.MeshIndices) - { - writer.WriteNumberValue(mi); - } - writer.WriteEndArray(); - - writer.WriteStartObject("properties"); - foreach (var group in el.Properties) - { - writer.WriteStartObject(group.Key); - foreach (var prop in group.Value) - { - writer.WriteString(prop.Key, prop.Value); - } - writer.WriteEndObject(); - } - writer.WriteEndObject(); - - writer.WriteEndObject(); - } - writer.WriteEndArray(); + throw new InvalidOperationException($"The embedded viewer page has no '{ordered[0].Token}' placeholder."); } - // Viewpoints - if (options.IncludeViewpoints && doc.Viewpoints.Count > 0) + int position = 0; + foreach (var (index, token, write) in ordered) { - writer.WriteStartArray("viewpoints"); - foreach (var vp in doc.Viewpoints) - { - writer.WriteStartObject(); - writer.WriteString("name", vp.Name); - writer.WriteStartObject("camera"); - writer.WriteStartArray("position"); - writer.WriteNumberValue(vp.Camera.Position.X - rtcOffset.X); - writer.WriteNumberValue(vp.Camera.Position.Y - rtcOffset.Y); - writer.WriteNumberValue(vp.Camera.Position.Z - rtcOffset.Z); - writer.WriteEndArray(); - writer.WriteStartArray("target"); - writer.WriteNumberValue(vp.Camera.Target.X - rtcOffset.X); - writer.WriteNumberValue(vp.Camera.Target.Y - rtcOffset.Y); - writer.WriteNumberValue(vp.Camera.Target.Z - rtcOffset.Z); - writer.WriteEndArray(); - writer.WriteEndObject(); - writer.WriteEndObject(); - } - writer.WriteEndArray(); + WriteText(stream, shell[position..index]); + write(stream); + position = index + token.Length; } - - // Grids - if (options.IncludeGrids && doc.Grids is not null && doc.Grids.Lines.Count > 0) - { - writer.WriteStartArray("grids"); - foreach (var line in doc.Grids.Lines) - { - writer.WriteStartObject(); - writer.WriteString("name", line.Name); - writer.WriteStartArray("start"); - writer.WriteNumberValue(line.Start.X - rtcOffset.X); - writer.WriteNumberValue(line.Start.Y - rtcOffset.Y); - writer.WriteNumberValue(line.Start.Z - rtcOffset.Z); - writer.WriteEndArray(); - writer.WriteStartArray("end"); - writer.WriteNumberValue(line.End.X - rtcOffset.X); - writer.WriteNumberValue(line.End.Y - rtcOffset.Y); - writer.WriteNumberValue(line.End.Z - rtcOffset.Z); - writer.WriteEndArray(); - writer.WriteEndObject(); - } - writer.WriteEndArray(); - } - - // 4D TimeLiner - if (options.IncludeTimeLiner && doc.TimeLiner is not null && doc.TimeLiner.Tasks.Count > 0) - { - writer.WriteStartObject("timeliner"); - writer.WriteString("name", doc.TimeLiner.Name); - writer.WriteString("start", doc.TimeLiner.GetOverallStart().ToString("yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture)); - writer.WriteString("end", doc.TimeLiner.GetOverallEnd().ToString("yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture)); - - writer.WriteStartArray("tasks"); - void WriteTask(ViewerTimeLinerTask t) - { - writer.WriteStartObject(); - writer.WriteString("id", t.Id); - writer.WriteString("name", t.Name); - writer.WriteString("type", t.TaskType.ToString().ToLowerInvariant()); - writer.WriteString("start", t.PlannedStart.ToString("yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture)); - writer.WriteString("end", t.PlannedEnd.ToString("yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture)); - if (t.PlannedCost.HasValue) writer.WriteNumber("cost", t.PlannedCost.Value); - writer.WriteNumber("progress", t.Progress); - - writer.WriteStartArray("attachedElements"); - foreach (var eid in t.AttachedElementIds) - { - writer.WriteNumberValue(eid); - } - writer.WriteEndArray(); - - writer.WriteEndObject(); - foreach (var child in t.Children) WriteTask(child); - } - - foreach (var task in doc.TimeLiner.Tasks) - { - WriteTask(task); - } - writer.WriteEndArray(); - writer.WriteEndObject(); - } - - // Clash Tests - if (options.IncludeClashes && doc.ClashTests.Count > 0) - { - writer.WriteStartArray("clashTests"); - foreach (var test in doc.ClashTests) - { - writer.WriteStartObject(); - writer.WriteString("name", test.Name); - writer.WriteStartArray("clashes"); - foreach (var clash in test.Clashes) - { - writer.WriteStartObject(); - writer.WriteString("name", clash.Name); - writer.WriteString("el1", clash.Element1Name); - writer.WriteString("el2", clash.Element2Name); - writer.WriteString("status", clash.Status.ToString()); - writer.WriteNumber("distance", clash.Distance); - writer.WriteStartArray("point"); - writer.WriteNumberValue(clash.Point.X - rtcOffset.X); - writer.WriteNumberValue(clash.Point.Y - rtcOffset.Y); - writer.WriteNumberValue(clash.Point.Z - rtcOffset.Z); - writer.WriteEndArray(); - writer.WriteEndObject(); - } - writer.WriteEndArray(); - writer.WriteEndObject(); - } - writer.WriteEndArray(); - } - - // Initial display state flags - writer.WriteBoolean("initialWireframe", options.InitialWireframe); - writer.WriteBoolean("initialWhiteMode", options.InitialWhiteMode); - - writer.WriteEndObject(); - writer.Flush(); - - return Encoding.UTF8.GetString(stream.ToArray()); + WriteText(stream, shell[position..]); + stream.Flush(); } - private static string PositionsToBase64(IReadOnlyList<Vector3d> vertices, Vector3d rtcOffset) + public static string ExportToString(ViewerDocument doc, HtmlViewerOptions? options = null) { - var floats = new float[vertices.Count * 3]; - int idx = 0; - for (int i = 0; i < vertices.Count; i++) - { - var v = vertices[i]; - floats[idx++] = (float)(v.X - rtcOffset.X); - floats[idx++] = (float)(v.Y - rtcOffset.Y); - floats[idx++] = (float)(v.Z - rtcOffset.Z); - } - - var bytes = new byte[floats.Length * sizeof(float)]; - Buffer.BlockCopy(floats, 0, bytes, 0, bytes.Length); - return Convert.ToBase64String(bytes); + using var ms = new MemoryStream(); + Export(doc, ms, options); + return Encoding.UTF8.GetString(ms.ToArray()); } - private static string Indices16ToBase64(IReadOnlyList<int> indices) - { - var shorts = new ushort[indices.Count]; - for (int i = 0; i < indices.Count; i++) - { - shorts[i] = (ushort)indices[i]; - } + private static void WriteText(Stream stream, string text) => stream.Write(Encoding.UTF8.GetBytes(text)); - var bytes = new byte[shorts.Length * sizeof(ushort)]; - Buffer.BlockCopy(shorts, 0, bytes, 0, bytes.Length); - return Convert.ToBase64String(bytes); - } + private static Stream OpenResource(string name) => + typeof(HtmlViewerExporter).Assembly.GetManifestResourceStream(name) + ?? throw new InvalidOperationException($"Embedded resource '{name}' is missing."); - private static string Indices32ToBase64(IReadOnlyList<int> indices) + private static string ReadResourceText(string name) { - var ints = new uint[indices.Count]; - for (int i = 0; i < indices.Count; i++) - { - ints[i] = (uint)indices[i]; - } - - var bytes = new byte[ints.Length * sizeof(uint)]; - Buffer.BlockCopy(ints, 0, bytes, 0, bytes.Length); - return Convert.ToBase64String(bytes); - } - - private const string Template = """ -<!DOCTYPE html> -<html lang="en"> -<head> -<meta charset="utf-8"> -<meta name="viewport" content="width=device-width, initial-scale=1.0"> -<title>IOViewer - 3D BIM & CAD Viewer - - - -
- - - - -
- - - - - - - - - -
- - -
- - - - -
-
FRONT
-
BACK
-
TOP
-
BOTTOM
-
LEFT
-
RIGHT
-
-
- - - - - - - - -
-
- - -
-
- - - - - - -
- -
- - -
-
- - --- - - -
-
- - -
IOViewer
-
- - - - - - -"""; + /// Escapes </tag in any letter case so inlined code cannot close its own <script> or <style> element. + internal static string EscapeEndTag(string text, string tag) => + Regex.Replace(text, " + /// Maximum total number of mesh vertices written to the viewer (each mesh counts the vertices it draws). + /// When the model exceeds it, meshes are written in order while they fit, the rest are left out, and the + /// viewer shows that the model was truncated. Defaults to (no limit). + /// + public int MaxMeshVertices { get; set; } = int.MaxValue; } diff --git a/src/IOViewer/Exporters/ViewerPayloadWriter.cs b/src/IOViewer/Exporters/ViewerPayloadWriter.cs new file mode 100644 index 0000000..dc0d946 --- /dev/null +++ b/src/IOViewer/Exporters/ViewerPayloadWriter.cs @@ -0,0 +1,724 @@ +using System.Globalization; +using System.Runtime.InteropServices; +using System.Text.Json; +using IOViewer.Model; + +namespace IOViewer.Exporters; + +/// +/// Builds the META, BIN and PROPS blocks of the HTML viewer (format 3) from a . +/// Geometry positions are written as u16 steps on the grid (with META q) when +/// they span at most 65535 steps, else as f32; normals as octahedral i16 pairs. +/// All input is validated and the binary blob is assembled by , before anything is written. +/// +internal sealed class ViewerPayloadWriter +{ + private const int FlushThreshold = 1 << 16; + private static readonly RgbaColor DefaultColor = new(200, 200, 200); + + private readonly ViewerDocument _doc; + private readonly HtmlViewerOptions _options; + private readonly BinaryBlobBuilder _blob = new(); + private readonly List _geometries = []; + private readonly Dictionary> _geometriesByHash = []; + private readonly Dictionary> _ownTwinsByHash = []; + private readonly List _materials = []; + private readonly Dictionary _materialIndex = []; + private readonly Dictionary _materialsById = []; + private readonly List _instanceGeometry = []; + private readonly List _instanceMaterial = []; + private readonly List _instanceElement = []; + private readonly List _instanceMatrix = []; + // The META elements: the document's, or, when it has none, stand-ins for the written meshes (0.1.0 listed meshes), + // so documents built without elements can still be listed, selected, hidden and isolated: one per mesh ElementId, + // grouping its meshes as 4D tasks and clashes refer to them, and one per mesh without an ElementId (written with + // "mesh": true, as its id is a mesh id that tasks and clashes must not match). + private readonly List _elements = []; + private readonly HashSet _meshStandIns = []; + private BoundingBox3d _bounds; + private Vector3d _rtcOffset; + private int _writtenMeshes; + private InstanceViews _instances; + + private ViewerPayloadWriter(ViewerDocument doc, HtmlViewerOptions options) + { + _doc = doc; + _options = options; + } + + /// Validates and lays out all geometry and instance arrays. + /// The document references missing geometry or has invalid indices. + public static ViewerPayloadWriter Create(ViewerDocument doc, HtmlViewerOptions options) + { + var writer = new ViewerPayloadWriter(doc, options); + writer.Build(); + return writer; + } + + private void Build() + { + var shared = new Dictionary(); + foreach (var geometry in _doc.Geometries) + { + if (!shared.TryAdd(geometry.Id, geometry)) + { + throw new InvalidDataException($"Geometry id {geometry.Id} is used by more than one geometry."); + } + CheckIndices(geometry.Indices, geometry.Vertices.Count, $"Geometry {geometry.Id}"); + } + + foreach (var mesh in _doc.Meshes) + { + if (mesh.GeometryId is int id) + { + if (!shared.ContainsKey(id)) + { + throw new InvalidDataException($"Mesh {mesh.Id} references geometry {id}, which is not in the document."); + } + if (mesh.Vertices.Count > 0) + { + throw new InvalidDataException($"Mesh {mesh.Id} has both geometry {id} and its own vertices."); + } + } + else + { + CheckIndices(mesh.Indices, mesh.Vertices.Count, $"Mesh {mesh.Id}"); + } + } + + foreach (var material in _doc.Materials) _materialsById.TryAdd(material.Id, material); + bool meshElements = _doc.Elements.Count == 0; + _elements.AddRange(_doc.Elements); + var elementIndex = new Dictionary(); + for (int i = 0; i < _elements.Count; i++) elementIndex.TryAdd(_elements[i].Id, i); + + _bounds = _doc.ComputeBoundingBox(); + _rtcOffset = _bounds.IsEmpty ? Vector3d.Zero : _bounds.Center; + + // Largest axis length of any instance per shared geometry: grid steps in local units grow by it in the world. + var maxScale = new Dictionary(); + foreach (var mesh in _doc.Meshes) + { + if (mesh.GeometryId is not int id || mesh.Transform is not { } t) continue; + double scale = Math.Max(t.XAxis.Length, Math.Max(t.YAxis.Length, t.ZAxis.Length)); + maxScale[id] = Math.Max(maxScale.GetValueOrDefault(id), scale); + } + + var sharedOutput = new Dictionary(); + long totalVertices = 0; + foreach (var mesh in _doc.Meshes) + { + var geometry = mesh.GeometryId is int id ? shared[id] : null; + int vertexCount = geometry?.Vertices.Count ?? mesh.Vertices.Count; + if (totalVertices + vertexCount > _options.MaxMeshVertices) break; + totalVertices += vertexCount; + + var transform = mesh.Transform ?? Transform3d.Identity; + int geometryIndex; + Vector3d translation; // in RTC + if (geometry is not null) + { + if (!sharedOutput.TryGetValue(geometry.Id, out var written)) + { + written = AddSharedGeometry(geometry, maxScale.GetValueOrDefault(geometry.Id) <= 1 + 1e-6); + sharedOutput.Add(geometry.Id, written); + } + geometryIndex = written.Index; + translation = transform.Apply(written.Origin) - _rtcOffset; // the written geometry starts at Origin + } + else + { + (geometryIndex, translation) = AddOwnGeometry(mesh); + transform = Transform3d.Identity; + } + + _instanceGeometry.Add((uint)geometryIndex); + _instanceMaterial.Add((uint)MaterialIndex(mesh)); + if (meshElements) + { + long? standInId = mesh.ElementId; + if (standInId is not long known || !elementIndex.TryGetValue(known, out int index)) + { + index = _elements.Count; + string fallback = standInId is long e ? $"Element {e}" : $"Mesh {mesh.Id}"; + _elements.Add(new ViewerElement { Id = standInId ?? mesh.Id, Name = string.IsNullOrEmpty(mesh.Name) ? fallback : mesh.Name }); + if (standInId is long grouped) elementIndex.Add(grouped, index); + else _meshStandIns.Add(index); + } + _instanceElement.Add(index); + } + else + { + _instanceElement.Add(mesh.ElementId is long e && elementIndex.TryGetValue(e, out int index) ? index : -1); + } + AddMatrix(transform, translation); + _writtenMeshes++; + } + + _instances = new InstanceViews( + _blob.Add(CollectionsMarshal.AsSpan(_instanceGeometry)), + _blob.Add(CollectionsMarshal.AsSpan(_instanceMaterial)), + _blob.Add(CollectionsMarshal.AsSpan(_instanceElement)), + _blob.Add(CollectionsMarshal.AsSpan(_instanceMatrix))); + } + + private static void CheckIndices(IReadOnlyList indices, int vertexCount, string owner) + { + for (int i = 0; i < indices.Count; i++) + { + if ((uint)indices[i] >= (uint)vertexCount) + { + throw new InvalidDataException( + $"{owner}: index {indices[i]} at position {i} is outside the {vertexCount} vertices."); + } + } + } + + // Written relative to Origin = Snap(local minimum), which each instance's double translation absorbs: quantized + // (with q.o = 0) when it fits and no instance scales it up (which would scale the Step/2 error with it), else f32. + // Geometry in world coordinates thus never reaches f32 or the viewer's GPU decode as large numbers. + private (int Index, Vector3d Origin) AddSharedGeometry(ViewerGeometry geometry, bool quantize) + { + var vertices = geometry.Vertices.ToArray(); + var normals = geometry.Normals is { } n && n.Count == vertices.Length ? GeometryQuantizer.OctEncode(n) : null; + var uvs = Uvs(geometry.UVs, vertices.Length); + var bounds = geometry.ComputeBoundingBox(); + BoundingBox3d Local(Vector3d origin) => bounds.IsEmpty ? bounds : new BoundingBox3d(bounds.Min - origin, bounds.Max - origin); + if (quantize && GeometryQuantizer.TryQuantize(vertices, out var origin, out var q)) + { + return (AddGeometry(q, null, Vector3d.Zero, geometry.Indices, normals, uvs, Local(origin)), origin); + } + var min = bounds.IsEmpty ? Vector3d.Zero : bounds.Min; + origin = new Vector3d(GeometryQuantizer.Snap(min.X), GeometryQuantizer.Snap(min.Y), GeometryQuantizer.Snap(min.Z)); + return (AddGeometry(null, Floats(vertices, origin), null, geometry.Indices, normals, uvs, Local(origin)), origin); + } + + // Returns the geometry of a mesh with its own vertices and the instance translation (RTC). + // A translated twin of an earlier own mesh (format 2's dedupe: equal float32 positions relative to the exact minimum + // corner, equal indices, normals and UVs) reuses that geometry with an exact, unsnapped translation; its vertices + // are then within Step/2 plus float32 rounding of the truth, but no longer bit-identical to coinciding vertices of + // other meshes. Otherwise the mesh gets its own geometry: quantized, the translation is the snapped minimum corner, + // a point of the global RTC grid, and q has origin 0, so vertices that coincide in the world decode to the same + // grid point in every mesh; over 64 m, the vertices are f32 relative to the exact minimum corner, as in format 2. + private (int Index, Vector3d Translation) AddOwnGeometry(ViewerMesh mesh) + { + var shape = Bake(mesh); + var hasher = new HashCode(); + hasher.AddBytes(Bytes(shape.Relative)); + foreach (int index in shape.Indices) hasher.Add(index); + hasher.AddBytes(Bytes(shape.Normals)); + hasher.AddBytes(Bytes(shape.Uvs)); + int hash = hasher.ToHashCode(); + if (!_ownTwinsByHash.TryGetValue(hash, out var twins)) + { + twins = []; + _ownTwinsByHash.Add(hash, twins); + } + foreach (var twin in twins) + { + var other = Bake(twin.Mesh); // rebaked on a hash match instead of keeping every mesh's floats in memory + if (SameBytes(shape.Relative, other.Relative) && shape.Indices.SequenceEqual(other.Indices) + && SameBytes(shape.Normals, other.Normals) && SameBytes(shape.Uvs, other.Uvs)) + { + return (twin.Geometry, twin.Translation + (shape.Min - other.Min)); + } + } + + int geometry; + Vector3d translation; + var box = shape.Box; + if (GeometryQuantizer.TryQuantize(shape.Rtc, out var origin, out var q)) + { + var local = box.IsEmpty ? box : new BoundingBox3d(box.Min - origin, box.Max - origin); + (geometry, translation) = (AddGeometry(q, null, Vector3d.Zero, shape.Indices, shape.Normals, shape.Uvs, local), origin); + } + else + { + var bounds = new BoundingBox3d(Vector3d.Zero, box.Max - shape.Min); // not empty: no vertices always quantize + (geometry, translation) = (AddGeometry(null, shape.Relative, null, shape.Indices, shape.Normals, shape.Uvs, bounds), shape.Min); + } + twins.Add(new OwnTwin(mesh, geometry, translation)); + return (geometry, translation); + } + + // Bakes the mesh transform into its vertices (in RTC), normals and, for a mirroring transform, the winding. + private OwnShape Bake(ViewerMesh mesh) + { + var transform = mesh.Transform ?? Transform3d.Identity; + var vertices = mesh.Vertices; + var rtc = new Vector3d[vertices.Count]; + var box = BoundingBox3d.Empty; + for (int i = 0; i < rtc.Length; i++) + { + rtc[i] = transform.Apply(vertices[i]) - _rtcOffset; + box = box.Encapsulate(rtc[i]); + } + var min = box.IsEmpty ? Vector3d.Zero : box.Min; + + short[]? normals = null; + if (mesh.Normals is { } n && n.Count == vertices.Count) + { + normals = GeometryQuantizer.OctEncode(mesh.Transform is null ? n : [.. n.Select(v => TransformNormal(transform, v))]); + } + + // A mirroring transform (det < 0, as in TransformNormal) turns the triangles inside out unless their winding is + // reversed too; the viewer only does that for shared geometry, from the instance matrix. + var indices = mesh.Indices; + if (Vector3d.Dot(transform.XAxis, Vector3d.Cross(transform.YAxis, transform.ZAxis)) < 0) + { + int[] flipped = [.. indices]; + for (int i = 0; i + 2 < flipped.Length; i += 3) (flipped[i + 1], flipped[i + 2]) = (flipped[i + 2], flipped[i + 1]); + indices = flipped; + } + return new OwnShape(rtc, box, min, Floats(rtc, min), indices, normals, Uvs(mesh.UVs, vertices.Count)); + } + + // Multiplies by the inverse transpose of the linear part, so normals stay perpendicular under any affine map. + private static Vector3d TransformNormal(Transform3d t, Vector3d n) + { + var bc = Vector3d.Cross(t.YAxis, t.ZAxis); + var r = bc * n.X + Vector3d.Cross(t.ZAxis, t.XAxis) * n.Y + Vector3d.Cross(t.XAxis, t.YAxis) * n.Z; + return (Vector3d.Dot(t.XAxis, bc) < 0 ? -r : r).Normalized(); + } + + private static float[]? Uvs(IReadOnlyList? uvs, int vertexCount) + { + if (uvs is null || uvs.Count != vertexCount) return null; + var result = new float[uvs.Count * 2]; + for (int i = 0; i < uvs.Count; i++) + { + result[i * 2] = (float)uvs[i].X; + result[i * 2 + 1] = (float)uvs[i].Y; + } + return result; + } + + private static float[] Floats(Vector3d[] vertices, Vector3d offset) + { + var result = new float[vertices.Length * 3]; + for (int i = 0; i < vertices.Length; i++) + { + result[i * 3] = (float)(vertices[i].X - offset.X); + result[i * 3 + 1] = (float)(vertices[i].Y - offset.Y); + result[i * 3 + 2] = (float)(vertices[i].Z - offset.Z); + } + return result; + } + + // Returns the index of an identical geometry written before (same bytes and origin), or writes this one. Positions + // are either grid steps q from origin, or f32 positions (origin null). + private int AddGeometry(ushort[]? q, float[]? positions, Vector3d? origin, IReadOnlyList indices, short[]? normals, float[]? uvs, BoundingBox3d bounds) + { + int vertexCount = (q?.Length ?? positions!.Length) / 3; + ushort[]? indices16 = null; + uint[]? indices32 = null; + if (vertexCount <= ushort.MaxValue) + { + indices16 = new ushort[indices.Count]; + for (int i = 0; i < indices.Count; i++) indices16[i] = (ushort)indices[i]; + } + else + { + indices32 = new uint[indices.Count]; + for (int i = 0; i < indices.Count; i++) indices32[i] = (uint)indices[i]; + } + + var hasher = new HashCode(); + hasher.Add(origin); + hasher.AddBytes(Bytes(q)); + hasher.AddBytes(Bytes(positions)); + hasher.AddBytes(Bytes(indices16)); + hasher.AddBytes(Bytes(indices32)); + hasher.Add(normals is null); + hasher.AddBytes(Bytes(normals)); + hasher.Add(uvs is null); + hasher.AddBytes(Bytes(uvs)); + int hash = hasher.ToHashCode(); + + if (!_geometriesByHash.TryGetValue(hash, out var candidates)) + { + candidates = []; + _geometriesByHash.Add(hash, candidates); + } + foreach (int candidate in candidates) + { + var entry = _geometries[candidate]; + if (entry.Origin == origin + && (q is not null ? Same(q, entry.Positions) : Same(positions, entry.Positions)) + && (indices16 is not null ? Same(indices16, entry.Indices) : Same(indices32, entry.Indices)) + && Same(normals, entry.Normals) + && Same(uvs, entry.Uvs)) + { + return candidate; + } + } + + var written = new GeometryEntry( + q is not null ? _blob.Add(q) : _blob.Add(positions), + origin, + indices16 is not null ? _blob.Add(indices16) : _blob.Add(indices32), + normals is null ? null : _blob.Add(normals), + uvs is null ? null : _blob.Add(uvs), + bounds); + _geometries.Add(written); + candidates.Add(_geometries.Count - 1); + return _geometries.Count - 1; + } + + private static bool SameBytes(T[]? a, T[]? b) where T : struct => + a is null ? b is null : b is not null && Bytes(a).SequenceEqual(Bytes(b)); + + private bool Same(T[]? values, BlobView? view) where T : struct => + values is null ? view is null : view is { } v && Bytes(values).SequenceEqual(_blob.Bytes(v)); + + private static ReadOnlySpan Bytes(T[]? values) where T : struct => + values is null ? default : MemoryMarshal.AsBytes(values.AsSpan()); + + private int MaterialIndex(ViewerMesh mesh) + { + MaterialKey key; + if (mesh.Color is null && mesh.MaterialId is int id && _materialsById.TryGetValue(id, out var material)) + { + var c = material.Color; + key = new MaterialKey(c.R, c.G, c.B, Math.Clamp(c.Af * material.Opacity * mesh.Opacity, 0, 1), + mesh.TextureIndex ?? material.TextureIndex); + } + else + { + var c = mesh.Color ?? DefaultColor; + key = new MaterialKey(c.R, c.G, c.B, Math.Clamp(c.Af * mesh.Opacity, 0, 1), mesh.TextureIndex); + } + + if (!_materialIndex.TryGetValue(key, out int index)) + { + index = _materials.Count; + _materials.Add(key); + _materialIndex.Add(key, index); + } + return index; + } + + // Matrix4.elements[0,1,2,4,5,6,8,9,10,12,13,14]: the three axes of t, then the translation, already in RTC. + private void AddMatrix(Transform3d t, Vector3d translation) + { + foreach (var axis in (ReadOnlySpan)[t.XAxis, t.YAxis, t.ZAxis, translation]) + { + _instanceMatrix.Add((float)axis.X); + _instanceMatrix.Add((float)axis.Y); + _instanceMatrix.Add((float)axis.Z); + } + } + + public void WriteBin(Stream output) => _blob.WriteBase64(output); + + public void WriteProps(Stream output) + { + using var w = new Utf8JsonWriter(output); + w.WriteStartObject(); + if (_options.IncludeProperties) + { + foreach (var el in _doc.Elements) + { + if (el.Properties.Count == 0) continue; + w.WriteStartObject(el.Id.ToString(CultureInfo.InvariantCulture)); + foreach (var group in el.Properties) + { + w.WriteStartObject(group.Key); + foreach (var prop in group.Value) w.WriteString(prop.Key, prop.Value); + w.WriteEndObject(); + } + w.WriteEndObject(); + FlushIfLarge(w); + } + } + w.WriteEndObject(); + } + + public void WriteMeta(Stream output) + { + using var w = new Utf8JsonWriter(output); + w.WriteStartObject(); + w.WriteNumber("format", 3); + w.WriteString("title", _options.Title); + w.WriteString("author", _doc.Author ?? "IOViewer"); + w.WriteString("createdAt", _doc.CreatedAt ?? DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture)); + WriteVector(w, "rtcOffset", _rtcOffset); + WriteBox(w, "bbox", _bounds, _rtcOffset); + + w.WriteStartObject("options"); + w.WriteBoolean("initialWireframe", _options.InitialWireframe); + w.WriteBoolean("initialWhiteMode", _options.InitialWhiteMode); + w.WriteEndObject(); + + if (_writtenMeshes < _doc.Meshes.Count) + { + w.WriteStartObject("truncated"); + w.WriteNumber("meshes", _writtenMeshes); + w.WriteNumber("totalMeshes", _doc.Meshes.Count); + w.WriteEndObject(); + } + else + { + w.WriteNull("truncated"); + } + + w.WriteStartArray("materials"); + foreach (var m in _materials) + { + w.WriteStartObject(); + w.WriteStartArray("color"); + w.WriteNumberValue(m.R / 255.0); + w.WriteNumberValue(m.G / 255.0); + w.WriteNumberValue(m.B / 255.0); + w.WriteEndArray(); + w.WriteNumber("opacity", m.Opacity); + if (m.Texture is int texture) w.WriteNumber("texture", texture); + else w.WriteNull("texture"); + w.WriteEndObject(); + } + w.WriteEndArray(); + + w.WriteStartArray("textures"); + foreach (var tex in _doc.Textures) + { + w.WriteStartObject(); + w.WriteString("mime", tex.MimeType); + w.WriteString("data", tex.Base64Data); + w.WriteNumber("sx", tex.ScaleX); + w.WriteNumber("sy", tex.ScaleY); + w.WriteEndObject(); + FlushIfLarge(w); + } + w.WriteEndArray(); + + w.WriteStartArray("geometries"); + foreach (var g in _geometries) + { + w.WriteStartObject(); + w.WritePropertyName("positions"); + g.Positions.WriteTo(w); + if (g.Origin is { } origin) + { + w.WriteStartObject("q"); + WriteVector(w, "o", origin); + w.WriteNumber("s", GeometryQuantizer.Step); + w.WriteEndObject(); + } + w.WritePropertyName("indices"); + g.Indices.WriteTo(w); + WriteView(w, "normals", g.Normals); + WriteView(w, "uvs", g.Uvs); + WriteBox(w, "bbox", g.Bounds, Vector3d.Zero); + w.WriteEndObject(); + FlushIfLarge(w); + } + w.WriteEndArray(); + + w.WriteStartObject("instances"); + w.WriteNumber("count", _writtenMeshes); + w.WritePropertyName("geometry"); + _instances.Geometry.WriteTo(w); + w.WritePropertyName("material"); + _instances.Material.WriteTo(w); + w.WritePropertyName("element"); + _instances.Element.WriteTo(w); + w.WritePropertyName("matrix"); + _instances.Matrix.WriteTo(w); + w.WriteEndObject(); + + w.WriteStartArray("elements"); + for (int i = 0; i < _elements.Count; i++) + { + var el = _elements[i]; + w.WriteStartObject(); + w.WriteNumber("id", el.Id); + if (_meshStandIns.Contains(i)) w.WriteBoolean("mesh", true); + w.WriteString("name", el.Name); + w.WriteString("category", string.IsNullOrEmpty(el.Category) ? "General" : el.Category); + w.WriteString("type", el.TypeName); + w.WriteString("level", el.Level); + if (string.IsNullOrEmpty(el.LinkName)) w.WriteNull("link"); + else w.WriteString("link", el.LinkName); + w.WriteEndObject(); + FlushIfLarge(w); + } + w.WriteEndArray(); + + WriteViewpoints(w); + WriteGrids(w); + WriteTimeLiner(w); + WriteLinkedFiles(w); + WriteClashTests(w); + + w.WriteEndObject(); + } + + private void WriteViewpoints(Utf8JsonWriter w) + { + w.WriteStartArray("viewpoints"); + if (_options.IncludeViewpoints) + { + foreach (var vp in _doc.Viewpoints) + { + w.WriteStartObject(); + w.WriteString("name", vp.Name); + w.WriteStartObject("camera"); + WriteVector(w, "position", vp.Camera.Position - _rtcOffset); + WriteVector(w, "target", vp.Camera.Target - _rtcOffset); + w.WriteString("projection", vp.Camera.Projection == ViewerProjection.Orthographic ? "orthographic" : "perspective"); + w.WriteEndObject(); + w.WriteEndObject(); + } + } + w.WriteEndArray(); + } + + private void WriteGrids(Utf8JsonWriter w) + { + w.WriteStartArray("grids"); + if (_options.IncludeGrids && _doc.Grids is not null) + { + foreach (var line in _doc.Grids.Lines) + { + w.WriteStartObject(); + w.WriteString("name", line.Name); + WriteVector(w, "start", line.Start - _rtcOffset); + WriteVector(w, "end", line.End - _rtcOffset); + w.WriteEndObject(); + } + } + w.WriteEndArray(); + } + + private void WriteTimeLiner(Utf8JsonWriter w) + { + var schedule = _doc.TimeLiner; + if (!_options.IncludeTimeLiner || schedule is null || schedule.Tasks.Count == 0) + { + w.WriteNull("timeliner"); + return; + } + + const string dateFormat = "yyyy-MM-ddTHH:mm:ssZ"; + w.WriteStartObject("timeliner"); + w.WriteString("name", schedule.Name); + w.WriteString("start", schedule.GetOverallStart().ToString(dateFormat, CultureInfo.InvariantCulture)); + w.WriteString("end", schedule.GetOverallEnd().ToString(dateFormat, CultureInfo.InvariantCulture)); + + w.WriteStartArray("tasks"); + void WriteTask(ViewerTimeLinerTask t) + { + w.WriteStartObject(); + w.WriteString("id", t.Id); + w.WriteString("name", t.Name); + w.WriteString("type", t.TaskType.ToString().ToLowerInvariant()); + w.WriteString("start", t.PlannedStart.ToString(dateFormat, CultureInfo.InvariantCulture)); + w.WriteString("end", t.PlannedEnd.ToString(dateFormat, CultureInfo.InvariantCulture)); + if (t.PlannedCost.HasValue) w.WriteNumber("cost", t.PlannedCost.Value); + w.WriteNumber("progress", t.Progress); + w.WriteStartArray("attachedElements"); + foreach (var id in t.AttachedElementIds) w.WriteNumberValue(id); + w.WriteEndArray(); + w.WriteEndObject(); + foreach (var child in t.Children) WriteTask(child); + } + foreach (var task in schedule.Tasks) WriteTask(task); + w.WriteEndArray(); + w.WriteEndObject(); + } + + private void WriteLinkedFiles(Utf8JsonWriter w) + { + w.WriteStartArray("linkedFiles"); + foreach (var file in _doc.LinkedFiles) + { + w.WriteStartObject(); + w.WriteString("name", file.Name); + w.WriteString("path", file.Path); + w.WriteBoolean("visible", file.Visible); + w.WriteEndObject(); + } + w.WriteEndArray(); + } + + private void WriteClashTests(Utf8JsonWriter w) + { + w.WriteStartArray("clashTests"); + if (_options.IncludeClashes) + { + foreach (var test in _doc.ClashTests) + { + w.WriteStartObject(); + w.WriteString("name", test.Name); + w.WriteStartArray("clashes"); + foreach (var clash in test.Clashes) + { + w.WriteStartObject(); + w.WriteString("id", clash.Id); + w.WriteString("name", clash.Name); + w.WriteString("element1Name", clash.Element1Name); + w.WriteString("element2Name", clash.Element2Name); + WriteNullableNumber(w, "element1Id", clash.Element1Id); + WriteNullableNumber(w, "element2Id", clash.Element2Id); + WriteVector(w, "point", clash.Point - _rtcOffset); + w.WriteNumber("distance", clash.Distance); + w.WriteString("status", clash.Status.ToString()); + w.WriteEndObject(); + } + w.WriteEndArray(); + w.WriteEndObject(); + } + } + w.WriteEndArray(); + } + + private static void WriteNullableNumber(Utf8JsonWriter w, string name, long? value) + { + if (value is long v) w.WriteNumber(name, v); + else w.WriteNull(name); + } + + private static void WriteView(Utf8JsonWriter w, string name, BlobView? view) + { + w.WritePropertyName(name); + if (view is { } v) v.WriteTo(w); + else w.WriteNullValue(); + } + + private static void WriteVector(Utf8JsonWriter w, string name, Vector3d v) + { + w.WriteStartArray(name); + w.WriteNumberValue(v.X); + w.WriteNumberValue(v.Y); + w.WriteNumberValue(v.Z); + w.WriteEndArray(); + } + + // An empty box (no vertices) is written as a zero box so the viewer always gets finite numbers. + private static void WriteBox(Utf8JsonWriter w, string name, BoundingBox3d box, Vector3d offset) + { + w.WriteStartObject(name); + WriteVector(w, "min", box.IsEmpty ? Vector3d.Zero : box.Min - offset); + WriteVector(w, "max", box.IsEmpty ? Vector3d.Zero : box.Max - offset); + w.WriteEndObject(); + } + + private static void FlushIfLarge(Utf8JsonWriter w) + { + if (w.BytesPending > FlushThreshold) w.Flush(); + } + + private readonly record struct MaterialKey(byte R, byte G, byte B, double Opacity, int? Texture); + + // Origin is META q.o: set for u16 grid-step positions, null for f32. + private sealed record GeometryEntry(BlobView Positions, Vector3d? Origin, BlobView Indices, BlobView? Normals, BlobView? Uvs, BoundingBox3d Bounds); + + // An own-vertex mesh baked into RTC: Relative = float32 positions relative to Min, the exact minimum corner; Indices + // with the winding reversed under a mirroring transform. + private sealed record OwnShape(Vector3d[] Rtc, BoundingBox3d Box, Vector3d Min, float[] Relative, IReadOnlyList Indices, short[]? Normals, float[]? Uvs); + + // The first own mesh of a shape, the geometry written for it and its instance translation (RTC). + private readonly record struct OwnTwin(ViewerMesh Mesh, int Geometry, Vector3d Translation); + + private readonly record struct InstanceViews(BlobView Geometry, BlobView Material, BlobView Element, BlobView Matrix); +} diff --git a/src/IOViewer/IOViewer.csproj b/src/IOViewer/IOViewer.csproj index d974f1f..adfa165 100644 --- a/src/IOViewer/IOViewer.csproj +++ b/src/IOViewer/IOViewer.csproj @@ -4,6 +4,7 @@ true IOViewer true + $(DefaultItemExcludes);Viewer/node_modules/**
@@ -11,4 +12,10 @@ + + + + + + diff --git a/src/IOViewer/Importers/GlbViewerImporter.cs b/src/IOViewer/Importers/GlbViewerImporter.cs index 09137f2..7d86c74 100644 --- a/src/IOViewer/Importers/GlbViewerImporter.cs +++ b/src/IOViewer/Importers/GlbViewerImporter.cs @@ -112,27 +112,43 @@ public static ViewerDocument Load(Stream stream, string documentTitle = "Importe } } - // Parse Meshes - if (root.TryGetProperty("meshes", out var meshesEl)) + // Parse meshes lazily: one ViewerGeometry per (mesh, primitive), one ViewerMesh per node occurrence. + var meshElements = root.TryGetProperty("meshes", out var meshesEl) ? meshesEl.EnumerateArray().ToArray() : []; + var geometryIds = new Dictionary<(int Mesh, int Primitive), int?>(); + int meshCounter = 0; + + // One element per node occurrence: all primitives of its mesh select, hide and list as one object. + void Instantiate(int meshIndex, Transform3d world, string? nodeName) { - int meshCounter = 0; - foreach (var meshEl in meshesEl.EnumerateArray()) + if (meshIndex < 0 || meshIndex >= meshElements.Length) { - string meshName = meshEl.TryGetProperty("name", out var mn) ? mn.GetString() ?? $"Mesh {meshCounter + 1}" : $"Mesh {meshCounter + 1}"; + throw new InvalidDataException($"glTF node references missing mesh {meshIndex}."); + } - if (meshEl.TryGetProperty("primitives", out var primsEl)) - { - foreach (var prim in primsEl.EnumerateArray()) - { - if (!prim.TryGetProperty("attributes", out var attrs) || - !attrs.TryGetProperty("POSITION", out var posAccIdx)) - { - continue; - } + var meshEl = meshElements[meshIndex]; + string meshName = meshEl.TryGetProperty("name", out var mn) ? mn.GetString() ?? $"Mesh {meshCounter + 1}" : $"Mesh {meshCounter + 1}"; + if (!meshEl.TryGetProperty("primitives", out var primsEl)) return; - var positions = ReadVec3Accessor(posAccIdx.GetInt32(), accessors, bufferViews, binBuffer); - if (positions.Count == 0) continue; + ViewerElement? element = null; + int primIndex = -1; + foreach (var prim in primsEl.EnumerateArray()) + { + primIndex++; + if (!prim.TryGetProperty("attributes", out var attrs) || + !attrs.TryGetProperty("POSITION", out var posAccIdx)) + { + continue; + } + if (!geometryIds.TryGetValue((meshIndex, primIndex), out int? geometryId)) + { + var positions = ReadVec3Accessor(posAccIdx.GetInt32(), accessors, bufferViews, binBuffer); + if (positions.Count == 0) + { + geometryId = null; + } + else + { List indices; if (prim.TryGetProperty("indices", out var indAccIdx)) { @@ -150,39 +166,139 @@ public static ViewerDocument Load(Stream stream, string documentTitle = "Importe normals = ReadVec3Accessor(normAccIdx.GetInt32(), accessors, bufferViews, binBuffer); } - int? matId = null; - RgbaColor? col = null; - if (prim.TryGetProperty("material", out var mProp)) - { - int mid = mProp.GetInt32(); - if (mid >= 0 && mid < materials.Count) - { - matId = mid; - col = materials[mid].Color; - } - } - - var vMesh = new ViewerMesh + geometryId = doc.Geometries.Count; + doc.Geometries.Add(new ViewerGeometry { - Id = ++meshCounter, - Name = meshName, + Id = geometryId.Value, Vertices = positions, Indices = indices, - Normals = normals, - MaterialId = matId, - Color = col ?? new RgbaColor(200, 200, 200) - }; + Normals = normals + }); + } + + geometryIds[(meshIndex, primIndex)] = geometryId; + } + + if (geometryId is null) continue; + + int? matId = null; + RgbaColor? col = null; + if (prim.TryGetProperty("material", out var mProp)) + { + int mid = mProp.GetInt32(); + if (mid >= 0 && mid < materials.Count) + { + matId = mid; + col = materials[mid].Color; + } + } + + if (element is null) + { + element = new ViewerElement { Id = doc.Elements.Count + 1, Name = string.IsNullOrEmpty(nodeName) ? meshName : nodeName, Category = "GLB" }; + doc.Elements.Add(element); + } + + element.MeshIndices.Add(doc.Meshes.Count); + doc.Meshes.Add(new ViewerMesh + { + Id = ++meshCounter, + Name = meshName, + ElementId = element.Id, + GeometryId = geometryId, + Transform = world.Then(YupToZup), + MaterialId = matId, + Color = col ?? new RgbaColor(200, 200, 200) + }); + } + } + + if (root.TryGetProperty("scenes", out var scenesEl) && root.TryGetProperty("nodes", out var nodesEl)) + { + var nodes = nodesEl.EnumerateArray().ToArray(); + var scenes = scenesEl.EnumerateArray().ToArray(); + int sceneIndex = root.TryGetProperty("scene", out var sceneProp) ? sceneProp.GetInt32() : 0; + if (sceneIndex >= 0 && sceneIndex < scenes.Length && + scenes[sceneIndex].TryGetProperty("nodes", out var rootNodes)) + { + // glTF node hierarchy is a strict tree: reaching a node twice (shared child, repeated root, cycle) is invalid. + var visited = new HashSet(); + var stack = new Stack<(int Node, Transform3d ParentWorld)>(); + foreach (var rootNode in rootNodes.EnumerateArray().Reverse()) stack.Push((rootNode.GetInt32(), Transform3d.Identity)); + + while (stack.Count > 0) + { + var (nodeIndex, parentWorld) = stack.Pop(); + if (nodeIndex < 0 || nodeIndex >= nodes.Length) + { + throw new InvalidDataException($"glTF references missing node {nodeIndex}."); + } + + if (!visited.Add(nodeIndex)) + { + throw new InvalidDataException($"glTF node {nodeIndex} is referenced more than once; the node hierarchy must be a tree."); + } + + var node = nodes[nodeIndex]; + var world = ReadNodeTransform(node).Then(parentWorld); + if (node.TryGetProperty("mesh", out var meshProp)) + { + Instantiate(meshProp.GetInt32(), world, node.TryGetProperty("name", out var nodeName) ? nodeName.GetString() : null); + } - doc.Meshes.Add(vMesh); + if (node.TryGetProperty("children", out var children)) + { + foreach (var child in children.EnumerateArray().Reverse()) stack.Push((child.GetInt32(), world)); } } } } + else + { + // No scene graph: import every mesh once without a node transform. + for (int i = 0; i < meshElements.Length; i++) Instantiate(i, Transform3d.Identity, null); + } doc.ComputeBoundingBox(); return doc; } + // glTF is Y-up; the viewer model is Z-up: (x, y, z) becomes (x, -z, y). + private static readonly Transform3d YupToZup = new(new Vector3d(1, 0, 0), new Vector3d(0, 0, 1), new Vector3d(0, -1, 0), Vector3d.Zero); + + private static Transform3d ReadNodeTransform(JsonElement node) + { + if (node.TryGetProperty("matrix", out var m)) + { + var v = m.EnumerateArray().Select(e => e.GetDouble()).ToArray(); + if (v.Length != 16) throw new InvalidDataException("glTF node matrix must have 16 elements."); + // Column-major: columns 0..2 are the axes, column 3 the translation. + return new Transform3d(new(v[0], v[1], v[2]), new(v[4], v[5], v[6]), new(v[8], v[9], v[10]), new(v[12], v[13], v[14])); + } + + var t = ReadVector(node, "translation", 3, [0, 0, 0]); + var r = ReadVector(node, "rotation", 4, [0, 0, 0, 1]); + var s = ReadVector(node, "scale", 3, [1, 1, 1]); + + double len = Math.Sqrt(r[0] * r[0] + r[1] * r[1] + r[2] * r[2] + r[3] * r[3]); + if (len < 1e-12) throw new InvalidDataException("glTF node rotation quaternion has zero length."); + double x = r[0] / len, y = r[1] / len, z = r[2] / len, w = r[3] / len; + + return new Transform3d( + new Vector3d(1 - 2 * (y * y + z * z), 2 * (x * y + z * w), 2 * (x * z - y * w)) * s[0], + new Vector3d(2 * (x * y - z * w), 1 - 2 * (x * x + z * z), 2 * (y * z + x * w)) * s[1], + new Vector3d(2 * (x * z + y * w), 2 * (y * z - x * w), 1 - 2 * (x * x + y * y)) * s[2], + new Vector3d(t[0], t[1], t[2])); + } + + private static double[] ReadVector(JsonElement node, string name, int length, double[] fallback) + { + if (!node.TryGetProperty(name, out var el)) return fallback; + var v = el.EnumerateArray().Select(e => e.GetDouble()).ToArray(); + if (v.Length != length) throw new InvalidDataException($"glTF node {name} must have {length} elements."); + return v; + } + private static List ReadVec3Accessor(int accIndex, JsonElement[] accessors, JsonElement[] bufferViews, byte[] bin) { var list = new List(); diff --git a/src/IOViewer/Importers/ObjViewerImporter.cs b/src/IOViewer/Importers/ObjViewerImporter.cs index 0e13670..e4b1ca0 100644 --- a/src/IOViewer/Importers/ObjViewerImporter.cs +++ b/src/IOViewer/Importers/ObjViewerImporter.cs @@ -80,36 +80,58 @@ public static ViewerDocument Load(Stream stream, string documentTitle = "Importe } else if (span.StartsWith("o ") || span.StartsWith("g ")) { - if (meshIndices.Count > 0) - { - doc.Meshes.Add(new ViewerMesh - { - Id = ++meshCounter, - Name = currentMeshName, - Vertices = [.. vertices], - Indices = [.. meshIndices], - Color = new RgbaColor(180, 180, 180) - }); - meshIndices.Clear(); - } + AddMesh(); currentMeshName = line.Substring(2).Trim(); } } - if (meshIndices.Count > 0) + AddMesh(); + + doc.ComputeBoundingBox(); + return doc; + + // One mesh and one element per OBJ object or group, so the viewer can list, select and hide it. + void AddMesh() { + if (meshIndices.Count == 0) return; + + int id = ++meshCounter; + + // Keep only the vertices this object's faces use, so its bounds cover the object and not the file so far. + var meshVertices = new List(); + var localIndex = new Dictionary(); + var indices = new int[meshIndices.Count]; + for (int i = 0; i < indices.Length; i++) + { + int v = meshIndices[i]; + if (v >= vertices.Count) + { + throw new InvalidDataException($"OBJ object '{currentMeshName}': face vertex {v + 1} is outside the {vertices.Count} vertices read so far."); + } + + if (!localIndex.TryGetValue(v, out int local)) + { + local = meshVertices.Count; + localIndex.Add(v, local); + meshVertices.Add(vertices[v]); + } + indices[i] = local; + } + + var element = new ViewerElement { Id = id, Name = currentMeshName, Category = "OBJ" }; + element.MeshIndices.Add(doc.Meshes.Count); + doc.Elements.Add(element); doc.Meshes.Add(new ViewerMesh { - Id = ++meshCounter, + Id = id, Name = currentMeshName, - Vertices = [.. vertices], - Indices = [.. meshIndices], + ElementId = id, + Vertices = meshVertices, + Indices = indices, Color = new RgbaColor(180, 180, 180) }); + meshIndices.Clear(); } - - doc.ComputeBoundingBox(); - return doc; } private static int ParseFaceVertex(string token, int totalVerts) diff --git a/src/IOViewer/Model/MathTypes.cs b/src/IOViewer/Model/MathTypes.cs index a91fa50..b2592d8 100644 --- a/src/IOViewer/Model/MathTypes.cs +++ b/src/IOViewer/Model/MathTypes.cs @@ -102,6 +102,36 @@ public BoundingBox3d Encapsulate(BoundingBox3d other) } } +/// +/// Affine 3D transform stored by columns: the result of is +/// XAxis * p.X + YAxis * p.Y + ZAxis * p.Z + Translation. +/// +/// Image of the unit X vector (first matrix column). +/// Image of the unit Y vector (second matrix column). +/// Image of the unit Z vector (third matrix column). +/// Translation applied after the linear part. +public readonly record struct Transform3d(Vector3d XAxis, Vector3d YAxis, Vector3d ZAxis, Vector3d Translation) +{ + /// The transform that leaves every point unchanged. + public static readonly Transform3d Identity = new(Vector3d.UnitX, Vector3d.UnitY, Vector3d.UnitZ, Vector3d.Zero); + + /// Creates a pure translation. + /// Translation vector. + public static Transform3d Translate(Vector3d t) => new(Vector3d.UnitX, Vector3d.UnitY, Vector3d.UnitZ, t); + + /// Transforms a point. + /// Point in local coordinates. + public Vector3d Apply(Vector3d p) => XAxis * p.X + YAxis * p.Y + ZAxis * p.Z + Translation; + + /// Composes two transforms: this one is applied first, then . + /// Transform applied after this one. + public Transform3d Then(Transform3d next) => new( + next.Apply(XAxis) - next.Translation, + next.Apply(YAxis) - next.Translation, + next.Apply(ZAxis) - next.Translation, + next.Apply(Translation)); +} + /// /// 8-bit per channel RGBA color with double precision conversion. /// diff --git a/src/IOViewer/Model/ViewerClash.cs b/src/IOViewer/Model/ViewerClash.cs index fc8c4e4..1508303 100644 --- a/src/IOViewer/Model/ViewerClash.cs +++ b/src/IOViewer/Model/ViewerClash.cs @@ -1,3 +1,5 @@ +using System.Text.Json.Serialization; + namespace IOViewer.Model; public enum ViewerClashStatus @@ -18,6 +20,13 @@ public sealed class ViewerClashResult public string Name { get; set; } = string.Empty; public string Element1Name { get; set; } = string.Empty; public string Element2Name { get; set; } = string.Empty; + + /// Id of the first clashing element, if known. + public long? Element1Id { get; set; } + + /// Id of the second clashing element, if known. + public long? Element2Id { get; set; } + public Vector3d Point { get; set; } public double Distance { get; set; } public ViewerClashStatus Status { get; set; } = ViewerClashStatus.Active; @@ -42,6 +51,7 @@ public ViewerClashResult(string id, string name, string el1, string el2, Vector3 public sealed class ViewerClashTest { public string Name { get; set; } = "Clash Test"; + [JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)] public List Clashes { get; } = []; public ViewerClashTest() { } diff --git a/src/IOViewer/Model/ViewerDocument.cs b/src/IOViewer/Model/ViewerDocument.cs index 6487f5f..755b016 100644 --- a/src/IOViewer/Model/ViewerDocument.cs +++ b/src/IOViewer/Model/ViewerDocument.cs @@ -28,6 +28,11 @@ public sealed class ViewerDocument /// public List Meshes { get; set; } = []; + /// + /// Geometries shared between meshes via . + /// + public List Geometries { get; set; } = []; + /// /// Material definitions with colors, opacity, and surface properties. /// @@ -81,15 +86,32 @@ public ViewerDocument(string title) } /// - /// Computes and updates the overall 3D bounding box from all meshes. + /// Computes and updates the overall 3D bounding box from all meshes, including shared geometry and mesh transforms. /// public BoundingBox3d ComputeBoundingBox() { + var geometries = new Dictionary(); + foreach (var g in Geometries) + { + geometries[g.Id] = g; + } + var box = BoundingBox3d.Empty; foreach (var mesh in Meshes) { - var mb = mesh.ComputeBoundingBox(); - box = box.Encapsulate(mb); + mesh.ComputeBoundingBox(); + var transform = mesh.Transform ?? Transform3d.Identity; + foreach (var v in mesh.Vertices) + { + box = box.Encapsulate(transform.Apply(v)); + } + if (mesh.GeometryId is int id && geometries.TryGetValue(id, out var geometry)) + { + foreach (var v in geometry.Vertices) + { + box = box.Encapsulate(transform.Apply(v)); + } + } } BoundingBox = box; return box; diff --git a/src/IOViewer/Model/ViewerElement.cs b/src/IOViewer/Model/ViewerElement.cs index 17eec0d..18ed756 100644 --- a/src/IOViewer/Model/ViewerElement.cs +++ b/src/IOViewer/Model/ViewerElement.cs @@ -1,3 +1,5 @@ +using System.Text.Json.Serialization; + namespace IOViewer.Model; /// @@ -15,11 +17,13 @@ public sealed class ViewerElement /// /// Parameters grouped by property category (e.g. "Identity Data", "Dimensions", "Phasing"). /// + [JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)] public Dictionary> Properties { get; } = new(StringComparer.OrdinalIgnoreCase); /// /// Indices of associated meshes in ViewerDocument.Meshes. /// + [JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)] public List MeshIndices { get; } = []; public BoundingBox3d BoundingBox { get; set; } = BoundingBox3d.Empty; diff --git a/src/IOViewer/Model/ViewerGeometry.cs b/src/IOViewer/Model/ViewerGeometry.cs new file mode 100644 index 0000000..bc338e6 --- /dev/null +++ b/src/IOViewer/Model/ViewerGeometry.cs @@ -0,0 +1,33 @@ +namespace IOViewer.Model; + +/// +/// Triangle geometry shared by several meshes; each mesh places it with its own . +/// +public sealed class ViewerGeometry +{ + /// Identifier referenced by . + public int Id { get; set; } + + /// Vertex positions in the geometry's local coordinates. + public IReadOnlyList Vertices { get; set; } = []; + + /// Triangle vertex indices (three per triangle). + public IReadOnlyList Indices { get; set; } = []; + + /// Optional per-vertex normals. + public IReadOnlyList? Normals { get; set; } + + /// Optional per-vertex texture coordinates. + public IReadOnlyList? UVs { get; set; } + + /// Computes the bounding box of in local coordinates. + public BoundingBox3d ComputeBoundingBox() + { + var box = BoundingBox3d.Empty; + foreach (var v in Vertices) + { + box = box.Encapsulate(v); + } + return box; + } +} diff --git a/src/IOViewer/Model/ViewerGridSystem.cs b/src/IOViewer/Model/ViewerGridSystem.cs index 15052df..bb9150b 100644 --- a/src/IOViewer/Model/ViewerGridSystem.cs +++ b/src/IOViewer/Model/ViewerGridSystem.cs @@ -1,3 +1,5 @@ +using System.Text.Json.Serialization; + namespace IOViewer.Model; /// @@ -6,6 +8,7 @@ namespace IOViewer.Model; public sealed class ViewerGridSystem { public string Name { get; set; } = "Grids"; + [JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)] public List Lines { get; } = []; } diff --git a/src/IOViewer/Model/ViewerMesh.cs b/src/IOViewer/Model/ViewerMesh.cs index aecd841..7b863a5 100644 --- a/src/IOViewer/Model/ViewerMesh.cs +++ b/src/IOViewer/Model/ViewerMesh.cs @@ -14,6 +14,12 @@ public sealed class ViewerMesh public IReadOnlyList? Normals { get; set; } public IReadOnlyList? UVs { get; set; } + /// Id of a in drawn by this mesh, if any. + public int? GeometryId { get; set; } + + /// Placement of this mesh's own and of the shared geometry; null means identity. + public Transform3d? Transform { get; set; } + public int? MaterialId { get; set; } public RgbaColor? Color { get; set; } public double Opacity { get; set; } = 1.0; diff --git a/src/IOViewer/Model/ViewerTimeLiner.cs b/src/IOViewer/Model/ViewerTimeLiner.cs index 24be5b2..06a0100 100644 --- a/src/IOViewer/Model/ViewerTimeLiner.cs +++ b/src/IOViewer/Model/ViewerTimeLiner.cs @@ -1,3 +1,5 @@ +using System.Text.Json.Serialization; + namespace IOViewer.Model; public enum ViewerTimeLinerTaskType @@ -26,7 +28,9 @@ public sealed class ViewerTimeLinerTask public double Progress { get; set; } public string? AttachedSetName { get; set; } + [JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)] public List AttachedElementIds { get; } = []; + [JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)] public List Children { get; } = []; public ViewerTimeLinerTask() { } @@ -47,6 +51,7 @@ public ViewerTimeLinerTask(string id, string name, DateTimeOffset start, DateTim public sealed class ViewerTimeLinerSchedule { public string Name { get; set; } = "TimeLiner Schedule"; + [JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)] public List Tasks { get; } = []; public ViewerTimeLinerSchedule() { } diff --git a/src/IOViewer/Viewer/dist/viewer.js b/src/IOViewer/Viewer/dist/viewer.js new file mode 100644 index 0000000..2671878 --- /dev/null +++ b/src/IOViewer/Viewer/dist/viewer.js @@ -0,0 +1,4301 @@ +(()=>{var Qc=i=>{throw TypeError(i)};var Ka=(i,t,e)=>t.has(i)||Qc("Cannot "+e);var Zn=(i,t,e)=>(Ka(i,t,"read from private field"),e?e.call(i):t.get(i)),ja=(i,t,e)=>t.has(i)?Qc("Cannot add the same private member more than once"):t instanceof WeakSet?t.add(i):t.set(i,e),th=(i,t,e,n)=>(Ka(i,t,"write to private field"),n?n.call(i,e):t.set(i,e),e),br=(i,t,e)=>(Ka(i,t,"access private method"),e);var Dh=0,Pl=1,Nh=2;var js=1,Uh=2,ps=3,ai=0,Je=1,Be=2,Pn=0,ms=1,Ll=2,Dl=3,Nl=4,Fh=5;var Ri=100,Bh=101,Oh=102,zh=103,kh=104,Vh=200,Hh=201,Gh=202,Wh=203,Ul=204,Fl=205,Xh=206,qh=207,Yh=208,$h=209,Zh=210,Jh=211,Kh=212,jh=213,Qh=214,Qr=0,to=1,eo=2,es=3,no=4,io=5,so=6,ro=7,Lo=0,tu=1,eu=2,_n=0,Bl=1,Ol=2,zl=3,kl=4,Vl=5,Hl=6,Gl=7;var Wl=300,li=301,Ci=302,Do=303,No=304,Qs=306,ns=1e3,En=1001,oo=1002,Ie=1003,nu=1004;var tr=1005;var De=1006,Uo=1007;var ci=1008;var Qe=1009,Xl=1010,ql=1011,gs=1012,Fo=1013,xn=1014,We=1015,vn=1016,Bo=1017,Oo=1018,_s=1020,Yl=35902,$l=35899,Zl=1021,Jl=1022,tn=1023,Tn=1026,hi=1027,zo=1028,ko=1029,ui=1030,Vo=1031;var Ho=1033,er=33776,nr=33777,ir=33778,sr=33779,Go=35840,Wo=35841,Xo=35842,qo=35843,Yo=36196,$o=37492,Zo=37496,Jo=37488,Ko=37489,rr=37490,jo=37491,Qo=37808,ta=37809,ea=37810,na=37811,ia=37812,sa=37813,ra=37814,oa=37815,aa=37816,la=37817,ca=37818,ha=37819,ua=37820,da=37821,fa=36492,pa=36494,ma=36495,ga=36283,_a=36284,or=36285,xa=36286;var Us=2300,ao=2301,Kr=2302,Sl=2303,bl=2400,wl=2401,El=2402;var iu=3200;var va=0,su=1,Wn="",Se="srgb",Fs="srgb-linear",Bs="linear",te="srgb";var jr=7680;var ru=519,ou=512,au=513,lu=514,ya=515,cu=516,hu=517,Ma=518,uu=519,du=35044;var Kl="300 es",mn=2e3,is=2001;function If(i){for(let t=i.length-1;t>=0;--t)if(i[t]>=65535)return!0;return!1}function Pf(i){return ArrayBuffer.isView(i)&&!(i instanceof DataView)}function Os(i){return document.createElementNS("http://www.w3.org/1999/xhtml",i)}function fu(){let i=Os("canvas");return i.style.display="block",i}var eh={},ss=null;function jl(...i){let t="THREE."+i.shift();ss?ss("log",t,...i):console.log(t,...i)}function pu(i){let t=i[0];if(typeof t=="string"&&t.startsWith("TSL:")){let e=i[1];e&&e.isStackTrace?i[0]+=" "+e.getLocation():i[1]='Stack trace not available. Enable "THREE.Node.captureStackTrace" to capture stack traces.'}return i}function Ct(...i){i=pu(i);let t="THREE."+i.shift();if(ss)ss("warn",t,...i);else{let e=i[0];e&&e.isStackTrace?console.warn(e.getError(t)):console.warn(t,...i)}}function Pt(...i){i=pu(i);let t="THREE."+i.shift();if(ss)ss("error",t,...i);else{let e=i[0];e&&e.isStackTrace?console.error(e.getError(t)):console.error(t,...i)}}function yi(...i){let t=i.join(" ");t in eh||(eh[t]=!0,Ct(...i))}function mu(i,t,e){return new Promise(function(n,s){function r(){switch(i.clientWaitSync(t,i.SYNC_FLUSH_COMMANDS_BIT,0)){case i.WAIT_FAILED:s();break;case i.TIMEOUT_EXPIRED:setTimeout(r,e);break;default:n()}}setTimeout(r,e)})}var gu={[Qr]:to,[eo]:so,[no]:ro,[es]:io,[to]:Qr,[so]:eo,[ro]:no,[io]:es},An=class{addEventListener(t,e){this._listeners===void 0&&(this._listeners={});let n=this._listeners;n[t]===void 0&&(n[t]=[]),n[t].indexOf(e)===-1&&n[t].push(e)}hasEventListener(t,e){let n=this._listeners;return n===void 0?!1:n[t]!==void 0&&n[t].indexOf(e)!==-1}removeEventListener(t,e){let n=this._listeners;if(n===void 0)return;let s=n[t];if(s!==void 0){let r=s.indexOf(e);r!==-1&&s.splice(r,1)}}dispatchEvent(t){let e=this._listeners;if(e===void 0)return;let n=e[t.type];if(n!==void 0){t.target=this;let s=n.slice(0);for(let r=0,o=s.length;r>8&255]+Ve[i>>16&255]+Ve[i>>24&255]+"-"+Ve[t&255]+Ve[t>>8&255]+"-"+Ve[t>>16&15|64]+Ve[t>>24&255]+"-"+Ve[e&63|128]+Ve[e>>8&255]+"-"+Ve[e>>16&255]+Ve[e>>24&255]+Ve[n&255]+Ve[n>>8&255]+Ve[n>>16&255]+Ve[n>>24&255]).toLowerCase()}function Yt(i,t,e){return Math.max(t,Math.min(e,i))}function Ql(i,t){return(i%t+t)%t}function Lf(i,t,e,n,s){return n+(i-t)*(s-n)/(e-t)}function Df(i,t,e){return i!==t?(e-i)/(t-i):0}function Ns(i,t,e){return(1-e)*i+e*t}function Nf(i,t,e,n){return Ns(i,t,1-Math.exp(-e*n))}function Uf(i,t=1){return t-Math.abs(Ql(i,t*2)-t)}function Ff(i,t,e){return i<=t?0:i>=e?1:(i=(i-t)/(e-t),i*i*(3-2*i))}function Bf(i,t,e){return i<=t?0:i>=e?1:(i=(i-t)/(e-t),i*i*i*(i*(i*6-15)+10))}function Of(i,t){return i+Math.floor(Math.random()*(t-i+1))}function zf(i,t){return i+Math.random()*(t-i)}function kf(i){return i*(.5-Math.random())}function Vf(i){i!==void 0&&(nh=i);let t=nh+=1831565813;return t=Math.imul(t^t>>>15,t|1),t^=t+Math.imul(t^t>>>7,t|61),((t^t>>>14)>>>0)/4294967296}function Hf(i){return i*Qi}function Gf(i){return i*rs}function Wf(i){return i>0&&Number.isInteger(i)&&2**Math.round(Math.log2(i))===i}function Xf(i){return Math.pow(2,Math.ceil(Math.log(i)/Math.LN2))}function qf(i){return Math.pow(2,Math.floor(Math.log(i)/Math.LN2))}function Yf(i,t,e,n,s){let r=Math.cos,o=Math.sin,a=r(e/2),l=o(e/2),c=r((t+n)/2),h=o((t+n)/2),d=r((t-n)/2),u=o((t-n)/2),p=r((n-t)/2),g=o((n-t)/2);switch(s){case"XYX":i.set(a*h,l*d,l*u,a*c);break;case"YZY":i.set(l*u,a*h,l*d,a*c);break;case"ZXZ":i.set(l*d,l*u,a*h,a*c);break;case"XZX":i.set(a*h,l*g,l*p,a*c);break;case"YXY":i.set(l*p,a*h,l*g,a*c);break;case"ZYZ":i.set(l*g,l*p,a*h,a*c);break;default:Ct("MathUtils: .setQuaternionFromProperEuler() encountered an unknown order: "+s)}}function ji(i,t){switch(t.constructor){case Float32Array:return i;case Uint32Array:return i/4294967295;case Uint16Array:return i/65535;case Uint8Array:case Uint8ClampedArray:return i/255;case Int32Array:return Math.max(i/2147483647,-1);case Int16Array:return Math.max(i/32767,-1);case Int8Array:return Math.max(i/127,-1);default:throw new Error("THREE.MathUtils: Invalid component type.")}}function $e(i,t){switch(t.constructor){case Float32Array:return i;case Uint32Array:return Math.round(i*4294967295);case Uint16Array:return Math.round(i*65535);case Uint8Array:case Uint8ClampedArray:return Math.round(i*255);case Int32Array:return Math.round(i*2147483647);case Int16Array:return Math.round(i*32767);case Int8Array:return Math.round(i*127);default:throw new Error("THREE.MathUtils: Invalid component type.")}}var Oe={DEG2RAD:Qi,RAD2DEG:rs,generateUUID:xs,clamp:Yt,euclideanModulo:Ql,mapLinear:Lf,inverseLerp:Df,lerp:Ns,damp:Nf,pingpong:Uf,smoothstep:Ff,smootherstep:Bf,randInt:Of,randFloat:zf,randFloatSpread:kf,seededRandom:Vf,degToRad:Hf,radToDeg:Gf,isPowerOfTwo:Wf,ceilPowerOfTwo:Xf,floorPowerOfTwo:qf,setQuaternionFromProperEuler:Yf,normalize:$e,denormalize:ji},sc=class sc{constructor(t=0,e=0){this.x=t,this.y=e}get width(){return this.x}set width(t){this.x=t}get height(){return this.y}set height(t){this.y=t}set(t,e){return this.x=t,this.y=e,this}setScalar(t){return this.x=t,this.y=t,this}setX(t){return this.x=t,this}setY(t){return this.y=t,this}setComponent(t,e){switch(t){case 0:this.x=e;break;case 1:this.y=e;break;default:throw new Error("THREE.Vector2: index is out of range: "+t)}return this}getComponent(t){switch(t){case 0:return this.x;case 1:return this.y;default:throw new Error("THREE.Vector2: index is out of range: "+t)}}clone(){return new this.constructor(this.x,this.y)}copy(t){return this.x=t.x,this.y=t.y,this}add(t){return this.x+=t.x,this.y+=t.y,this}addScalar(t){return this.x+=t,this.y+=t,this}addVectors(t,e){return this.x=t.x+e.x,this.y=t.y+e.y,this}addScaledVector(t,e){return this.x+=t.x*e,this.y+=t.y*e,this}sub(t){return this.x-=t.x,this.y-=t.y,this}subScalar(t){return this.x-=t,this.y-=t,this}subVectors(t,e){return this.x=t.x-e.x,this.y=t.y-e.y,this}multiply(t){return this.x*=t.x,this.y*=t.y,this}multiplyScalar(t){return this.x*=t,this.y*=t,this}divide(t){return this.x/=t.x,this.y/=t.y,this}divideScalar(t){return this.multiplyScalar(1/t)}applyMatrix3(t){let e=this.x,n=this.y,s=t.elements;return this.x=s[0]*e+s[3]*n+s[6],this.y=s[1]*e+s[4]*n+s[7],this}min(t){return this.x=Math.min(this.x,t.x),this.y=Math.min(this.y,t.y),this}max(t){return this.x=Math.max(this.x,t.x),this.y=Math.max(this.y,t.y),this}clamp(t,e){return this.x=Yt(this.x,t.x,e.x),this.y=Yt(this.y,t.y,e.y),this}clampScalar(t,e){return this.x=Yt(this.x,t,e),this.y=Yt(this.y,t,e),this}clampLength(t,e){let n=this.length();return this.divideScalar(n||1).multiplyScalar(Yt(n,t,e))}floor(){return this.x=Math.floor(this.x),this.y=Math.floor(this.y),this}ceil(){return this.x=Math.ceil(this.x),this.y=Math.ceil(this.y),this}round(){return this.x=Math.round(this.x),this.y=Math.round(this.y),this}roundToZero(){return this.x=Math.trunc(this.x),this.y=Math.trunc(this.y),this}negate(){return this.x=-this.x,this.y=-this.y,this}dot(t){return this.x*t.x+this.y*t.y}cross(t){return this.x*t.y-this.y*t.x}lengthSq(){return this.x*this.x+this.y*this.y}length(){return Math.sqrt(this.x*this.x+this.y*this.y)}manhattanLength(){return Math.abs(this.x)+Math.abs(this.y)}normalize(){return this.divideScalar(this.length()||1)}angle(){return Math.atan2(-this.y,-this.x)+Math.PI}angleTo(t){let e=Math.sqrt(this.lengthSq()*t.lengthSq());if(e===0)return Math.PI/2;let n=this.dot(t)/e;return Math.acos(Yt(n,-1,1))}distanceTo(t){return Math.sqrt(this.distanceToSquared(t))}distanceToSquared(t){let e=this.x-t.x,n=this.y-t.y;return e*e+n*n}manhattanDistanceTo(t){return Math.abs(this.x-t.x)+Math.abs(this.y-t.y)}setLength(t){return this.normalize().multiplyScalar(t)}lerp(t,e){return this.x+=(t.x-this.x)*e,this.y+=(t.y-this.y)*e,this}lerpVectors(t,e,n){return this.x=t.x+(e.x-t.x)*n,this.y=t.y+(e.y-t.y)*n,this}equals(t){return t.x===this.x&&t.y===this.y}fromArray(t,e=0){return this.x=t[e],this.y=t[e+1],this}toArray(t=[],e=0){return t[e]=this.x,t[e+1]=this.y,t}fromBufferAttribute(t,e){return this.x=t.getX(e),this.y=t.getY(e),this}rotateAround(t,e){let n=Math.cos(e),s=Math.sin(e),r=this.x-t.x,o=this.y-t.y;return this.x=r*n-o*s+t.x,this.y=r*s+o*n+t.y,this}random(){return this.x=Math.random(),this.y=Math.random(),this}*[Symbol.iterator](){yield this.x,yield this.y}};sc.prototype.isVector2=!0;var zt=sc,Ne=class{constructor(t=0,e=0,n=0,s=1){this.isQuaternion=!0,this._x=t,this._y=e,this._z=n,this._w=s}static slerpFlat(t,e,n,s,r,o,a){let l=n[s+0],c=n[s+1],h=n[s+2],d=n[s+3],u=r[o+0],p=r[o+1],g=r[o+2],_=r[o+3];if(d!==_||l!==u||c!==p||h!==g){let m=l*u+c*p+h*g+d*_;m<0&&(u=-u,p=-p,g=-g,_=-_,m=-m);let f=1-a;if(m<.9995){let w=Math.acos(m),R=Math.sin(w);f=Math.sin(f*w)/R,a=Math.sin(a*w)/R,l=l*f+u*a,c=c*f+p*a,h=h*f+g*a,d=d*f+_*a}else{l=l*f+u*a,c=c*f+p*a,h=h*f+g*a,d=d*f+_*a;let w=1/Math.sqrt(l*l+c*c+h*h+d*d);l*=w,c*=w,h*=w,d*=w}}t[e]=l,t[e+1]=c,t[e+2]=h,t[e+3]=d}static multiplyQuaternionsFlat(t,e,n,s,r,o){let a=n[s],l=n[s+1],c=n[s+2],h=n[s+3],d=r[o],u=r[o+1],p=r[o+2],g=r[o+3];return t[e]=a*g+h*d+l*p-c*u,t[e+1]=l*g+h*u+c*d-a*p,t[e+2]=c*g+h*p+a*u-l*d,t[e+3]=h*g-a*d-l*u-c*p,t}get x(){return this._x}set x(t){this._x=t,this._onChangeCallback()}get y(){return this._y}set y(t){this._y=t,this._onChangeCallback()}get z(){return this._z}set z(t){this._z=t,this._onChangeCallback()}get w(){return this._w}set w(t){this._w=t,this._onChangeCallback()}set(t,e,n,s){return this._x=t,this._y=e,this._z=n,this._w=s,this._onChangeCallback(),this}clone(){return new this.constructor(this._x,this._y,this._z,this._w)}copy(t){return this._x=t.x,this._y=t.y,this._z=t.z,this._w=t.w,this._onChangeCallback(),this}setFromEuler(t,e=!0){let n=t._x,s=t._y,r=t._z,o=t._order,a=Math.cos,l=Math.sin,c=a(n/2),h=a(s/2),d=a(r/2),u=l(n/2),p=l(s/2),g=l(r/2);switch(o){case"XYZ":this._x=u*h*d+c*p*g,this._y=c*p*d-u*h*g,this._z=c*h*g+u*p*d,this._w=c*h*d-u*p*g;break;case"YXZ":this._x=u*h*d+c*p*g,this._y=c*p*d-u*h*g,this._z=c*h*g-u*p*d,this._w=c*h*d+u*p*g;break;case"ZXY":this._x=u*h*d-c*p*g,this._y=c*p*d+u*h*g,this._z=c*h*g+u*p*d,this._w=c*h*d-u*p*g;break;case"ZYX":this._x=u*h*d-c*p*g,this._y=c*p*d+u*h*g,this._z=c*h*g-u*p*d,this._w=c*h*d+u*p*g;break;case"YZX":this._x=u*h*d+c*p*g,this._y=c*p*d+u*h*g,this._z=c*h*g-u*p*d,this._w=c*h*d-u*p*g;break;case"XZY":this._x=u*h*d-c*p*g,this._y=c*p*d-u*h*g,this._z=c*h*g+u*p*d,this._w=c*h*d+u*p*g;break;default:Ct("Quaternion: .setFromEuler() encountered an unknown order: "+o)}return e===!0&&this._onChangeCallback(),this}setFromAxisAngle(t,e){let n=e/2,s=Math.sin(n);return this._x=t.x*s,this._y=t.y*s,this._z=t.z*s,this._w=Math.cos(n),this._onChangeCallback(),this}setFromRotationMatrix(t){let e=t.elements,n=e[0],s=e[4],r=e[8],o=e[1],a=e[5],l=e[9],c=e[2],h=e[6],d=e[10],u=n+a+d;if(u>0){let p=.5/Math.sqrt(u+1);this._w=.25/p,this._x=(h-l)*p,this._y=(r-c)*p,this._z=(o-s)*p}else if(n>a&&n>d){let p=2*Math.sqrt(1+n-a-d);this._w=(h-l)/p,this._x=.25*p,this._y=(s+o)/p,this._z=(r+c)/p}else if(a>d){let p=2*Math.sqrt(1+a-n-d);this._w=(r-c)/p,this._x=(s+o)/p,this._y=.25*p,this._z=(l+h)/p}else{let p=2*Math.sqrt(1+d-n-a);this._w=(o-s)/p,this._x=(r+c)/p,this._y=(l+h)/p,this._z=.25*p}return this._onChangeCallback(),this}setFromUnitVectors(t,e){let n=t.dot(e)+1;return n<1e-8?(n=0,Math.abs(t.x)>Math.abs(t.z)?(this._x=-t.y,this._y=t.x,this._z=0,this._w=n):(this._x=0,this._y=-t.z,this._z=t.y,this._w=n)):(this._x=t.y*e.z-t.z*e.y,this._y=t.z*e.x-t.x*e.z,this._z=t.x*e.y-t.y*e.x,this._w=n),this.normalize()}angleTo(t){return 2*Math.acos(Math.abs(Yt(this.dot(t),-1,1)))}rotateTowards(t,e){let n=this.angleTo(t);if(n===0)return this;let s=Math.min(1,e/n);return this.slerp(t,s),this}identity(){return this.set(0,0,0,1)}invert(){return this.conjugate()}conjugate(){return this._x*=-1,this._y*=-1,this._z*=-1,this._onChangeCallback(),this}dot(t){return this._x*t._x+this._y*t._y+this._z*t._z+this._w*t._w}lengthSq(){return this._x*this._x+this._y*this._y+this._z*this._z+this._w*this._w}length(){return Math.sqrt(this._x*this._x+this._y*this._y+this._z*this._z+this._w*this._w)}normalize(){let t=this.length();return t===0?(this._x=0,this._y=0,this._z=0,this._w=1):(t=1/t,this._x=this._x*t,this._y=this._y*t,this._z=this._z*t,this._w=this._w*t),this._onChangeCallback(),this}multiply(t){return this.multiplyQuaternions(this,t)}premultiply(t){return this.multiplyQuaternions(t,this)}multiplyQuaternions(t,e){let n=t._x,s=t._y,r=t._z,o=t._w,a=e._x,l=e._y,c=e._z,h=e._w;return this._x=n*h+o*a+s*c-r*l,this._y=s*h+o*l+r*a-n*c,this._z=r*h+o*c+n*l-s*a,this._w=o*h-n*a-s*l-r*c,this._onChangeCallback(),this}slerp(t,e){let n=t._x,s=t._y,r=t._z,o=t._w,a=this.dot(t);a<0&&(n=-n,s=-s,r=-r,o=-o,a=-a);let l=1-e;if(a<.9995){let c=Math.acos(a),h=Math.sin(c);l=Math.sin(l*c)/h,e=Math.sin(e*c)/h,this._x=this._x*l+n*e,this._y=this._y*l+s*e,this._z=this._z*l+r*e,this._w=this._w*l+o*e,this._onChangeCallback()}else this._x=this._x*l+n*e,this._y=this._y*l+s*e,this._z=this._z*l+r*e,this._w=this._w*l+o*e,this.normalize();return this}slerpQuaternions(t,e,n){return this.copy(t).slerp(e,n)}random(){let t=2*Math.PI*Math.random(),e=2*Math.PI*Math.random(),n=Math.random(),s=Math.sqrt(1-n),r=Math.sqrt(n);return this.set(s*Math.sin(t),s*Math.cos(t),r*Math.sin(e),r*Math.cos(e))}equals(t){return t._x===this._x&&t._y===this._y&&t._z===this._z&&t._w===this._w}fromArray(t,e=0){return this._x=t[e],this._y=t[e+1],this._z=t[e+2],this._w=t[e+3],this._onChangeCallback(),this}toArray(t=[],e=0){return t[e]=this._x,t[e+1]=this._y,t[e+2]=this._z,t[e+3]=this._w,t}fromBufferAttribute(t,e){return this._x=t.getX(e),this._y=t.getY(e),this._z=t.getZ(e),this._w=t.getW(e),this._onChangeCallback(),this}toJSON(){return this.toArray()}_onChange(t){return this._onChangeCallback=t,this}_onChangeCallback(){}*[Symbol.iterator](){yield this._x,yield this._y,yield this._z,yield this._w}},rc=class rc{constructor(t=0,e=0,n=0){this.x=t,this.y=e,this.z=n}set(t,e,n){return n===void 0&&(n=this.z),this.x=t,this.y=e,this.z=n,this}setScalar(t){return this.x=t,this.y=t,this.z=t,this}setX(t){return this.x=t,this}setY(t){return this.y=t,this}setZ(t){return this.z=t,this}setComponent(t,e){switch(t){case 0:this.x=e;break;case 1:this.y=e;break;case 2:this.z=e;break;default:throw new Error("THREE.Vector3: index is out of range: "+t)}return this}getComponent(t){switch(t){case 0:return this.x;case 1:return this.y;case 2:return this.z;default:throw new Error("THREE.Vector3: index is out of range: "+t)}}clone(){return new this.constructor(this.x,this.y,this.z)}copy(t){return this.x=t.x,this.y=t.y,this.z=t.z,this}add(t){return this.x+=t.x,this.y+=t.y,this.z+=t.z,this}addScalar(t){return this.x+=t,this.y+=t,this.z+=t,this}addVectors(t,e){return this.x=t.x+e.x,this.y=t.y+e.y,this.z=t.z+e.z,this}addScaledVector(t,e){return this.x+=t.x*e,this.y+=t.y*e,this.z+=t.z*e,this}sub(t){return this.x-=t.x,this.y-=t.y,this.z-=t.z,this}subScalar(t){return this.x-=t,this.y-=t,this.z-=t,this}subVectors(t,e){return this.x=t.x-e.x,this.y=t.y-e.y,this.z=t.z-e.z,this}multiply(t){return this.x*=t.x,this.y*=t.y,this.z*=t.z,this}multiplyScalar(t){return this.x*=t,this.y*=t,this.z*=t,this}multiplyVectors(t,e){return this.x=t.x*e.x,this.y=t.y*e.y,this.z=t.z*e.z,this}applyEuler(t){return this.applyQuaternion(ih.setFromEuler(t))}applyAxisAngle(t,e){return this.applyQuaternion(ih.setFromAxisAngle(t,e))}applyMatrix3(t){let e=this.x,n=this.y,s=this.z,r=t.elements;return this.x=r[0]*e+r[3]*n+r[6]*s,this.y=r[1]*e+r[4]*n+r[7]*s,this.z=r[2]*e+r[5]*n+r[8]*s,this}applyNormalMatrix(t){return this.applyMatrix3(t).normalize()}applyMatrix4(t){let e=this.x,n=this.y,s=this.z,r=t.elements,o=1/(r[3]*e+r[7]*n+r[11]*s+r[15]);return this.x=(r[0]*e+r[4]*n+r[8]*s+r[12])*o,this.y=(r[1]*e+r[5]*n+r[9]*s+r[13])*o,this.z=(r[2]*e+r[6]*n+r[10]*s+r[14])*o,this}applyQuaternion(t){let e=this.x,n=this.y,s=this.z,r=t.x,o=t.y,a=t.z,l=t.w,c=2*(o*s-a*n),h=2*(a*e-r*s),d=2*(r*n-o*e);return this.x=e+l*c+o*d-a*h,this.y=n+l*h+a*c-r*d,this.z=s+l*d+r*h-o*c,this}project(t){return this.applyMatrix4(t.matrixWorldInverse).applyMatrix4(t.projectionMatrix)}unproject(t){return this.applyMatrix4(t.projectionMatrixInverse).applyMatrix4(t.matrixWorld)}transformDirection(t){let e=this.x,n=this.y,s=this.z,r=t.elements;return this.x=r[0]*e+r[4]*n+r[8]*s,this.y=r[1]*e+r[5]*n+r[9]*s,this.z=r[2]*e+r[6]*n+r[10]*s,this.normalize()}divide(t){return this.x/=t.x,this.y/=t.y,this.z/=t.z,this}divideScalar(t){return this.multiplyScalar(1/t)}min(t){return this.x=Math.min(this.x,t.x),this.y=Math.min(this.y,t.y),this.z=Math.min(this.z,t.z),this}max(t){return this.x=Math.max(this.x,t.x),this.y=Math.max(this.y,t.y),this.z=Math.max(this.z,t.z),this}clamp(t,e){return this.x=Yt(this.x,t.x,e.x),this.y=Yt(this.y,t.y,e.y),this.z=Yt(this.z,t.z,e.z),this}clampScalar(t,e){return this.x=Yt(this.x,t,e),this.y=Yt(this.y,t,e),this.z=Yt(this.z,t,e),this}clampLength(t,e){let n=this.length();return this.divideScalar(n||1).multiplyScalar(Yt(n,t,e))}floor(){return this.x=Math.floor(this.x),this.y=Math.floor(this.y),this.z=Math.floor(this.z),this}ceil(){return this.x=Math.ceil(this.x),this.y=Math.ceil(this.y),this.z=Math.ceil(this.z),this}round(){return this.x=Math.round(this.x),this.y=Math.round(this.y),this.z=Math.round(this.z),this}roundToZero(){return this.x=Math.trunc(this.x),this.y=Math.trunc(this.y),this.z=Math.trunc(this.z),this}negate(){return this.x=-this.x,this.y=-this.y,this.z=-this.z,this}dot(t){return this.x*t.x+this.y*t.y+this.z*t.z}lengthSq(){return this.x*this.x+this.y*this.y+this.z*this.z}length(){return Math.sqrt(this.x*this.x+this.y*this.y+this.z*this.z)}manhattanLength(){return Math.abs(this.x)+Math.abs(this.y)+Math.abs(this.z)}normalize(){return this.divideScalar(this.length()||1)}setLength(t){return this.normalize().multiplyScalar(t)}lerp(t,e){return this.x+=(t.x-this.x)*e,this.y+=(t.y-this.y)*e,this.z+=(t.z-this.z)*e,this}lerpVectors(t,e,n){return this.x=t.x+(e.x-t.x)*n,this.y=t.y+(e.y-t.y)*n,this.z=t.z+(e.z-t.z)*n,this}cross(t){return this.crossVectors(this,t)}crossVectors(t,e){let n=t.x,s=t.y,r=t.z,o=e.x,a=e.y,l=e.z;return this.x=s*l-r*a,this.y=r*o-n*l,this.z=n*a-s*o,this}projectOnVector(t){let e=t.lengthSq();if(e===0)return this.set(0,0,0);let n=t.dot(this)/e;return this.copy(t).multiplyScalar(n)}projectOnPlane(t){return Qa.copy(this).projectOnVector(t),this.sub(Qa)}reflect(t){return this.sub(Qa.copy(t).multiplyScalar(2*this.dot(t)))}angleTo(t){let e=Math.sqrt(this.lengthSq()*t.lengthSq());if(e===0)return Math.PI/2;let n=this.dot(t)/e;return Math.acos(Yt(n,-1,1))}distanceTo(t){return Math.sqrt(this.distanceToSquared(t))}distanceToSquared(t){let e=this.x-t.x,n=this.y-t.y,s=this.z-t.z;return e*e+n*n+s*s}manhattanDistanceTo(t){return Math.abs(this.x-t.x)+Math.abs(this.y-t.y)+Math.abs(this.z-t.z)}setFromSpherical(t){return this.setFromSphericalCoords(t.radius,t.phi,t.theta)}setFromSphericalCoords(t,e,n){let s=Math.sin(e)*t;return this.x=s*Math.sin(n),this.y=Math.cos(e)*t,this.z=s*Math.cos(n),this}setFromCylindrical(t){return this.setFromCylindricalCoords(t.radius,t.theta,t.y)}setFromCylindricalCoords(t,e,n){return this.x=t*Math.sin(e),this.y=n,this.z=t*Math.cos(e),this}setFromMatrixPosition(t){let e=t.elements;return this.x=e[12],this.y=e[13],this.z=e[14],this}setFromMatrixScale(t){let e=this.setFromMatrixColumn(t,0).length(),n=this.setFromMatrixColumn(t,1).length(),s=this.setFromMatrixColumn(t,2).length();return this.x=e,this.y=n,this.z=s,this}setFromMatrixColumn(t,e){return this.fromArray(t.elements,e*4)}setFromMatrix3Column(t,e){return this.fromArray(t.elements,e*3)}setFromEuler(t){return this.x=t._x,this.y=t._y,this.z=t._z,this}setFromColor(t){return this.x=t.r,this.y=t.g,this.z=t.b,this}equals(t){return t.x===this.x&&t.y===this.y&&t.z===this.z}fromArray(t,e=0){return this.x=t[e],this.y=t[e+1],this.z=t[e+2],this}toArray(t=[],e=0){return t[e]=this.x,t[e+1]=this.y,t[e+2]=this.z,t}fromBufferAttribute(t,e){return this.x=t.getX(e),this.y=t.getY(e),this.z=t.getZ(e),this}random(){return this.x=Math.random(),this.y=Math.random(),this.z=Math.random(),this}randomDirection(){let t=Math.random()*Math.PI*2,e=Math.random()*2-1,n=Math.sqrt(1-e*e);return this.x=n*Math.cos(t),this.y=e,this.z=n*Math.sin(t),this}*[Symbol.iterator](){yield this.x,yield this.y,yield this.z}};rc.prototype.isVector3=!0;var C=rc,Qa=new C,ih=new Ne,oc=class oc{constructor(t,e,n,s,r,o,a,l,c){this.elements=[1,0,0,0,1,0,0,0,1],t!==void 0&&this.set(t,e,n,s,r,o,a,l,c)}set(t,e,n,s,r,o,a,l,c){let h=this.elements;return h[0]=t,h[1]=s,h[2]=a,h[3]=e,h[4]=r,h[5]=l,h[6]=n,h[7]=o,h[8]=c,this}identity(){return this.set(1,0,0,0,1,0,0,0,1),this}copy(t){let e=this.elements,n=t.elements;return e[0]=n[0],e[1]=n[1],e[2]=n[2],e[3]=n[3],e[4]=n[4],e[5]=n[5],e[6]=n[6],e[7]=n[7],e[8]=n[8],this}extractBasis(t,e,n){return t.setFromMatrix3Column(this,0),e.setFromMatrix3Column(this,1),n.setFromMatrix3Column(this,2),this}setFromMatrix4(t){let e=t.elements;return this.set(e[0],e[4],e[8],e[1],e[5],e[9],e[2],e[6],e[10]),this}multiply(t){return this.multiplyMatrices(this,t)}premultiply(t){return this.multiplyMatrices(t,this)}multiplyMatrices(t,e){let n=t.elements,s=e.elements,r=this.elements,o=n[0],a=n[3],l=n[6],c=n[1],h=n[4],d=n[7],u=n[2],p=n[5],g=n[8],_=s[0],m=s[3],f=s[6],w=s[1],R=s[4],S=s[7],b=s[2],y=s[5],T=s[8];return r[0]=o*_+a*w+l*b,r[3]=o*m+a*R+l*y,r[6]=o*f+a*S+l*T,r[1]=c*_+h*w+d*b,r[4]=c*m+h*R+d*y,r[7]=c*f+h*S+d*T,r[2]=u*_+p*w+g*b,r[5]=u*m+p*R+g*y,r[8]=u*f+p*S+g*T,this}multiplyScalar(t){let e=this.elements;return e[0]*=t,e[3]*=t,e[6]*=t,e[1]*=t,e[4]*=t,e[7]*=t,e[2]*=t,e[5]*=t,e[8]*=t,this}determinant(){let t=this.elements,e=t[0],n=t[1],s=t[2],r=t[3],o=t[4],a=t[5],l=t[6],c=t[7],h=t[8];return e*o*h-e*a*c-n*r*h+n*a*l+s*r*c-s*o*l}invert(){let t=this.elements,e=t[0],n=t[1],s=t[2],r=t[3],o=t[4],a=t[5],l=t[6],c=t[7],h=t[8],d=h*o-a*c,u=a*l-h*r,p=c*r-o*l,g=e*d+n*u+s*p;if(g===0)return this.set(0,0,0,0,0,0,0,0,0);let _=1/g;return t[0]=d*_,t[1]=(s*c-h*n)*_,t[2]=(a*n-s*o)*_,t[3]=u*_,t[4]=(h*e-s*l)*_,t[5]=(s*r-a*e)*_,t[6]=p*_,t[7]=(n*l-c*e)*_,t[8]=(o*e-n*r)*_,this}transpose(){let t,e=this.elements;return t=e[1],e[1]=e[3],e[3]=t,t=e[2],e[2]=e[6],e[6]=t,t=e[5],e[5]=e[7],e[7]=t,this}getNormalMatrix(t){return this.setFromMatrix4(t).invert().transpose()}transposeIntoArray(t){let e=this.elements;return t[0]=e[0],t[1]=e[3],t[2]=e[6],t[3]=e[1],t[4]=e[4],t[5]=e[7],t[6]=e[2],t[7]=e[5],t[8]=e[8],this}setUvTransform(t,e,n,s,r,o,a){let l=Math.cos(r),c=Math.sin(r);return this.set(n*l,n*c,-n*(l*o+c*a)+o+t,-s*c,s*l,-s*(-c*o+l*a)+a+e,0,0,1),this}scale(t,e){return yi("Matrix3: .scale() is deprecated. Use .makeScale() instead."),this.premultiply(tl.makeScale(t,e)),this}rotate(t){return yi("Matrix3: .rotate() is deprecated. Use .makeRotation() instead."),this.premultiply(tl.makeRotation(-t)),this}translate(t,e){return yi("Matrix3: .translate() is deprecated. Use .makeTranslation() instead."),this.premultiply(tl.makeTranslation(t,e)),this}makeTranslation(t,e){return t.isVector2?this.set(1,0,t.x,0,1,t.y,0,0,1):this.set(1,0,t,0,1,e,0,0,1),this}makeRotation(t){let e=Math.cos(t),n=Math.sin(t);return this.set(e,-n,0,n,e,0,0,0,1),this}makeScale(t,e){return this.set(t,0,0,0,e,0,0,0,1),this}equals(t){let e=this.elements,n=t.elements;for(let s=0;s<9;s++)if(e[s]!==n[s])return!1;return!0}fromArray(t,e=0){for(let n=0;n<9;n++)this.elements[n]=t[n+e];return this}toArray(t=[],e=0){let n=this.elements;return t[e]=n[0],t[e+1]=n[1],t[e+2]=n[2],t[e+3]=n[3],t[e+4]=n[4],t[e+5]=n[5],t[e+6]=n[6],t[e+7]=n[7],t[e+8]=n[8],t}clone(){return new this.constructor().fromArray(this.elements)}};oc.prototype.isMatrix3=!0;var Ut=oc,tl=new Ut,sh=new Ut().set(.4123908,.3575843,.1804808,.212639,.7151687,.0721923,.0193308,.1191948,.9505322),rh=new Ut().set(3.2409699,-1.5373832,-.4986108,-.9692436,1.8759675,.0415551,.0556301,-.203977,1.0569715);function $f(){let i={enabled:!0,workingColorSpace:Fs,spaces:{},convert:function(s,r,o){return this.enabled===!1||r===o||!r||!o||(this.spaces[r].transfer===te&&(s.r=kn(s.r),s.g=kn(s.g),s.b=kn(s.b)),this.spaces[r].primaries!==this.spaces[o].primaries&&(s.applyMatrix3(this.spaces[r].toXYZ),s.applyMatrix3(this.spaces[o].fromXYZ)),this.spaces[o].transfer===te&&(s.r=ts(s.r),s.g=ts(s.g),s.b=ts(s.b))),s},workingToColorSpace:function(s,r){return this.convert(s,this.workingColorSpace,r)},colorSpaceToWorking:function(s,r){return this.convert(s,r,this.workingColorSpace)},getPrimaries:function(s){return this.spaces[s].primaries},getTransfer:function(s){return s===Wn?Bs:this.spaces[s].transfer},getToneMappingMode:function(s){return this.spaces[s].outputColorSpaceConfig.toneMappingMode||"standard"},getLuminanceCoefficients:function(s,r=this.workingColorSpace){return s.fromArray(this.spaces[r].luminanceCoefficients)},define:function(s){Object.assign(this.spaces,s)},_getMatrix:function(s,r,o){return s.copy(this.spaces[r].toXYZ).multiply(this.spaces[o].fromXYZ)},_getDrawingBufferColorSpace:function(s){return this.spaces[s].outputColorSpaceConfig.drawingBufferColorSpace},_getUnpackColorSpace:function(s=this.workingColorSpace){return this.spaces[s].workingColorSpaceConfig.unpackColorSpace},fromWorkingColorSpace:function(s,r){return yi("ColorManagement: .fromWorkingColorSpace() has been renamed to .workingToColorSpace()."),i.workingToColorSpace(s,r)},toWorkingColorSpace:function(s,r){return yi("ColorManagement: .toWorkingColorSpace() has been renamed to .colorSpaceToWorking()."),i.colorSpaceToWorking(s,r)}},t=[.64,.33,.3,.6,.15,.06],e=[.2126,.7152,.0722],n=[.3127,.329];return i.define({[Fs]:{primaries:t,whitePoint:n,transfer:Bs,toXYZ:sh,fromXYZ:rh,luminanceCoefficients:e,workingColorSpaceConfig:{unpackColorSpace:Se},outputColorSpaceConfig:{drawingBufferColorSpace:Se}},[Se]:{primaries:t,whitePoint:n,transfer:te,toXYZ:sh,fromXYZ:rh,luminanceCoefficients:e,outputColorSpaceConfig:{drawingBufferColorSpace:Se}}}),i}var qt=$f();function kn(i){return i<.04045?i*.0773993808:Math.pow(i*.9478672986+.0521327014,2.4)}function ts(i){return i<.0031308?i*12.92:1.055*Math.pow(i,.41666)-.055}var Oi,lo=class{static getDataURL(t,e="image/png"){if(/^data:/i.test(t.src)||typeof HTMLCanvasElement>"u")return t.src;let n;if(t instanceof HTMLCanvasElement)n=t;else{Oi===void 0&&(Oi=Os("canvas")),Oi.width=t.width,Oi.height=t.height;let s=Oi.getContext("2d");t instanceof ImageData?s.putImageData(t,0,0):s.drawImage(t,0,0,t.width,t.height),n=Oi}return n.toDataURL(e)}static sRGBToLinear(t){if(typeof HTMLImageElement<"u"&&t instanceof HTMLImageElement||typeof HTMLCanvasElement<"u"&&t instanceof HTMLCanvasElement||typeof ImageBitmap<"u"&&t instanceof ImageBitmap){let e=Os("canvas");e.width=t.width,e.height=t.height;let n=e.getContext("2d");n.drawImage(t,0,0,t.width,t.height);let s=n.getImageData(0,0,t.width,t.height),r=s.data;for(let o=0;o1),this.pmremVersion=0,this.normalized=!1}get width(){return this.source.getSize(nl).x}get height(){return this.source.getSize(nl).y}get depth(){return this.source.getSize(nl).z}get image(){return this.source.data}set image(t){this.source.data=t}updateMatrix(){this.matrix.setUvTransform(this.offset.x,this.offset.y,this.repeat.x,this.repeat.y,this.rotation,this.center.x,this.center.y)}addUpdateRange(t,e){this.updateRanges.push({start:t,count:e})}clearUpdateRanges(){this.updateRanges.length=0}clone(){return new this.constructor().copy(this)}copy(t){return this.name=t.name,this.source=t.source,this.mipmaps=t.mipmaps.slice(0),this.mapping=t.mapping,this.channel=t.channel,this.wrapS=t.wrapS,this.wrapT=t.wrapT,this.magFilter=t.magFilter,this.minFilter=t.minFilter,this.anisotropy=t.anisotropy,this.format=t.format,this.internalFormat=t.internalFormat,this.type=t.type,this.normalized=t.normalized,this.offset.copy(t.offset),this.repeat.copy(t.repeat),this.center.copy(t.center),this.rotation=t.rotation,this.matrixAutoUpdate=t.matrixAutoUpdate,this.matrix.copy(t.matrix),this.generateMipmaps=t.generateMipmaps,this.premultiplyAlpha=t.premultiplyAlpha,this.flipY=t.flipY,this.unpackAlignment=t.unpackAlignment,this.colorSpace=t.colorSpace,this.renderTarget=t.renderTarget,this.isRenderTargetTexture=t.isRenderTargetTexture,this.isArrayTexture=t.isArrayTexture,this.userData=JSON.parse(JSON.stringify(t.userData)),this.needsUpdate=!0,this}setValues(t){for(let e in t){let n=t[e];if(n===void 0){Ct(`Texture.setValues(): parameter '${e}' has value of undefined.`);continue}let s=this[e];if(s===void 0){Ct(`Texture.setValues(): property '${e}' does not exist.`);continue}s&&n&&s.isVector2&&n.isVector2||s&&n&&s.isVector3&&n.isVector3||s&&n&&s.isMatrix3&&n.isMatrix3?s.copy(n):this[e]=n}}toJSON(t){let e=t===void 0||typeof t=="string";if(!e&&t.textures[this.uuid]!==void 0)return t.textures[this.uuid];let n={metadata:{version:4.7,type:"Texture",generator:"Texture.toJSON"},uuid:this.uuid,name:this.name,image:this.source.toJSON(t).uuid,mapping:this.mapping,channel:this.channel,repeat:[this.repeat.x,this.repeat.y],offset:[this.offset.x,this.offset.y],center:[this.center.x,this.center.y],rotation:this.rotation,wrap:[this.wrapS,this.wrapT],format:this.format,internalFormat:this.internalFormat,type:this.type,normalized:this.normalized,colorSpace:this.colorSpace,minFilter:this.minFilter,magFilter:this.magFilter,anisotropy:this.anisotropy,flipY:this.flipY,generateMipmaps:this.generateMipmaps,premultiplyAlpha:this.premultiplyAlpha,unpackAlignment:this.unpackAlignment};return Object.keys(this.userData).length>0&&(n.userData=this.userData),e||(t.textures[this.uuid]=n),n}dispose(){this.dispatchEvent({type:"dispose"})}transformUv(t){if(this.mapping!==Wl)return t;if(t.applyMatrix3(this.matrix),t.x<0||t.x>1)switch(this.wrapS){case ns:t.x=t.x-Math.floor(t.x);break;case En:t.x=t.x<0?0:1;break;case oo:Math.abs(Math.floor(t.x)%2)===1?t.x=Math.ceil(t.x)-t.x:t.x=t.x-Math.floor(t.x);break}if(t.y<0||t.y>1)switch(this.wrapT){case ns:t.y=t.y-Math.floor(t.y);break;case En:t.y=t.y<0?0:1;break;case oo:Math.abs(Math.floor(t.y)%2)===1?t.y=Math.ceil(t.y)-t.y:t.y=t.y-Math.floor(t.y);break}return this.flipY&&(t.y=1-t.y),t}set needsUpdate(t){t===!0&&(this.version++,this.source.needsUpdate=!0)}set needsPMREMUpdate(t){t===!0&&this.pmremVersion++}};Ue.DEFAULT_IMAGE=null;Ue.DEFAULT_MAPPING=Wl;Ue.DEFAULT_ANISOTROPY=1;var ac=class ac{constructor(t=0,e=0,n=0,s=1){this.x=t,this.y=e,this.z=n,this.w=s}get width(){return this.z}set width(t){this.z=t}get height(){return this.w}set height(t){this.w=t}set(t,e,n,s){return this.x=t,this.y=e,this.z=n,this.w=s,this}setScalar(t){return this.x=t,this.y=t,this.z=t,this.w=t,this}setX(t){return this.x=t,this}setY(t){return this.y=t,this}setZ(t){return this.z=t,this}setW(t){return this.w=t,this}setComponent(t,e){switch(t){case 0:this.x=e;break;case 1:this.y=e;break;case 2:this.z=e;break;case 3:this.w=e;break;default:throw new Error("THREE.Vector4: index is out of range: "+t)}return this}getComponent(t){switch(t){case 0:return this.x;case 1:return this.y;case 2:return this.z;case 3:return this.w;default:throw new Error("THREE.Vector4: index is out of range: "+t)}}clone(){return new this.constructor(this.x,this.y,this.z,this.w)}copy(t){return this.x=t.x,this.y=t.y,this.z=t.z,this.w=t.w!==void 0?t.w:1,this}add(t){return this.x+=t.x,this.y+=t.y,this.z+=t.z,this.w+=t.w,this}addScalar(t){return this.x+=t,this.y+=t,this.z+=t,this.w+=t,this}addVectors(t,e){return this.x=t.x+e.x,this.y=t.y+e.y,this.z=t.z+e.z,this.w=t.w+e.w,this}addScaledVector(t,e){return this.x+=t.x*e,this.y+=t.y*e,this.z+=t.z*e,this.w+=t.w*e,this}sub(t){return this.x-=t.x,this.y-=t.y,this.z-=t.z,this.w-=t.w,this}subScalar(t){return this.x-=t,this.y-=t,this.z-=t,this.w-=t,this}subVectors(t,e){return this.x=t.x-e.x,this.y=t.y-e.y,this.z=t.z-e.z,this.w=t.w-e.w,this}multiply(t){return this.x*=t.x,this.y*=t.y,this.z*=t.z,this.w*=t.w,this}multiplyScalar(t){return this.x*=t,this.y*=t,this.z*=t,this.w*=t,this}applyMatrix4(t){let e=this.x,n=this.y,s=this.z,r=this.w,o=t.elements;return this.x=o[0]*e+o[4]*n+o[8]*s+o[12]*r,this.y=o[1]*e+o[5]*n+o[9]*s+o[13]*r,this.z=o[2]*e+o[6]*n+o[10]*s+o[14]*r,this.w=o[3]*e+o[7]*n+o[11]*s+o[15]*r,this}divide(t){return this.x/=t.x,this.y/=t.y,this.z/=t.z,this.w/=t.w,this}divideScalar(t){return this.multiplyScalar(1/t)}setAxisAngleFromQuaternion(t){this.w=2*Math.acos(t.w);let e=Math.sqrt(1-t.w*t.w);return e<1e-4?(this.x=1,this.y=0,this.z=0):(this.x=t.x/e,this.y=t.y/e,this.z=t.z/e),this}setAxisAngleFromRotationMatrix(t){let e,n,s,r,l=t.elements,c=l[0],h=l[4],d=l[8],u=l[1],p=l[5],g=l[9],_=l[2],m=l[6],f=l[10];if(Math.abs(h-u)<.01&&Math.abs(d-_)<.01&&Math.abs(g-m)<.01){if(Math.abs(h+u)<.1&&Math.abs(d+_)<.1&&Math.abs(g+m)<.1&&Math.abs(c+p+f-3)<.1)return this.set(1,0,0,0),this;e=Math.PI;let R=(c+1)/2,S=(p+1)/2,b=(f+1)/2,y=(h+u)/4,T=(d+_)/4,v=(g+m)/4;return R>S&&R>b?R<.01?(n=0,s=.707106781,r=.707106781):(n=Math.sqrt(R),s=y/n,r=T/n):S>b?S<.01?(n=.707106781,s=0,r=.707106781):(s=Math.sqrt(S),n=y/s,r=v/s):b<.01?(n=.707106781,s=.707106781,r=0):(r=Math.sqrt(b),n=T/r,s=v/r),this.set(n,s,r,e),this}let w=Math.sqrt((m-g)*(m-g)+(d-_)*(d-_)+(u-h)*(u-h));return Math.abs(w)<.001&&(w=1),this.x=(m-g)/w,this.y=(d-_)/w,this.z=(u-h)/w,this.w=Math.acos((c+p+f-1)/2),this}setFromMatrixPosition(t){let e=t.elements;return this.x=e[12],this.y=e[13],this.z=e[14],this.w=e[15],this}min(t){return this.x=Math.min(this.x,t.x),this.y=Math.min(this.y,t.y),this.z=Math.min(this.z,t.z),this.w=Math.min(this.w,t.w),this}max(t){return this.x=Math.max(this.x,t.x),this.y=Math.max(this.y,t.y),this.z=Math.max(this.z,t.z),this.w=Math.max(this.w,t.w),this}clamp(t,e){return this.x=Yt(this.x,t.x,e.x),this.y=Yt(this.y,t.y,e.y),this.z=Yt(this.z,t.z,e.z),this.w=Yt(this.w,t.w,e.w),this}clampScalar(t,e){return this.x=Yt(this.x,t,e),this.y=Yt(this.y,t,e),this.z=Yt(this.z,t,e),this.w=Yt(this.w,t,e),this}clampLength(t,e){let n=this.length();return this.divideScalar(n||1).multiplyScalar(Yt(n,t,e))}floor(){return this.x=Math.floor(this.x),this.y=Math.floor(this.y),this.z=Math.floor(this.z),this.w=Math.floor(this.w),this}ceil(){return this.x=Math.ceil(this.x),this.y=Math.ceil(this.y),this.z=Math.ceil(this.z),this.w=Math.ceil(this.w),this}round(){return this.x=Math.round(this.x),this.y=Math.round(this.y),this.z=Math.round(this.z),this.w=Math.round(this.w),this}roundToZero(){return this.x=Math.trunc(this.x),this.y=Math.trunc(this.y),this.z=Math.trunc(this.z),this.w=Math.trunc(this.w),this}negate(){return this.x=-this.x,this.y=-this.y,this.z=-this.z,this.w=-this.w,this}dot(t){return this.x*t.x+this.y*t.y+this.z*t.z+this.w*t.w}lengthSq(){return this.x*this.x+this.y*this.y+this.z*this.z+this.w*this.w}length(){return Math.sqrt(this.x*this.x+this.y*this.y+this.z*this.z+this.w*this.w)}manhattanLength(){return Math.abs(this.x)+Math.abs(this.y)+Math.abs(this.z)+Math.abs(this.w)}normalize(){return this.divideScalar(this.length()||1)}setLength(t){return this.normalize().multiplyScalar(t)}lerp(t,e){return this.x+=(t.x-this.x)*e,this.y+=(t.y-this.y)*e,this.z+=(t.z-this.z)*e,this.w+=(t.w-this.w)*e,this}lerpVectors(t,e,n){return this.x=t.x+(e.x-t.x)*n,this.y=t.y+(e.y-t.y)*n,this.z=t.z+(e.z-t.z)*n,this.w=t.w+(e.w-t.w)*n,this}equals(t){return t.x===this.x&&t.y===this.y&&t.z===this.z&&t.w===this.w}fromArray(t,e=0){return this.x=t[e],this.y=t[e+1],this.z=t[e+2],this.w=t[e+3],this}toArray(t=[],e=0){return t[e]=this.x,t[e+1]=this.y,t[e+2]=this.z,t[e+3]=this.w,t}fromBufferAttribute(t,e){return this.x=t.getX(e),this.y=t.getY(e),this.z=t.getZ(e),this.w=t.getW(e),this}random(){return this.x=Math.random(),this.y=Math.random(),this.z=Math.random(),this.w=Math.random(),this}*[Symbol.iterator](){yield this.x,yield this.y,yield this.z,yield this.w}};ac.prototype.isVector4=!0;var pe=ac,co=class extends An{constructor(t=1,e=1,n={}){super(),n=Object.assign({generateMipmaps:!1,internalFormat:null,minFilter:De,depthBuffer:!0,stencilBuffer:!1,resolveColorBuffer:!0,resolveDepthBuffer:!0,resolveStencilBuffer:!0,storeMultisampledColorBuffer:!0,storeMultisampledDepthBuffer:!0,storeMultisampledStencilBuffer:!0,depthTexture:null,samples:0,count:1,depth:1,multiview:!1,useArrayDepthTexture:!1},n),this.isRenderTarget=!0,this.width=t,this.height=e,this.depth=n.depth,this.scissor=new pe(0,0,t,e),this.scissorTest=!1,this.viewport=new pe(0,0,t,e),this.textures=[];let s={width:t,height:e,depth:n.depth},r=new Ue(s),o=n.count;for(let a=0;a1);this.dispose()}this.viewport.set(0,0,t,e),this.scissor.set(0,0,t,e)}clone(){return new this.constructor().copy(this)}copy(t){this.width=t.width,this.height=t.height,this.depth=t.depth,this.scissor.copy(t.scissor),this.scissorTest=t.scissorTest,this.viewport.copy(t.viewport),this.textures.length=0;for(let e=0,n=t.textures.length;e>>0}enable(t){this.mask|=1<1){for(let e=0;e1){for(let n=0;n0&&(s.userData=this.userData),s.layers=this.layers.mask,s.matrix=this.matrix.toArray(),s.up=this.up.toArray(),this.pivot!==null&&(s.pivot=this.pivot.toArray()),this.morphTargetDictionary!==void 0&&(s.morphTargetDictionary=Object.assign({},this.morphTargetDictionary)),this.morphTargetInfluences!==void 0&&(s.morphTargetInfluences=this.morphTargetInfluences.slice()),this.isInstancedMesh&&(s.type="InstancedMesh",s.count=this.count,s.instanceMatrix=this.instanceMatrix.toJSON(),this.instanceColor!==null&&(s.instanceColor=this.instanceColor.toJSON())),this.isBatchedMesh&&(s.type="BatchedMesh",s.perObjectFrustumCulled=this.perObjectFrustumCulled,s.sortObjects=this.sortObjects,s.drawRanges=this._drawRanges,s.reservedRanges=this._reservedRanges,s.geometryInfo=this._geometryInfo.map(a=>({...a,boundingBox:a.boundingBox?a.boundingBox.toJSON():void 0,boundingSphere:a.boundingSphere?a.boundingSphere.toJSON():void 0})),s.instanceInfo=this._instanceInfo.map(a=>({...a})),s.availableInstanceIds=this._availableInstanceIds.slice(),s.availableGeometryIds=this._availableGeometryIds.slice(),s.nextIndexStart=this._nextIndexStart,s.nextVertexStart=this._nextVertexStart,s.geometryCount=this._geometryCount,s.maxInstanceCount=this._maxInstanceCount,s.maxVertexCount=this._maxVertexCount,s.maxIndexCount=this._maxIndexCount,s.geometryInitialized=this._geometryInitialized,s.matricesTexture=this._matricesTexture.toJSON(t),s.indirectTexture=this._indirectTexture.toJSON(t),this._colorsTexture!==null&&(s.colorsTexture=this._colorsTexture.toJSON(t)),this.boundingSphere!==null&&(s.boundingSphere=this.boundingSphere.toJSON()),this.boundingBox!==null&&(s.boundingBox=this.boundingBox.toJSON()));function r(a,l){return a[l.uuid]===void 0&&(a[l.uuid]=l.toJSON(t)),l.uuid}if(this.isScene)this.background&&(this.background.isColor?s.background=this.background.toJSON():this.background.isTexture&&(s.background=this.background.toJSON(t).uuid)),this.environment&&this.environment.isTexture&&this.environment.isRenderTargetTexture!==!0&&(s.environment=this.environment.toJSON(t).uuid);else if(this.isMesh||this.isLine||this.isPoints){s.geometry=r(t.geometries,this.geometry);let a=this.geometry.parameters;if(a!==void 0&&a.shapes!==void 0){let l=a.shapes;if(Array.isArray(l))for(let c=0,h=l.length;c0){s.children=[];for(let a=0;a0){s.animations=[];for(let a=0;a0&&(n.geometries=a),l.length>0&&(n.materials=l),c.length>0&&(n.textures=c),h.length>0&&(n.images=h),d.length>0&&(n.shapes=d),u.length>0&&(n.skeletons=u),p.length>0&&(n.animations=p),g.length>0&&(n.nodes=g)}return n.object=s,n;function o(a){let l=[];for(let c in a){let h=a[c];delete h.metadata,l.push(h)}return l}}clone(t){return new this.constructor().copy(this,t)}copy(t,e=!0){if(this.name=t.name,this.up.copy(t.up),this.position.copy(t.position),this.rotation.order=t.rotation.order,this.quaternion.copy(t.quaternion),this.scale.copy(t.scale),this.pivot=t.pivot!==null?t.pivot.clone():null,this.matrix.copy(t.matrix),this.matrixWorld.copy(t.matrixWorld),this.matrixAutoUpdate=t.matrixAutoUpdate,this.matrixWorldAutoUpdate=t.matrixWorldAutoUpdate,this.matrixWorldNeedsUpdate=t.matrixWorldNeedsUpdate,this.layers.mask=t.layers.mask,this.visible=t.visible,this.castShadow=t.castShadow,this.receiveShadow=t.receiveShadow,this.frustumCulled=t.frustumCulled,this.renderOrder=t.renderOrder,this.static=t.static,this.animations=t.animations.slice(),this.userData=JSON.parse(JSON.stringify(t.userData)),e===!0)for(let n=0;np+g?(c.inputState.pinching=!1,this.dispatchEvent({type:"pinchend",handedness:t.handedness,target:this})):!c.inputState.pinching&&u<=p-g&&(c.inputState.pinching=!0,this.dispatchEvent({type:"pinchstart",handedness:t.handedness,target:this}))}else l!==null&&t.gripSpace&&(r=e.getPose(t.gripSpace,n),r!==null&&(l.matrix.fromArray(r.transform.matrix),l.matrix.decompose(l.position,l.rotation,l.scale),l.matrixWorldNeedsUpdate=!0,r.linearVelocity?(l.hasLinearVelocity=!0,l.linearVelocity.copy(r.linearVelocity)):l.hasLinearVelocity=!1,r.angularVelocity?(l.hasAngularVelocity=!0,l.angularVelocity.copy(r.angularVelocity)):l.hasAngularVelocity=!1,l.eventsEnabled&&l.dispatchEvent({type:"gripUpdated",data:t,target:this})));a!==null&&(s=e.getPose(t.targetRaySpace,n),s===null&&r!==null&&(s=r),s!==null&&(a.matrix.fromArray(s.transform.matrix),a.matrix.decompose(a.position,a.rotation,a.scale),a.matrixWorldNeedsUpdate=!0,s.linearVelocity?(a.hasLinearVelocity=!0,a.linearVelocity.copy(s.linearVelocity)):a.hasLinearVelocity=!1,s.angularVelocity?(a.hasAngularVelocity=!0,a.angularVelocity.copy(s.angularVelocity)):a.hasAngularVelocity=!1,this.dispatchEvent(ip)))}return a!==null&&(a.visible=s!==null),l!==null&&(l.visible=r!==null),c!==null&&(c.visible=o!==null),this}_getHandJoint(t,e){if(t.joints[e.jointName]===void 0){let n=new be;n.matrixAutoUpdate=!1,n.visible=!1,t.joints[e.jointName]=n,t.add(n)}return t.joints[e.jointName]}},_u={aliceblue:15792383,antiquewhite:16444375,aqua:65535,aquamarine:8388564,azure:15794175,beige:16119260,bisque:16770244,black:0,blanchedalmond:16772045,blue:255,blueviolet:9055202,brown:10824234,burlywood:14596231,cadetblue:6266528,chartreuse:8388352,chocolate:13789470,coral:16744272,cornflowerblue:6591981,cornsilk:16775388,crimson:14423100,cyan:65535,darkblue:139,darkcyan:35723,darkgoldenrod:12092939,darkgray:11119017,darkgreen:25600,darkgrey:11119017,darkkhaki:12433259,darkmagenta:9109643,darkolivegreen:5597999,darkorange:16747520,darkorchid:10040012,darkred:9109504,darksalmon:15308410,darkseagreen:9419919,darkslateblue:4734347,darkslategray:3100495,darkslategrey:3100495,darkturquoise:52945,darkviolet:9699539,deeppink:16716947,deepskyblue:49151,dimgray:6908265,dimgrey:6908265,dodgerblue:2003199,firebrick:11674146,floralwhite:16775920,forestgreen:2263842,fuchsia:16711935,gainsboro:14474460,ghostwhite:16316671,gold:16766720,goldenrod:14329120,gray:8421504,green:32768,greenyellow:11403055,grey:8421504,honeydew:15794160,hotpink:16738740,indianred:13458524,indigo:4915330,ivory:16777200,khaki:15787660,lavender:15132410,lavenderblush:16773365,lawngreen:8190976,lemonchiffon:16775885,lightblue:11393254,lightcoral:15761536,lightcyan:14745599,lightgoldenrodyellow:16448210,lightgray:13882323,lightgreen:9498256,lightgrey:13882323,lightpink:16758465,lightsalmon:16752762,lightseagreen:2142890,lightskyblue:8900346,lightslategray:7833753,lightslategrey:7833753,lightsteelblue:11584734,lightyellow:16777184,lime:65280,limegreen:3329330,linen:16445670,magenta:16711935,maroon:8388608,mediumaquamarine:6737322,mediumblue:205,mediumorchid:12211667,mediumpurple:9662683,mediumseagreen:3978097,mediumslateblue:8087790,mediumspringgreen:64154,mediumturquoise:4772300,mediumvioletred:13047173,midnightblue:1644912,mintcream:16121850,mistyrose:16770273,moccasin:16770229,navajowhite:16768685,navy:128,oldlace:16643558,olive:8421376,olivedrab:7048739,orange:16753920,orangered:16729344,orchid:14315734,palegoldenrod:15657130,palegreen:10025880,paleturquoise:11529966,palevioletred:14381203,papayawhip:16773077,peachpuff:16767673,peru:13468991,pink:16761035,plum:14524637,powderblue:11591910,purple:8388736,rebeccapurple:6697881,red:16711680,rosybrown:12357519,royalblue:4286945,saddlebrown:9127187,salmon:16416882,sandybrown:16032864,seagreen:3050327,seashell:16774638,sienna:10506797,silver:12632256,skyblue:8900331,slateblue:6970061,slategray:7372944,slategrey:7372944,snow:16775930,springgreen:65407,steelblue:4620980,tan:13808780,teal:32896,thistle:14204888,tomato:16737095,turquoise:4251856,violet:15631086,wheat:16113331,white:16777215,whitesmoke:16119285,yellow:16776960,yellowgreen:10145074},Kn={h:0,s:0,l:0},Tr={h:0,s:0,l:0};function sl(i,t,e){return e<0&&(e+=1),e>1&&(e-=1),e<1/6?i+(t-i)*6*e:e<1/2?t:e<2/3?i+(t-i)*6*(2/3-e):i}var Dt=class{constructor(t,e,n){return this.isColor=!0,this.r=1,this.g=1,this.b=1,this.set(t,e,n)}set(t,e,n){if(e===void 0&&n===void 0){let s=t;s&&s.isColor?this.copy(s):typeof s=="number"?this.setHex(s):typeof s=="string"&&this.setStyle(s)}else this.setRGB(t,e,n);return this}setScalar(t){return this.r=t,this.g=t,this.b=t,this}setHex(t,e=Se){return t=Math.floor(t),this.r=(t>>16&255)/255,this.g=(t>>8&255)/255,this.b=(t&255)/255,qt.colorSpaceToWorking(this,e),this}setRGB(t,e,n,s=qt.workingColorSpace){return this.r=t,this.g=e,this.b=n,qt.colorSpaceToWorking(this,s),this}setHSL(t,e,n,s=qt.workingColorSpace){if(t=Ql(t,1),e=Yt(e,0,1),n=Yt(n,0,1),e===0)this.r=this.g=this.b=n;else{let r=n<=.5?n*(1+e):n+e-n*e,o=2*n-r;this.r=sl(o,r,t+1/3),this.g=sl(o,r,t),this.b=sl(o,r,t-1/3)}return qt.colorSpaceToWorking(this,s),this}setStyle(t,e=Se){function n(r){r!==void 0&&parseFloat(r)<1&&Ct("Color: Alpha component of "+t+" will be ignored.")}let s;if(s=/^(\w+)\(([^\)]*)\)/.exec(t)){let r,o=s[1],a=s[2];switch(o){case"rgb":case"rgba":if(r=/^\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*(\d*\.?\d+)\s*)?$/.exec(a))return n(r[4]),this.setRGB(Math.min(255,parseInt(r[1],10))/255,Math.min(255,parseInt(r[2],10))/255,Math.min(255,parseInt(r[3],10))/255,e);if(r=/^\s*(\d+)\%\s*,\s*(\d+)\%\s*,\s*(\d+)\%\s*(?:,\s*(\d*\.?\d+)\s*)?$/.exec(a))return n(r[4]),this.setRGB(Math.min(100,parseInt(r[1],10))/100,Math.min(100,parseInt(r[2],10))/100,Math.min(100,parseInt(r[3],10))/100,e);break;case"hsl":case"hsla":if(r=/^\s*(\d*\.?\d+)\s*,\s*(\d*\.?\d+)\%\s*,\s*(\d*\.?\d+)\%\s*(?:,\s*(\d*\.?\d+)\s*)?$/.exec(a))return n(r[4]),this.setHSL(parseFloat(r[1])/360,parseFloat(r[2])/100,parseFloat(r[3])/100,e);break;default:Ct("Color: Unknown color model "+t)}}else if(s=/^\#([A-Fa-f\d]+)$/.exec(t)){let r=s[1],o=r.length;if(o===3)return this.setRGB(parseInt(r.charAt(0),16)/15,parseInt(r.charAt(1),16)/15,parseInt(r.charAt(2),16)/15,e);if(o===6)return this.setHex(parseInt(r,16),e);Ct("Color: Invalid hex color "+t)}else if(t&&t.length>0)return this.setColorName(t,e);return this}setColorName(t,e=Se){let n=_u[t.toLowerCase()];return n!==void 0?this.setHex(n,e):Ct("Color: Unknown color "+t),this}clone(){return new this.constructor(this.r,this.g,this.b)}copy(t){return this.r=t.r,this.g=t.g,this.b=t.b,this}copySRGBToLinear(t){return this.r=kn(t.r),this.g=kn(t.g),this.b=kn(t.b),this}copyLinearToSRGB(t){return this.r=ts(t.r),this.g=ts(t.g),this.b=ts(t.b),this}convertSRGBToLinear(){return this.copySRGBToLinear(this),this}convertLinearToSRGB(){return this.copyLinearToSRGB(this),this}getHex(t=Se){return qt.workingToColorSpace(He.copy(this),t),Math.round(Yt(He.r*255,0,255))*65536+Math.round(Yt(He.g*255,0,255))*256+Math.round(Yt(He.b*255,0,255))}getHexString(t=Se){return("000000"+this.getHex(t).toString(16)).slice(-6)}getHSL(t,e=qt.workingColorSpace){qt.workingToColorSpace(He.copy(this),e);let n=He.r,s=He.g,r=He.b,o=Math.max(n,s,r),a=Math.min(n,s,r),l,c,h=(a+o)/2;if(a===o)l=0,c=0;else{let d=o-a;switch(c=h<=.5?d/(o+a):d/(2-o-a),o){case n:l=(s-r)/d+(s0?s.multiplyScalar(1/Math.sqrt(r)):s.set(0,0,0)}static getBarycoord(t,e,n,s,r){fn.subVectors(s,e),Un.subVectors(n,e),rl.subVectors(t,e);let o=fn.dot(fn),a=fn.dot(Un),l=fn.dot(rl),c=Un.dot(Un),h=Un.dot(rl),d=o*c-a*a;if(d===0)return r.set(0,0,0),null;let u=1/d,p=(c*l-a*h)*u,g=(o*h-a*l)*u;return r.set(1-p-g,g,p)}static containsPoint(t,e,n,s){return this.getBarycoord(t,e,n,s,Fn)===null?!1:Fn.x>=0&&Fn.y>=0&&Fn.x+Fn.y<=1}static getInterpolation(t,e,n,s,r,o,a,l){return this.getBarycoord(t,e,n,s,Fn)===null?(l.x=0,l.y=0,"z"in l&&(l.z=0),"w"in l&&(l.w=0),null):(l.setScalar(0),l.addScaledVector(r,Fn.x),l.addScaledVector(o,Fn.y),l.addScaledVector(a,Fn.z),l)}static getInterpolatedAttribute(t,e,n,s,r,o){return cl.setScalar(0),hl.setScalar(0),ul.setScalar(0),cl.fromBufferAttribute(t,e),hl.fromBufferAttribute(t,n),ul.fromBufferAttribute(t,s),o.setScalar(0),o.addScaledVector(cl,r.x),o.addScaledVector(hl,r.y),o.addScaledVector(ul,r.z),o}static isFrontFacing(t,e,n,s){return fn.subVectors(n,e),Un.subVectors(t,e),fn.cross(Un).dot(s)<0}set(t,e,n){return this.a.copy(t),this.b.copy(e),this.c.copy(n),this}setFromPointsAndIndices(t,e,n,s){return this.a.copy(t[e]),this.b.copy(t[n]),this.c.copy(t[s]),this}setFromAttributeAndIndices(t,e,n,s){return this.a.fromBufferAttribute(t,e),this.b.fromBufferAttribute(t,n),this.c.fromBufferAttribute(t,s),this}clone(){return new this.constructor().copy(this)}copy(t){return this.a.copy(t.a),this.b.copy(t.b),this.c.copy(t.c),this}getArea(){return fn.subVectors(this.c,this.b),Un.subVectors(this.a,this.b),fn.cross(Un).length()*.5}getMidpoint(t){return t.addVectors(this.a,this.b).add(this.c).multiplyScalar(1/3)}getNormal(t){return i.getNormal(this.a,this.b,this.c,t)}getPlane(t){return t.setFromCoplanarPoints(this.a,this.b,this.c)}getBarycoord(t,e){return i.getBarycoord(t,this.a,this.b,this.c,e)}getInterpolation(t,e,n,s,r){return i.getInterpolation(t,this.a,this.b,this.c,e,n,s,r)}containsPoint(t){return i.containsPoint(t,this.a,this.b,this.c)}isFrontFacing(t){return i.isFrontFacing(this.a,this.b,this.c,t)}intersectsBox(t){return t.intersectsTriangle(this)}closestPointToPoint(t,e){let n=this.a,s=this.b,r=this.c,o,a;Hi.subVectors(s,n),Gi.subVectors(r,n),ol.subVectors(t,n);let l=Hi.dot(ol),c=Gi.dot(ol);if(l<=0&&c<=0)return e.copy(n);al.subVectors(t,s);let h=Hi.dot(al),d=Gi.dot(al);if(h>=0&&d<=h)return e.copy(s);let u=l*d-h*c;if(u<=0&&l>=0&&h<=0)return o=l/(l-h),e.copy(n).addScaledVector(Hi,o);ll.subVectors(t,r);let p=Hi.dot(ll),g=Gi.dot(ll);if(g>=0&&p<=g)return e.copy(r);let _=p*c-l*g;if(_<=0&&c>=0&&g<=0)return a=c/(c-g),e.copy(n).addScaledVector(Gi,a);let m=h*g-p*d;if(m<=0&&d-h>=0&&p-g>=0)return fh.subVectors(r,s),a=(d-h)/(d-h+(p-g)),e.copy(s).addScaledVector(fh,a);let f=1/(m+_+u);return o=_*f,a=u*f,e.copy(n).addScaledVector(Hi,o).addScaledVector(Gi,a)}equals(t){return t.a.equals(this.a)&&t.b.equals(this.b)&&t.c.equals(this.c)}},se=class{constructor(t=new C(1/0,1/0,1/0),e=new C(-1/0,-1/0,-1/0)){this.isBox3=!0,this.min=t,this.max=e}set(t,e){return this.min.copy(t),this.max.copy(e),this}setFromArray(t){this.makeEmpty();for(let e=0,n=t.length;e=this.min.x&&t.x<=this.max.x&&t.y>=this.min.y&&t.y<=this.max.y&&t.z>=this.min.z&&t.z<=this.max.z}containsBox(t){return this.min.x<=t.min.x&&t.max.x<=this.max.x&&this.min.y<=t.min.y&&t.max.y<=this.max.y&&this.min.z<=t.min.z&&t.max.z<=this.max.z}getParameter(t,e){return e.set((t.x-this.min.x)/(this.max.x-this.min.x),(t.y-this.min.y)/(this.max.y-this.min.y),(t.z-this.min.z)/(this.max.z-this.min.z))}intersectsBox(t){return t.max.x>=this.min.x&&t.min.x<=this.max.x&&t.max.y>=this.min.y&&t.min.y<=this.max.y&&t.max.z>=this.min.z&&t.min.z<=this.max.z}intersectsSphere(t){return this.clampPoint(t.center,pn),pn.distanceToSquared(t.center)<=t.radius*t.radius}intersectsPlane(t){let e,n;return t.normal.x>0?(e=t.normal.x*this.min.x,n=t.normal.x*this.max.x):(e=t.normal.x*this.max.x,n=t.normal.x*this.min.x),t.normal.y>0?(e+=t.normal.y*this.min.y,n+=t.normal.y*this.max.y):(e+=t.normal.y*this.max.y,n+=t.normal.y*this.min.y),t.normal.z>0?(e+=t.normal.z*this.min.z,n+=t.normal.z*this.max.z):(e+=t.normal.z*this.max.z,n+=t.normal.z*this.min.z),e<=-t.constant&&n>=-t.constant}intersectsTriangle(t){if(this.isEmpty())return!1;this.getCenter(Rs),Rr.subVectors(this.max,Rs),Wi.subVectors(t.a,Rs),Xi.subVectors(t.b,Rs),qi.subVectors(t.c,Rs),jn.subVectors(Xi,Wi),Qn.subVectors(qi,Xi),gi.subVectors(Wi,qi);let e=[0,-jn.z,jn.y,0,-Qn.z,Qn.y,0,-gi.z,gi.y,jn.z,0,-jn.x,Qn.z,0,-Qn.x,gi.z,0,-gi.x,-jn.y,jn.x,0,-Qn.y,Qn.x,0,-gi.y,gi.x,0];return!dl(e,Wi,Xi,qi,Rr)||(e=[1,0,0,0,1,0,0,0,1],!dl(e,Wi,Xi,qi,Rr))?!1:(Cr.crossVectors(jn,Qn),e=[Cr.x,Cr.y,Cr.z],dl(e,Wi,Xi,qi,Rr))}clampPoint(t,e){return e.copy(t).clamp(this.min,this.max)}distanceToPoint(t){return this.clampPoint(t,pn).distanceTo(t)}getBoundingSphere(t){return this.isEmpty()?t.makeEmpty():(this.getCenter(t.center),t.radius=this.getSize(pn).length()*.5),t}intersect(t){return this.min.max(t.min),this.max.min(t.max),this.isEmpty()&&this.makeEmpty(),this}union(t){return this.min.min(t.min),this.max.max(t.max),this}applyMatrix4(t){return this.isEmpty()?this:(Bn[0].set(this.min.x,this.min.y,this.min.z).applyMatrix4(t),Bn[1].set(this.min.x,this.min.y,this.max.z).applyMatrix4(t),Bn[2].set(this.min.x,this.max.y,this.min.z).applyMatrix4(t),Bn[3].set(this.min.x,this.max.y,this.max.z).applyMatrix4(t),Bn[4].set(this.max.x,this.min.y,this.min.z).applyMatrix4(t),Bn[5].set(this.max.x,this.min.y,this.max.z).applyMatrix4(t),Bn[6].set(this.max.x,this.max.y,this.min.z).applyMatrix4(t),Bn[7].set(this.max.x,this.max.y,this.max.z).applyMatrix4(t),this.setFromPoints(Bn),this)}translate(t){return this.min.add(t),this.max.add(t),this}equals(t){return t.min.equals(this.min)&&t.max.equals(this.max)}toJSON(){return{min:this.min.toArray(),max:this.max.toArray()}}fromJSON(t){return this.min.fromArray(t.min),this.max.fromArray(t.max),this}},Bn=[new C,new C,new C,new C,new C,new C,new C,new C],pn=new C,Ar=new se,Wi=new C,Xi=new C,qi=new C,jn=new C,Qn=new C,gi=new C,Rs=new C,Rr=new C,Cr=new C,_i=new C;function dl(i,t,e,n,s){for(let r=0,o=i.length-3;r<=o;r+=3){_i.fromArray(i,r);let a=s.x*Math.abs(_i.x)+s.y*Math.abs(_i.y)+s.z*Math.abs(_i.z),l=t.dot(_i),c=e.dot(_i),h=n.dot(_i);if(Math.max(-Math.max(l,c,h),Math.min(l,c,h))>a)return!1}return!0}var Me=new C,Ir=new zt,sp=0,we=class extends An{constructor(t,e,n=!1){if(super(),Array.isArray(t))throw new TypeError("THREE.BufferAttribute: array should be a Typed Array.");this.isBufferAttribute=!0,Object.defineProperty(this,"id",{value:sp++}),this.name="",this.array=t,this.itemSize=e,this.count=t!==void 0?t.length/e:0,this.normalized=n,this.usage=du,this.updateRanges=[],this.gpuType=We,this.version=0}onUploadCallback(){}set needsUpdate(t){t===!0&&this.version++}setUsage(t){return this.usage=t,this}addUpdateRange(t,e){this.updateRanges.push({start:t,count:e})}clearUpdateRanges(){this.updateRanges.length=0}copy(t){return this.name=t.name,this.array=new t.array.constructor(t.array),this.itemSize=t.itemSize,this.count=t.count,this.normalized=t.normalized,this.usage=t.usage,this.gpuType=t.gpuType,this}copyAt(t,e,n){t*=this.itemSize,n*=e.itemSize;for(let s=0,r=this.itemSize;sthis.radius*this.radius&&(e.sub(this.center).normalize(),e.multiplyScalar(this.radius).add(this.center)),e}getBoundingBox(t){return this.isEmpty()?(t.makeEmpty(),t):(t.set(this.center,this.center),t.expandByScalar(this.radius),t)}applyMatrix4(t){return this.center.applyMatrix4(t),this.radius=this.radius*t.getMaxScaleOnAxis(),this}translate(t){return this.center.add(t),this}expandByPoint(t){if(this.isEmpty())return this.center.copy(t),this.radius=0,this;Cs.subVectors(t,this.center);let e=Cs.lengthSq();if(e>this.radius*this.radius){let n=Math.sqrt(e),s=(n-this.radius)*.5;this.center.addScaledVector(Cs,s/n),this.radius+=s}return this}union(t){return t.isEmpty()?this:this.isEmpty()?(this.copy(t),this):(this.center.equals(t.center)===!0?this.radius=Math.max(this.radius,t.radius):(fl.subVectors(t.center,this.center).setLength(t.radius),this.expandByPoint(Cs.copy(t.center).add(fl)),this.expandByPoint(Cs.copy(t.center).sub(fl))),this)}equals(t){return t.center.equals(this.center)&&t.radius===this.radius}clone(){return new this.constructor().copy(this)}toJSON(){return{radius:this.radius,center:this.center.toArray()}}fromJSON(t){return this.radius=t.radius,this.center.fromArray(t.center),this}},op=0,ln=new Ht,pl=new Pe,Yi=new C,nn=new se,Is=new se,Ce=new C,re=class i extends An{constructor(){super(),this.isBufferGeometry=!0,Object.defineProperty(this,"id",{value:op++}),this.uuid=xs(),this.name="",this.type="BufferGeometry",this.index=null,this.indirect=null,this.indirectOffset=0,this.attributes={},this.morphAttributes={},this.morphTargetsRelative=!1,this.groups=[],this.boundingBox=null,this.boundingSphere=null,this.drawRange={start:0,count:1/0},this.userData={},this._transformed=!1}getIndex(){return this.index}setIndex(t){return Array.isArray(t)?this.index=new(If(t)?Vs:ks)(t,1):this.index=t,this}setIndirect(t,e=0){return this.indirect=t,this.indirectOffset=e,this}getIndirect(){return this.indirect}getAttribute(t){return this.attributes[t]}setAttribute(t,e){return this.attributes[t]=e,this}deleteAttribute(t){return delete this.attributes[t],this}hasAttribute(t){return this.attributes[t]!==void 0}addGroup(t,e,n=0){this.groups.push({start:t,count:e,materialIndex:n})}clearGroups(){this.groups=[]}setDrawRange(t,e){this.drawRange.start=t,this.drawRange.count=e}applyMatrix4(t){let e=this.attributes.position;e!==void 0&&(e.applyMatrix4(t),e.needsUpdate=!0);let n=this.attributes.normal;if(n!==void 0){let r=new Ut().getNormalMatrix(t);n.applyNormalMatrix(r),n.needsUpdate=!0}let s=this.attributes.tangent;return s!==void 0&&(s.transformDirection(t),s.needsUpdate=!0),this.boundingBox!==null&&this.computeBoundingBox(),this.boundingSphere!==null&&this.computeBoundingSphere(),this._transformed=!0,this}applyQuaternion(t){return ln.makeRotationFromQuaternion(t),this.applyMatrix4(ln),this}rotateX(t){return ln.makeRotationX(t),this.applyMatrix4(ln),this}rotateY(t){return ln.makeRotationY(t),this.applyMatrix4(ln),this}rotateZ(t){return ln.makeRotationZ(t),this.applyMatrix4(ln),this}translate(t,e,n){return ln.makeTranslation(t,e,n),this.applyMatrix4(ln),this}scale(t,e,n){return ln.makeScale(t,e,n),this.applyMatrix4(ln),this}lookAt(t){return pl.lookAt(t),pl.updateMatrix(),this.applyMatrix4(pl.matrix),this}center(){return this.computeBoundingBox(),this.boundingBox.getCenter(Yi).negate(),this.translate(Yi.x,Yi.y,Yi.z),this}setFromPoints(t){let e=this.getAttribute("position");if(e===void 0){let n=[];for(let s=0,r=t.length;se.count&&Ct("BufferGeometry: Buffer size too small for points data. Use .dispose() and create a new geometry."),e.needsUpdate=!0}return this}computeBoundingBox(){this.boundingBox===null&&(this.boundingBox=new se);let t=this.attributes.position,e=this.morphAttributes.position;if(t&&t.isGLBufferAttribute){Pt("BufferGeometry.computeBoundingBox(): GLBufferAttribute requires a manual bounding box.",this),this.boundingBox.set(new C(-1/0,-1/0,-1/0),new C(1/0,1/0,1/0));return}if(t!==void 0){if(this.boundingBox.setFromBufferAttribute(t),e)for(let n=0,s=e.length;n0&&(t.userData=this.userData),this.parameters!==void 0&&this._transformed!==!0){let l=this.parameters;for(let c in l)l[c]!==void 0&&(t[c]=l[c]);return t}t.data={attributes:{}};let e=this.index;e!==null&&(t.data.index={type:e.array.constructor.name,array:Array.prototype.slice.call(e.array)});let n=this.attributes;for(let l in n){let c=n[l];t.data.attributes[l]=c.toJSON(t.data)}let s={},r=!1;for(let l in this.morphAttributes){let c=this.morphAttributes[l],h=[];for(let d=0,u=c.length;d0&&(s[l]=h,r=!0)}r&&(t.data.morphAttributes=s,t.data.morphTargetsRelative=this.morphTargetsRelative);let o=this.groups;o.length>0&&(t.data.groups=JSON.parse(JSON.stringify(o)));let a=this.boundingSphere;return a!==null&&(t.data.boundingSphere=a.toJSON()),t}clone(){return new this.constructor().copy(this)}copy(t){this.index=null,this.attributes={},this.morphAttributes={},this.groups=[],this.boundingBox=null,this.boundingSphere=null;let e={};this.name=t.name;let n=t.index;n!==null&&this.setIndex(n.clone());let s=t.attributes;for(let c in s){let h=s[c];this.setAttribute(c,h.clone(e))}let r=t.morphAttributes;for(let c in r){let h=[],d=r[c];for(let u=0,p=d.length;u1)?null:e.copy(t.start).addScaledVector(s,o)}intersectsLine(t){let e=this.distanceToPoint(t.start),n=this.distanceToPoint(t.end);return e<0&&n>0||n<0&&e>0}intersectsBox(t){return t.intersectsPlane(this)}intersectsSphere(t){return t.intersectsPlane(this)}coplanarPoint(t){return t.copy(this.normal).multiplyScalar(-this.constant)}applyMatrix4(t,e){let n=e||lp.getNormalMatrix(t),s=this.coplanarPoint(ml).applyMatrix4(t),r=this.normal.applyMatrix3(n).normalize();return this.constant=-s.dot(r),this}translate(t){return this.constant-=t.dot(this.normal),this}equals(t){return t.normal.equals(this.normal)&&t.constant===this.constant}clone(){return new this.constructor().copy(this)}toJSON(){return{normal:this.normal.toArray(),constant:this.constant}}fromJSON(t){return this.normal.fromArray(t.normal),this.constant=t.constant,this}},cp=0,Rn=class extends An{constructor(){super(),this.isMaterial=!0,Object.defineProperty(this,"id",{value:cp++}),this.uuid=xs(),this.name="",this.type="Material",this.blending=ms,this.side=ai,this.vertexColors=!1,this.opacity=1,this.transparent=!1,this.alphaHash=!1,this.blendSrc=Ul,this.blendDst=Fl,this.blendEquation=Ri,this.blendSrcAlpha=null,this.blendDstAlpha=null,this.blendEquationAlpha=null,this.blendColor=new Dt(0,0,0),this.blendAlpha=0,this.depthFunc=es,this.depthTest=!0,this.depthWrite=!0,this.stencilWriteMask=255,this.stencilFunc=ru,this.stencilRef=0,this.stencilFuncMask=255,this.stencilFail=jr,this.stencilZFail=jr,this.stencilZPass=jr,this.stencilWrite=!1,this.clippingPlanes=null,this.clipIntersection=!1,this.clipShadows=!1,this.shadowSide=null,this.colorWrite=!0,this.precision=null,this.polygonOffset=!1,this.polygonOffsetFactor=0,this.polygonOffsetUnits=0,this.dithering=!1,this.alphaToCoverage=!1,this.premultipliedAlpha=!1,this.forceSinglePass=!1,this.allowOverride=!0,this.visible=!0,this.toneMapped=!0,this.userData={},this.version=0,this._alphaTest=0}get alphaTest(){return this._alphaTest}set alphaTest(t){this._alphaTest>0!=t>0&&this.version++,this._alphaTest=t}onBeforeRender(){}onBeforeCompile(){}customProgramCacheKey(){return this.onBeforeCompile.toString()}setValues(t){if(t!==void 0)for(let e in t){let n=t[e];if(n===void 0){Ct(`Material: parameter '${e}' has value of undefined.`);continue}let s=this[e];if(s===void 0){Ct(`Material: '${e}' is not a property of THREE.${this.type}.`);continue}s&&s.isColor?s.set(n):s&&s.isVector2&&n&&n.isVector2||s&&s.isEuler&&n&&n.isEuler||s&&s.isVector3&&n&&n.isVector3?s.copy(n):this[e]=n}}toJSON(t){let e=t===void 0||typeof t=="string";e&&(t={textures:{},images:{}});let n={metadata:{version:4.7,type:"Material",generator:"Material.toJSON"}};n.uuid=this.uuid,n.type=this.type,n.blending=this.blending,n.side=this.side,n.shadowSide=this.shadowSide,n.vertexColors=this.vertexColors,n.opacity=this.opacity,n.transparent=this.transparent,n.blendSrc=this.blendSrc,n.blendDst=this.blendDst,n.blendEquation=this.blendEquation,n.blendSrcAlpha=this.blendSrcAlpha,n.blendDstAlpha=this.blendDstAlpha,n.blendEquationAlpha=this.blendEquationAlpha,n.blendColor=this.blendColor.getHex(),n.blendAlpha=this.blendAlpha,n.depthFunc=this.depthFunc,n.depthTest=this.depthTest,n.depthWrite=this.depthWrite,n.colorWrite=this.colorWrite,n.clipIntersection=this.clipIntersection,n.clipShadows=this.clipShadows,n.stencilWriteMask=this.stencilWriteMask,n.stencilFunc=this.stencilFunc,n.stencilRef=this.stencilRef,n.stencilFuncMask=this.stencilFuncMask,n.stencilFail=this.stencilFail,n.stencilZFail=this.stencilZFail,n.stencilZPass=this.stencilZPass,n.stencilWrite=this.stencilWrite,n.polygonOffset=this.polygonOffset,n.polygonOffsetFactor=this.polygonOffsetFactor,n.polygonOffsetUnits=this.polygonOffsetUnits,n.dithering=this.dithering,n.alphaTest=this.alphaTest,n.alphaHash=this.alphaHash,n.alphaToCoverage=this.alphaToCoverage,n.premultipliedAlpha=this.premultipliedAlpha,n.forceSinglePass=this.forceSinglePass,n.allowOverride=this.allowOverride,n.visible=this.visible,n.toneMapped=this.toneMapped,n.name=this.name,this.color&&this.color.isColor&&(n.color=this.color.getHex()),this.roughness!==void 0&&(n.roughness=this.roughness),this.metalness!==void 0&&(n.metalness=this.metalness),this.sheen!==void 0&&(n.sheen=this.sheen),this.sheenColor&&this.sheenColor.isColor&&(n.sheenColor=this.sheenColor.getHex()),this.sheenRoughness!==void 0&&(n.sheenRoughness=this.sheenRoughness),this.emissive&&this.emissive.isColor&&(n.emissive=this.emissive.getHex()),this.emissiveIntensity!==void 0&&(n.emissiveIntensity=this.emissiveIntensity),this.specular&&this.specular.isColor&&(n.specular=this.specular.getHex()),this.specularIntensity!==void 0&&(n.specularIntensity=this.specularIntensity),this.specularColor&&this.specularColor.isColor&&(n.specularColor=this.specularColor.getHex()),this.shininess!==void 0&&(n.shininess=this.shininess),this.clearcoat!==void 0&&(n.clearcoat=this.clearcoat),this.clearcoatRoughness!==void 0&&(n.clearcoatRoughness=this.clearcoatRoughness),this.clearcoatMap&&this.clearcoatMap.isTexture&&(n.clearcoatMap=this.clearcoatMap.toJSON(t).uuid),this.clearcoatRoughnessMap&&this.clearcoatRoughnessMap.isTexture&&(n.clearcoatRoughnessMap=this.clearcoatRoughnessMap.toJSON(t).uuid),this.clearcoatNormalMap&&this.clearcoatNormalMap.isTexture&&(n.clearcoatNormalMap=this.clearcoatNormalMap.toJSON(t).uuid,n.clearcoatNormalScale=this.clearcoatNormalScale.toArray()),this.sheenColorMap&&this.sheenColorMap.isTexture&&(n.sheenColorMap=this.sheenColorMap.toJSON(t).uuid),this.sheenRoughnessMap&&this.sheenRoughnessMap.isTexture&&(n.sheenRoughnessMap=this.sheenRoughnessMap.toJSON(t).uuid),this.dispersion!==void 0&&(n.dispersion=this.dispersion),this.retroreflectivity!==void 0&&(n.retroreflectivity=this.retroreflectivity),this.iridescence!==void 0&&(n.iridescence=this.iridescence),this.iridescenceIOR!==void 0&&(n.iridescenceIOR=this.iridescenceIOR),this.iridescenceThicknessRange!==void 0&&(n.iridescenceThicknessRange=this.iridescenceThicknessRange),this.iridescenceMap&&this.iridescenceMap.isTexture&&(n.iridescenceMap=this.iridescenceMap.toJSON(t).uuid),this.iridescenceThicknessMap&&this.iridescenceThicknessMap.isTexture&&(n.iridescenceThicknessMap=this.iridescenceThicknessMap.toJSON(t).uuid),this.anisotropy!==void 0&&(n.anisotropy=this.anisotropy),this.anisotropyRotation!==void 0&&(n.anisotropyRotation=this.anisotropyRotation),this.anisotropyMap&&this.anisotropyMap.isTexture&&(n.anisotropyMap=this.anisotropyMap.toJSON(t).uuid),this.map&&this.map.isTexture&&(n.map=this.map.toJSON(t).uuid),this.matcap&&this.matcap.isTexture&&(n.matcap=this.matcap.toJSON(t).uuid),this.alphaMap&&this.alphaMap.isTexture&&(n.alphaMap=this.alphaMap.toJSON(t).uuid),this.lightMap&&this.lightMap.isTexture&&(n.lightMap=this.lightMap.toJSON(t).uuid,n.lightMapIntensity=this.lightMapIntensity),this.aoMap&&this.aoMap.isTexture&&(n.aoMap=this.aoMap.toJSON(t).uuid,n.aoMapIntensity=this.aoMapIntensity),this.bumpMap&&this.bumpMap.isTexture&&(n.bumpMap=this.bumpMap.toJSON(t).uuid,n.bumpScale=this.bumpScale),this.normalMap&&this.normalMap.isTexture&&(n.normalMap=this.normalMap.toJSON(t).uuid,n.normalMapType=this.normalMapType,n.normalScale=this.normalScale.toArray()),this.displacementMap&&this.displacementMap.isTexture&&(n.displacementMap=this.displacementMap.toJSON(t).uuid,n.displacementScale=this.displacementScale,n.displacementBias=this.displacementBias),this.roughnessMap&&this.roughnessMap.isTexture&&(n.roughnessMap=this.roughnessMap.toJSON(t).uuid),this.metalnessMap&&this.metalnessMap.isTexture&&(n.metalnessMap=this.metalnessMap.toJSON(t).uuid),this.emissiveMap&&this.emissiveMap.isTexture&&(n.emissiveMap=this.emissiveMap.toJSON(t).uuid),this.specularMap&&this.specularMap.isTexture&&(n.specularMap=this.specularMap.toJSON(t).uuid),this.specularIntensityMap&&this.specularIntensityMap.isTexture&&(n.specularIntensityMap=this.specularIntensityMap.toJSON(t).uuid),this.specularColorMap&&this.specularColorMap.isTexture&&(n.specularColorMap=this.specularColorMap.toJSON(t).uuid),this.envMap&&this.envMap.isTexture&&(n.envMap=this.envMap.toJSON(t).uuid,this.combine!==void 0&&(n.combine=this.combine)),this.envMapRotation!==void 0&&(n.envMapRotation=this.envMapRotation.toArray()),this.envMapIntensity!==void 0&&(n.envMapIntensity=this.envMapIntensity),this.reflectivity!==void 0&&(n.reflectivity=this.reflectivity),this.refractionRatio!==void 0&&(n.refractionRatio=this.refractionRatio),this.gradientMap&&this.gradientMap.isTexture&&(n.gradientMap=this.gradientMap.toJSON(t).uuid),this.transmission!==void 0&&(n.transmission=this.transmission),this.transmissionMap&&this.transmissionMap.isTexture&&(n.transmissionMap=this.transmissionMap.toJSON(t).uuid),this.thickness!==void 0&&(n.thickness=this.thickness),this.thicknessMap&&this.thicknessMap.isTexture&&(n.thicknessMap=this.thicknessMap.toJSON(t).uuid),this.attenuationDistance!==void 0&&(n.attenuationDistance=this.attenuationDistance),this.attenuationColor!==void 0&&(n.attenuationColor=this.attenuationColor.getHex()),this.size!==void 0&&(n.size=this.size),this.sizeAttenuation!==void 0&&(n.sizeAttenuation=this.sizeAttenuation),Array.isArray(this.clippingPlanes)&&this.clippingPlanes.length>0&&(n.clippingPlanes=this.clippingPlanes.map(r=>r.toJSON())),this.rotation!==void 0&&(n.rotation=this.rotation),this.depthPacking!==void 0&&(n.depthPacking=this.depthPacking),this.linewidth!==void 0&&(n.linewidth=this.linewidth),this.linecap!==void 0&&(n.linecap=this.linecap),this.linejoin!==void 0&&(n.linejoin=this.linejoin),this.dashSize!==void 0&&(n.dashSize=this.dashSize),this.gapSize!==void 0&&(n.gapSize=this.gapSize),this.scale!==void 0&&(n.scale=this.scale),this.wireframe!==void 0&&(n.wireframe=this.wireframe),this.wireframeLinewidth!==void 0&&(n.wireframeLinewidth=this.wireframeLinewidth),this.wireframeLinecap!==void 0&&(n.wireframeLinecap=this.wireframeLinecap),this.wireframeLinejoin!==void 0&&(n.wireframeLinejoin=this.wireframeLinejoin),this.flatShading!==void 0&&(n.flatShading=this.flatShading),this.fog!==void 0&&(n.fog=this.fog),Object.keys(this.userData).length>0&&(n.userData=this.userData);function s(r){let o=[];for(let a in r){let l=r[a];delete l.metadata,o.push(l)}return o}if(e){let r=s(t.textures),o=s(t.images);r.length>0&&(n.textures=r),o.length>0&&(n.images=o)}return n}fromJSON(t,e){if(t.uuid!==void 0&&(this.uuid=t.uuid),t.name!==void 0&&(this.name=t.name),t.color!==void 0&&this.color!==void 0&&this.color.setHex(t.color),t.roughness!==void 0&&(this.roughness=t.roughness),t.metalness!==void 0&&(this.metalness=t.metalness),t.sheen!==void 0&&(this.sheen=t.sheen),t.sheenColor!==void 0&&(this.sheenColor=new Dt().setHex(t.sheenColor)),t.sheenRoughness!==void 0&&(this.sheenRoughness=t.sheenRoughness),t.emissive!==void 0&&this.emissive!==void 0&&this.emissive.setHex(t.emissive),t.specular!==void 0&&this.specular!==void 0&&this.specular.setHex(t.specular),t.specularIntensity!==void 0&&(this.specularIntensity=t.specularIntensity),t.specularColor!==void 0&&this.specularColor!==void 0&&this.specularColor.setHex(t.specularColor),t.shininess!==void 0&&(this.shininess=t.shininess),t.clearcoat!==void 0&&(this.clearcoat=t.clearcoat),t.clearcoatRoughness!==void 0&&(this.clearcoatRoughness=t.clearcoatRoughness),t.dispersion!==void 0&&(this.dispersion=t.dispersion),t.retroreflectivity!==void 0&&(this.retroreflectivity=t.retroreflectivity),t.iridescence!==void 0&&(this.iridescence=t.iridescence),t.iridescenceIOR!==void 0&&(this.iridescenceIOR=t.iridescenceIOR),t.iridescenceThicknessRange!==void 0&&(this.iridescenceThicknessRange=t.iridescenceThicknessRange),t.transmission!==void 0&&(this.transmission=t.transmission),t.thickness!==void 0&&(this.thickness=t.thickness),t.attenuationDistance!==void 0&&(this.attenuationDistance=t.attenuationDistance),t.attenuationColor!==void 0&&this.attenuationColor!==void 0&&this.attenuationColor.setHex(t.attenuationColor),t.anisotropy!==void 0&&(this.anisotropy=t.anisotropy),t.anisotropyRotation!==void 0&&(this.anisotropyRotation=t.anisotropyRotation),t.fog!==void 0&&(this.fog=t.fog),t.flatShading!==void 0&&(this.flatShading=t.flatShading),t.blending!==void 0&&(this.blending=t.blending),t.combine!==void 0&&(this.combine=t.combine),t.side!==void 0&&(this.side=t.side),t.shadowSide!==void 0&&(this.shadowSide=t.shadowSide),t.opacity!==void 0&&(this.opacity=t.opacity),t.transparent!==void 0&&(this.transparent=t.transparent),t.alphaTest!==void 0&&(this.alphaTest=t.alphaTest),t.alphaHash!==void 0&&(this.alphaHash=t.alphaHash),t.depthFunc!==void 0&&(this.depthFunc=t.depthFunc),t.depthTest!==void 0&&(this.depthTest=t.depthTest),t.depthWrite!==void 0&&(this.depthWrite=t.depthWrite),t.colorWrite!==void 0&&(this.colorWrite=t.colorWrite),t.clippingPlanes!==void 0&&(this.clippingPlanes=t.clippingPlanes.map(n=>new ge().fromJSON(n))),t.clipIntersection!==void 0&&(this.clipIntersection=t.clipIntersection),t.clipShadows!==void 0&&(this.clipShadows=t.clipShadows),t.depthPacking!==void 0&&(this.depthPacking=t.depthPacking),t.blendSrc!==void 0&&(this.blendSrc=t.blendSrc),t.blendDst!==void 0&&(this.blendDst=t.blendDst),t.blendEquation!==void 0&&(this.blendEquation=t.blendEquation),t.blendSrcAlpha!==void 0&&(this.blendSrcAlpha=t.blendSrcAlpha),t.blendDstAlpha!==void 0&&(this.blendDstAlpha=t.blendDstAlpha),t.blendEquationAlpha!==void 0&&(this.blendEquationAlpha=t.blendEquationAlpha),t.blendColor!==void 0&&this.blendColor!==void 0&&this.blendColor.setHex(t.blendColor),t.blendAlpha!==void 0&&(this.blendAlpha=t.blendAlpha),t.stencilWriteMask!==void 0&&(this.stencilWriteMask=t.stencilWriteMask),t.stencilFunc!==void 0&&(this.stencilFunc=t.stencilFunc),t.stencilRef!==void 0&&(this.stencilRef=t.stencilRef),t.stencilFuncMask!==void 0&&(this.stencilFuncMask=t.stencilFuncMask),t.stencilFail!==void 0&&(this.stencilFail=t.stencilFail),t.stencilZFail!==void 0&&(this.stencilZFail=t.stencilZFail),t.stencilZPass!==void 0&&(this.stencilZPass=t.stencilZPass),t.stencilWrite!==void 0&&(this.stencilWrite=t.stencilWrite),t.wireframe!==void 0&&(this.wireframe=t.wireframe),t.wireframeLinewidth!==void 0&&(this.wireframeLinewidth=t.wireframeLinewidth),t.wireframeLinecap!==void 0&&(this.wireframeLinecap=t.wireframeLinecap),t.wireframeLinejoin!==void 0&&(this.wireframeLinejoin=t.wireframeLinejoin),t.rotation!==void 0&&(this.rotation=t.rotation),t.linewidth!==void 0&&(this.linewidth=t.linewidth),t.linecap!==void 0&&(this.linecap=t.linecap),t.linejoin!==void 0&&(this.linejoin=t.linejoin),t.dashSize!==void 0&&(this.dashSize=t.dashSize),t.gapSize!==void 0&&(this.gapSize=t.gapSize),t.scale!==void 0&&(this.scale=t.scale),t.polygonOffset!==void 0&&(this.polygonOffset=t.polygonOffset),t.polygonOffsetFactor!==void 0&&(this.polygonOffsetFactor=t.polygonOffsetFactor),t.polygonOffsetUnits!==void 0&&(this.polygonOffsetUnits=t.polygonOffsetUnits),t.dithering!==void 0&&(this.dithering=t.dithering),t.alphaToCoverage!==void 0&&(this.alphaToCoverage=t.alphaToCoverage),t.premultipliedAlpha!==void 0&&(this.premultipliedAlpha=t.premultipliedAlpha),t.forceSinglePass!==void 0&&(this.forceSinglePass=t.forceSinglePass),t.allowOverride!==void 0&&(this.allowOverride=t.allowOverride),t.visible!==void 0&&(this.visible=t.visible),t.toneMapped!==void 0&&(this.toneMapped=t.toneMapped),t.userData!==void 0&&(this.userData=t.userData),t.vertexColors!==void 0&&(typeof t.vertexColors=="number"?this.vertexColors=t.vertexColors>0:this.vertexColors=t.vertexColors),t.size!==void 0&&(this.size=t.size),t.sizeAttenuation!==void 0&&(this.sizeAttenuation=t.sizeAttenuation),t.map!==void 0&&(this.map=e[t.map]||null),t.matcap!==void 0&&(this.matcap=e[t.matcap]||null),t.alphaMap!==void 0&&(this.alphaMap=e[t.alphaMap]||null),t.bumpMap!==void 0&&(this.bumpMap=e[t.bumpMap]||null),t.bumpScale!==void 0&&(this.bumpScale=t.bumpScale),t.normalMap!==void 0&&(this.normalMap=e[t.normalMap]||null),t.normalMapType!==void 0&&(this.normalMapType=t.normalMapType),t.normalScale!==void 0){let n=t.normalScale;Array.isArray(n)===!1&&(n=[n,n]),this.normalScale=new zt().fromArray(n)}return t.displacementMap!==void 0&&(this.displacementMap=e[t.displacementMap]||null),t.displacementScale!==void 0&&(this.displacementScale=t.displacementScale),t.displacementBias!==void 0&&(this.displacementBias=t.displacementBias),t.roughnessMap!==void 0&&(this.roughnessMap=e[t.roughnessMap]||null),t.metalnessMap!==void 0&&(this.metalnessMap=e[t.metalnessMap]||null),t.emissiveMap!==void 0&&(this.emissiveMap=e[t.emissiveMap]||null),t.emissiveIntensity!==void 0&&(this.emissiveIntensity=t.emissiveIntensity),t.specularMap!==void 0&&(this.specularMap=e[t.specularMap]||null),t.specularIntensityMap!==void 0&&(this.specularIntensityMap=e[t.specularIntensityMap]||null),t.specularColorMap!==void 0&&(this.specularColorMap=e[t.specularColorMap]||null),t.envMap!==void 0&&(this.envMap=e[t.envMap]||null),t.envMapRotation!==void 0&&this.envMapRotation.fromArray(t.envMapRotation),t.envMapIntensity!==void 0&&(this.envMapIntensity=t.envMapIntensity),t.reflectivity!==void 0&&(this.reflectivity=t.reflectivity),t.refractionRatio!==void 0&&(this.refractionRatio=t.refractionRatio),t.lightMap!==void 0&&(this.lightMap=e[t.lightMap]||null),t.lightMapIntensity!==void 0&&(this.lightMapIntensity=t.lightMapIntensity),t.aoMap!==void 0&&(this.aoMap=e[t.aoMap]||null),t.aoMapIntensity!==void 0&&(this.aoMapIntensity=t.aoMapIntensity),t.gradientMap!==void 0&&(this.gradientMap=e[t.gradientMap]||null),t.clearcoatMap!==void 0&&(this.clearcoatMap=e[t.clearcoatMap]||null),t.clearcoatRoughnessMap!==void 0&&(this.clearcoatRoughnessMap=e[t.clearcoatRoughnessMap]||null),t.clearcoatNormalMap!==void 0&&(this.clearcoatNormalMap=e[t.clearcoatNormalMap]||null),t.clearcoatNormalScale!==void 0&&(this.clearcoatNormalScale=new zt().fromArray(t.clearcoatNormalScale)),t.iridescenceMap!==void 0&&(this.iridescenceMap=e[t.iridescenceMap]||null),t.iridescenceThicknessMap!==void 0&&(this.iridescenceThicknessMap=e[t.iridescenceThicknessMap]||null),t.transmissionMap!==void 0&&(this.transmissionMap=e[t.transmissionMap]||null),t.thicknessMap!==void 0&&(this.thicknessMap=e[t.thicknessMap]||null),t.anisotropyMap!==void 0&&(this.anisotropyMap=e[t.anisotropyMap]||null),t.sheenColorMap!==void 0&&(this.sheenColorMap=e[t.sheenColorMap]||null),t.sheenRoughnessMap!==void 0&&(this.sheenRoughnessMap=e[t.sheenRoughnessMap]||null),this}clone(){return new this.constructor().copy(this)}copy(t){this.name=t.name,this.blending=t.blending,this.side=t.side,this.vertexColors=t.vertexColors,this.opacity=t.opacity,this.transparent=t.transparent,this.blendSrc=t.blendSrc,this.blendDst=t.blendDst,this.blendEquation=t.blendEquation,this.blendSrcAlpha=t.blendSrcAlpha,this.blendDstAlpha=t.blendDstAlpha,this.blendEquationAlpha=t.blendEquationAlpha,this.blendColor.copy(t.blendColor),this.blendAlpha=t.blendAlpha,this.depthFunc=t.depthFunc,this.depthTest=t.depthTest,this.depthWrite=t.depthWrite,this.stencilWriteMask=t.stencilWriteMask,this.stencilFunc=t.stencilFunc,this.stencilRef=t.stencilRef,this.stencilFuncMask=t.stencilFuncMask,this.stencilFail=t.stencilFail,this.stencilZFail=t.stencilZFail,this.stencilZPass=t.stencilZPass,this.stencilWrite=t.stencilWrite;let e=t.clippingPlanes,n=null;if(e!==null){let s=e.length;n=new Array(s);for(let r=0;r!==s;++r)n[r]=e[r].clone()}return this.clippingPlanes=n,this.clipIntersection=t.clipIntersection,this.clipShadows=t.clipShadows,this.shadowSide=t.shadowSide,this.colorWrite=t.colorWrite,this.precision=t.precision,this.polygonOffset=t.polygonOffset,this.polygonOffsetFactor=t.polygonOffsetFactor,this.polygonOffsetUnits=t.polygonOffsetUnits,this.dithering=t.dithering,this.alphaTest=t.alphaTest,this.alphaHash=t.alphaHash,this.alphaToCoverage=t.alphaToCoverage,this.premultipliedAlpha=t.premultipliedAlpha,this.forceSinglePass=t.forceSinglePass,this.allowOverride=t.allowOverride,this.visible=t.visible,this.toneMapped=t.toneMapped,this.userData=JSON.parse(JSON.stringify(t.userData)),this}dispose(){this.dispatchEvent({type:"dispose"})}set needsUpdate(t){t===!0&&this.version++}};var On=new C,gl=new C,Pr=new C,Lr=new C,Si=class{constructor(t=new C,e=new C(0,0,-1)){this.origin=t,this.direction=e}set(t,e){return this.origin.copy(t),this.direction.copy(e),this}copy(t){return this.origin.copy(t.origin),this.direction.copy(t.direction),this}at(t,e){return e.copy(this.origin).addScaledVector(this.direction,t)}lookAt(t){return this.direction.copy(t).sub(this.origin).normalize(),this}recast(t){return this.origin.copy(this.at(t,On)),this}closestPointToPoint(t,e){e.subVectors(t,this.origin);let n=e.dot(this.direction);return n<0?e.copy(this.origin):e.copy(this.origin).addScaledVector(this.direction,n)}distanceToPoint(t){return Math.sqrt(this.distanceSqToPoint(t))}distanceSqToPoint(t){let e=On.subVectors(t,this.origin).dot(this.direction);return e<0?this.origin.distanceToSquared(t):(On.copy(this.origin).addScaledVector(this.direction,e),On.distanceToSquared(t))}distanceSqToSegment(t,e,n,s){gl.copy(t).add(e).multiplyScalar(.5),Pr.copy(e).sub(t).normalize(),Lr.copy(this.origin).sub(gl);let r=t.distanceTo(e)*.5,o=-this.direction.dot(Pr),a=Lr.dot(this.direction),l=-Lr.dot(Pr),c=Lr.lengthSq(),h=Math.abs(1-o*o),d,u,p,g;if(h>0)if(d=o*l-a,u=o*a-l,g=r*h,d>=0)if(u>=-g)if(u<=g){let _=1/h;d*=_,u*=_,p=d*(d+o*u+2*a)+u*(o*d+u+2*l)+c}else u=r,d=Math.max(0,-(o*u+a)),p=-d*d+u*(u+2*l)+c;else u=-r,d=Math.max(0,-(o*u+a)),p=-d*d+u*(u+2*l)+c;else u<=-g?(d=Math.max(0,-(-o*r+a)),u=d>0?-r:Math.min(Math.max(-r,-l),r),p=-d*d+u*(u+2*l)+c):u<=g?(d=0,u=Math.min(Math.max(-r,-l),r),p=u*(u+2*l)+c):(d=Math.max(0,-(o*r+a)),u=d>0?r:Math.min(Math.max(-r,-l),r),p=-d*d+u*(u+2*l)+c);else u=o>0?-r:r,d=Math.max(0,-(o*u+a)),p=-d*d+u*(u+2*l)+c;return n&&n.copy(this.origin).addScaledVector(this.direction,d),s&&s.copy(gl).addScaledVector(Pr,u),p}intersectSphere(t,e){if(t.radius<0)return null;On.subVectors(t.center,this.origin);let n=On.dot(this.direction),s=On.dot(On)-n*n,r=t.radius*t.radius;if(s>r)return null;let o=Math.sqrt(r-s),a=n-o,l=n+o;return l<0?null:a<0?this.at(l,e):this.at(a,e)}intersectsSphere(t){return t.radius<0?!1:this.distanceSqToPoint(t.center)<=t.radius*t.radius}distanceToPlane(t){let e=t.normal.dot(this.direction);if(e===0)return t.distanceToPoint(this.origin)===0?0:null;let n=-(this.origin.dot(t.normal)+t.constant)/e;return n>=0?n:null}intersectPlane(t,e){let n=this.distanceToPlane(t);return n===null?null:this.at(n,e)}intersectsPlane(t){let e=t.distanceToPoint(this.origin);return e===0||t.normal.dot(this.direction)*e<0}intersectBox(t,e){let n,s,r,o,a,l,c=1/this.direction.x,h=1/this.direction.y,d=1/this.direction.z,u=this.origin;return c>=0?(n=(t.min.x-u.x)*c,s=(t.max.x-u.x)*c):(n=(t.max.x-u.x)*c,s=(t.min.x-u.x)*c),h>=0?(r=(t.min.y-u.y)*h,o=(t.max.y-u.y)*h):(r=(t.max.y-u.y)*h,o=(t.min.y-u.y)*h),n>o||r>s||((r>n||isNaN(n))&&(n=r),(o=0?(a=(t.min.z-u.z)*d,l=(t.max.z-u.z)*d):(a=(t.max.z-u.z)*d,l=(t.min.z-u.z)*d),n>l||a>s)||((a>n||n!==n)&&(n=a),(l=0?n:s,e)}intersectsBox(t){return this.intersectBox(t,On)!==null}intersectTriangle(t,e,n,s,r){let o=this.origin,a=this.direction,l=a.x,c=a.y,h=a.z,d=t.x-o.x,u=t.y-o.y,p=t.z-o.z,g=e.x-o.x,_=e.y-o.y,m=e.z-o.z,f=n.x-o.x,w=n.y-o.y,R=n.z-o.z,S=Math.abs(l),b=Math.abs(c),y=Math.abs(h),T,v,E,P,D,z,O,I,k,X,Y,B;if(S>=b&&S>=y?(E=l,z=d,k=g,B=f,l>=0?(T=c,v=h,P=u,D=p,O=_,I=m,X=w,Y=R):(T=h,v=c,P=p,D=u,O=m,I=_,X=R,Y=w)):b>=y?(E=c,z=u,k=_,B=w,c>=0?(T=h,v=l,P=p,D=d,O=m,I=g,X=R,Y=f):(T=l,v=h,P=d,D=p,O=g,I=m,X=f,Y=R)):(E=h,z=p,k=m,B=R,h>=0?(T=l,v=c,P=d,D=u,O=g,I=_,X=f,Y=w):(T=c,v=l,P=u,D=d,O=_,I=g,X=w,Y=f)),E===0)return null;let U=T/E,H=v/E,Z=1/E,rt=P-U*z,at=D-H*z,Ft=O-U*k,Nt=I-H*k,$t=X-U*B,J=Y-H*B,et=$t*Nt-J*Ft,xt=rt*J-at*$t,Lt=Ft*at-Nt*rt;if(s){if(et<0||xt<0||Lt<0)return null}else if((et<0||xt<0||Lt<0)&&(et>0||xt>0||Lt>0))return null;let yt=et+xt+Lt;if(yt===0)return null;let Vt=Z*(et*z+xt*k+Lt*B);return(yt>0?Vt<0:Vt>0)?null:this.at(Vt/yt,r)}applyMatrix4(t){return this.origin.applyMatrix4(t),this.direction.transformDirection(t),this}equals(t){return t.origin.equals(this.origin)&&t.direction.equals(this.direction)}clone(){return new this.constructor().copy(this)}},cn=class extends Rn{constructor(t){super(),this.isMeshBasicMaterial=!0,this.type="MeshBasicMaterial",this.color=new Dt(16777215),this.map=null,this.lightMap=null,this.lightMapIntensity=1,this.aoMap=null,this.aoMapIntensity=1,this.specularMap=null,this.alphaMap=null,this.envMap=null,this.envMapRotation=new Vn,this.combine=Lo,this.reflectivity=1,this.refractionRatio=.98,this.wireframe=!1,this.wireframeLinewidth=1,this.wireframeLinecap="round",this.wireframeLinejoin="round",this.fog=!0,this.setValues(t)}copy(t){return super.copy(t),this.color.copy(t.color),this.map=t.map,this.lightMap=t.lightMap,this.lightMapIntensity=t.lightMapIntensity,this.aoMap=t.aoMap,this.aoMapIntensity=t.aoMapIntensity,this.specularMap=t.specularMap,this.alphaMap=t.alphaMap,this.envMap=t.envMap,this.envMapRotation.copy(t.envMapRotation),this.combine=t.combine,this.reflectivity=t.reflectivity,this.refractionRatio=t.refractionRatio,this.wireframe=t.wireframe,this.wireframeLinewidth=t.wireframeLinewidth,this.wireframeLinecap=t.wireframeLinecap,this.wireframeLinejoin=t.wireframeLinejoin,this.fog=t.fog,this}},ph=new Ht,xi=new Si,Dr=new Le,mh=new C,Nr=new C,Ur=new C,Fr=new C,_l=new C,Br=new C,gh=new C,Or=new C,_e=class extends Pe{constructor(t=new re,e=new cn){super(),this.isMesh=!0,this.type="Mesh",this.geometry=t,this.material=e,this.morphTargetDictionary=void 0,this.morphTargetInfluences=void 0,this.count=1,this.updateMorphTargets()}copy(t,e){return super.copy(t,e),t.morphTargetInfluences!==void 0&&(this.morphTargetInfluences=t.morphTargetInfluences.slice()),t.morphTargetDictionary!==void 0&&(this.morphTargetDictionary=Object.assign({},t.morphTargetDictionary)),this.material=Array.isArray(t.material)?t.material.slice():t.material,this.geometry=t.geometry,this}updateMorphTargets(){let e=this.geometry.morphAttributes,n=Object.keys(e);if(n.length>0){let s=e[n[0]];if(s!==void 0){this.morphTargetInfluences=[],this.morphTargetDictionary={};for(let r=0,o=s.length;r(t.far-t.near)**2))&&(ph.copy(r).invert(),xi.copy(t.ray).applyMatrix4(ph),!(n.boundingBox!==null&&xi.intersectsBox(n.boundingBox)===!1)&&this._computeIntersections(t,e,xi)))}_computeIntersections(t,e,n){let s,r=this.geometry,o=this.material,a=r.index,l=r.attributes.position,c=r.attributes.uv,h=r.attributes.uv1,d=r.attributes.normal,u=r.groups,p=r.drawRange;if(a!==null)if(Array.isArray(o))for(let g=0,_=u.length;g<_;g++){let m=u[g],f=o[m.materialIndex],w=Math.max(m.start,p.start),R=Math.min(a.count,Math.min(m.start+m.count,p.start+p.count));for(let S=w,b=R;Se.far?null:{distance:c,point:Or.clone(),object:i}}function zr(i,t,e,n,s,r,o,a,l,c){i.getVertexPosition(a,Nr),i.getVertexPosition(l,Ur),i.getVertexPosition(c,Fr);let h=hp(i,t,e,n,Nr,Ur,Fr,gh);if(h){let d=new C;zn.getBarycoord(gh,Nr,Ur,Fr,d),s&&(h.uv=zn.getInterpolatedAttribute(s,a,l,c,d,new zt)),r&&(h.uv1=zn.getInterpolatedAttribute(r,a,l,c,d,new zt)),o&&(h.normal=zn.getInterpolatedAttribute(o,a,l,c,d,new C),h.normal.dot(n.direction)>0&&h.normal.multiplyScalar(-1));let u={a,b:l,c,normal:new C,materialIndex:0};zn.getNormal(Nr,Ur,Fr,u.normal),h.face=u,h.barycoord=d}return h}var ei=class extends Ue{constructor(t=null,e=1,n=1,s,r,o,a,l,c=Ie,h=Ie,d,u){super(null,o,a,l,c,h,s,r,d,u),this.isDataTexture=!0,this.image={data:t,width:e,height:n},this.generateMipmaps=!1,this.flipY=!1,this.unpackAlignment=1}};var Hn=class extends we{constructor(t,e,n,s=1){super(t,e,n),this.isInstancedBufferAttribute=!0,this.meshPerAttribute=s}copy(t){return super.copy(t),this.meshPerAttribute=t.meshPerAttribute,this}toJSON(){let t=super.toJSON();return t.meshPerAttribute=this.meshPerAttribute,t.isInstancedBufferAttribute=!0,t}},$i=new Ht,_h=new Ht,kr=[],xh=new se,up=new Ht,Ps=new _e,Ls=new Le,cs=class extends _e{constructor(t,e,n){super(t,e),this.isInstancedMesh=!0,this.instanceMatrix=new Hn(new Float32Array(n*16),16),this.instanceColor=null,this.morphTexture=null,this.count=n,this.boundingBox=null,this.boundingSphere=null;for(let s=0;s0?t.max.x:t.min.x,Vr.y=s.normal.y>0?t.max.y:t.min.y,Vr.z=s.normal.z>0?t.max.z:t.min.z,s.distanceToPoint(Vr)<0)return!1}return!0}containsPoint(t){let e=this.planes;for(let n=0;n<6;n++)if(e[n].distanceToPoint(t)<0)return!1;return!0}clone(){return new this.constructor().copy(this)}};var sn=class extends Rn{constructor(t){super(),this.isLineBasicMaterial=!0,this.type="LineBasicMaterial",this.color=new Dt(16777215),this.map=null,this.linewidth=1,this.linecap="round",this.linejoin="round",this.fog=!0,this.setValues(t)}copy(t){return super.copy(t),this.color.copy(t.color),this.map=t.map,this.linewidth=t.linewidth,this.linecap=t.linecap,this.linejoin=t.linejoin,this.fog=t.fog,this}},uo=new C,fo=new C,vh=new Ht,Ds=new Si,Hr=new Le,xl=new C,yh=new C,bi=class extends Pe{constructor(t=new re,e=new sn){super(),this.isLine=!0,this.type="Line",this.geometry=t,this.material=e,this.morphTargetDictionary=void 0,this.morphTargetInfluences=void 0,this.updateMorphTargets()}copy(t,e){return super.copy(t,e),this.material=Array.isArray(t.material)?t.material.slice():t.material,this.geometry=t.geometry,this}computeLineDistances(){let t=this.geometry;if(t.index===null){let e=t.attributes.position,n=[0];for(let s=1,r=e.count;s0){let s=e[n[0]];if(s!==void 0){this.morphTargetInfluences=[],this.morphTargetDictionary={};for(let r=0,o=s.length;rn)return;xl.applyMatrix4(i.matrixWorld);let c=t.ray.origin.distanceTo(xl);if(!(ct.far))return{distance:c,point:yh.clone().applyMatrix4(i.matrixWorld),index:o,face:null,faceIndex:null,barycoord:null,object:i}}var Mh=new C,Sh=new C,gn=class extends bi{constructor(t,e){super(t,e),this.isLineSegments=!0,this.type="LineSegments"}computeLineDistances(){let t=this.geometry;if(t.index===null){let e=t.attributes.position,n=[];for(let s=0,r=e.count;s0){let s=e[n[0]];if(s!==void 0){this.morphTargetInfluences=[],this.morphTargetDictionary={};for(let r=0,o=s.length;rs.far)return;r.push({distance:c,distanceToRay:Math.sqrt(a),point:l,index:t,face:null,faceIndex:null,barycoord:null,object:o})}}var Gs=class extends Ue{constructor(t=[],e=li,n,s,r,o,a,l,c,h){super(t,e,n,s,r,o,a,l,c,h),this.isCubeTexture=!0,this.flipY=!1}get images(){return this.image}set images(t){this.image=t}},Ws=class extends Ue{constructor(t,e,n,s,r,o,a,l,c){super(t,e,n,s,r,o,a,l,c),this.isCanvasTexture=!0,this.needsUpdate=!0}};var ii=class extends Ue{constructor(t,e,n=xn,s,r,o,a=Ie,l=Ie,c,h=Tn,d=1){if(h!==Tn&&h!==hi)throw new Error("THREE.DepthTexture: format must be either THREE.DepthFormat or THREE.DepthStencilFormat");let u={width:t,height:e,depth:d};super(u,s,r,o,a,l,h,n,c),this.isDepthTexture=!0,this.flipY=!1,this.generateMipmaps=!1,this.compareFunction=null}copy(t){return super.copy(t),this.source=new os(Object.assign({},t.image)),this.compareFunction=t.compareFunction,this}toJSON(t){let e=super.toJSON(t);return e.compareFunction=this.compareFunction,e}},po=class extends ii{constructor(t,e=xn,n=li,s,r,o=Ie,a=Ie,l,c=Tn){let h={width:t,height:t,depth:1},d=[h,h,h,h,h,h];super(t,t,e,n,s,r,o,a,l,c),this.image=d,this.isCubeDepthTexture=!0,this.isCubeTexture=!0}get images(){return this.image}set images(t){this.image=t}},Xs=class extends Ue{constructor(t=null){super(),this.sourceTexture=t,this.isExternalTexture=!0}copy(t){return super.copy(t),this.sourceTexture=t.sourceTexture,this}},hn=class i extends re{constructor(t=1,e=1,n=1,s=1,r=1,o=1){super(),this.type="BoxGeometry",this.parameters={width:t,height:e,depth:n,widthSegments:s,heightSegments:r,depthSegments:o};let a=this;s=Math.floor(s),r=Math.floor(r),o=Math.floor(o);let l=[],c=[],h=[],d=[],u=0,p=0;g("z","y","x",-1,-1,n,e,t,o,r,0),g("z","y","x",1,-1,n,e,-t,o,r,1),g("x","z","y",1,1,t,n,e,s,o,2),g("x","z","y",1,-1,t,n,-e,s,o,3),g("x","y","z",1,-1,t,e,n,s,r,4),g("x","y","z",-1,-1,t,e,-n,s,r,5),this.setIndex(l),this.setAttribute("position",new ce(c,3)),this.setAttribute("normal",new ce(h,3)),this.setAttribute("uv",new ce(d,2));function g(_,m,f,w,R,S,b,y,T,v,E){let P=S/T,D=b/v,z=S/2,O=b/2,I=y/2,k=T+1,X=v+1,Y=0,B=0,U=new C;for(let H=0;H0?1:-1,h.push(U.x,U.y,U.z),d.push(rt/T),d.push(1-H/v),Y+=1}}for(let H=0;H0&&R(!0),e>0&&R(!1)),this.setIndex(h),this.setAttribute("position",new ce(d,3)),this.setAttribute("normal",new ce(u,3)),this.setAttribute("uv",new ce(p,2));function w(){let S=new C,b=new C,y=0,T=(e-t)/n;for(let v=0;v<=r;v++){let E=[],P=v/r,D=P*(e-t)+t;for(let z=0;z<=s;z++){let O=z/s,I=O*l+a,k=Math.sin(I),X=Math.cos(I);b.x=D*k,b.y=-P*n+m,b.z=D*X,d.push(b.x,b.y,b.z),S.set(k,T,X).normalize(),u.push(S.x,S.y,S.z),p.push(O,1-P),E.push(g++)}_.push(E)}for(let v=0;v0||E!==0)&&(h.push(P,D,O),y+=3),(e>0||E!==r-1)&&(h.push(D,z,O),y+=3)}c.addGroup(f,y,0),f+=y}function R(S){let b=g,y=new zt,T=new C,v=0,E=S===!0?t:e,P=S===!0?1:-1;for(let z=1;z<=s;z++)d.push(0,m*P,0),u.push(0,P,0),p.push(.5,.5),g++;let D=g;for(let z=0;z<=s;z++){let I=z/s*l+a,k=Math.cos(I),X=Math.sin(I);T.x=E*X,T.y=m*P,T.z=E*k,d.push(T.x,T.y,T.z),u.push(0,P,0),y.x=k*.5+.5,y.y=X*.5*P+.5,p.push(y.x,y.y),g++}for(let z=0;z0)&&p.push(R,S,y),(f!==n-1||l0&&(e.defines=this.defines),e.vertexShader=this.vertexShader,e.fragmentShader=this.fragmentShader,e.lights=this.lights,e.clipping=this.clipping;let n={};for(let s in this.extensions)this.extensions[s]===!0&&(n[s]=!0);return Object.keys(n).length>0&&(e.extensions=n),e}fromJSON(t,e){if(super.fromJSON(t,e),t.uniforms!==void 0)for(let n in t.uniforms){let s=t.uniforms[n];switch(this.uniforms[n]={},s.type){case"t":this.uniforms[n].value=e[s.value]||null;break;case"c":this.uniforms[n].value=new Dt().setHex(s.value);break;case"v2":this.uniforms[n].value=new zt().fromArray(s.value);break;case"v3":this.uniforms[n].value=new C().fromArray(s.value);break;case"v4":this.uniforms[n].value=new pe().fromArray(s.value);break;case"m3":this.uniforms[n].value=new Ut().fromArray(s.value);break;case"m4":this.uniforms[n].value=new Ht().fromArray(s.value);break;default:this.uniforms[n].value=s.value}}if(t.defines!==void 0&&(this.defines=t.defines),t.vertexShader!==void 0&&(this.vertexShader=t.vertexShader),t.fragmentShader!==void 0&&(this.fragmentShader=t.fragmentShader),t.glslVersion!==void 0&&(this.glslVersion=t.glslVersion),t.extensions!==void 0)for(let n in t.extensions)this.extensions[n]=t.extensions[n];return t.lights!==void 0&&(this.lights=t.lights),t.clipping!==void 0&&(this.clipping=t.clipping),this}},mo=class extends Ze{constructor(t){super(t),this.isRawShaderMaterial=!0,this.type="RawShaderMaterial"}};var ds=class extends Rn{constructor(t){super(),this.isMeshLambertMaterial=!0,this.type="MeshLambertMaterial",this.color=new Dt(16777215),this.map=null,this.lightMap=null,this.lightMapIntensity=1,this.aoMap=null,this.aoMapIntensity=1,this.emissive=new Dt(0),this.emissiveIntensity=1,this.emissiveMap=null,this.bumpMap=null,this.bumpScale=1,this.normalMap=null,this.normalMapType=va,this.normalScale=new zt(1,1),this.displacementMap=null,this.displacementScale=1,this.displacementBias=0,this.specularMap=null,this.alphaMap=null,this.envMap=null,this.envMapRotation=new Vn,this.combine=Lo,this.reflectivity=1,this.envMapIntensity=1,this.refractionRatio=.98,this.wireframe=!1,this.wireframeLinewidth=1,this.wireframeLinecap="round",this.wireframeLinejoin="round",this.flatShading=!1,this.fog=!0,this.setValues(t)}copy(t){return super.copy(t),this.color.copy(t.color),this.map=t.map,this.lightMap=t.lightMap,this.lightMapIntensity=t.lightMapIntensity,this.aoMap=t.aoMap,this.aoMapIntensity=t.aoMapIntensity,this.emissive.copy(t.emissive),this.emissiveMap=t.emissiveMap,this.emissiveIntensity=t.emissiveIntensity,this.bumpMap=t.bumpMap,this.bumpScale=t.bumpScale,this.normalMap=t.normalMap,this.normalMapType=t.normalMapType,this.normalScale.copy(t.normalScale),this.displacementMap=t.displacementMap,this.displacementScale=t.displacementScale,this.displacementBias=t.displacementBias,this.specularMap=t.specularMap,this.alphaMap=t.alphaMap,this.envMap=t.envMap,this.envMapRotation.copy(t.envMapRotation),this.combine=t.combine,this.reflectivity=t.reflectivity,this.envMapIntensity=t.envMapIntensity,this.refractionRatio=t.refractionRatio,this.wireframe=t.wireframe,this.wireframeLinewidth=t.wireframeLinewidth,this.wireframeLinecap=t.wireframeLinecap,this.wireframeLinejoin=t.wireframeLinejoin,this.flatShading=t.flatShading,this.fog=t.fog,this}},go=class extends Rn{constructor(t){super(),this.isMeshDepthMaterial=!0,this.type="MeshDepthMaterial",this.depthPacking=iu,this.map=null,this.alphaMap=null,this.displacementMap=null,this.displacementScale=1,this.displacementBias=0,this.wireframe=!1,this.wireframeLinewidth=1,this.setValues(t)}copy(t){return super.copy(t),this.depthPacking=t.depthPacking,this.map=t.map,this.alphaMap=t.alphaMap,this.displacementMap=t.displacementMap,this.displacementScale=t.displacementScale,this.displacementBias=t.displacementBias,this.wireframe=t.wireframe,this.wireframeLinewidth=t.wireframeLinewidth,this}},_o=class extends Rn{constructor(t){super(),this.isMeshDistanceMaterial=!0,this.type="MeshDistanceMaterial",this.map=null,this.alphaMap=null,this.displacementMap=null,this.displacementScale=1,this.displacementBias=0,this.setValues(t)}copy(t){return super.copy(t),this.map=t.map,this.alphaMap=t.alphaMap,this.displacementMap=t.displacementMap,this.displacementScale=t.displacementScale,this.displacementBias=t.displacementBias,this}};function Zi(i,t){return!i||i.constructor===t?i:typeof t.BYTES_PER_ELEMENT=="number"?new t(i):Array.prototype.slice.call(i)}function yl(i){return i!==void 0&&i.inTangents!==void 0&&i.outTangents!==void 0}var si=class{constructor(t,e,n,s){this.parameterPositions=t,this._cachedIndex=0,this.resultBuffer=s!==void 0?s:new e.constructor(n),this.sampleValues=e,this.valueSize=n,this.settings=null,this.DefaultSettings_={}}evaluate(t){let e=this.parameterPositions,n=this._cachedIndex,s=e[n],r=e[n-1];n:{t:{let o;e:{i:if(!(t=r)){let a=e[1];t=r)break t}o=n,n=0;break e}break n}for(;n>>1;te;)--o;if(++o,r!==0||o!==s){r>=o&&(o=Math.max(o,1),r=o-1);let a=this.getValueSize();this.times=n.slice(r,o),this.values=this.values.slice(r*a,o*a)}return this}validate(){let t=!0,e=this.getValueSize();e-Math.floor(e)!==0&&(Pt("KeyframeTrack: Invalid value size in track.",this),t=!1);let n=this.times,s=this.values,r=n.length;r===0&&(Pt("KeyframeTrack: Track is empty.",this),t=!1);let o=null;for(let a=0;a!==r;a++){let l=n[a];if(typeof l=="number"&&isNaN(l)){Pt("KeyframeTrack: Time is not a valid number.",this,a,l),t=!1;break}if(o!==null&&o>l){Pt("KeyframeTrack: Out of order keys.",this,a,l,o),t=!1;break}o=l}if(s!==void 0&&Pf(s))for(let a=0,l=s.length;a!==l;++a){let c=s[a];if(isNaN(c)){Pt("KeyframeTrack: Value is not a valid number.",this,a,c),t=!1;break}}return t}optimize(){let t=this.times.slice(),e=this.values.slice(),n=this.getValueSize(),s=this.getInterpolation()===Kr,r=t.length-1,o=1;for(let a=1;a0){t[o]=t[r];for(let a=r*n,l=o*n,c=0;c!==n;++c)e[l+c]=e[a+c];++o}return o!==t.length?(this.times=t.slice(0,o),this.values=e.slice(0,o*n)):(this.times=t,this.values=e),this}clone(){let t=this.times.slice(),e=this.values.slice(),n=this.constructor,s=new n(this.name,t,e);return s.createInterpolant=this.createInterpolant,yl(this.settings)&&(s.settings={inTangents:this.settings.inTangents.slice(),outTangents:this.settings.outTangents.slice()}),s}};function Th(i,t){for(let e=0,n=i.length;e!==n;e+=2)i[e]*=t}rn.prototype.ValueTypeName="";rn.prototype.TimeBufferType=Float32Array;rn.prototype.ValueBufferType=Float32Array;rn.prototype.DefaultInterpolation=ao;var ri=class extends rn{constructor(t,e,n){super(t,e,n)}};ri.prototype.ValueTypeName="bool";ri.prototype.ValueBufferType=Array;ri.prototype.DefaultInterpolation=Us;ri.prototype.InterpolantFactoryMethodLinear=void 0;ri.prototype.InterpolantFactoryMethodSmooth=void 0;var So=class extends rn{constructor(t,e,n,s){super(t,e,n,s)}};So.prototype.ValueTypeName="color";var bo=class extends rn{constructor(t,e,n,s){super(t,e,n,s)}};bo.prototype.ValueTypeName="number";var wo=class extends si{constructor(t,e,n,s){super(t,e,n,s)}interpolate_(t,e,n,s){let r=this.resultBuffer,o=this.sampleValues,a=this.valueSize,l=(n-e)/(s-e),c=t*a;for(let h=c+a;c!==h;c+=4)Ne.slerpFlat(r,0,o,c-a,o,c,l);return r}},Ys=class extends rn{constructor(t,e,n,s){super(t,e,n,s)}InterpolantFactoryMethodLinear(t){return new wo(this.times,this.values,this.getValueSize(),t)}};Ys.prototype.ValueTypeName="quaternion";Ys.prototype.InterpolantFactoryMethodSmooth=void 0;var oi=class extends rn{constructor(t,e,n){super(t,e,n)}};oi.prototype.ValueTypeName="string";oi.prototype.ValueBufferType=Array;oi.prototype.DefaultInterpolation=Us;oi.prototype.InterpolantFactoryMethodLinear=void 0;oi.prototype.InterpolantFactoryMethodSmooth=void 0;var Eo=class extends rn{constructor(t,e,n,s){super(t,e,n,s)}};Eo.prototype.ValueTypeName="vector";var To=class{constructor(t,e,n){let s=this,r=!1,o=0,a=0,l,c=[];this.onStart=void 0,this.onLoad=t,this.onProgress=e,this.onError=n,this._abortController=null,this.itemStart=function(h){a++,r===!1&&s.onStart!==void 0&&s.onStart(h,o,a),r=!0},this.itemEnd=function(h){o++,s.onProgress!==void 0&&s.onProgress(h,o,a),o===a&&(r=!1,s.onLoad!==void 0&&s.onLoad())},this.itemError=function(h){s.onError!==void 0&&s.onError(h)},this.resolveURL=function(h){return h=h.normalize("NFC"),l?l(h):h},this.setURLModifier=function(h){return l=h,this},this.addHandler=function(h,d){return c.push(h,d),this},this.removeHandler=function(h){let d=c.indexOf(h);return d!==-1&&c.splice(d,2),this},this.getHandler=function(h){for(let d=0,u=c.length;dp.start-g.start);let u=0;for(let p=1;p 0 + vec4 plane; + #ifdef ALPHA_TO_COVERAGE + float distanceToPlane, distanceGradient; + float clipOpacity = 1.0; + #pragma unroll_loop_start + for ( int i = 0; i < UNION_CLIPPING_PLANES; i ++ ) { + plane = clippingPlanes[ i ]; + distanceToPlane = - dot( vClipPosition, plane.xyz ) + plane.w; + distanceGradient = fwidth( distanceToPlane ) / 2.0; + clipOpacity *= smoothstep( - distanceGradient, distanceGradient, distanceToPlane ); + if ( clipOpacity == 0.0 ) discard; + } + #pragma unroll_loop_end + #if UNION_CLIPPING_PLANES < NUM_CLIPPING_PLANES + float unionClipOpacity = 1.0; + #pragma unroll_loop_start + for ( int i = UNION_CLIPPING_PLANES; i < NUM_CLIPPING_PLANES; i ++ ) { + plane = clippingPlanes[ i ]; + distanceToPlane = - dot( vClipPosition, plane.xyz ) + plane.w; + distanceGradient = fwidth( distanceToPlane ) / 2.0; + unionClipOpacity *= 1.0 - smoothstep( - distanceGradient, distanceGradient, distanceToPlane ); + } + #pragma unroll_loop_end + clipOpacity *= 1.0 - unionClipOpacity; + #endif + diffuseColor.a *= clipOpacity; + if ( diffuseColor.a == 0.0 ) discard; + #else + #pragma unroll_loop_start + for ( int i = 0; i < UNION_CLIPPING_PLANES; i ++ ) { + plane = clippingPlanes[ i ]; + if ( dot( vClipPosition, plane.xyz ) > plane.w ) discard; + } + #pragma unroll_loop_end + #if UNION_CLIPPING_PLANES < NUM_CLIPPING_PLANES + bool clipped = true; + #pragma unroll_loop_start + for ( int i = UNION_CLIPPING_PLANES; i < NUM_CLIPPING_PLANES; i ++ ) { + plane = clippingPlanes[ i ]; + clipped = ( dot( vClipPosition, plane.xyz ) > plane.w ) && clipped; + } + #pragma unroll_loop_end + if ( clipped ) discard; + #endif + #endif +#endif`,Xp=`#if NUM_CLIPPING_PLANES > 0 + varying vec3 vClipPosition; + uniform vec4 clippingPlanes[ NUM_CLIPPING_PLANES ]; +#endif`,qp=`#if NUM_CLIPPING_PLANES > 0 + varying vec3 vClipPosition; +#endif`,Yp=`#if NUM_CLIPPING_PLANES > 0 + vClipPosition = - mvPosition.xyz; +#endif`,$p=`#if defined( USE_COLOR ) || defined( USE_COLOR_ALPHA ) + diffuseColor *= vColor; +#endif`,Zp=`#if defined( USE_COLOR ) || defined( USE_COLOR_ALPHA ) + varying vec4 vColor; +#endif`,Jp=`#if defined( USE_COLOR ) || defined( USE_COLOR_ALPHA ) || defined( USE_INSTANCING_COLOR ) || defined( USE_BATCHING_COLOR ) + varying vec4 vColor; +#endif`,Kp=`#if defined( USE_COLOR ) || defined( USE_COLOR_ALPHA ) || defined( USE_INSTANCING_COLOR ) || defined( USE_BATCHING_COLOR ) + vColor = vec4( 1.0 ); +#endif +#ifdef USE_COLOR_ALPHA + vColor *= color; +#elif defined( USE_COLOR ) + vColor.rgb *= color; +#endif +#ifdef USE_INSTANCING_COLOR + vColor.rgb *= instanceColor.rgb; +#endif +#ifdef USE_BATCHING_COLOR + vColor *= getBatchingColor( getIndirectIndex( gl_DrawID ) ); +#endif`,jp=`#define PI 3.141592653589793 +#define PI2 6.283185307179586 +#define PI_HALF 1.5707963267948966 +#define RECIPROCAL_PI 0.3183098861837907 +#define RECIPROCAL_PI2 0.15915494309189535 +#define EPSILON 1e-6 +#ifndef saturate +#define saturate( a ) clamp( a, 0.0, 1.0 ) +#endif +#define whiteComplement( a ) ( 1.0 - saturate( a ) ) +float pow2( const in float x ) { return x*x; } +vec3 pow2( const in vec3 x ) { return x*x; } +float pow3( const in float x ) { return x*x*x; } +float pow4( const in float x ) { float x2 = x*x; return x2*x2; } +float max3( const in vec3 v ) { return max( max( v.x, v.y ), v.z ); } +float average( const in vec3 v ) { return dot( v, vec3( 0.3333333 ) ); } +highp float rand( const in vec2 uv ) { + const highp float a = 12.9898, b = 78.233, c = 43758.5453; + highp float dt = dot( uv.xy, vec2( a,b ) ), sn = mod( dt, PI ); + return fract( sin( sn ) * c ); +} +#ifdef HIGH_PRECISION + float precisionSafeLength( vec3 v ) { return length( v ); } +#else + float precisionSafeLength( vec3 v ) { + float maxComponent = max3( abs( v ) ); + return length( v / maxComponent ) * maxComponent; + } +#endif +struct IncidentLight { + vec3 color; + vec3 direction; + bool visible; +}; +struct ReflectedLight { + vec3 directDiffuse; + vec3 directSpecular; + vec3 indirectDiffuse; + vec3 indirectSpecular; +}; +#ifdef USE_ALPHAHASH + varying vec3 vPosition; +#endif +vec3 transformDirection( in vec3 dir, in mat4 matrix ) { + return normalize( ( matrix * vec4( dir, 0.0 ) ).xyz ); +} +#define inverseTransformDirection transformDirectionByInverseViewMatrix +vec3 transformNormalByInverseViewMatrix( in vec3 normal, in mat4 viewMatrix ) { + return normalize( ( vec4( normal, 0.0 ) * viewMatrix ).xyz ); +} +vec3 transformDirectionByInverseViewMatrix( in vec3 dir, in mat4 viewMatrix ) { + return normalize( ( vec4( dir, 0.0 ) * viewMatrix ).xyz ); +} +bool isPerspectiveMatrix( mat4 m ) { + return m[ 2 ][ 3 ] == - 1.0; +} +vec2 equirectUv( in vec3 dir ) { + float u = atan( dir.z, dir.x ) * RECIPROCAL_PI2 + 0.5; + float v = asin( clamp( dir.y, - 1.0, 1.0 ) ) * RECIPROCAL_PI + 0.5; + return vec2( u, v ); +} +vec3 BRDF_Lambert( const in vec3 diffuseColor ) { + return RECIPROCAL_PI * diffuseColor; +} +vec3 F_Schlick( const in vec3 f0, const in float f90, const in float dotVH ) { + float fresnel = exp2( ( - 5.55473 * dotVH - 6.98316 ) * dotVH ); + return f0 * ( 1.0 - fresnel ) + ( f90 * fresnel ); +} +float F_Schlick( const in float f0, const in float f90, const in float dotVH ) { + float fresnel = exp2( ( - 5.55473 * dotVH - 6.98316 ) * dotVH ); + return f0 * ( 1.0 - fresnel ) + ( f90 * fresnel ); +} // validated`,Qp=`#ifdef ENVMAP_TYPE_CUBE_UV + #define cubeUV_minMipLevel 4.0 + #define cubeUV_minTileSize 16.0 + float getFace( vec3 direction ) { + vec3 absDirection = abs( direction ); + float face = - 1.0; + if ( absDirection.x > absDirection.z ) { + if ( absDirection.x > absDirection.y ) + face = direction.x > 0.0 ? 0.0 : 3.0; + else + face = direction.y > 0.0 ? 1.0 : 4.0; + } else { + if ( absDirection.z > absDirection.y ) + face = direction.z > 0.0 ? 2.0 : 5.0; + else + face = direction.y > 0.0 ? 1.0 : 4.0; + } + return face; + } + vec2 getUV( vec3 direction, float face ) { + vec2 uv; + if ( face == 0.0 ) { + uv = vec2( direction.z, direction.y ) / abs( direction.x ); + } else if ( face == 1.0 ) { + uv = vec2( - direction.x, - direction.z ) / abs( direction.y ); + } else if ( face == 2.0 ) { + uv = vec2( - direction.x, direction.y ) / abs( direction.z ); + } else if ( face == 3.0 ) { + uv = vec2( - direction.z, direction.y ) / abs( direction.x ); + } else if ( face == 4.0 ) { + uv = vec2( - direction.x, direction.z ) / abs( direction.y ); + } else { + uv = vec2( direction.x, direction.y ) / abs( direction.z ); + } + return 0.5 * ( uv + 1.0 ); + } + vec3 bilinearCubeUV( sampler2D envMap, vec3 direction, float mipInt ) { + float face = getFace( direction ); + float filterInt = max( cubeUV_minMipLevel - mipInt, 0.0 ); + mipInt = max( mipInt, cubeUV_minMipLevel ); + float faceSize = exp2( mipInt ); + highp vec2 uv = getUV( direction, face ) * ( faceSize - 2.0 ) + 1.0; + if ( face > 2.0 ) { + uv.y += faceSize; + face -= 3.0; + } + uv.x += face * faceSize; + uv.x += filterInt * 3.0 * cubeUV_minTileSize; + uv.y += 4.0 * ( exp2( CUBEUV_MAX_MIP ) - faceSize ); + uv.x *= CUBEUV_TEXEL_WIDTH; + uv.y *= CUBEUV_TEXEL_HEIGHT; + #ifdef texture2DGradEXT + return texture2DGradEXT( envMap, uv, vec2( 0.0 ), vec2( 0.0 ) ).rgb; + #else + return texture2D( envMap, uv ).rgb; + #endif + } + #define cubeUV_r0 1.0 + #define cubeUV_m0 - 2.0 + #define cubeUV_r1 0.8 + #define cubeUV_m1 - 1.0 + #define cubeUV_r4 0.4 + #define cubeUV_m4 2.0 + #define cubeUV_r5 0.305 + #define cubeUV_m5 3.0 + #define cubeUV_r6 0.21 + #define cubeUV_m6 4.0 + float roughnessToMip( float roughness ) { + float mip = 0.0; + if ( roughness >= cubeUV_r1 ) { + mip = ( cubeUV_r0 - roughness ) * ( cubeUV_m1 - cubeUV_m0 ) / ( cubeUV_r0 - cubeUV_r1 ) + cubeUV_m0; + } else if ( roughness >= cubeUV_r4 ) { + mip = ( cubeUV_r1 - roughness ) * ( cubeUV_m4 - cubeUV_m1 ) / ( cubeUV_r1 - cubeUV_r4 ) + cubeUV_m1; + } else if ( roughness >= cubeUV_r5 ) { + mip = ( cubeUV_r4 - roughness ) * ( cubeUV_m5 - cubeUV_m4 ) / ( cubeUV_r4 - cubeUV_r5 ) + cubeUV_m4; + } else if ( roughness >= cubeUV_r6 ) { + mip = ( cubeUV_r5 - roughness ) * ( cubeUV_m6 - cubeUV_m5 ) / ( cubeUV_r5 - cubeUV_r6 ) + cubeUV_m5; + } else { + mip = - 2.0 * log2( 1.16 * roughness ); } + return mip; + } + vec4 textureCubeUV( sampler2D envMap, vec3 sampleDir, float roughness ) { + float mip = clamp( roughnessToMip( roughness ), cubeUV_m0, CUBEUV_MAX_MIP ); + float mipF = fract( mip ); + float mipInt = floor( mip ); + vec3 color0 = bilinearCubeUV( envMap, sampleDir, mipInt ); + if ( mipF == 0.0 ) { + return vec4( color0, 1.0 ); + } else { + vec3 color1 = bilinearCubeUV( envMap, sampleDir, mipInt + 1.0 ); + return vec4( mix( color0, color1, mipF ), 1.0 ); + } + } +#endif`,tm=`vec3 transformedNormal = objectNormal; +#ifdef USE_TANGENT + vec3 transformedTangent = objectTangent; +#endif +#ifdef USE_BATCHING + mat3 bm = mat3( batchingMatrix ); + transformedNormal /= vec3( dot( bm[ 0 ], bm[ 0 ] ), dot( bm[ 1 ], bm[ 1 ] ), dot( bm[ 2 ], bm[ 2 ] ) ); + transformedNormal = bm * transformedNormal; + #ifdef USE_TANGENT + transformedTangent = bm * transformedTangent; + #endif +#endif +#ifdef USE_INSTANCING + mat3 im = mat3( instanceMatrix ); + transformedNormal /= vec3( dot( im[ 0 ], im[ 0 ] ), dot( im[ 1 ], im[ 1 ] ), dot( im[ 2 ], im[ 2 ] ) ); + transformedNormal = im * transformedNormal; + #ifdef USE_TANGENT + transformedTangent = im * transformedTangent; + #endif +#endif +transformedNormal = normalMatrix * transformedNormal; +#ifdef FLIP_SIDED + transformedNormal = - transformedNormal; +#endif +#ifdef USE_TANGENT + transformedTangent = ( modelViewMatrix * vec4( transformedTangent, 0.0 ) ).xyz; +#endif`,em=`#ifdef USE_DISPLACEMENTMAP + uniform sampler2D displacementMap; + uniform float displacementScale; + uniform float displacementBias; +#endif`,nm=`#ifdef USE_DISPLACEMENTMAP + transformed += normalize( objectNormal ) * ( texture2D( displacementMap, vDisplacementMapUv ).x * displacementScale + displacementBias ); +#endif`,im=`#ifdef USE_EMISSIVEMAP + vec4 emissiveColor = texture2D( emissiveMap, vEmissiveMapUv ); + #ifdef DECODE_VIDEO_TEXTURE_EMISSIVE + emissiveColor = sRGBTransferEOTF( emissiveColor ); + #endif + totalEmissiveRadiance *= emissiveColor.rgb; +#endif`,sm=`#ifdef USE_EMISSIVEMAP + uniform sampler2D emissiveMap; +#endif`,rm="gl_FragColor = linearToOutputTexel( gl_FragColor );",om=`vec4 LinearTransferOETF( in vec4 value ) { + return value; +} +vec4 sRGBTransferEOTF( in vec4 value ) { + return vec4( mix( pow( value.rgb * 0.9478672986 + vec3( 0.0521327014 ), vec3( 2.4 ) ), value.rgb * 0.0773993808, vec3( lessThanEqual( value.rgb, vec3( 0.04045 ) ) ) ), value.a ); +} +vec4 sRGBTransferOETF( in vec4 value ) { + return vec4( mix( pow( value.rgb, vec3( 0.41666 ) ) * 1.055 - vec3( 0.055 ), value.rgb * 12.92, vec3( lessThanEqual( value.rgb, vec3( 0.0031308 ) ) ) ), value.a ); +}`,am=`#ifdef USE_ENVMAP + #ifdef ENV_WORLDPOS + vec3 cameraToFrag; + if ( isOrthographic ) { + cameraToFrag = normalize( vec3( - viewMatrix[ 0 ][ 2 ], - viewMatrix[ 1 ][ 2 ], - viewMatrix[ 2 ][ 2 ] ) ); + } else { + cameraToFrag = normalize( vWorldPosition - cameraPosition ); + } + vec3 worldNormal = transformNormalByInverseViewMatrix( normal, viewMatrix ); + #ifdef ENVMAP_MODE_REFLECTION + vec3 reflectVec = reflect( cameraToFrag, worldNormal ); + #else + vec3 reflectVec = refract( cameraToFrag, worldNormal, refractionRatio ); + #endif + #else + vec3 reflectVec = vReflect; + #endif + #ifdef ENVMAP_TYPE_CUBE + vec4 envColor = textureCube( envMap, envMapRotation * reflectVec ); + #ifdef ENVMAP_BLENDING_MULTIPLY + outgoingLight = mix( outgoingLight, outgoingLight * envColor.xyz, specularStrength * reflectivity ); + #elif defined( ENVMAP_BLENDING_MIX ) + outgoingLight = mix( outgoingLight, envColor.xyz, specularStrength * reflectivity ); + #elif defined( ENVMAP_BLENDING_ADD ) + outgoingLight += envColor.xyz * specularStrength * reflectivity; + #endif + #endif +#endif`,lm=`#ifdef USE_ENVMAP + uniform float envMapIntensity; + uniform mat3 envMapRotation; + #ifdef ENVMAP_TYPE_CUBE + uniform samplerCube envMap; + #else + uniform sampler2D envMap; + #endif +#endif`,cm=`#ifdef USE_ENVMAP + uniform float reflectivity; + #if defined( USE_BUMPMAP ) || defined( USE_NORMALMAP ) || defined( PHONG ) || defined( LAMBERT ) + #define ENV_WORLDPOS + #endif + #ifdef ENV_WORLDPOS + varying vec3 vWorldPosition; + uniform float refractionRatio; + #else + varying vec3 vReflect; + #endif +#endif`,hm=`#ifdef USE_ENVMAP + #if defined( USE_BUMPMAP ) || defined( USE_NORMALMAP ) || defined( PHONG ) || defined( LAMBERT ) + #define ENV_WORLDPOS + #endif + #ifdef ENV_WORLDPOS + + varying vec3 vWorldPosition; + #else + varying vec3 vReflect; + uniform float refractionRatio; + #endif +#endif`,um=`#ifdef USE_ENVMAP + #ifdef ENV_WORLDPOS + vWorldPosition = worldPosition.xyz; + #else + vec3 cameraToVertex; + if ( isOrthographic ) { + cameraToVertex = normalize( vec3( - viewMatrix[ 0 ][ 2 ], - viewMatrix[ 1 ][ 2 ], - viewMatrix[ 2 ][ 2 ] ) ); + } else { + cameraToVertex = normalize( worldPosition.xyz - cameraPosition ); + } + vec3 worldNormal = transformNormalByInverseViewMatrix( transformedNormal, viewMatrix ); + #ifdef ENVMAP_MODE_REFLECTION + vReflect = reflect( cameraToVertex, worldNormal ); + #else + vReflect = refract( cameraToVertex, worldNormal, refractionRatio ); + #endif + #endif +#endif`,dm=`#ifdef USE_FOG + vFogDepth = - mvPosition.z; +#endif`,fm=`#ifdef USE_FOG + varying float vFogDepth; +#endif`,pm=`#ifdef USE_FOG + #ifdef FOG_EXP2 + float fogFactor = 1.0 - exp( - fogDensity * fogDensity * vFogDepth * vFogDepth ); + #else + float fogFactor = smoothstep( fogNear, fogFar, vFogDepth ); + #endif + gl_FragColor.rgb = mix( gl_FragColor.rgb, fogColor, fogFactor ); +#endif`,mm=`#ifdef USE_FOG + uniform vec3 fogColor; + varying float vFogDepth; + #ifdef FOG_EXP2 + uniform float fogDensity; + #else + uniform float fogNear; + uniform float fogFar; + #endif +#endif`,gm=`#ifdef USE_GRADIENTMAP + uniform sampler2D gradientMap; +#endif +vec3 getGradientIrradiance( vec3 normal, vec3 lightDirection ) { + float dotNL = dot( normal, lightDirection ); + vec2 coord = vec2( dotNL * 0.5 + 0.5, 0.0 ); + #ifdef USE_GRADIENTMAP + return vec3( texture2D( gradientMap, coord ).r ); + #else + vec2 fw = fwidth( coord ) * 0.5; + return mix( vec3( 0.7 ), vec3( 1.0 ), smoothstep( 0.7 - fw.x, 0.7 + fw.x, coord.x ) ); + #endif +}`,_m=`#ifdef USE_LIGHTMAP + uniform sampler2D lightMap; + uniform float lightMapIntensity; +#endif`,xm=`LambertMaterial material; +material.diffuseColor = diffuseColor.rgb; +material.specularStrength = specularStrength;`,vm=`varying vec3 vViewPosition; +struct LambertMaterial { + vec3 diffuseColor; + float specularStrength; +}; +void RE_Direct_Lambert( const in IncidentLight directLight, const in vec3 geometryPosition, const in vec3 geometryNormal, const in vec3 geometryViewDir, const in vec3 geometryClearcoatNormal, const in LambertMaterial material, inout ReflectedLight reflectedLight ) { + float dotNL = saturate( dot( geometryNormal, directLight.direction ) ); + vec3 irradiance = dotNL * directLight.color; + reflectedLight.directDiffuse += irradiance * BRDF_Lambert( material.diffuseColor ); +} +void RE_IndirectDiffuse_Lambert( const in vec3 irradiance, const in vec3 geometryPosition, const in vec3 geometryNormal, const in vec3 geometryViewDir, const in vec3 geometryClearcoatNormal, const in LambertMaterial material, inout ReflectedLight reflectedLight ) { + reflectedLight.indirectDiffuse += irradiance * BRDF_Lambert( material.diffuseColor ); +} +#define RE_Direct RE_Direct_Lambert +#define RE_IndirectDiffuse RE_IndirectDiffuse_Lambert`,ym=`uniform bool receiveShadow; +uniform vec3 ambientLightColor; +#if defined( USE_LIGHT_PROBES ) + uniform vec3 lightProbe[ 9 ]; +#endif +vec3 shGetIrradianceAt( in vec3 normal, in vec3 shCoefficients[ 9 ] ) { + float x = normal.x, y = normal.y, z = normal.z; + vec3 result = shCoefficients[ 0 ] * 0.886227; + result += shCoefficients[ 1 ] * 2.0 * 0.511664 * y; + result += shCoefficients[ 2 ] * 2.0 * 0.511664 * z; + result += shCoefficients[ 3 ] * 2.0 * 0.511664 * x; + result += shCoefficients[ 4 ] * 2.0 * 0.429043 * x * y; + result += shCoefficients[ 5 ] * 2.0 * 0.429043 * y * z; + result += shCoefficients[ 6 ] * ( 0.743125 * z * z - 0.247708 ); + result += shCoefficients[ 7 ] * 2.0 * 0.429043 * x * z; + result += shCoefficients[ 8 ] * 0.429043 * ( x * x - y * y ); + return result; +} +vec3 getLightProbeIrradiance( const in vec3 lightProbe[ 9 ], const in vec3 normal ) { + vec3 worldNormal = transformNormalByInverseViewMatrix( normal, viewMatrix ); + vec3 irradiance = shGetIrradianceAt( worldNormal, lightProbe ); + return irradiance; +} +vec3 getAmbientLightIrradiance( const in vec3 ambientLightColor ) { + vec3 irradiance = ambientLightColor; + return irradiance; +} +float getDistanceAttenuation( const in float lightDistance, const in float cutoffDistance, const in float decayExponent ) { + float distanceFalloff = 1.0 / max( pow( lightDistance, decayExponent ), 0.01 ); + if ( cutoffDistance > 0.0 ) { + distanceFalloff *= pow2( saturate( 1.0 - pow4( lightDistance / cutoffDistance ) ) ); + } + return distanceFalloff; +} +float getSpotAttenuation( const in float coneCosine, const in float penumbraCosine, const in float angleCosine ) { + return smoothstep( coneCosine, penumbraCosine, angleCosine ); +} +#if NUM_SUN_LIGHTS > 0 + struct SunLight { + vec3 direction; + vec3 color; + }; + uniform SunLight sunLights[ NUM_SUN_LIGHTS ]; + void getSunLightInfo( const in SunLight sunLight, out IncidentLight light ) { + light.color = sunLight.color; + light.direction = sunLight.direction; + light.visible = true; + } +#endif +#if NUM_DIR_LIGHTS > 0 + struct DirectionalLight { + vec3 direction; + vec3 color; + }; + uniform DirectionalLight directionalLights[ NUM_DIR_LIGHTS ]; + void getDirectionalLightInfo( const in DirectionalLight directionalLight, out IncidentLight light ) { + light.color = directionalLight.color; + light.direction = directionalLight.direction; + light.visible = true; + } +#endif +#if NUM_POINT_LIGHTS > 0 + struct PointLight { + vec3 position; + vec3 color; + float distance; + float decay; + }; + uniform PointLight pointLights[ NUM_POINT_LIGHTS ]; + void getPointLightInfo( const in PointLight pointLight, const in vec3 geometryPosition, out IncidentLight light ) { + vec3 lVector = pointLight.position - geometryPosition; + light.direction = normalize( lVector ); + float lightDistance = length( lVector ); + light.color = pointLight.color; + light.color *= getDistanceAttenuation( lightDistance, pointLight.distance, pointLight.decay ); + light.visible = ( light.color != vec3( 0.0 ) ); + } +#endif +#if NUM_SPOT_LIGHTS > 0 + struct SpotLight { + vec3 position; + vec3 direction; + vec3 color; + float distance; + float decay; + float coneCos; + float penumbraCos; + }; + uniform SpotLight spotLights[ NUM_SPOT_LIGHTS ]; + void getSpotLightInfo( const in SpotLight spotLight, const in vec3 geometryPosition, out IncidentLight light ) { + vec3 lVector = spotLight.position - geometryPosition; + light.direction = normalize( lVector ); + float angleCos = dot( light.direction, spotLight.direction ); + float spotAttenuation = getSpotAttenuation( spotLight.coneCos, spotLight.penumbraCos, angleCos ); + if ( spotAttenuation > 0.0 ) { + float lightDistance = length( lVector ); + light.color = spotLight.color * spotAttenuation; + light.color *= getDistanceAttenuation( lightDistance, spotLight.distance, spotLight.decay ); + light.visible = ( light.color != vec3( 0.0 ) ); + } else { + light.color = vec3( 0.0 ); + light.visible = false; + } + } +#endif +#if NUM_RECT_AREA_LIGHTS > 0 + struct RectAreaLight { + vec3 color; + vec3 position; + vec3 halfWidth; + vec3 halfHeight; + }; + uniform sampler2D ltc_1; uniform sampler2D ltc_2; + uniform RectAreaLight rectAreaLights[ NUM_RECT_AREA_LIGHTS ]; +#endif +#if NUM_HEMI_LIGHTS > 0 + struct HemisphereLight { + vec3 direction; + vec3 skyColor; + vec3 groundColor; + }; + uniform HemisphereLight hemisphereLights[ NUM_HEMI_LIGHTS ]; + vec3 getHemisphereLightIrradiance( const in HemisphereLight hemiLight, const in vec3 normal ) { + float dotNL = dot( normal, hemiLight.direction ); + float hemiDiffuseWeight = 0.5 * dotNL + 0.5; + vec3 irradiance = mix( hemiLight.groundColor, hemiLight.skyColor, hemiDiffuseWeight ); + return irradiance; + } +#endif +#include `,Mm=`#ifdef USE_ENVMAP + vec3 getIBLIrradiance( const in vec3 normal ) { + #ifdef ENVMAP_TYPE_CUBE_UV + vec3 worldNormal = transformNormalByInverseViewMatrix( normal, viewMatrix ); + vec4 envMapColor = textureCubeUV( envMap, envMapRotation * worldNormal, 1.0 ); + return PI * envMapColor.rgb * envMapIntensity; + #else + return vec3( 0.0 ); + #endif + } + vec3 getIBLRadiance( const in vec3 viewDir, const in vec3 normal, const in float roughness ) { + #ifdef ENVMAP_TYPE_CUBE_UV + vec3 reflectVec = reflect( - viewDir, normal ); + reflectVec = normalize( mix( reflectVec, normal, pow4( roughness ) ) ); + reflectVec = transformDirectionByInverseViewMatrix( reflectVec, viewMatrix ); + vec4 envMapColor = textureCubeUV( envMap, envMapRotation * reflectVec, roughness ); + return envMapColor.rgb * envMapIntensity; + #else + return vec3( 0.0 ); + #endif + } + #ifdef USE_RETROREFLECTION + vec3 getIBLRetroRadiance( const in vec3 viewDir, const in vec3 normal, const in float roughness ) { + #ifdef ENVMAP_TYPE_CUBE_UV + vec3 retroVec = normalize( mix( viewDir, normal, pow4( roughness ) ) ); + retroVec = transformDirectionByInverseViewMatrix( retroVec, viewMatrix ); + vec4 envMapColor = textureCubeUV( envMap, envMapRotation * retroVec, roughness ); + return envMapColor.rgb * envMapIntensity; + #else + return vec3( 0.0 ); + #endif + } + #endif + #ifdef USE_ANISOTROPY + vec3 getIBLAnisotropyRadiance( const in vec3 viewDir, const in vec3 normal, const in float roughness, const in vec3 bitangent, const in float anisotropy ) { + #ifdef ENVMAP_TYPE_CUBE_UV + vec3 bentNormal = cross( bitangent, viewDir ); + bentNormal = normalize( cross( bentNormal, bitangent ) ); + bentNormal = normalize( mix( bentNormal, normal, pow2( pow2( 1.0 - anisotropy * ( 1.0 - roughness ) ) ) ) ); + return getIBLRadiance( viewDir, bentNormal, roughness ); + #else + return vec3( 0.0 ); + #endif + } + #ifdef USE_RETROREFLECTION + vec3 getIBLAnisotropyRetroRadiance( const in vec3 viewDir, const in vec3 normal, const in float roughness, const in vec3 bitangent, const in float anisotropy ) { + #ifdef ENVMAP_TYPE_CUBE_UV + vec3 bentNormal = cross( bitangent, viewDir ); + bentNormal = normalize( cross( bentNormal, bitangent ) ); + bentNormal = normalize( mix( bentNormal, normal, pow2( pow2( 1.0 - anisotropy * ( 1.0 - roughness ) ) ) ) ); + return getIBLRetroRadiance( viewDir, bentNormal, roughness ); + #else + return vec3( 0.0 ); + #endif + } + #endif + #endif +#endif`,Sm=`ToonMaterial material; +material.diffuseColor = diffuseColor.rgb;`,bm=`varying vec3 vViewPosition; +struct ToonMaterial { + vec3 diffuseColor; +}; +void RE_Direct_Toon( const in IncidentLight directLight, const in vec3 geometryPosition, const in vec3 geometryNormal, const in vec3 geometryViewDir, const in vec3 geometryClearcoatNormal, const in ToonMaterial material, inout ReflectedLight reflectedLight ) { + vec3 irradiance = getGradientIrradiance( geometryNormal, directLight.direction ) * directLight.color; + reflectedLight.directDiffuse += irradiance * BRDF_Lambert( material.diffuseColor ); +} +void RE_IndirectDiffuse_Toon( const in vec3 irradiance, const in vec3 geometryPosition, const in vec3 geometryNormal, const in vec3 geometryViewDir, const in vec3 geometryClearcoatNormal, const in ToonMaterial material, inout ReflectedLight reflectedLight ) { + reflectedLight.indirectDiffuse += irradiance * BRDF_Lambert( material.diffuseColor ); +} +#define RE_Direct RE_Direct_Toon +#define RE_IndirectDiffuse RE_IndirectDiffuse_Toon`,wm=`BlinnPhongMaterial material; +material.diffuseColor = diffuseColor.rgb; +material.specularColor = specular; +material.specularShininess = shininess; +material.specularStrength = specularStrength;`,Em=`varying vec3 vViewPosition; +struct BlinnPhongMaterial { + vec3 diffuseColor; + vec3 specularColor; + float specularShininess; + float specularStrength; +}; +void RE_Direct_BlinnPhong( const in IncidentLight directLight, const in vec3 geometryPosition, const in vec3 geometryNormal, const in vec3 geometryViewDir, const in vec3 geometryClearcoatNormal, const in BlinnPhongMaterial material, inout ReflectedLight reflectedLight ) { + float dotNL = saturate( dot( geometryNormal, directLight.direction ) ); + vec3 irradiance = dotNL * directLight.color; + reflectedLight.directDiffuse += irradiance * BRDF_Lambert( material.diffuseColor ); + reflectedLight.directSpecular += irradiance * BRDF_BlinnPhong( directLight.direction, geometryViewDir, geometryNormal, material.specularColor, material.specularShininess ) * material.specularStrength; +} +void RE_IndirectDiffuse_BlinnPhong( const in vec3 irradiance, const in vec3 geometryPosition, const in vec3 geometryNormal, const in vec3 geometryViewDir, const in vec3 geometryClearcoatNormal, const in BlinnPhongMaterial material, inout ReflectedLight reflectedLight ) { + reflectedLight.indirectDiffuse += irradiance * BRDF_Lambert( material.diffuseColor ); +} +#define RE_Direct RE_Direct_BlinnPhong +#define RE_IndirectDiffuse RE_IndirectDiffuse_BlinnPhong`,Tm=`PhysicalMaterial material; +material.diffuseColor = diffuseColor.rgb; +material.diffuseContribution = diffuseColor.rgb * ( 1.0 - metalnessFactor ); +material.metalness = metalnessFactor; +vec3 dxy = max( abs( dFdx( nonPerturbedNormal ) ), abs( dFdy( nonPerturbedNormal ) ) ); +float geometryRoughness = max( max( dxy.x, dxy.y ), dxy.z ); +material.roughness = max( roughnessFactor, 0.0525 );material.roughness += geometryRoughness; +material.roughness = min( material.roughness, 1.0 ); +#ifdef IOR + material.ior = ior; + #ifdef USE_SPECULAR + float specularIntensityFactor = specularIntensity; + vec3 specularColorFactor = specularColor; + #ifdef USE_SPECULAR_COLORMAP + specularColorFactor *= texture2D( specularColorMap, vSpecularColorMapUv ).rgb; + #endif + #ifdef USE_SPECULAR_INTENSITYMAP + specularIntensityFactor *= texture2D( specularIntensityMap, vSpecularIntensityMapUv ).a; + #endif + material.specularF90 = mix( specularIntensityFactor, 1.0, metalnessFactor ); + #else + float specularIntensityFactor = 1.0; + vec3 specularColorFactor = vec3( 1.0 ); + material.specularF90 = 1.0; + #endif + material.specularColor = min( pow2( ( material.ior - 1.0 ) / ( material.ior + 1.0 ) ) * specularColorFactor, vec3( 1.0 ) ) * specularIntensityFactor; + material.specularColorBlended = mix( material.specularColor, diffuseColor.rgb, metalnessFactor ); +#else + material.specularColor = vec3( 0.04 ); + material.specularColorBlended = mix( material.specularColor, diffuseColor.rgb, metalnessFactor ); + material.specularF90 = 1.0; +#endif +#ifdef USE_CLEARCOAT + material.clearcoat = clearcoat; + material.clearcoatRoughness = clearcoatRoughness; + material.clearcoatF0 = vec3( 0.04 ); + material.clearcoatF90 = 1.0; + #ifdef USE_CLEARCOATMAP + material.clearcoat *= texture2D( clearcoatMap, vClearcoatMapUv ).x; + #endif + #ifdef USE_CLEARCOAT_ROUGHNESSMAP + material.clearcoatRoughness *= texture2D( clearcoatRoughnessMap, vClearcoatRoughnessMapUv ).y; + #endif + material.clearcoat = saturate( material.clearcoat ); material.clearcoatRoughness = max( material.clearcoatRoughness, 0.0525 ); + material.clearcoatRoughness += geometryRoughness; + material.clearcoatRoughness = min( material.clearcoatRoughness, 1.0 ); +#endif +#ifdef USE_DISPERSION + material.dispersion = dispersion; +#endif +#ifdef USE_RETROREFLECTION + material.retroreflectivity = retroreflectivity; +#endif +#ifdef USE_IRIDESCENCE + material.iridescence = iridescence; + material.iridescenceIOR = iridescenceIOR; + #ifdef USE_IRIDESCENCEMAP + material.iridescence *= texture2D( iridescenceMap, vIridescenceMapUv ).r; + #endif + #ifdef USE_IRIDESCENCE_THICKNESSMAP + material.iridescenceThickness = (iridescenceThicknessMaximum - iridescenceThicknessMinimum) * texture2D( iridescenceThicknessMap, vIridescenceThicknessMapUv ).g + iridescenceThicknessMinimum; + #else + material.iridescenceThickness = iridescenceThicknessMaximum; + #endif +#endif +#ifdef USE_SHEEN + material.sheenColor = sheenColor; + #ifdef USE_SHEEN_COLORMAP + material.sheenColor *= texture2D( sheenColorMap, vSheenColorMapUv ).rgb; + #endif + material.sheenRoughness = clamp( sheenRoughness, 0.0001, 1.0 ); + #ifdef USE_SHEEN_ROUGHNESSMAP + material.sheenRoughness *= texture2D( sheenRoughnessMap, vSheenRoughnessMapUv ).a; + #endif +#endif +#ifdef USE_ANISOTROPY + #ifdef USE_ANISOTROPYMAP + mat2 anisotropyMat = mat2( anisotropyVector.x, anisotropyVector.y, - anisotropyVector.y, anisotropyVector.x ); + vec3 anisotropyPolar = texture2D( anisotropyMap, vAnisotropyMapUv ).rgb; + vec2 anisotropyV = anisotropyMat * normalize( 2.0 * anisotropyPolar.rg - vec2( 1.0 ) ) * anisotropyPolar.b; + #else + vec2 anisotropyV = anisotropyVector; + #endif + material.anisotropy = length( anisotropyV ); + if( material.anisotropy == 0.0 ) { + anisotropyV = vec2( 1.0, 0.0 ); + } else { + anisotropyV /= material.anisotropy; + material.anisotropy = saturate( material.anisotropy ); + } + material.alphaT = mix( pow2( material.roughness ), 1.0, pow2( material.anisotropy ) ); + material.anisotropyT = tbn[ 0 ] * anisotropyV.x + tbn[ 1 ] * anisotropyV.y; + material.anisotropyB = tbn[ 1 ] * anisotropyV.x - tbn[ 0 ] * anisotropyV.y; +#endif`,Am=`uniform sampler2D dfgLUT; +struct PhysicalMaterial { + vec3 diffuseColor; + vec3 diffuseContribution; + vec3 specularColor; + vec3 specularColorBlended; + float roughness; + float metalness; + float specularF90; + float dispersion; + vec2 dfg; + vec3 multiScatteringCompensation; + #ifdef USE_RETROREFLECTION + float retroreflectivity; + #endif + #ifdef USE_CLEARCOAT + float clearcoat; + float clearcoatRoughness; + vec3 clearcoatF0; + float clearcoatF90; + #endif + #ifdef USE_IRIDESCENCE + float iridescence; + float iridescenceIOR; + float iridescenceThickness; + vec3 iridescenceFresnel; + vec3 iridescenceF0Dielectric; + vec3 iridescenceF0Metallic; + #endif + #ifdef USE_SHEEN + vec3 sheenColor; + float sheenRoughness; + #endif + #ifdef IOR + float ior; + #endif + #ifdef USE_TRANSMISSION + float transmission; + float transmissionAlpha; + float thickness; + float attenuationDistance; + vec3 attenuationColor; + #endif + #ifdef USE_ANISOTROPY + float anisotropy; + float alphaT; + vec3 anisotropyT; + vec3 anisotropyB; + #endif +}; +vec3 clearcoatSpecularDirect = vec3( 0.0 ); +vec3 clearcoatSpecularIndirect = vec3( 0.0 ); +vec3 sheenSpecularDirect = vec3( 0.0 ); +vec3 sheenSpecularIndirect = vec3(0.0 ); +vec3 Schlick_to_F0( const in vec3 f, const in float f90, const in float dotVH ) { + float x = clamp( 1.0 - dotVH, 0.0, 1.0 ); + float x2 = x * x; + float x5 = clamp( x * x2 * x2, 0.0, 0.9999 ); + return ( f - vec3( f90 ) * x5 ) / ( 1.0 - x5 ); +} +float V_GGX_SmithCorrelated( const in float alpha, const in float dotNL, const in float dotNV ) { + float a2 = pow2( alpha ); + float gv = dotNL * sqrt( a2 + ( 1.0 - a2 ) * pow2( dotNV ) ); + float gl = dotNV * sqrt( a2 + ( 1.0 - a2 ) * pow2( dotNL ) ); + return 0.5 / max( gv + gl, EPSILON ); +} +float D_GGX( const in float alpha, const in float dotNH ) { + float a2 = pow2( alpha ); + float denom = pow2( dotNH ) * ( a2 - 1.0 ) + 1.0; + return RECIPROCAL_PI * a2 / pow2( denom ); +} +#ifdef USE_ANISOTROPY + float V_GGX_SmithCorrelated_Anisotropic( const in float alphaT, const in float alphaB, const in float dotTV, const in float dotBV, const in float dotTL, const in float dotBL, const in float dotNV, const in float dotNL ) { + float gv = dotNL * length( vec3( alphaT * dotTV, alphaB * dotBV, dotNV ) ); + float gl = dotNV * length( vec3( alphaT * dotTL, alphaB * dotBL, dotNL ) ); + return 0.5 / max( gv + gl, EPSILON ); + } + float D_GGX_Anisotropic( const in float alphaT, const in float alphaB, const in float dotNH, const in float dotTH, const in float dotBH ) { + float a2 = alphaT * alphaB; + highp vec3 v = vec3( alphaB * dotTH, alphaT * dotBH, a2 * dotNH ); + highp float v2 = dot( v, v ); + float w2 = a2 / v2; + return RECIPROCAL_PI * a2 * pow2 ( w2 ); + } +#endif +#ifdef USE_CLEARCOAT + vec3 BRDF_GGX_Clearcoat( const in vec3 lightDir, const in vec3 viewDir, const in vec3 normal, const in PhysicalMaterial material) { + vec3 f0 = material.clearcoatF0; + float f90 = material.clearcoatF90; + float roughness = material.clearcoatRoughness; + float alpha = pow2( roughness ); + vec3 halfDir = normalize( lightDir + viewDir ); + float dotNL = saturate( dot( normal, lightDir ) ); + float dotNV = saturate( dot( normal, viewDir ) ); + float dotNH = saturate( dot( normal, halfDir ) ); + float dotVH = saturate( dot( viewDir, halfDir ) ); + vec3 F = F_Schlick( f0, f90, dotVH ); + float V = V_GGX_SmithCorrelated( alpha, dotNL, dotNV ); + float D = D_GGX( alpha, dotNH ); + return F * ( V * D ); + } +#endif +vec3 BRDF_GGX( const in vec3 lightDir, const in vec3 viewDir, const in vec3 normal, const in PhysicalMaterial material ) { + vec3 f0 = material.specularColorBlended; + float f90 = material.specularF90; + float roughness = material.roughness; + float alpha = pow2( roughness ); + vec3 halfDir = normalize( lightDir + viewDir ); + float dotNL = saturate( dot( normal, lightDir ) ); + float dotNV = saturate( dot( normal, viewDir ) ); + float dotNH = saturate( dot( normal, halfDir ) ); + float dotVH = saturate( dot( viewDir, halfDir ) ); + vec3 F = F_Schlick( f0, f90, dotVH ); + #ifdef USE_IRIDESCENCE + F = mix( F, material.iridescenceFresnel, material.iridescence ); + #endif + #ifdef USE_ANISOTROPY + float dotTL = dot( material.anisotropyT, lightDir ); + float dotTV = dot( material.anisotropyT, viewDir ); + float dotTH = dot( material.anisotropyT, halfDir ); + float dotBL = dot( material.anisotropyB, lightDir ); + float dotBV = dot( material.anisotropyB, viewDir ); + float dotBH = dot( material.anisotropyB, halfDir ); + float V = V_GGX_SmithCorrelated_Anisotropic( material.alphaT, alpha, dotTV, dotBV, dotTL, dotBL, dotNV, dotNL ); + float D = D_GGX_Anisotropic( material.alphaT, alpha, dotNH, dotTH, dotBH ); + #else + float V = V_GGX_SmithCorrelated( alpha, dotNL, dotNV ); + float D = D_GGX( alpha, dotNH ); + #endif + return F * ( V * D ); +} +vec2 LTC_Uv( const in vec3 N, const in vec3 V, const in float roughness ) { + const float LUT_SIZE = 64.0; + const float LUT_SCALE = ( LUT_SIZE - 1.0 ) / LUT_SIZE; + const float LUT_BIAS = 0.5 / LUT_SIZE; + float dotNV = saturate( dot( N, V ) ); + vec2 uv = vec2( roughness, sqrt( 1.0 - dotNV ) ); + uv = uv * LUT_SCALE + LUT_BIAS; + return uv; +} +float LTC_ClippedSphereFormFactor( const in vec3 f ) { + float l = length( f ); + return max( ( l * l + f.z ) / ( l + 1.0 ), 0.0 ); +} +vec3 LTC_EdgeVectorFormFactor( const in vec3 v1, const in vec3 v2 ) { + float x = dot( v1, v2 ); + float y = abs( x ); + float a = 0.8543985 + ( 0.4965155 + 0.0145206 * y ) * y; + float b = 3.4175940 + ( 4.1616724 + y ) * y; + float v = a / b; + float theta_sintheta = ( x > 0.0 ) ? v : 0.5 * inversesqrt( max( 1.0 - x * x, 1e-7 ) ) - v; + return cross( v1, v2 ) * theta_sintheta; +} +vec3 LTC_Evaluate( const in vec3 N, const in vec3 V, const in vec3 P, const in mat3 mInv, const in vec3 rectCoords[ 4 ] ) { + vec3 v1 = rectCoords[ 1 ] - rectCoords[ 0 ]; + vec3 v2 = rectCoords[ 3 ] - rectCoords[ 0 ]; + vec3 lightNormal = cross( v1, v2 ); + if( dot( lightNormal, P - rectCoords[ 0 ] ) < 0.0 ) return vec3( 0.0 ); + vec3 T1, T2; + T1 = normalize( V - N * dot( V, N ) ); + T2 = - cross( N, T1 ); + mat3 mat = mInv * transpose( mat3( T1, T2, N ) ); + vec3 coords[ 4 ]; + coords[ 0 ] = mat * ( rectCoords[ 0 ] - P ); + coords[ 1 ] = mat * ( rectCoords[ 1 ] - P ); + coords[ 2 ] = mat * ( rectCoords[ 2 ] - P ); + coords[ 3 ] = mat * ( rectCoords[ 3 ] - P ); + coords[ 0 ] = normalize( coords[ 0 ] ); + coords[ 1 ] = normalize( coords[ 1 ] ); + coords[ 2 ] = normalize( coords[ 2 ] ); + coords[ 3 ] = normalize( coords[ 3 ] ); + vec3 vectorFormFactor = vec3( 0.0 ); + vectorFormFactor += LTC_EdgeVectorFormFactor( coords[ 0 ], coords[ 1 ] ); + vectorFormFactor += LTC_EdgeVectorFormFactor( coords[ 1 ], coords[ 2 ] ); + vectorFormFactor += LTC_EdgeVectorFormFactor( coords[ 2 ], coords[ 3 ] ); + vectorFormFactor += LTC_EdgeVectorFormFactor( coords[ 3 ], coords[ 0 ] ); + float result = LTC_ClippedSphereFormFactor( vectorFormFactor ); + return vec3( result ); +} +#if defined( USE_SHEEN ) +float D_Charlie( float roughness, float dotNH ) { + float alpha = pow2( roughness ); + float invAlpha = 1.0 / alpha; + float cos2h = dotNH * dotNH; + float sin2h = max( 1.0 - cos2h, 0.0078125 ); + return ( 2.0 + invAlpha ) * pow( sin2h, invAlpha * 0.5 ) / ( 2.0 * PI ); +} +float V_Neubelt( float dotNV, float dotNL ) { + return saturate( 1.0 / ( 4.0 * ( dotNL + dotNV - dotNL * dotNV ) ) ); +} +vec3 BRDF_Sheen( const in vec3 lightDir, const in vec3 viewDir, const in vec3 normal, vec3 sheenColor, const in float sheenRoughness ) { + vec3 halfDir = normalize( lightDir + viewDir ); + float dotNL = saturate( dot( normal, lightDir ) ); + float dotNV = saturate( dot( normal, viewDir ) ); + float dotNH = saturate( dot( normal, halfDir ) ); + float D = D_Charlie( sheenRoughness, dotNH ); + float V = V_Neubelt( dotNV, dotNL ); + return sheenColor * ( D * V ); +} +#endif +float IBLSheenBRDF( const in vec3 normal, const in vec3 viewDir, const in float roughness ) { + float dotNV = saturate( dot( normal, viewDir ) ); + float r2 = roughness * roughness; + float rInv = 1.0 / ( roughness + 0.1 ); + float a = -1.9362 + 1.0678 * roughness + 0.4573 * r2 - 0.8469 * rInv; + float b = -0.6014 + 0.5538 * roughness - 0.4670 * r2 - 0.1255 * rInv; + float DG = exp( a * dotNV + b ); + return saturate( DG ); +} +vec3 EnvironmentBRDF( const in vec3 normal, const in vec3 viewDir, const in vec3 specularColor, const in float specularF90, const in float roughness ) { + float dotNV = saturate( dot( normal, viewDir ) ); + vec2 fab = texture2D( dfgLUT, vec2( roughness, dotNV ) ).rg; + return specularColor * fab.x + specularF90 * fab.y; +} +#ifdef USE_IRIDESCENCE +void computeMultiscatteringIridescence( const in vec2 fab, const in vec3 specularColor, const in float specularF90, const in float iridescence, const in vec3 iridescenceF0, inout vec3 singleScatter, inout vec3 multiScatter ) { +#else +void computeMultiscattering( const in vec2 fab, const in vec3 specularColor, const in float specularF90, inout vec3 singleScatter, inout vec3 multiScatter ) { +#endif + #ifdef USE_IRIDESCENCE + vec3 Fr = mix( specularColor, iridescenceF0, iridescence ); + #else + vec3 Fr = specularColor; + #endif + vec3 FssEss = Fr * fab.x + specularF90 * fab.y; + float Ess = fab.x + fab.y; + float Ems = 1.0 - Ess; + vec3 Favg = Fr + ( 1.0 - Fr ) * 0.047619; vec3 Fms = FssEss * Favg / ( 1.0 - Ems * Favg ); + singleScatter += FssEss; + multiScatter += Fms * Ems; +} +#if NUM_RECT_AREA_LIGHTS > 0 + void RE_Direct_RectArea_Physical( const in RectAreaLight rectAreaLight, const in vec3 geometryPosition, const in vec3 geometryNormal, const in vec3 geometryViewDir, const in vec3 geometryClearcoatNormal, const in PhysicalMaterial material, inout ReflectedLight reflectedLight ) { + vec3 normal = geometryNormal; + vec3 viewDir = geometryViewDir; + vec3 position = geometryPosition; + vec3 lightPos = rectAreaLight.position; + vec3 halfWidth = rectAreaLight.halfWidth; + vec3 halfHeight = rectAreaLight.halfHeight; + vec3 lightColor = rectAreaLight.color; + float roughness = material.roughness; + vec3 rectCoords[ 4 ]; + rectCoords[ 0 ] = lightPos + halfWidth - halfHeight; rectCoords[ 1 ] = lightPos - halfWidth - halfHeight; + rectCoords[ 2 ] = lightPos - halfWidth + halfHeight; + rectCoords[ 3 ] = lightPos + halfWidth + halfHeight; + vec2 uv = LTC_Uv( normal, viewDir, roughness ); + vec4 t1 = texture2D( ltc_1, uv ); + vec4 t2 = texture2D( ltc_2, uv ); + mat3 mInv = mat3( + vec3( t1.x, 0, t1.y ), + vec3( 0, 1, 0 ), + vec3( t1.z, 0, t1.w ) + ); + vec3 fresnel = ( material.specularColorBlended * t2.x + ( material.specularF90 - material.specularColorBlended ) * t2.y ); + reflectedLight.directSpecular += lightColor * fresnel * LTC_Evaluate( normal, viewDir, position, mInv, rectCoords ); + reflectedLight.directDiffuse += lightColor * material.diffuseContribution * LTC_Evaluate( normal, viewDir, position, mat3( 1.0 ), rectCoords ); + #ifdef USE_CLEARCOAT + vec3 Ncc = geometryClearcoatNormal; + vec2 uvClearcoat = LTC_Uv( Ncc, viewDir, material.clearcoatRoughness ); + vec4 t1Clearcoat = texture2D( ltc_1, uvClearcoat ); + vec4 t2Clearcoat = texture2D( ltc_2, uvClearcoat ); + mat3 mInvClearcoat = mat3( + vec3( t1Clearcoat.x, 0, t1Clearcoat.y ), + vec3( 0, 1, 0 ), + vec3( t1Clearcoat.z, 0, t1Clearcoat.w ) + ); + vec3 fresnelClearcoat = material.clearcoatF0 * t2Clearcoat.x + ( material.clearcoatF90 - material.clearcoatF0 ) * t2Clearcoat.y; + clearcoatSpecularDirect += lightColor * fresnelClearcoat * LTC_Evaluate( Ncc, viewDir, position, mInvClearcoat, rectCoords ); + #endif + } +#endif +void RE_Direct_Physical( const in IncidentLight directLight, const in vec3 geometryPosition, const in vec3 geometryNormal, const in vec3 geometryViewDir, const in vec3 geometryClearcoatNormal, const in PhysicalMaterial material, inout ReflectedLight reflectedLight ) { + float dotNL = saturate( dot( geometryNormal, directLight.direction ) ); + vec3 irradiance = dotNL * directLight.color; + #ifdef USE_CLEARCOAT + float dotNLcc = saturate( dot( geometryClearcoatNormal, directLight.direction ) ); + vec3 ccIrradiance = dotNLcc * directLight.color; + clearcoatSpecularDirect += ccIrradiance * BRDF_GGX_Clearcoat( directLight.direction, geometryViewDir, geometryClearcoatNormal, material ); + #endif + #ifdef USE_SHEEN + + sheenSpecularDirect += irradiance * BRDF_Sheen( directLight.direction, geometryViewDir, geometryNormal, material.sheenColor, material.sheenRoughness ); + + float sheenAlbedoV = IBLSheenBRDF( geometryNormal, geometryViewDir, material.sheenRoughness ); + float sheenAlbedoL = IBLSheenBRDF( geometryNormal, directLight.direction, material.sheenRoughness ); + + float sheenEnergyComp = 1.0 - max3( material.sheenColor ) * max( sheenAlbedoV, sheenAlbedoL ); + + irradiance *= sheenEnergyComp; + + #endif + vec3 specularBRDF = BRDF_GGX( directLight.direction, geometryViewDir, geometryNormal, material ); + #ifdef USE_RETROREFLECTION + vec3 retroViewDir = reflect( - geometryViewDir, geometryNormal ); + vec3 retroSpecularBRDF = BRDF_GGX( directLight.direction, retroViewDir, geometryNormal, material ); + specularBRDF = mix( specularBRDF, retroSpecularBRDF, saturate( material.retroreflectivity ) ); + #endif + reflectedLight.directSpecular += irradiance * specularBRDF * material.multiScatteringCompensation; + vec3 halfDir = normalize( directLight.direction + geometryViewDir ); + float dotVH = saturate( dot( geometryViewDir, halfDir ) ); + vec3 F = F_Schlick( material.specularColor, material.specularF90, dotVH ); + #ifdef USE_RETROREFLECTION + vec3 retroHalfDir = normalize( directLight.direction + retroViewDir ); + float dotRetroVH = saturate( dot( retroViewDir, retroHalfDir ) ); + vec3 retroF = F_Schlick( material.specularColor, material.specularF90, dotRetroVH ); + F = mix( F, retroF, saturate( material.retroreflectivity ) ); + #endif + reflectedLight.directDiffuse += irradiance * BRDF_Lambert( material.diffuseContribution ) * ( 1.0 - F ); +} +void RE_IndirectDiffuse_Physical( const in vec3 irradiance, const in vec3 geometryPosition, const in vec3 geometryNormal, const in vec3 geometryViewDir, const in vec3 geometryClearcoatNormal, const in PhysicalMaterial material, inout ReflectedLight reflectedLight ) { + vec3 singleScattering = vec3( 0.0 ); + vec3 multiScattering = vec3( 0.0 ); + #ifdef USE_IRIDESCENCE + computeMultiscatteringIridescence( material.dfg, material.specularColor, material.specularF90, material.iridescence, material.iridescenceF0Dielectric, singleScattering, multiScattering ); + #else + computeMultiscattering( material.dfg, material.specularColor, material.specularF90, singleScattering, multiScattering ); + #endif + vec3 diffuse = irradiance * BRDF_Lambert( material.diffuseContribution ) * ( 1.0 - singleScattering - multiScattering ); + #ifdef USE_SHEEN + float sheenAlbedo = IBLSheenBRDF( geometryNormal, geometryViewDir, material.sheenRoughness ); + sheenSpecularIndirect += irradiance * material.sheenColor * sheenAlbedo * RECIPROCAL_PI; + float sheenEnergyComp = 1.0 - max3( material.sheenColor ) * sheenAlbedo; + diffuse *= sheenEnergyComp; + #endif + reflectedLight.indirectDiffuse += diffuse; +} +void RE_IndirectSpecular_Physical( const in vec3 radiance, const in vec3 irradiance, const in vec3 clearcoatRadiance, const in vec3 geometryPosition, const in vec3 geometryNormal, const in vec3 geometryViewDir, const in vec3 geometryClearcoatNormal, const in PhysicalMaterial material, inout ReflectedLight reflectedLight) { + #ifdef USE_CLEARCOAT + clearcoatSpecularIndirect += clearcoatRadiance * EnvironmentBRDF( geometryClearcoatNormal, geometryViewDir, material.clearcoatF0, material.clearcoatF90, material.clearcoatRoughness ); + #endif + #ifdef USE_SHEEN + sheenSpecularIndirect += irradiance * material.sheenColor * IBLSheenBRDF( geometryNormal, geometryViewDir, material.sheenRoughness ) * RECIPROCAL_PI; + #endif + vec3 singleScatteringDielectric = vec3( 0.0 ); + vec3 multiScatteringDielectric = vec3( 0.0 ); + vec3 singleScatteringMetallic = vec3( 0.0 ); + vec3 multiScatteringMetallic = vec3( 0.0 ); + #ifdef USE_IRIDESCENCE + computeMultiscatteringIridescence( material.dfg, material.specularColor, material.specularF90, material.iridescence, material.iridescenceF0Dielectric, singleScatteringDielectric, multiScatteringDielectric ); + computeMultiscatteringIridescence( material.dfg, material.diffuseColor, material.specularF90, material.iridescence, material.iridescenceF0Metallic, singleScatteringMetallic, multiScatteringMetallic ); + #else + computeMultiscattering( material.dfg, material.specularColor, material.specularF90, singleScatteringDielectric, multiScatteringDielectric ); + computeMultiscattering( material.dfg, material.diffuseColor, material.specularF90, singleScatteringMetallic, multiScatteringMetallic ); + #endif + vec3 singleScattering = mix( singleScatteringDielectric, singleScatteringMetallic, material.metalness ); + vec3 multiScattering = mix( multiScatteringDielectric, multiScatteringMetallic, material.metalness ); + vec3 totalScatteringDielectric = singleScatteringDielectric + multiScatteringDielectric; + vec3 diffuse = material.diffuseContribution * ( 1.0 - totalScatteringDielectric ); + vec3 cosineWeightedIrradiance = irradiance * RECIPROCAL_PI; + vec3 indirectSpecular = radiance * singleScattering; + indirectSpecular += multiScattering * cosineWeightedIrradiance; + vec3 indirectDiffuse = diffuse * cosineWeightedIrradiance; + #ifdef USE_SHEEN + float sheenAlbedo = IBLSheenBRDF( geometryNormal, geometryViewDir, material.sheenRoughness ); + float sheenEnergyComp = 1.0 - max3( material.sheenColor ) * sheenAlbedo; + indirectSpecular *= sheenEnergyComp; + indirectDiffuse *= sheenEnergyComp; + #endif + reflectedLight.indirectSpecular += indirectSpecular; + reflectedLight.indirectDiffuse += indirectDiffuse; +} +#define RE_Direct RE_Direct_Physical +#define RE_Direct_RectArea RE_Direct_RectArea_Physical +#define RE_IndirectDiffuse RE_IndirectDiffuse_Physical +#define RE_IndirectSpecular RE_IndirectSpecular_Physical +float computeSpecularOcclusion( const in float dotNV, const in float ambientOcclusion, const in float roughness ) { + return saturate( pow( dotNV + ambientOcclusion, exp2( - 16.0 * roughness - 1.0 ) ) - 1.0 + ambientOcclusion ); +}`,Rm=` +vec3 geometryPosition = - vViewPosition; +vec3 geometryNormal = normal; +vec3 geometryViewDir = ( isOrthographic ) ? vec3( 0, 0, 1 ) : normalize( vViewPosition ); +vec3 geometryClearcoatNormal = vec3( 0.0 ); +#ifdef USE_CLEARCOAT + geometryClearcoatNormal = clearcoatNormal; +#endif +#ifdef USE_IRIDESCENCE + float dotNVi = saturate( dot( normal, geometryViewDir ) ); + if ( material.iridescenceThickness == 0.0 ) { + material.iridescence = 0.0; + } else { + material.iridescence = saturate( material.iridescence ); + } + if ( material.iridescence > 0.0 ) { + vec3 iridescenceFresnelDielectric = evalIridescence( 1.0, material.iridescenceIOR, dotNVi, material.iridescenceThickness, material.specularColor ); + vec3 iridescenceFresnelMetallic = evalIridescence( 1.0, material.iridescenceIOR, dotNVi, material.iridescenceThickness, material.diffuseColor ); + material.iridescenceFresnel = mix( iridescenceFresnelDielectric, iridescenceFresnelMetallic, material.metalness ); + material.iridescenceF0Dielectric = Schlick_to_F0( iridescenceFresnelDielectric, 1.0, dotNVi ); + material.iridescenceF0Metallic = Schlick_to_F0( iridescenceFresnelMetallic, 1.0, dotNVi ); + } +#endif +#ifdef STANDARD + float dotNVms = saturate( dot( geometryNormal, geometryViewDir ) ); + material.dfg = texture2D( dfgLUT, vec2( material.roughness, dotNVms ) ).rg; + #if ( NUM_SUN_LIGHTS > 0 || NUM_DIR_LIGHTS > 0 || NUM_POINT_LIGHTS > 0 || NUM_SPOT_LIGHTS > 0 ) + float EssMs = material.dfg.x + material.dfg.y; + material.multiScatteringCompensation = 1.0 + material.specularColorBlended * ( 1.0 / EssMs - 1.0 ); + #endif +#endif +IncidentLight directLight; +#if ( NUM_POINT_LIGHTS > 0 ) && defined( RE_Direct ) + PointLight pointLight; + #if defined( USE_SHADOWMAP ) && NUM_POINT_LIGHT_SHADOWS > 0 + PointLightShadow pointLightShadow; + #endif + #pragma unroll_loop_start + for ( int i = 0; i < NUM_POINT_LIGHTS; i ++ ) { + pointLight = pointLights[ i ]; + getPointLightInfo( pointLight, geometryPosition, directLight ); + #if defined( USE_SHADOWMAP ) && ( UNROLLED_LOOP_INDEX < NUM_POINT_LIGHT_SHADOWS ) && ( defined( SHADOWMAP_TYPE_PCF ) || defined( SHADOWMAP_TYPE_BASIC ) ) + pointLightShadow = pointLightShadows[ i ]; + directLight.color *= ( directLight.visible && receiveShadow ) ? getPointShadow( pointShadowMap[ i ], pointLightShadow.shadowMapSize, pointLightShadow.shadowIntensity, pointLightShadow.shadowBias, pointLightShadow.shadowRadius, vPointShadowCoord[ i ], pointLightShadow.shadowCameraNear, pointLightShadow.shadowCameraFar ) : 1.0; + #endif + RE_Direct( directLight, geometryPosition, geometryNormal, geometryViewDir, geometryClearcoatNormal, material, reflectedLight ); + } + #pragma unroll_loop_end +#endif +#if ( NUM_SPOT_LIGHTS > 0 ) && defined( RE_Direct ) + SpotLight spotLight; + vec4 spotColor; + vec3 spotLightCoord; + bool inSpotLightMap; + #if defined( USE_SHADOWMAP ) && NUM_SPOT_LIGHT_SHADOWS > 0 + SpotLightShadow spotLightShadow; + #endif + #pragma unroll_loop_start + for ( int i = 0; i < NUM_SPOT_LIGHTS; i ++ ) { + spotLight = spotLights[ i ]; + getSpotLightInfo( spotLight, geometryPosition, directLight ); + #if ( UNROLLED_LOOP_INDEX < NUM_SPOT_LIGHT_SHADOWS_WITH_MAPS ) + #define SPOT_LIGHT_MAP_INDEX UNROLLED_LOOP_INDEX + #elif ( UNROLLED_LOOP_INDEX < NUM_SPOT_LIGHT_SHADOWS ) + #define SPOT_LIGHT_MAP_INDEX NUM_SPOT_LIGHT_MAPS + #else + #define SPOT_LIGHT_MAP_INDEX ( UNROLLED_LOOP_INDEX - NUM_SPOT_LIGHT_SHADOWS + NUM_SPOT_LIGHT_SHADOWS_WITH_MAPS ) + #endif + #if ( SPOT_LIGHT_MAP_INDEX < NUM_SPOT_LIGHT_MAPS ) + spotLightCoord = vSpotLightCoord[ i ].xyz / vSpotLightCoord[ i ].w; + inSpotLightMap = all( lessThan( abs( spotLightCoord * 2. - 1. ), vec3( 1.0 ) ) ); + spotColor = texture2D( spotLightMap[ SPOT_LIGHT_MAP_INDEX ], spotLightCoord.xy ); + directLight.color = inSpotLightMap ? directLight.color * spotColor.rgb : directLight.color; + #endif + #undef SPOT_LIGHT_MAP_INDEX + #if defined( USE_SHADOWMAP ) && ( UNROLLED_LOOP_INDEX < NUM_SPOT_LIGHT_SHADOWS ) + spotLightShadow = spotLightShadows[ i ]; + directLight.color *= ( directLight.visible && receiveShadow ) ? getShadow( spotShadowMap[ i ], spotLightShadow.shadowMapSize, spotLightShadow.shadowIntensity, spotLightShadow.shadowBias, spotLightShadow.shadowRadius, vSpotLightCoord[ i ] ) : 1.0; + #endif + RE_Direct( directLight, geometryPosition, geometryNormal, geometryViewDir, geometryClearcoatNormal, material, reflectedLight ); + } + #pragma unroll_loop_end +#endif +#if ( NUM_SUN_LIGHTS > 0 ) && defined( RE_Direct ) + SunLight sunLight; + #if defined( USE_SHADOWMAP ) && NUM_SUN_LIGHT_SHADOWS > 0 + SunLightShadow sunLightShadow; + #endif + #pragma unroll_loop_start + for ( int i = 0; i < NUM_SUN_LIGHTS; i ++ ) { + sunLight = sunLights[ i ]; + getSunLightInfo( sunLight, directLight ); + #if defined( USE_SHADOWMAP ) && ( UNROLLED_LOOP_INDEX < NUM_SUN_LIGHT_SHADOWS ) + sunLightShadow = sunLightShadows[ i ]; + directLight.color *= ( directLight.visible && receiveShadow ) ? getSunShadow( sunShadowMap[ i ], sunLightShadow, UNROLLED_LOOP_INDEX ) : 1.0; + #endif + RE_Direct( directLight, geometryPosition, geometryNormal, geometryViewDir, geometryClearcoatNormal, material, reflectedLight ); + } + #pragma unroll_loop_end +#endif +#if ( NUM_DIR_LIGHTS > 0 ) && defined( RE_Direct ) + DirectionalLight directionalLight; + #if defined( USE_SHADOWMAP ) && NUM_DIR_LIGHT_SHADOWS > 0 + DirectionalLightShadow directionalLightShadow; + #endif + #pragma unroll_loop_start + for ( int i = 0; i < NUM_DIR_LIGHTS; i ++ ) { + directionalLight = directionalLights[ i ]; + getDirectionalLightInfo( directionalLight, directLight ); + #if defined( USE_SHADOWMAP ) && ( UNROLLED_LOOP_INDEX < NUM_DIR_LIGHT_SHADOWS ) + directionalLightShadow = directionalLightShadows[ i ]; + directLight.color *= ( directLight.visible && receiveShadow ) ? getShadow( directionalShadowMap[ i ], directionalLightShadow.shadowMapSize, directionalLightShadow.shadowIntensity, directionalLightShadow.shadowBias, directionalLightShadow.shadowRadius, vDirectionalShadowCoord[ i ] ) : 1.0; + #endif + RE_Direct( directLight, geometryPosition, geometryNormal, geometryViewDir, geometryClearcoatNormal, material, reflectedLight ); + } + #pragma unroll_loop_end +#endif +#if ( NUM_RECT_AREA_LIGHTS > 0 ) && defined( RE_Direct_RectArea ) + RectAreaLight rectAreaLight; + #pragma unroll_loop_start + for ( int i = 0; i < NUM_RECT_AREA_LIGHTS; i ++ ) { + rectAreaLight = rectAreaLights[ i ]; + RE_Direct_RectArea( rectAreaLight, geometryPosition, geometryNormal, geometryViewDir, geometryClearcoatNormal, material, reflectedLight ); + } + #pragma unroll_loop_end +#endif +#if defined( RE_IndirectDiffuse ) + vec3 iblIrradiance = vec3( 0.0 ); + vec3 irradiance = getAmbientLightIrradiance( ambientLightColor ); + #if defined( USE_LIGHT_PROBES ) + irradiance += getLightProbeIrradiance( lightProbe, geometryNormal ); + #endif + #if ( NUM_HEMI_LIGHTS > 0 ) + #pragma unroll_loop_start + for ( int i = 0; i < NUM_HEMI_LIGHTS; i ++ ) { + irradiance += getHemisphereLightIrradiance( hemisphereLights[ i ], geometryNormal ); + } + #pragma unroll_loop_end + #endif + #ifdef USE_LIGHT_PROBES_GRID + vec3 probeWorldPos = ( ( vec4( geometryPosition, 1.0 ) - viewMatrix[ 3 ] ) * viewMatrix ).xyz; + vec3 probeWorldNormal = transformNormalByInverseViewMatrix( geometryNormal, viewMatrix ); + irradiance += getLightProbeGridIrradiance( probeWorldPos, probeWorldNormal ); + #endif +#endif +#if defined( RE_IndirectSpecular ) + vec3 radiance = vec3( 0.0 ); + vec3 clearcoatRadiance = vec3( 0.0 ); +#endif`,Cm=`#if defined( RE_IndirectDiffuse ) + #ifdef USE_LIGHTMAP + vec4 lightMapTexel = texture2D( lightMap, vLightMapUv ); + vec3 lightMapIrradiance = lightMapTexel.rgb * lightMapIntensity; + irradiance += lightMapIrradiance; + #endif + #if defined( USE_ENVMAP ) && defined( ENVMAP_TYPE_CUBE_UV ) + #if defined( STANDARD ) || defined( LAMBERT ) || defined( PHONG ) + iblIrradiance += getIBLIrradiance( geometryNormal ); + #endif + #endif +#endif +#if defined( USE_ENVMAP ) && defined( RE_IndirectSpecular ) + #ifdef USE_ANISOTROPY + vec3 iblRadiance = getIBLAnisotropyRadiance( geometryViewDir, geometryNormal, material.roughness, material.anisotropyB, material.anisotropy ); + #else + vec3 iblRadiance = getIBLRadiance( geometryViewDir, geometryNormal, material.roughness ); + #endif + #ifdef USE_RETROREFLECTION + #ifdef USE_ANISOTROPY + vec3 retroIBLRadiance = getIBLAnisotropyRetroRadiance( geometryViewDir, geometryNormal, material.roughness, material.anisotropyB, material.anisotropy ); + #else + vec3 retroIBLRadiance = getIBLRetroRadiance( geometryViewDir, geometryNormal, material.roughness ); + #endif + iblRadiance = mix( iblRadiance, retroIBLRadiance, saturate( material.retroreflectivity ) ); + #endif + radiance += iblRadiance; + #ifdef USE_CLEARCOAT + clearcoatRadiance += getIBLRadiance( geometryViewDir, geometryClearcoatNormal, material.clearcoatRoughness ); + #endif +#endif`,Im=`#if defined( RE_IndirectDiffuse ) + #if defined( LAMBERT ) || defined( PHONG ) + irradiance += iblIrradiance; + #endif + RE_IndirectDiffuse( irradiance, geometryPosition, geometryNormal, geometryViewDir, geometryClearcoatNormal, material, reflectedLight ); +#endif +#if defined( RE_IndirectSpecular ) + RE_IndirectSpecular( radiance, iblIrradiance, clearcoatRadiance, geometryPosition, geometryNormal, geometryViewDir, geometryClearcoatNormal, material, reflectedLight ); +#endif`,Pm=`#ifdef USE_LIGHT_PROBES_GRID +uniform highp sampler3D probesSH; +uniform vec3 probesMin; +uniform vec3 probesMax; +uniform vec3 probesResolution; +vec3 getLightProbeGridIrradiance( vec3 worldPos, vec3 worldNormal ) { + vec3 res = probesResolution; + vec3 gridRange = probesMax - probesMin; + vec3 resMinusOne = res - 1.0; + vec3 probeSpacing = gridRange / resMinusOne; + vec3 samplePos = worldPos + worldNormal * probeSpacing * 0.5; + vec3 uvw = clamp( ( samplePos - probesMin ) / gridRange, 0.0, 1.0 ); + uvw = uvw * resMinusOne / res + 0.5 / res; + float nz = res.z; + float paddedSlices = nz + 2.0; + float atlasDepth = 7.0 * paddedSlices; + float uvZBase = uvw.z * nz + 1.0; + vec4 s0 = texture( probesSH, vec3( uvw.xy, ( uvZBase ) / atlasDepth ) ); + vec4 s1 = texture( probesSH, vec3( uvw.xy, ( uvZBase + paddedSlices ) / atlasDepth ) ); + vec4 s2 = texture( probesSH, vec3( uvw.xy, ( uvZBase + 2.0 * paddedSlices ) / atlasDepth ) ); + vec4 s3 = texture( probesSH, vec3( uvw.xy, ( uvZBase + 3.0 * paddedSlices ) / atlasDepth ) ); + vec4 s4 = texture( probesSH, vec3( uvw.xy, ( uvZBase + 4.0 * paddedSlices ) / atlasDepth ) ); + vec4 s5 = texture( probesSH, vec3( uvw.xy, ( uvZBase + 5.0 * paddedSlices ) / atlasDepth ) ); + vec4 s6 = texture( probesSH, vec3( uvw.xy, ( uvZBase + 6.0 * paddedSlices ) / atlasDepth ) ); + vec3 c0 = s0.xyz; + vec3 c1 = vec3( s0.w, s1.xy ); + vec3 c2 = vec3( s1.zw, s2.x ); + vec3 c3 = s2.yzw; + vec3 c4 = s3.xyz; + vec3 c5 = vec3( s3.w, s4.xy ); + vec3 c6 = vec3( s4.zw, s5.x ); + vec3 c7 = s5.yzw; + vec3 c8 = s6.xyz; + float x = worldNormal.x, y = worldNormal.y, z = worldNormal.z; + vec3 result = c0 * 0.886227; + result += c1 * 2.0 * 0.511664 * y; + result += c2 * 2.0 * 0.511664 * z; + result += c3 * 2.0 * 0.511664 * x; + result += c4 * 2.0 * 0.429043 * x * y; + result += c5 * 2.0 * 0.429043 * y * z; + result += c6 * ( 0.743125 * z * z - 0.247708 ); + result += c7 * 2.0 * 0.429043 * x * z; + result += c8 * 0.429043 * ( x * x - y * y ); + return max( result, vec3( 0.0 ) ); +} +#endif`,Lm=`#if defined( USE_LOGARITHMIC_DEPTH_BUFFER ) + gl_FragDepth = vIsPerspective == 0.0 ? gl_FragCoord.z : log2( vFragDepth ) * logDepthBufFC * 0.5; +#endif`,Dm=`#if defined( USE_LOGARITHMIC_DEPTH_BUFFER ) + uniform float logDepthBufFC; + varying float vFragDepth; + varying float vIsPerspective; +#endif`,Nm=`#ifdef USE_LOGARITHMIC_DEPTH_BUFFER + varying float vFragDepth; + varying float vIsPerspective; +#endif`,Um=`#ifdef USE_LOGARITHMIC_DEPTH_BUFFER + vFragDepth = 1.0 + gl_Position.w; + vIsPerspective = float( isPerspectiveMatrix( projectionMatrix ) ); +#endif`,Fm=`#ifdef USE_MAP + vec4 sampledDiffuseColor = texture2D( map, vMapUv ); + #ifdef DECODE_VIDEO_TEXTURE + sampledDiffuseColor = sRGBTransferEOTF( sampledDiffuseColor ); + #endif + diffuseColor *= sampledDiffuseColor; +#endif`,Bm=`#ifdef USE_MAP + uniform sampler2D map; +#endif`,Om=`#if defined( USE_MAP ) || defined( USE_ALPHAMAP ) + #if defined( USE_POINTS_UV ) + vec2 uv = vUv; + #else + vec2 uv = ( uvTransform * vec3( gl_PointCoord.x, 1.0 - gl_PointCoord.y, 1 ) ).xy; + #endif +#endif +#ifdef USE_MAP + diffuseColor *= texture2D( map, uv ); +#endif +#ifdef USE_ALPHAMAP + diffuseColor.a *= texture2D( alphaMap, uv ).g; +#endif`,zm=`#if defined( USE_POINTS_UV ) + varying vec2 vUv; +#else + #if defined( USE_MAP ) || defined( USE_ALPHAMAP ) + uniform mat3 uvTransform; + #endif +#endif +#ifdef USE_MAP + uniform sampler2D map; +#endif +#ifdef USE_ALPHAMAP + uniform sampler2D alphaMap; +#endif`,km=`float metalnessFactor = metalness; +#ifdef USE_METALNESSMAP + vec4 texelMetalness = texture2D( metalnessMap, vMetalnessMapUv ); + metalnessFactor *= texelMetalness.b; +#endif`,Vm=`#ifdef USE_METALNESSMAP + uniform sampler2D metalnessMap; +#endif`,Hm=`#ifdef USE_INSTANCING_MORPH + float morphTargetInfluences[ MORPHTARGETS_COUNT ]; + float morphTargetBaseInfluence = texelFetch( morphTexture, ivec2( 0, gl_InstanceID ), 0 ).r; + for ( int i = 0; i < MORPHTARGETS_COUNT; i ++ ) { + morphTargetInfluences[i] = texelFetch( morphTexture, ivec2( i + 1, gl_InstanceID ), 0 ).r; + } +#endif`,Gm=`#if defined( USE_MORPHCOLORS ) + vColor *= morphTargetBaseInfluence; + for ( int i = 0; i < MORPHTARGETS_COUNT; i ++ ) { + #if defined( USE_COLOR_ALPHA ) + if ( morphTargetInfluences[ i ] != 0.0 ) vColor += getMorph( gl_VertexID, i, 2 ) * morphTargetInfluences[ i ]; + #elif defined( USE_COLOR ) + if ( morphTargetInfluences[ i ] != 0.0 ) vColor += getMorph( gl_VertexID, i, 2 ).rgb * morphTargetInfluences[ i ]; + #endif + } +#endif`,Wm=`#ifdef USE_MORPHNORMALS + objectNormal *= morphTargetBaseInfluence; + for ( int i = 0; i < MORPHTARGETS_COUNT; i ++ ) { + if ( morphTargetInfluences[ i ] != 0.0 ) objectNormal += getMorph( gl_VertexID, i, 1 ).xyz * morphTargetInfluences[ i ]; + } +#endif`,Xm=`#ifdef USE_MORPHTARGETS + #ifndef USE_INSTANCING_MORPH + uniform float morphTargetBaseInfluence; + uniform float morphTargetInfluences[ MORPHTARGETS_COUNT ]; + #endif + uniform sampler2DArray morphTargetsTexture; + uniform ivec2 morphTargetsTextureSize; + vec4 getMorph( const in int vertexIndex, const in int morphTargetIndex, const in int offset ) { + int texelIndex = vertexIndex * MORPHTARGETS_TEXTURE_STRIDE + offset; + int y = texelIndex / morphTargetsTextureSize.x; + int x = texelIndex - y * morphTargetsTextureSize.x; + ivec3 morphUV = ivec3( x, y, morphTargetIndex ); + return texelFetch( morphTargetsTexture, morphUV, 0 ); + } +#endif`,qm=`#ifdef USE_MORPHTARGETS + transformed *= morphTargetBaseInfluence; + for ( int i = 0; i < MORPHTARGETS_COUNT; i ++ ) { + if ( morphTargetInfluences[ i ] != 0.0 ) transformed += getMorph( gl_VertexID, i, 0 ).xyz * morphTargetInfluences[ i ]; + } +#endif`,Ym=`float faceDirection = gl_FrontFacing ? 1.0 : - 1.0; +#ifdef FLAT_SHADED + vec3 fdx = dFdx( vViewPosition ); + vec3 fdy = dFdy( vViewPosition ); + vec3 normal = normalize( cross( fdx, fdy ) ); +#else + vec3 normal = normalize( vNormal ); + #ifdef DOUBLE_SIDED + normal *= faceDirection; + #endif +#endif +#if defined( USE_NORMALMAP_TANGENTSPACE ) || defined( USE_CLEARCOAT_NORMALMAP ) || defined( USE_ANISOTROPY ) + #ifdef USE_TANGENT + mat3 tbn = mat3( normalize( vTangent ), normalize( vBitangent ), normal ); + #else + mat3 tbn = getTangentFrame( - vViewPosition, normal, + #if defined( USE_NORMALMAP ) + vNormalMapUv + #elif defined( USE_CLEARCOAT_NORMALMAP ) + vClearcoatNormalMapUv + #else + vUv + #endif + ); + #endif + #ifdef DOUBLE_SIDED + tbn[0] *= faceDirection; + tbn[1] *= faceDirection; + #endif +#endif +#ifdef USE_CLEARCOAT_NORMALMAP + #ifdef USE_TANGENT + mat3 tbn2 = mat3( normalize( vTangent ), normalize( vBitangent ), normal ); + #else + mat3 tbn2 = getTangentFrame( - vViewPosition, normal, vClearcoatNormalMapUv ); + #endif + #ifdef DOUBLE_SIDED + tbn2[0] *= faceDirection; + tbn2[1] *= faceDirection; + #endif +#endif +vec3 nonPerturbedNormal = normal;`,$m=`#ifdef USE_NORMALMAP_OBJECTSPACE + normal = texture2D( normalMap, vNormalMapUv ).xyz * 2.0 - 1.0; + #ifdef FLIP_SIDED + normal = - normal; + #endif + #ifdef DOUBLE_SIDED + normal = normal * faceDirection; + #endif + normal = normalize( normalMatrix * normal ); +#elif defined( USE_NORMALMAP_TANGENTSPACE ) + vec3 mapN = texture2D( normalMap, vNormalMapUv ).xyz * 2.0 - 1.0; + #if defined( USE_PACKED_NORMALMAP ) + mapN = vec3( mapN.xy, sqrt( saturate( 1.0 - dot( mapN.xy, mapN.xy ) ) ) ); + #endif + mapN.xy *= normalScale; + normal = normalize( tbn * mapN ); +#elif defined( USE_BUMPMAP ) + normal = perturbNormalArb( - vViewPosition, normal, dHdxy_fwd(), faceDirection ); +#endif`,Zm=`#ifndef FLAT_SHADED + varying vec3 vNormal; + #ifdef USE_TANGENT + varying vec3 vTangent; + varying vec3 vBitangent; + #endif +#endif`,Jm=`#ifndef FLAT_SHADED + varying vec3 vNormal; + #ifdef USE_TANGENT + varying vec3 vTangent; + varying vec3 vBitangent; + #endif +#endif`,Km=`#ifndef FLAT_SHADED + vNormal = normalize( transformedNormal ); + #ifdef USE_TANGENT + vTangent = normalize( transformedTangent ); + vBitangent = normalize( cross( vNormal, vTangent ) * tangent.w ); + #ifdef FLIP_SIDED + vBitangent = - vBitangent; + #endif + #endif +#endif`,jm=`#ifdef USE_NORMALMAP + uniform sampler2D normalMap; + uniform vec2 normalScale; +#endif +#ifdef USE_NORMALMAP_OBJECTSPACE + uniform mat3 normalMatrix; +#endif +#if ! defined ( USE_TANGENT ) && ( defined ( USE_NORMALMAP_TANGENTSPACE ) || defined ( USE_CLEARCOAT_NORMALMAP ) || defined( USE_ANISOTROPY ) ) + mat3 getTangentFrame( vec3 eye_pos, vec3 surf_norm, vec2 uv ) { + vec3 q0 = dFdx( eye_pos.xyz ); + vec3 q1 = dFdy( eye_pos.xyz ); + vec2 st0 = dFdx( uv.st ); + vec2 st1 = dFdy( uv.st ); + vec3 N = surf_norm; + vec3 q1perp = cross( q1, N ); + vec3 q0perp = cross( N, q0 ); + vec3 T = q1perp * st0.x + q0perp * st1.x; + vec3 B = q1perp * st0.y + q0perp * st1.y; + float det = max( dot( T, T ), dot( B, B ) ); + float scale = ( det == 0.0 ) ? 0.0 : inversesqrt( det ); + return mat3( T * scale, B * scale, N ); + } +#endif`,Qm=`#ifdef USE_CLEARCOAT + vec3 clearcoatNormal = nonPerturbedNormal; +#endif`,tg=`#ifdef USE_CLEARCOAT_NORMALMAP + vec3 clearcoatMapN = texture2D( clearcoatNormalMap, vClearcoatNormalMapUv ).xyz * 2.0 - 1.0; + clearcoatMapN.xy *= clearcoatNormalScale; + clearcoatNormal = normalize( tbn2 * clearcoatMapN ); +#endif`,eg=`#ifdef USE_CLEARCOATMAP + uniform sampler2D clearcoatMap; +#endif +#ifdef USE_CLEARCOAT_NORMALMAP + uniform sampler2D clearcoatNormalMap; + uniform vec2 clearcoatNormalScale; +#endif +#ifdef USE_CLEARCOAT_ROUGHNESSMAP + uniform sampler2D clearcoatRoughnessMap; +#endif`,ng=`#ifdef USE_IRIDESCENCEMAP + uniform sampler2D iridescenceMap; +#endif +#ifdef USE_IRIDESCENCE_THICKNESSMAP + uniform sampler2D iridescenceThicknessMap; +#endif`,ig=`#ifdef OPAQUE +diffuseColor.a = 1.0; +#endif +#ifdef USE_TRANSMISSION +diffuseColor.a *= material.transmissionAlpha; +#endif +gl_FragColor = vec4( outgoingLight, diffuseColor.a );`,sg=`vec3 packNormalToRGB( const in vec3 normal ) { + return normalize( normal ) * 0.5 + 0.5; +} +vec3 unpackRGBToNormal( const in vec3 rgb ) { + return 2.0 * rgb.xyz - 1.0; +} +const float PackUpscale = 256. / 255.;const float UnpackDownscale = 255. / 256.;const float ShiftRight8 = 1. / 256.; +const float Inv255 = 1. / 255.; +const vec4 PackFactors = vec4( 1.0, 256.0, 256.0 * 256.0, 256.0 * 256.0 * 256.0 ); +const vec2 UnpackFactors2 = vec2( UnpackDownscale, 1.0 / PackFactors.g ); +const vec3 UnpackFactors3 = vec3( UnpackDownscale / PackFactors.rg, 1.0 / PackFactors.b ); +const vec4 UnpackFactors4 = vec4( UnpackDownscale / PackFactors.rgb, 1.0 / PackFactors.a ); +vec4 packDepthToRGBA( const in float v ) { + if( v <= 0.0 ) + return vec4( 0., 0., 0., 0. ); + if( v >= 1.0 ) + return vec4( 1., 1., 1., 1. ); + float vuf; + float af = modf( v * PackFactors.a, vuf ); + float bf = modf( vuf * ShiftRight8, vuf ); + float gf = modf( vuf * ShiftRight8, vuf ); + return vec4( vuf * Inv255, gf * PackUpscale, bf * PackUpscale, af ); +} +vec3 packDepthToRGB( const in float v ) { + if( v <= 0.0 ) + return vec3( 0., 0., 0. ); + if( v >= 1.0 ) + return vec3( 1., 1., 1. ); + float vuf; + float bf = modf( v * PackFactors.b, vuf ); + float gf = modf( vuf * ShiftRight8, vuf ); + return vec3( vuf * Inv255, gf * PackUpscale, bf ); +} +vec2 packDepthToRG( const in float v ) { + if( v <= 0.0 ) + return vec2( 0., 0. ); + if( v >= 1.0 ) + return vec2( 1., 1. ); + float vuf; + float gf = modf( v * 256., vuf ); + return vec2( vuf * Inv255, gf ); +} +float unpackRGBAToDepth( const in vec4 v ) { + return dot( v, UnpackFactors4 ); +} +float unpackRGBToDepth( const in vec3 v ) { + return dot( v, UnpackFactors3 ); +} +float unpackRGToDepth( const in vec2 v ) { + return v.r * UnpackFactors2.r + v.g * UnpackFactors2.g; +} +vec4 pack2HalfToRGBA( const in vec2 v ) { + vec4 r = vec4( v.x, fract( v.x * 255.0 ), v.y, fract( v.y * 255.0 ) ); + return vec4( r.x - r.y / 255.0, r.y, r.z - r.w / 255.0, r.w ); +} +vec2 unpackRGBATo2Half( const in vec4 v ) { + return vec2( v.x + ( v.y / 255.0 ), v.z + ( v.w / 255.0 ) ); +} +float viewZToOrthographicDepth( const in float viewZ, const in float near, const in float far ) { + return ( viewZ + near ) / ( near - far ); +} +float orthographicDepthToViewZ( const in float depth, const in float near, const in float far ) { + #ifdef USE_REVERSED_DEPTH_BUFFER + + return depth * ( far - near ) - far; + #else + return depth * ( near - far ) - near; + #endif +} +float viewZToPerspectiveDepth( const in float viewZ, const in float near, const in float far ) { + return ( ( near + viewZ ) * far ) / ( ( far - near ) * viewZ ); +} +float perspectiveDepthToViewZ( const in float depth, const in float near, const in float far ) { + + #ifdef USE_REVERSED_DEPTH_BUFFER + return ( near * far ) / ( ( near - far ) * depth - near ); + #else + return ( near * far ) / ( ( far - near ) * depth - far ); + #endif +}`,rg=`#ifdef PREMULTIPLIED_ALPHA + gl_FragColor.rgb *= gl_FragColor.a; +#endif`,og=`vec4 mvPosition = vec4( transformed, 1.0 ); +#ifdef USE_BATCHING + mvPosition = batchingMatrix * mvPosition; +#endif +#ifdef USE_INSTANCING + mvPosition = instanceMatrix * mvPosition; +#endif +mvPosition = modelViewMatrix * mvPosition; +gl_Position = projectionMatrix * mvPosition;`,ag=`#ifdef DITHERING + gl_FragColor.rgb = dithering( gl_FragColor.rgb ); +#endif`,lg=`#ifdef DITHERING + vec3 dithering( vec3 color ) { + float grid_position = rand( gl_FragCoord.xy ); + vec3 dither_shift_RGB = vec3( 0.25 / 255.0, -0.25 / 255.0, 0.25 / 255.0 ); + dither_shift_RGB = mix( 2.0 * dither_shift_RGB, -2.0 * dither_shift_RGB, grid_position ); + return color + dither_shift_RGB; + } +#endif`,cg=`float roughnessFactor = roughness; +#ifdef USE_ROUGHNESSMAP + vec4 texelRoughness = texture2D( roughnessMap, vRoughnessMapUv ); + roughnessFactor *= texelRoughness.g; +#endif`,hg=`#ifdef USE_ROUGHNESSMAP + uniform sampler2D roughnessMap; +#endif`,ug=`#if NUM_SPOT_LIGHT_COORDS > 0 + varying vec4 vSpotLightCoord[ NUM_SPOT_LIGHT_COORDS ]; +#endif +#if NUM_SPOT_LIGHT_MAPS > 0 + uniform sampler2D spotLightMap[ NUM_SPOT_LIGHT_MAPS ]; +#endif +#ifdef USE_SHADOWMAP + #if NUM_SUN_LIGHT_SHADOWS > 0 + #define SUN_LIGHT_CASCADES 2 + #if defined( SHADOWMAP_TYPE_PCF ) + uniform sampler2DShadow sunShadowMap[ NUM_SUN_LIGHT_SHADOWS ]; + #else + uniform sampler2D sunShadowMap[ NUM_SUN_LIGHT_SHADOWS ]; + #endif + uniform mat4 sunShadowMatrix[ NUM_SUN_LIGHT_SHADOWS * SUN_LIGHT_CASCADES ]; + uniform vec4 sunShadowCascade[ NUM_SUN_LIGHT_SHADOWS * SUN_LIGHT_CASCADES ]; + varying vec4 vSunShadowWorldPosition; + varying vec3 vSunShadowWorldNormal; + struct SunLightShadow { + float shadowIntensity; + float shadowBias; + float shadowNormalBias; + float shadowRadius; + vec2 shadowMapSize; + }; + uniform SunLightShadow sunLightShadows[ NUM_SUN_LIGHT_SHADOWS ]; + #endif + #if NUM_DIR_LIGHT_SHADOWS > 0 + #if defined( SHADOWMAP_TYPE_PCF ) + uniform sampler2DShadow directionalShadowMap[ NUM_DIR_LIGHT_SHADOWS ]; + #else + uniform sampler2D directionalShadowMap[ NUM_DIR_LIGHT_SHADOWS ]; + #endif + varying vec4 vDirectionalShadowCoord[ NUM_DIR_LIGHT_SHADOWS ]; + struct DirectionalLightShadow { + float shadowIntensity; + float shadowBias; + float shadowNormalBias; + float shadowRadius; + vec2 shadowMapSize; + }; + uniform DirectionalLightShadow directionalLightShadows[ NUM_DIR_LIGHT_SHADOWS ]; + #endif + #if NUM_SPOT_LIGHT_SHADOWS > 0 + #if defined( SHADOWMAP_TYPE_PCF ) + uniform sampler2DShadow spotShadowMap[ NUM_SPOT_LIGHT_SHADOWS ]; + #else + uniform sampler2D spotShadowMap[ NUM_SPOT_LIGHT_SHADOWS ]; + #endif + struct SpotLightShadow { + float shadowIntensity; + float shadowBias; + float shadowNormalBias; + float shadowRadius; + vec2 shadowMapSize; + }; + uniform SpotLightShadow spotLightShadows[ NUM_SPOT_LIGHT_SHADOWS ]; + #endif + #if NUM_POINT_LIGHT_SHADOWS > 0 + #if defined( SHADOWMAP_TYPE_PCF ) + uniform samplerCubeShadow pointShadowMap[ NUM_POINT_LIGHT_SHADOWS ]; + #elif defined( SHADOWMAP_TYPE_BASIC ) + uniform samplerCube pointShadowMap[ NUM_POINT_LIGHT_SHADOWS ]; + #endif + varying vec4 vPointShadowCoord[ NUM_POINT_LIGHT_SHADOWS ]; + struct PointLightShadow { + float shadowIntensity; + float shadowBias; + float shadowNormalBias; + float shadowRadius; + vec2 shadowMapSize; + float shadowCameraNear; + float shadowCameraFar; + }; + uniform PointLightShadow pointLightShadows[ NUM_POINT_LIGHT_SHADOWS ]; + #endif + #if defined( SHADOWMAP_TYPE_PCF ) + float interleavedGradientNoise( vec2 position ) { + return fract( 52.9829189 * fract( dot( position, vec2( 0.06711056, 0.00583715 ) ) ) ); + } + vec2 vogelDiskSample( int sampleIndex, int samplesCount, float phi ) { + const float goldenAngle = 2.399963229728653; + float r = sqrt( ( float( sampleIndex ) + 0.5 ) / float( samplesCount ) ); + float theta = float( sampleIndex ) * goldenAngle + phi; + return vec2( cos( theta ), sin( theta ) ) * r; + } + #endif + #if defined( SHADOWMAP_TYPE_PCF ) + float getShadow( sampler2DShadow shadowMap, vec2 shadowMapSize, float shadowIntensity, float shadowBias, float shadowRadius, vec4 shadowCoord ) { + float shadow = 1.0; + shadowCoord.xyz /= shadowCoord.w; + shadowCoord.z += shadowBias; + bool inFrustum = shadowCoord.x >= 0.0 && shadowCoord.x <= 1.0 && shadowCoord.y >= 0.0 && shadowCoord.y <= 1.0; + bool frustumTest = inFrustum && shadowCoord.z <= 1.0; + if ( frustumTest ) { + vec2 texelSize = vec2( 1.0 ) / shadowMapSize; + float radius = shadowRadius * texelSize.x; + float phi = interleavedGradientNoise( gl_FragCoord.xy ) * PI2; + shadow = ( + texture( shadowMap, vec3( shadowCoord.xy + vogelDiskSample( 0, 5, phi ) * radius, shadowCoord.z ) ) + + texture( shadowMap, vec3( shadowCoord.xy + vogelDiskSample( 1, 5, phi ) * radius, shadowCoord.z ) ) + + texture( shadowMap, vec3( shadowCoord.xy + vogelDiskSample( 2, 5, phi ) * radius, shadowCoord.z ) ) + + texture( shadowMap, vec3( shadowCoord.xy + vogelDiskSample( 3, 5, phi ) * radius, shadowCoord.z ) ) + + texture( shadowMap, vec3( shadowCoord.xy + vogelDiskSample( 4, 5, phi ) * radius, shadowCoord.z ) ) + ) * 0.2; + } + return mix( 1.0, shadow, shadowIntensity ); + } + #elif defined( SHADOWMAP_TYPE_VSM ) + float getShadow( sampler2D shadowMap, vec2 shadowMapSize, float shadowIntensity, float shadowBias, float shadowRadius, vec4 shadowCoord ) { + float shadow = 1.0; + shadowCoord.xyz /= shadowCoord.w; + #ifdef USE_REVERSED_DEPTH_BUFFER + shadowCoord.z -= shadowBias; + #else + shadowCoord.z += shadowBias; + #endif + bool inFrustum = shadowCoord.x >= 0.0 && shadowCoord.x <= 1.0 && shadowCoord.y >= 0.0 && shadowCoord.y <= 1.0; + bool frustumTest = inFrustum && shadowCoord.z <= 1.0; + if ( frustumTest ) { + vec2 distribution = texture2D( shadowMap, shadowCoord.xy ).rg; + float mean = distribution.x; + float variance = distribution.y * distribution.y; + #ifdef USE_REVERSED_DEPTH_BUFFER + float hard_shadow = step( mean, shadowCoord.z ); + #else + float hard_shadow = step( shadowCoord.z, mean ); + #endif + + if ( hard_shadow == 1.0 ) { + shadow = 1.0; + } else { + variance = max( variance, 0.0000001 ); + float d = shadowCoord.z - mean; + float p_max = variance / ( variance + d * d ); + p_max = clamp( ( p_max - 0.3 ) / 0.65, 0.0, 1.0 ); + shadow = max( hard_shadow, p_max ); + } + } + return mix( 1.0, shadow, shadowIntensity ); + } + #else + float getShadow( sampler2D shadowMap, vec2 shadowMapSize, float shadowIntensity, float shadowBias, float shadowRadius, vec4 shadowCoord ) { + float shadow = 1.0; + shadowCoord.xyz /= shadowCoord.w; + #ifdef USE_REVERSED_DEPTH_BUFFER + shadowCoord.z -= shadowBias; + #else + shadowCoord.z += shadowBias; + #endif + bool inFrustum = shadowCoord.x >= 0.0 && shadowCoord.x <= 1.0 && shadowCoord.y >= 0.0 && shadowCoord.y <= 1.0; + bool frustumTest = inFrustum && shadowCoord.z <= 1.0; + if ( frustumTest ) { + float depth = texture2D( shadowMap, shadowCoord.xy ).r; + #ifdef USE_REVERSED_DEPTH_BUFFER + shadow = step( depth, shadowCoord.z ); + #else + shadow = step( shadowCoord.z, depth ); + #endif + } + return mix( 1.0, shadow, shadowIntensity ); + } + #endif + #if NUM_SUN_LIGHT_SHADOWS > 0 + float getSunShadow( + #if defined( SHADOWMAP_TYPE_PCF ) + sampler2DShadow shadowMap, + #else + sampler2D shadowMap, + #endif + SunLightShadow sunLightShadow, + int shadowIndex + ) { + vec4 shadowWorldPosition = vec4( vSunShadowWorldPosition.xyz + vSunShadowWorldNormal * sunLightShadow.shadowNormalBias, 1.0 ); + float viewDepth = vSunShadowWorldPosition.w; + int cascadeOffset = shadowIndex * SUN_LIGHT_CASCADES; + float shadow = 1.0; + for ( int i = SUN_LIGHT_CASCADES - 1; i >= 0; i -- ) { + vec4 cascade = sunShadowCascade[ cascadeOffset + i ]; + if ( viewDepth >= cascade.x && viewDepth < cascade.y ) { + float cascadeShadow = getShadow( + shadowMap, + sunLightShadow.shadowMapSize, + sunLightShadow.shadowIntensity, + sunLightShadow.shadowBias, + sunLightShadow.shadowRadius, + sunShadowMatrix[ cascadeOffset + i ] * shadowWorldPosition + ); + shadow = mix( cascadeShadow, shadow, smoothstep( cascade.z, cascade.y, viewDepth ) ); + } + } + return shadow; + } + #endif + #if NUM_POINT_LIGHT_SHADOWS > 0 + #if defined( SHADOWMAP_TYPE_PCF ) + float getPointShadow( samplerCubeShadow shadowMap, vec2 shadowMapSize, float shadowIntensity, float shadowBias, float shadowRadius, vec4 shadowCoord, float shadowCameraNear, float shadowCameraFar ) { + float shadow = 1.0; + vec3 lightToPosition = shadowCoord.xyz; + vec3 bd3D = normalize( lightToPosition ); + vec3 absVec = abs( lightToPosition ); + float viewSpaceZ = max( max( absVec.x, absVec.y ), absVec.z ); + if ( viewSpaceZ - shadowCameraFar <= 0.0 && viewSpaceZ - shadowCameraNear >= 0.0 ) { + #ifdef USE_REVERSED_DEPTH_BUFFER + float dp = ( shadowCameraNear * ( shadowCameraFar - viewSpaceZ ) ) / ( viewSpaceZ * ( shadowCameraFar - shadowCameraNear ) ); + dp -= shadowBias; + #else + float dp = ( shadowCameraFar * ( viewSpaceZ - shadowCameraNear ) ) / ( viewSpaceZ * ( shadowCameraFar - shadowCameraNear ) ); + dp += shadowBias; + #endif + float texelSize = shadowRadius / shadowMapSize.x; + vec3 absDir = abs( bd3D ); + vec3 tangent = absDir.x > absDir.z ? vec3( 0.0, 1.0, 0.0 ) : vec3( 1.0, 0.0, 0.0 ); + tangent = normalize( cross( bd3D, tangent ) ); + vec3 bitangent = cross( bd3D, tangent ); + float phi = interleavedGradientNoise( gl_FragCoord.xy ) * PI2; + vec2 sample0 = vogelDiskSample( 0, 5, phi ); + vec2 sample1 = vogelDiskSample( 1, 5, phi ); + vec2 sample2 = vogelDiskSample( 2, 5, phi ); + vec2 sample3 = vogelDiskSample( 3, 5, phi ); + vec2 sample4 = vogelDiskSample( 4, 5, phi ); + shadow = ( + texture( shadowMap, vec4( bd3D + ( tangent * sample0.x + bitangent * sample0.y ) * texelSize, dp ) ) + + texture( shadowMap, vec4( bd3D + ( tangent * sample1.x + bitangent * sample1.y ) * texelSize, dp ) ) + + texture( shadowMap, vec4( bd3D + ( tangent * sample2.x + bitangent * sample2.y ) * texelSize, dp ) ) + + texture( shadowMap, vec4( bd3D + ( tangent * sample3.x + bitangent * sample3.y ) * texelSize, dp ) ) + + texture( shadowMap, vec4( bd3D + ( tangent * sample4.x + bitangent * sample4.y ) * texelSize, dp ) ) + ) * 0.2; + } + return mix( 1.0, shadow, shadowIntensity ); + } + #elif defined( SHADOWMAP_TYPE_BASIC ) + float getPointShadow( samplerCube shadowMap, vec2 shadowMapSize, float shadowIntensity, float shadowBias, float shadowRadius, vec4 shadowCoord, float shadowCameraNear, float shadowCameraFar ) { + float shadow = 1.0; + vec3 lightToPosition = shadowCoord.xyz; + vec3 absVec = abs( lightToPosition ); + float viewSpaceZ = max( max( absVec.x, absVec.y ), absVec.z ); + if ( viewSpaceZ - shadowCameraFar <= 0.0 && viewSpaceZ - shadowCameraNear >= 0.0 ) { + float dp = ( shadowCameraFar * ( viewSpaceZ - shadowCameraNear ) ) / ( viewSpaceZ * ( shadowCameraFar - shadowCameraNear ) ); + dp += shadowBias; + vec3 bd3D = normalize( lightToPosition ); + float depth = textureCube( shadowMap, bd3D ).r; + #ifdef USE_REVERSED_DEPTH_BUFFER + depth = 1.0 - depth; + #endif + shadow = step( dp, depth ); + } + return mix( 1.0, shadow, shadowIntensity ); + } + #endif + #endif +#endif`,dg=`#if NUM_SPOT_LIGHT_COORDS > 0 + uniform mat4 spotLightMatrix[ NUM_SPOT_LIGHT_COORDS ]; + varying vec4 vSpotLightCoord[ NUM_SPOT_LIGHT_COORDS ]; +#endif +#ifdef USE_SHADOWMAP + #if NUM_SUN_LIGHT_SHADOWS > 0 + varying vec4 vSunShadowWorldPosition; + varying vec3 vSunShadowWorldNormal; + #endif + #if NUM_DIR_LIGHT_SHADOWS > 0 + uniform mat4 directionalShadowMatrix[ NUM_DIR_LIGHT_SHADOWS ]; + varying vec4 vDirectionalShadowCoord[ NUM_DIR_LIGHT_SHADOWS ]; + struct DirectionalLightShadow { + float shadowIntensity; + float shadowBias; + float shadowNormalBias; + float shadowRadius; + vec2 shadowMapSize; + }; + uniform DirectionalLightShadow directionalLightShadows[ NUM_DIR_LIGHT_SHADOWS ]; + #endif + #if NUM_SPOT_LIGHT_SHADOWS > 0 + struct SpotLightShadow { + float shadowIntensity; + float shadowBias; + float shadowNormalBias; + float shadowRadius; + vec2 shadowMapSize; + }; + uniform SpotLightShadow spotLightShadows[ NUM_SPOT_LIGHT_SHADOWS ]; + #endif + #if NUM_POINT_LIGHT_SHADOWS > 0 + uniform mat4 pointShadowMatrix[ NUM_POINT_LIGHT_SHADOWS ]; + varying vec4 vPointShadowCoord[ NUM_POINT_LIGHT_SHADOWS ]; + struct PointLightShadow { + float shadowIntensity; + float shadowBias; + float shadowNormalBias; + float shadowRadius; + vec2 shadowMapSize; + float shadowCameraNear; + float shadowCameraFar; + }; + uniform PointLightShadow pointLightShadows[ NUM_POINT_LIGHT_SHADOWS ]; + #endif +#endif`,fg=`#if ( defined( USE_SHADOWMAP ) && ( NUM_DIR_LIGHT_SHADOWS > 0 || NUM_SUN_LIGHT_SHADOWS > 0 || NUM_POINT_LIGHT_SHADOWS > 0 ) ) || ( NUM_SPOT_LIGHT_COORDS > 0 ) + #ifdef HAS_NORMAL + vec3 shadowWorldNormal = transformNormalByInverseViewMatrix( transformedNormal, viewMatrix ); + #else + vec3 shadowWorldNormal = vec3( 0.0 ); + #endif + vec4 shadowWorldPosition; +#endif +#if defined( USE_SHADOWMAP ) + #if NUM_SUN_LIGHT_SHADOWS > 0 + vSunShadowWorldPosition = vec4( worldPosition.xyz, - mvPosition.z ); + vSunShadowWorldNormal = shadowWorldNormal; + #endif + #if NUM_DIR_LIGHT_SHADOWS > 0 + #pragma unroll_loop_start + for ( int i = 0; i < NUM_DIR_LIGHT_SHADOWS; i ++ ) { + shadowWorldPosition = worldPosition + vec4( shadowWorldNormal * directionalLightShadows[ i ].shadowNormalBias, 0 ); + vDirectionalShadowCoord[ i ] = directionalShadowMatrix[ i ] * shadowWorldPosition; + } + #pragma unroll_loop_end + #endif + #if NUM_POINT_LIGHT_SHADOWS > 0 + #pragma unroll_loop_start + for ( int i = 0; i < NUM_POINT_LIGHT_SHADOWS; i ++ ) { + shadowWorldPosition = worldPosition + vec4( shadowWorldNormal * pointLightShadows[ i ].shadowNormalBias, 0 ); + vPointShadowCoord[ i ] = pointShadowMatrix[ i ] * shadowWorldPosition; + } + #pragma unroll_loop_end + #endif +#endif +#if NUM_SPOT_LIGHT_COORDS > 0 + #pragma unroll_loop_start + for ( int i = 0; i < NUM_SPOT_LIGHT_COORDS; i ++ ) { + shadowWorldPosition = worldPosition; + #if ( defined( USE_SHADOWMAP ) && UNROLLED_LOOP_INDEX < NUM_SPOT_LIGHT_SHADOWS ) + shadowWorldPosition.xyz += shadowWorldNormal * spotLightShadows[ i ].shadowNormalBias; + #endif + vSpotLightCoord[ i ] = spotLightMatrix[ i ] * shadowWorldPosition; + } + #pragma unroll_loop_end +#endif`,pg=`float getShadowMask() { + float shadow = 1.0; + #ifdef USE_SHADOWMAP + #if NUM_SUN_LIGHT_SHADOWS > 0 + SunLightShadow sunLight; + #pragma unroll_loop_start + for ( int i = 0; i < NUM_SUN_LIGHT_SHADOWS; i ++ ) { + sunLight = sunLightShadows[ i ]; + shadow *= receiveShadow ? getSunShadow( sunShadowMap[ i ], sunLight, UNROLLED_LOOP_INDEX ) : 1.0; + } + #pragma unroll_loop_end + #endif + #if NUM_DIR_LIGHT_SHADOWS > 0 + DirectionalLightShadow directionalLight; + #pragma unroll_loop_start + for ( int i = 0; i < NUM_DIR_LIGHT_SHADOWS; i ++ ) { + directionalLight = directionalLightShadows[ i ]; + shadow *= receiveShadow ? getShadow( directionalShadowMap[ i ], directionalLight.shadowMapSize, directionalLight.shadowIntensity, directionalLight.shadowBias, directionalLight.shadowRadius, vDirectionalShadowCoord[ i ] ) : 1.0; + } + #pragma unroll_loop_end + #endif + #if NUM_SPOT_LIGHT_SHADOWS > 0 + SpotLightShadow spotLight; + #pragma unroll_loop_start + for ( int i = 0; i < NUM_SPOT_LIGHT_SHADOWS; i ++ ) { + spotLight = spotLightShadows[ i ]; + shadow *= receiveShadow ? getShadow( spotShadowMap[ i ], spotLight.shadowMapSize, spotLight.shadowIntensity, spotLight.shadowBias, spotLight.shadowRadius, vSpotLightCoord[ i ] ) : 1.0; + } + #pragma unroll_loop_end + #endif + #if NUM_POINT_LIGHT_SHADOWS > 0 && ( defined( SHADOWMAP_TYPE_PCF ) || defined( SHADOWMAP_TYPE_BASIC ) ) + PointLightShadow pointLight; + #pragma unroll_loop_start + for ( int i = 0; i < NUM_POINT_LIGHT_SHADOWS; i ++ ) { + pointLight = pointLightShadows[ i ]; + shadow *= receiveShadow ? getPointShadow( pointShadowMap[ i ], pointLight.shadowMapSize, pointLight.shadowIntensity, pointLight.shadowBias, pointLight.shadowRadius, vPointShadowCoord[ i ], pointLight.shadowCameraNear, pointLight.shadowCameraFar ) : 1.0; + } + #pragma unroll_loop_end + #endif + #endif + return shadow; +}`,mg=`#ifdef USE_SKINNING + mat4 boneMatX = getBoneMatrix( skinIndex.x ); + mat4 boneMatY = getBoneMatrix( skinIndex.y ); + mat4 boneMatZ = getBoneMatrix( skinIndex.z ); + mat4 boneMatW = getBoneMatrix( skinIndex.w ); +#endif`,gg=`#ifdef USE_SKINNING + uniform mat4 bindMatrix; + uniform mat4 bindMatrixInverse; + uniform highp sampler2D boneTexture; + mat4 getBoneMatrix( const in float i ) { + int size = textureSize( boneTexture, 0 ).x; + int j = int( i ) * 4; + int x = j % size; + int y = j / size; + vec4 v1 = texelFetch( boneTexture, ivec2( x, y ), 0 ); + vec4 v2 = texelFetch( boneTexture, ivec2( x + 1, y ), 0 ); + vec4 v3 = texelFetch( boneTexture, ivec2( x + 2, y ), 0 ); + vec4 v4 = texelFetch( boneTexture, ivec2( x + 3, y ), 0 ); + return mat4( v1, v2, v3, v4 ); + } +#endif`,_g=`#ifdef USE_SKINNING + vec4 skinVertex = bindMatrix * vec4( transformed, 1.0 ); + vec4 skinned = vec4( 0.0 ); + skinned += boneMatX * skinVertex * skinWeight.x; + skinned += boneMatY * skinVertex * skinWeight.y; + skinned += boneMatZ * skinVertex * skinWeight.z; + skinned += boneMatW * skinVertex * skinWeight.w; + transformed = ( bindMatrixInverse * skinned ).xyz; +#endif`,xg=`#ifdef USE_SKINNING + mat4 skinMatrix = mat4( 0.0 ); + skinMatrix += skinWeight.x * boneMatX; + skinMatrix += skinWeight.y * boneMatY; + skinMatrix += skinWeight.z * boneMatZ; + skinMatrix += skinWeight.w * boneMatW; + skinMatrix = bindMatrixInverse * skinMatrix * bindMatrix; + objectNormal = vec4( skinMatrix * vec4( objectNormal, 0.0 ) ).xyz; + #ifdef USE_TANGENT + objectTangent = vec4( skinMatrix * vec4( objectTangent, 0.0 ) ).xyz; + #endif +#endif`,vg=`float specularStrength; +#ifdef USE_SPECULARMAP + vec4 texelSpecular = texture2D( specularMap, vSpecularMapUv ); + specularStrength = texelSpecular.r; +#else + specularStrength = 1.0; +#endif`,yg=`#ifdef USE_SPECULARMAP + uniform sampler2D specularMap; +#endif`,Mg=`#if defined( TONE_MAPPING ) + gl_FragColor.rgb = toneMapping( gl_FragColor.rgb ); +#endif`,Sg=`#ifndef saturate +#define saturate( a ) clamp( a, 0.0, 1.0 ) +#endif +uniform float toneMappingExposure; +vec3 LinearToneMapping( vec3 color ) { + return saturate( toneMappingExposure * color ); +} +vec3 ReinhardToneMapping( vec3 color ) { + color *= toneMappingExposure; + return saturate( color / ( vec3( 1.0 ) + color ) ); +} +vec3 CineonToneMapping( vec3 color ) { + color *= toneMappingExposure; + color = max( vec3( 0.0 ), color - 0.004 ); + return pow( ( color * ( 6.2 * color + 0.5 ) ) / ( color * ( 6.2 * color + 1.7 ) + 0.06 ), vec3( 2.2 ) ); +} +vec3 RRTAndODTFit( vec3 v ) { + vec3 a = v * ( v + 0.0245786 ) - 0.000090537; + vec3 b = v * ( 0.983729 * v + 0.4329510 ) + 0.238081; + return a / b; +} +vec3 ACESFilmicToneMapping( vec3 color ) { + const mat3 ACESInputMat = mat3( + vec3( 0.59719, 0.07600, 0.02840 ), vec3( 0.35458, 0.90834, 0.13383 ), + vec3( 0.04823, 0.01566, 0.83777 ) + ); + const mat3 ACESOutputMat = mat3( + vec3( 1.60475, -0.10208, -0.00327 ), vec3( -0.53108, 1.10813, -0.07276 ), + vec3( -0.07367, -0.00605, 1.07602 ) + ); + color *= toneMappingExposure / 0.6; + color = ACESInputMat * color; + color = RRTAndODTFit( color ); + color = ACESOutputMat * color; + return saturate( color ); +} +const mat3 LINEAR_REC2020_TO_LINEAR_SRGB = mat3( + vec3( 1.6605, - 0.1246, - 0.0182 ), + vec3( - 0.5876, 1.1329, - 0.1006 ), + vec3( - 0.0728, - 0.0083, 1.1187 ) +); +const mat3 LINEAR_SRGB_TO_LINEAR_REC2020 = mat3( + vec3( 0.6274, 0.0691, 0.0164 ), + vec3( 0.3293, 0.9195, 0.0880 ), + vec3( 0.0433, 0.0113, 0.8956 ) +); +vec3 agxDefaultContrastApprox( vec3 x ) { + vec3 x2 = x * x; + vec3 x4 = x2 * x2; + return + 15.5 * x4 * x2 + - 40.14 * x4 * x + + 31.96 * x4 + - 6.868 * x2 * x + + 0.4298 * x2 + + 0.1191 * x + - 0.00232; +} +vec3 AgXToneMapping( vec3 color ) { + const mat3 AgXInsetMatrix = mat3( + vec3( 0.856627153315983, 0.137318972929847, 0.11189821299995 ), + vec3( 0.0951212405381588, 0.761241990602591, 0.0767994186031903 ), + vec3( 0.0482516061458583, 0.101439036467562, 0.811302368396859 ) + ); + const mat3 AgXOutsetMatrix = mat3( + vec3( 1.1271005818144368, - 0.1413297634984383, - 0.14132976349843826 ), + vec3( - 0.11060664309660323, 1.157823702216272, - 0.11060664309660294 ), + vec3( - 0.016493938717834573, - 0.016493938717834257, 1.2519364065950405 ) + ); + const float AgxMinEv = - 12.47393; const float AgxMaxEv = 4.026069; + color *= toneMappingExposure; + color = LINEAR_SRGB_TO_LINEAR_REC2020 * color; + color = AgXInsetMatrix * color; + color = max( color, 1e-10 ); color = log2( color ); + color = ( color - AgxMinEv ) / ( AgxMaxEv - AgxMinEv ); + color = clamp( color, 0.0, 1.0 ); + color = agxDefaultContrastApprox( color ); + color = AgXOutsetMatrix * color; + color = pow( max( vec3( 0.0 ), color ), vec3( 2.2 ) ); + color = LINEAR_REC2020_TO_LINEAR_SRGB * color; + color = clamp( color, 0.0, 1.0 ); + return color; +} +vec3 NeutralToneMapping( vec3 color ) { + const float StartCompression = 0.8 - 0.04; + const float Desaturation = 0.15; + color *= toneMappingExposure; + float x = min( color.r, min( color.g, color.b ) ); + float offset = x < 0.08 ? x - 6.25 * x * x : 0.04; + color -= offset; + float peak = max( color.r, max( color.g, color.b ) ); + if ( peak < StartCompression ) return color; + float d = 1. - StartCompression; + float newPeak = 1. - d * d / ( peak + d - StartCompression ); + color *= newPeak / peak; + float g = 1. - 1. / ( Desaturation * ( peak - newPeak ) + 1. ); + return mix( color, vec3( newPeak ), g ); +} +vec3 CustomToneMapping( vec3 color ) { return color; }`,bg=`#ifdef USE_TRANSMISSION + material.transmission = transmission; + material.transmissionAlpha = 1.0; + material.thickness = thickness; + material.attenuationDistance = attenuationDistance; + material.attenuationColor = attenuationColor; + #ifdef USE_TRANSMISSIONMAP + material.transmission *= texture2D( transmissionMap, vTransmissionMapUv ).r; + #endif + #ifdef USE_THICKNESSMAP + material.thickness *= texture2D( thicknessMap, vThicknessMapUv ).g; + #endif + vec3 pos = vWorldPosition; + vec3 v = normalize( cameraPosition - pos ); + vec3 n = transformNormalByInverseViewMatrix( normal, viewMatrix ); + vec4 transmitted = getIBLVolumeRefraction( + n, v, material.roughness, material.diffuseContribution, material.specularColorBlended, material.specularF90, + pos, modelMatrix, viewMatrix, projectionMatrix, material.dispersion, material.ior, material.thickness, + material.attenuationColor, material.attenuationDistance ); + material.transmissionAlpha = mix( material.transmissionAlpha, transmitted.a, material.transmission ); + totalDiffuse = mix( totalDiffuse, transmitted.rgb, material.transmission ); +#endif`,wg=`#ifdef USE_TRANSMISSION + uniform float transmission; + uniform float thickness; + uniform float attenuationDistance; + uniform vec3 attenuationColor; + #ifdef USE_TRANSMISSIONMAP + uniform sampler2D transmissionMap; + #endif + #ifdef USE_THICKNESSMAP + uniform sampler2D thicknessMap; + #endif + uniform vec2 transmissionSamplerSize; + uniform sampler2D transmissionSamplerMap; + uniform mat4 modelMatrix; + uniform mat4 projectionMatrix; + varying vec3 vWorldPosition; + float w0( float a ) { + return ( 1.0 / 6.0 ) * ( a * ( a * ( - a + 3.0 ) - 3.0 ) + 1.0 ); + } + float w1( float a ) { + return ( 1.0 / 6.0 ) * ( a * a * ( 3.0 * a - 6.0 ) + 4.0 ); + } + float w2( float a ){ + return ( 1.0 / 6.0 ) * ( a * ( a * ( - 3.0 * a + 3.0 ) + 3.0 ) + 1.0 ); + } + float w3( float a ) { + return ( 1.0 / 6.0 ) * ( a * a * a ); + } + float g0( float a ) { + return w0( a ) + w1( a ); + } + float g1( float a ) { + return w2( a ) + w3( a ); + } + float h0( float a ) { + return - 1.0 + w1( a ) / ( w0( a ) + w1( a ) ); + } + float h1( float a ) { + return 1.0 + w3( a ) / ( w2( a ) + w3( a ) ); + } + vec4 bicubic( sampler2D tex, vec2 uv, vec4 texelSize, float lod ) { + uv = uv * texelSize.zw + 0.5; + vec2 iuv = floor( uv ); + vec2 fuv = fract( uv ); + float g0x = g0( fuv.x ); + float g1x = g1( fuv.x ); + float h0x = h0( fuv.x ); + float h1x = h1( fuv.x ); + float h0y = h0( fuv.y ); + float h1y = h1( fuv.y ); + vec2 p0 = ( vec2( iuv.x + h0x, iuv.y + h0y ) - 0.5 ) * texelSize.xy; + vec2 p1 = ( vec2( iuv.x + h1x, iuv.y + h0y ) - 0.5 ) * texelSize.xy; + vec2 p2 = ( vec2( iuv.x + h0x, iuv.y + h1y ) - 0.5 ) * texelSize.xy; + vec2 p3 = ( vec2( iuv.x + h1x, iuv.y + h1y ) - 0.5 ) * texelSize.xy; + return g0( fuv.y ) * ( g0x * textureLod( tex, p0, lod ) + g1x * textureLod( tex, p1, lod ) ) + + g1( fuv.y ) * ( g0x * textureLod( tex, p2, lod ) + g1x * textureLod( tex, p3, lod ) ); + } + vec4 textureBicubic( sampler2D sampler, vec2 uv, float lod ) { + vec2 fLodSize = vec2( textureSize( sampler, int( lod ) ) ); + vec2 cLodSize = vec2( textureSize( sampler, int( lod + 1.0 ) ) ); + vec2 fLodSizeInv = 1.0 / fLodSize; + vec2 cLodSizeInv = 1.0 / cLodSize; + vec4 fSample = bicubic( sampler, uv, vec4( fLodSizeInv, fLodSize ), floor( lod ) ); + vec4 cSample = bicubic( sampler, uv, vec4( cLodSizeInv, cLodSize ), ceil( lod ) ); + return mix( fSample, cSample, fract( lod ) ); + } + vec3 getVolumeTransmissionRay( const in vec3 n, const in vec3 v, const in float thickness, const in float ior, const in mat4 modelMatrix ) { + vec3 refractionVector = refract( - v, normalize( n ), 1.0 / ior ); + vec3 modelScale; + modelScale.x = length( vec3( modelMatrix[ 0 ].xyz ) ); + modelScale.y = length( vec3( modelMatrix[ 1 ].xyz ) ); + modelScale.z = length( vec3( modelMatrix[ 2 ].xyz ) ); + return normalize( refractionVector ) * thickness * modelScale; + } + float applyIorToRoughness( const in float roughness, const in float ior ) { + return roughness * clamp( ior * 2.0 - 2.0, 0.0, 1.0 ); + } + vec4 getTransmissionSample( const in vec2 fragCoord, const in float roughness, const in float ior ) { + float lod = log2( transmissionSamplerSize.x ) * applyIorToRoughness( roughness, ior ); + return textureBicubic( transmissionSamplerMap, fragCoord.xy, lod ); + } + vec3 volumeAttenuation( const in float transmissionDistance, const in vec3 attenuationColor, const in float attenuationDistance ) { + if ( isinf( attenuationDistance ) ) { + return vec3( 1.0 ); + } else { + vec3 attenuationCoefficient = -log( attenuationColor ) / attenuationDistance; + vec3 transmittance = exp( - attenuationCoefficient * transmissionDistance ); return transmittance; + } + } + vec4 getIBLVolumeRefraction( const in vec3 n, const in vec3 v, const in float roughness, const in vec3 diffuseColor, + const in vec3 specularColor, const in float specularF90, const in vec3 position, const in mat4 modelMatrix, + const in mat4 viewMatrix, const in mat4 projMatrix, const in float dispersion, const in float ior, const in float thickness, + const in vec3 attenuationColor, const in float attenuationDistance ) { + vec4 transmittedLight; + vec3 transmittance; + #ifdef USE_DISPERSION + float halfSpread = ( ior - 1.0 ) * 0.025 * dispersion; + vec3 iors = vec3( ior - halfSpread, ior, ior + halfSpread ); + for ( int i = 0; i < 3; i ++ ) { + vec3 transmissionRay = getVolumeTransmissionRay( n, v, thickness, iors[ i ], modelMatrix ); + vec3 refractedRayExit = position + transmissionRay; + vec4 ndcPos = projMatrix * viewMatrix * vec4( refractedRayExit, 1.0 ); + vec2 refractionCoords = ndcPos.xy / ndcPos.w; + refractionCoords += 1.0; + refractionCoords /= 2.0; + vec4 transmissionSample = getTransmissionSample( refractionCoords, roughness, iors[ i ] ); + transmittedLight[ i ] = transmissionSample[ i ]; + transmittedLight.a += transmissionSample.a; + transmittance[ i ] = diffuseColor[ i ] * volumeAttenuation( length( transmissionRay ), attenuationColor, attenuationDistance )[ i ]; + } + transmittedLight.a /= 3.0; + #else + vec3 transmissionRay = getVolumeTransmissionRay( n, v, thickness, ior, modelMatrix ); + vec3 refractedRayExit = position + transmissionRay; + vec4 ndcPos = projMatrix * viewMatrix * vec4( refractedRayExit, 1.0 ); + vec2 refractionCoords = ndcPos.xy / ndcPos.w; + refractionCoords += 1.0; + refractionCoords /= 2.0; + transmittedLight = getTransmissionSample( refractionCoords, roughness, ior ); + transmittance = diffuseColor * volumeAttenuation( length( transmissionRay ), attenuationColor, attenuationDistance ); + #endif + vec3 attenuatedColor = transmittance * transmittedLight.rgb; + vec3 F = EnvironmentBRDF( n, v, specularColor, specularF90, roughness ); + float transmittanceFactor = ( transmittance.r + transmittance.g + transmittance.b ) / 3.0; + return vec4( ( 1.0 - F ) * attenuatedColor, 1.0 - ( 1.0 - transmittedLight.a ) * transmittanceFactor ); + } +#endif`,Eg=`#if defined( USE_UV ) || defined( USE_ANISOTROPY ) + varying vec2 vUv; +#endif +#ifdef USE_MAP + varying vec2 vMapUv; +#endif +#ifdef USE_ALPHAMAP + varying vec2 vAlphaMapUv; +#endif +#ifdef USE_LIGHTMAP + varying vec2 vLightMapUv; +#endif +#ifdef USE_AOMAP + varying vec2 vAoMapUv; +#endif +#ifdef USE_BUMPMAP + varying vec2 vBumpMapUv; +#endif +#ifdef USE_NORMALMAP + varying vec2 vNormalMapUv; +#endif +#ifdef USE_EMISSIVEMAP + varying vec2 vEmissiveMapUv; +#endif +#ifdef USE_METALNESSMAP + varying vec2 vMetalnessMapUv; +#endif +#ifdef USE_ROUGHNESSMAP + varying vec2 vRoughnessMapUv; +#endif +#ifdef USE_ANISOTROPYMAP + varying vec2 vAnisotropyMapUv; +#endif +#ifdef USE_CLEARCOATMAP + varying vec2 vClearcoatMapUv; +#endif +#ifdef USE_CLEARCOAT_NORMALMAP + varying vec2 vClearcoatNormalMapUv; +#endif +#ifdef USE_CLEARCOAT_ROUGHNESSMAP + varying vec2 vClearcoatRoughnessMapUv; +#endif +#ifdef USE_IRIDESCENCEMAP + varying vec2 vIridescenceMapUv; +#endif +#ifdef USE_IRIDESCENCE_THICKNESSMAP + varying vec2 vIridescenceThicknessMapUv; +#endif +#ifdef USE_SHEEN_COLORMAP + varying vec2 vSheenColorMapUv; +#endif +#ifdef USE_SHEEN_ROUGHNESSMAP + varying vec2 vSheenRoughnessMapUv; +#endif +#ifdef USE_SPECULARMAP + varying vec2 vSpecularMapUv; +#endif +#ifdef USE_SPECULAR_COLORMAP + varying vec2 vSpecularColorMapUv; +#endif +#ifdef USE_SPECULAR_INTENSITYMAP + varying vec2 vSpecularIntensityMapUv; +#endif +#ifdef USE_TRANSMISSIONMAP + uniform mat3 transmissionMapTransform; + varying vec2 vTransmissionMapUv; +#endif +#ifdef USE_THICKNESSMAP + uniform mat3 thicknessMapTransform; + varying vec2 vThicknessMapUv; +#endif`,Tg=`#if defined( USE_UV ) || defined( USE_ANISOTROPY ) + varying vec2 vUv; +#endif +#ifdef USE_MAP + uniform mat3 mapTransform; + varying vec2 vMapUv; +#endif +#ifdef USE_ALPHAMAP + uniform mat3 alphaMapTransform; + varying vec2 vAlphaMapUv; +#endif +#ifdef USE_LIGHTMAP + uniform mat3 lightMapTransform; + varying vec2 vLightMapUv; +#endif +#ifdef USE_AOMAP + uniform mat3 aoMapTransform; + varying vec2 vAoMapUv; +#endif +#ifdef USE_BUMPMAP + uniform mat3 bumpMapTransform; + varying vec2 vBumpMapUv; +#endif +#ifdef USE_NORMALMAP + uniform mat3 normalMapTransform; + varying vec2 vNormalMapUv; +#endif +#ifdef USE_DISPLACEMENTMAP + uniform mat3 displacementMapTransform; + varying vec2 vDisplacementMapUv; +#endif +#ifdef USE_EMISSIVEMAP + uniform mat3 emissiveMapTransform; + varying vec2 vEmissiveMapUv; +#endif +#ifdef USE_METALNESSMAP + uniform mat3 metalnessMapTransform; + varying vec2 vMetalnessMapUv; +#endif +#ifdef USE_ROUGHNESSMAP + uniform mat3 roughnessMapTransform; + varying vec2 vRoughnessMapUv; +#endif +#ifdef USE_ANISOTROPYMAP + uniform mat3 anisotropyMapTransform; + varying vec2 vAnisotropyMapUv; +#endif +#ifdef USE_CLEARCOATMAP + uniform mat3 clearcoatMapTransform; + varying vec2 vClearcoatMapUv; +#endif +#ifdef USE_CLEARCOAT_NORMALMAP + uniform mat3 clearcoatNormalMapTransform; + varying vec2 vClearcoatNormalMapUv; +#endif +#ifdef USE_CLEARCOAT_ROUGHNESSMAP + uniform mat3 clearcoatRoughnessMapTransform; + varying vec2 vClearcoatRoughnessMapUv; +#endif +#ifdef USE_SHEEN_COLORMAP + uniform mat3 sheenColorMapTransform; + varying vec2 vSheenColorMapUv; +#endif +#ifdef USE_SHEEN_ROUGHNESSMAP + uniform mat3 sheenRoughnessMapTransform; + varying vec2 vSheenRoughnessMapUv; +#endif +#ifdef USE_IRIDESCENCEMAP + uniform mat3 iridescenceMapTransform; + varying vec2 vIridescenceMapUv; +#endif +#ifdef USE_IRIDESCENCE_THICKNESSMAP + uniform mat3 iridescenceThicknessMapTransform; + varying vec2 vIridescenceThicknessMapUv; +#endif +#ifdef USE_SPECULARMAP + uniform mat3 specularMapTransform; + varying vec2 vSpecularMapUv; +#endif +#ifdef USE_SPECULAR_COLORMAP + uniform mat3 specularColorMapTransform; + varying vec2 vSpecularColorMapUv; +#endif +#ifdef USE_SPECULAR_INTENSITYMAP + uniform mat3 specularIntensityMapTransform; + varying vec2 vSpecularIntensityMapUv; +#endif +#ifdef USE_TRANSMISSIONMAP + uniform mat3 transmissionMapTransform; + varying vec2 vTransmissionMapUv; +#endif +#ifdef USE_THICKNESSMAP + uniform mat3 thicknessMapTransform; + varying vec2 vThicknessMapUv; +#endif`,Ag=`#if defined( USE_UV ) || defined( USE_ANISOTROPY ) + vUv = vec3( uv, 1 ).xy; +#endif +#ifdef USE_MAP + vMapUv = ( mapTransform * vec3( MAP_UV, 1 ) ).xy; +#endif +#ifdef USE_ALPHAMAP + vAlphaMapUv = ( alphaMapTransform * vec3( ALPHAMAP_UV, 1 ) ).xy; +#endif +#ifdef USE_LIGHTMAP + vLightMapUv = ( lightMapTransform * vec3( LIGHTMAP_UV, 1 ) ).xy; +#endif +#ifdef USE_AOMAP + vAoMapUv = ( aoMapTransform * vec3( AOMAP_UV, 1 ) ).xy; +#endif +#ifdef USE_BUMPMAP + vBumpMapUv = ( bumpMapTransform * vec3( BUMPMAP_UV, 1 ) ).xy; +#endif +#ifdef USE_NORMALMAP + vNormalMapUv = ( normalMapTransform * vec3( NORMALMAP_UV, 1 ) ).xy; +#endif +#ifdef USE_DISPLACEMENTMAP + vDisplacementMapUv = ( displacementMapTransform * vec3( DISPLACEMENTMAP_UV, 1 ) ).xy; +#endif +#ifdef USE_EMISSIVEMAP + vEmissiveMapUv = ( emissiveMapTransform * vec3( EMISSIVEMAP_UV, 1 ) ).xy; +#endif +#ifdef USE_METALNESSMAP + vMetalnessMapUv = ( metalnessMapTransform * vec3( METALNESSMAP_UV, 1 ) ).xy; +#endif +#ifdef USE_ROUGHNESSMAP + vRoughnessMapUv = ( roughnessMapTransform * vec3( ROUGHNESSMAP_UV, 1 ) ).xy; +#endif +#ifdef USE_ANISOTROPYMAP + vAnisotropyMapUv = ( anisotropyMapTransform * vec3( ANISOTROPYMAP_UV, 1 ) ).xy; +#endif +#ifdef USE_CLEARCOATMAP + vClearcoatMapUv = ( clearcoatMapTransform * vec3( CLEARCOATMAP_UV, 1 ) ).xy; +#endif +#ifdef USE_CLEARCOAT_NORMALMAP + vClearcoatNormalMapUv = ( clearcoatNormalMapTransform * vec3( CLEARCOAT_NORMALMAP_UV, 1 ) ).xy; +#endif +#ifdef USE_CLEARCOAT_ROUGHNESSMAP + vClearcoatRoughnessMapUv = ( clearcoatRoughnessMapTransform * vec3( CLEARCOAT_ROUGHNESSMAP_UV, 1 ) ).xy; +#endif +#ifdef USE_IRIDESCENCEMAP + vIridescenceMapUv = ( iridescenceMapTransform * vec3( IRIDESCENCEMAP_UV, 1 ) ).xy; +#endif +#ifdef USE_IRIDESCENCE_THICKNESSMAP + vIridescenceThicknessMapUv = ( iridescenceThicknessMapTransform * vec3( IRIDESCENCE_THICKNESSMAP_UV, 1 ) ).xy; +#endif +#ifdef USE_SHEEN_COLORMAP + vSheenColorMapUv = ( sheenColorMapTransform * vec3( SHEEN_COLORMAP_UV, 1 ) ).xy; +#endif +#ifdef USE_SHEEN_ROUGHNESSMAP + vSheenRoughnessMapUv = ( sheenRoughnessMapTransform * vec3( SHEEN_ROUGHNESSMAP_UV, 1 ) ).xy; +#endif +#ifdef USE_SPECULARMAP + vSpecularMapUv = ( specularMapTransform * vec3( SPECULARMAP_UV, 1 ) ).xy; +#endif +#ifdef USE_SPECULAR_COLORMAP + vSpecularColorMapUv = ( specularColorMapTransform * vec3( SPECULAR_COLORMAP_UV, 1 ) ).xy; +#endif +#ifdef USE_SPECULAR_INTENSITYMAP + vSpecularIntensityMapUv = ( specularIntensityMapTransform * vec3( SPECULAR_INTENSITYMAP_UV, 1 ) ).xy; +#endif +#ifdef USE_TRANSMISSIONMAP + vTransmissionMapUv = ( transmissionMapTransform * vec3( TRANSMISSIONMAP_UV, 1 ) ).xy; +#endif +#ifdef USE_THICKNESSMAP + vThicknessMapUv = ( thicknessMapTransform * vec3( THICKNESSMAP_UV, 1 ) ).xy; +#endif`,Rg=`#if defined( USE_ENVMAP ) || defined( DISTANCE ) || defined ( USE_SHADOWMAP ) || defined ( USE_TRANSMISSION ) || NUM_SPOT_LIGHT_COORDS > 0 + vec4 worldPosition = vec4( transformed, 1.0 ); + #ifdef USE_BATCHING + worldPosition = batchingMatrix * worldPosition; + #endif + #ifdef USE_INSTANCING + worldPosition = instanceMatrix * worldPosition; + #endif + worldPosition = modelMatrix * worldPosition; +#endif`,Cg=`varying vec2 vUv; +uniform mat3 uvTransform; +void main() { + vUv = ( uvTransform * vec3( uv, 1 ) ).xy; + gl_Position = vec4( position.xy, 1.0, 1.0 ); +}`,Ig=`uniform sampler2D t2D; +uniform float backgroundIntensity; +varying vec2 vUv; +void main() { + vec4 texColor = texture2D( t2D, vUv ); + #ifdef DECODE_VIDEO_TEXTURE + texColor = vec4( mix( pow( texColor.rgb * 0.9478672986 + vec3( 0.0521327014 ), vec3( 2.4 ) ), texColor.rgb * 0.0773993808, vec3( lessThanEqual( texColor.rgb, vec3( 0.04045 ) ) ) ), texColor.w ); + #endif + texColor.rgb *= backgroundIntensity; + gl_FragColor = texColor; + #include + #include +}`,Pg=`varying vec3 vWorldDirection; +#include +void main() { + vWorldDirection = transformDirection( position, modelMatrix ); + #include + #include + gl_Position.z = gl_Position.w; +}`,Lg=`#ifdef ENVMAP_TYPE_CUBE + uniform samplerCube envMap; +#elif defined( ENVMAP_TYPE_CUBE_UV ) + uniform sampler2D envMap; +#endif +uniform float backgroundBlurriness; +uniform float backgroundIntensity; +uniform mat3 backgroundRotation; +varying vec3 vWorldDirection; +#include +void main() { + #ifdef ENVMAP_TYPE_CUBE + vec4 texColor = textureCube( envMap, backgroundRotation * vWorldDirection ); + #elif defined( ENVMAP_TYPE_CUBE_UV ) + vec4 texColor = textureCubeUV( envMap, backgroundRotation * vWorldDirection, backgroundBlurriness ); + #else + vec4 texColor = vec4( 0.0, 0.0, 0.0, 1.0 ); + #endif + texColor.rgb *= backgroundIntensity; + gl_FragColor = texColor; + #include + #include +}`,Dg=`varying vec3 vWorldDirection; +#include +void main() { + vWorldDirection = transformDirection( position, modelMatrix ); + #include + #include + gl_Position.z = gl_Position.w; +}`,Ng=`uniform samplerCube tCube; +uniform float tFlip; +uniform float opacity; +varying vec3 vWorldDirection; +void main() { + vec4 texColor = textureCube( tCube, vec3( tFlip * vWorldDirection.x, vWorldDirection.yz ) ); + gl_FragColor = texColor; + gl_FragColor.a *= opacity; + #include + #include +}`,Ug=`#include +#include +#include +#include +#include +#include +#include +#include +varying vec2 vHighPrecisionZW; +void main() { + #include + #include + #include + #include + #ifdef USE_DISPLACEMENTMAP + #include + #include + #include + #endif + #include + #include + #include + #include + #include + #include + #include + vHighPrecisionZW = gl_Position.zw; +}`,Fg=`#if DEPTH_PACKING == 3200 + uniform float opacity; +#endif +#include +#include +#include +#include +#include +#include +#include +#include +#include +varying vec2 vHighPrecisionZW; +void main() { + vec4 diffuseColor = vec4( 1.0 ); + #include + #if DEPTH_PACKING == 3200 + diffuseColor.a = opacity; + #endif + #include + #include + #include + #include + #include + #ifdef USE_REVERSED_DEPTH_BUFFER + float fragCoordZ = vHighPrecisionZW[ 0 ] / vHighPrecisionZW[ 1 ]; + #else + float fragCoordZ = 0.5 * vHighPrecisionZW[ 0 ] / vHighPrecisionZW[ 1 ] + 0.5; + #endif + #if DEPTH_PACKING == 3200 + gl_FragColor = vec4( vec3( 1.0 - fragCoordZ ), opacity ); + #elif DEPTH_PACKING == 3201 + gl_FragColor = packDepthToRGBA( fragCoordZ ); + #elif DEPTH_PACKING == 3202 + gl_FragColor = vec4( packDepthToRGB( fragCoordZ ), 1.0 ); + #elif DEPTH_PACKING == 3203 + gl_FragColor = vec4( packDepthToRG( fragCoordZ ), 0.0, 1.0 ); + #endif +}`,Bg=`#define DISTANCE +varying vec3 vWorldPosition; +#include +#include +#include +#include +#include +#include +#include +void main() { + #include + #include + #include + #include + #ifdef USE_DISPLACEMENTMAP + #include + #include + #include + #endif + #include + #include + #include + #include + #include + #include + #include + vWorldPosition = worldPosition.xyz; +}`,Og=`#define DISTANCE +uniform vec3 referencePosition; +uniform float nearDistance; +uniform float farDistance; +varying vec3 vWorldPosition; +#include +#include +#include +#include +#include +#include +#include +void main() { + vec4 diffuseColor = vec4( 1.0 ); + #include + #include + #include + #include + #include + float dist = length( vWorldPosition - referencePosition ); + dist = ( dist - nearDistance ) / ( farDistance - nearDistance ); + dist = saturate( dist ); + gl_FragColor = vec4( dist, 0.0, 0.0, 1.0 ); +}`,zg=`varying vec3 vWorldDirection; +#include +void main() { + vWorldDirection = transformDirection( position, modelMatrix ); + #include + #include +}`,kg=`uniform sampler2D tEquirect; +varying vec3 vWorldDirection; +#include +void main() { + vec3 direction = normalize( vWorldDirection ); + vec2 sampleUV = equirectUv( direction ); + gl_FragColor = texture2D( tEquirect, sampleUV ); + #include + #include +}`,Vg=`uniform float scale; +attribute float lineDistance; +varying float vLineDistance; +#include +#include +#include +#include +#include +#include +#include +void main() { + vLineDistance = scale * lineDistance; + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include +}`,Hg=`uniform vec3 diffuse; +uniform float opacity; +uniform float dashSize; +uniform float totalSize; +varying float vLineDistance; +#include +#include +#include +#include +#include +#include +#include +void main() { + vec4 diffuseColor = vec4( diffuse, opacity ); + #include + if ( mod( vLineDistance, totalSize ) > dashSize ) { + discard; + } + vec3 outgoingLight = vec3( 0.0 ); + #include + #include + #include + outgoingLight = diffuseColor.rgb; + #include + #include + #include + #include + #include +}`,Gg=`#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +void main() { + #include + #include + #include + #include + #include + #if defined ( USE_ENVMAP ) || defined ( USE_SKINNING ) + #include + #include + #include + #include + #include + #endif + #include + #include + #include + #include + #include + #include + #include + #include + #include +}`,Wg=`uniform vec3 diffuse; +uniform float opacity; +#ifndef FLAT_SHADED + varying vec3 vNormal; +#endif +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +void main() { + vec4 diffuseColor = vec4( diffuse, opacity ); + #include + #include + #include + #include + #include + #include + #include + #include + ReflectedLight reflectedLight = ReflectedLight( vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ) ); + #ifdef USE_LIGHTMAP + vec4 lightMapTexel = texture2D( lightMap, vLightMapUv ); + reflectedLight.indirectDiffuse += lightMapTexel.rgb * lightMapIntensity * RECIPROCAL_PI; + #else + reflectedLight.indirectDiffuse += vec3( 1.0 ); + #endif + #include + reflectedLight.indirectDiffuse *= diffuseColor.rgb; + vec3 outgoingLight = reflectedLight.indirectDiffuse; + #include + #include + #include + #include + #include + #include + #include +}`,Xg=`#define LAMBERT +varying vec3 vViewPosition; +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +void main() { + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + vViewPosition = - mvPosition.xyz; + #include + #include + #include + #include +}`,qg=`#define LAMBERT +uniform vec3 diffuse; +uniform vec3 emissive; +uniform float opacity; +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +void main() { + vec4 diffuseColor = vec4( diffuse, opacity ); + #include + ReflectedLight reflectedLight = ReflectedLight( vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ) ); + vec3 totalEmissiveRadiance = emissive; + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + vec3 outgoingLight = reflectedLight.directDiffuse + reflectedLight.indirectDiffuse + totalEmissiveRadiance; + #include + #include + #include + #include + #include + #include + #include +}`,Yg=`#define MATCAP +varying vec3 vViewPosition; +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +void main() { + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + vViewPosition = - mvPosition.xyz; +}`,$g=`#define MATCAP +uniform vec3 diffuse; +uniform float opacity; +uniform sampler2D matcap; +varying vec3 vViewPosition; +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +void main() { + vec4 diffuseColor = vec4( diffuse, opacity ); + #include + #include + #include + #include + #include + #include + #include + #include + #include + vec3 viewDir = normalize( vViewPosition ); + vec3 x = normalize( vec3( viewDir.z, 0.0, - viewDir.x ) ); + vec3 y = cross( viewDir, x ); + vec2 uv = vec2( dot( x, normal ), dot( y, normal ) ) * 0.495 + 0.5; + #ifdef USE_MATCAP + vec4 matcapColor = texture2D( matcap, uv ); + #else + vec4 matcapColor = vec4( vec3( mix( 0.2, 0.8, uv.y ) ), 1.0 ); + #endif + vec3 outgoingLight = diffuseColor.rgb * matcapColor.rgb; + #include + #include + #include + #include + #include + #include +}`,Zg=`#define NORMAL +#if defined( FLAT_SHADED ) || defined( USE_BUMPMAP ) || defined( USE_NORMALMAP_TANGENTSPACE ) + varying vec3 vViewPosition; +#endif +#include +#include +#include +#include +#include +#include +#include +#include +#include +void main() { + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include +#if defined( FLAT_SHADED ) || defined( USE_BUMPMAP ) || defined( USE_NORMALMAP_TANGENTSPACE ) + vViewPosition = - mvPosition.xyz; +#endif +}`,Jg=`#define NORMAL +uniform float opacity; +#if defined( FLAT_SHADED ) || defined( USE_BUMPMAP ) || defined( USE_NORMALMAP_TANGENTSPACE ) + varying vec3 vViewPosition; +#endif +#include +#include +#include +#include +#include +#include +void main() { + vec4 diffuseColor = vec4( 0.0, 0.0, 0.0, opacity ); + #include + #include + #include + #include + gl_FragColor = vec4( normalize( normal ) * 0.5 + 0.5, diffuseColor.a ); + #ifdef OPAQUE + gl_FragColor.a = 1.0; + #endif +}`,Kg=`#define PHONG +varying vec3 vViewPosition; +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +void main() { + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + vViewPosition = - mvPosition.xyz; + #include + #include + #include + #include +}`,jg=`#define PHONG +uniform vec3 diffuse; +uniform vec3 emissive; +uniform vec3 specular; +uniform float shininess; +uniform float opacity; +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +void main() { + vec4 diffuseColor = vec4( diffuse, opacity ); + #include + ReflectedLight reflectedLight = ReflectedLight( vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ) ); + vec3 totalEmissiveRadiance = emissive; + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + vec3 outgoingLight = reflectedLight.directDiffuse + reflectedLight.indirectDiffuse + reflectedLight.directSpecular + reflectedLight.indirectSpecular + totalEmissiveRadiance; + #include + #include + #include + #include + #include + #include + #include +}`,Qg=`#define STANDARD +varying vec3 vViewPosition; +#ifdef USE_TRANSMISSION + varying vec3 vWorldPosition; +#endif +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +void main() { + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + vViewPosition = - mvPosition.xyz; + #include + #include + #include +#ifdef USE_TRANSMISSION + vWorldPosition = worldPosition.xyz; +#endif +}`,t_=`#define STANDARD +#ifdef PHYSICAL + #define IOR + #define USE_SPECULAR +#endif +uniform vec3 diffuse; +uniform vec3 emissive; +uniform float roughness; +uniform float metalness; +uniform float opacity; +#ifdef IOR + uniform float ior; +#endif +#ifdef USE_SPECULAR + uniform float specularIntensity; + uniform vec3 specularColor; + #ifdef USE_SPECULAR_COLORMAP + uniform sampler2D specularColorMap; + #endif + #ifdef USE_SPECULAR_INTENSITYMAP + uniform sampler2D specularIntensityMap; + #endif +#endif +#ifdef USE_CLEARCOAT + uniform float clearcoat; + uniform float clearcoatRoughness; +#endif +#ifdef USE_DISPERSION + uniform float dispersion; +#endif +#ifdef USE_RETROREFLECTION + uniform float retroreflectivity; +#endif +#ifdef USE_IRIDESCENCE + uniform float iridescence; + uniform float iridescenceIOR; + uniform float iridescenceThicknessMinimum; + uniform float iridescenceThicknessMaximum; +#endif +#ifdef USE_SHEEN + uniform vec3 sheenColor; + uniform float sheenRoughness; + #ifdef USE_SHEEN_COLORMAP + uniform sampler2D sheenColorMap; + #endif + #ifdef USE_SHEEN_ROUGHNESSMAP + uniform sampler2D sheenRoughnessMap; + #endif +#endif +#ifdef USE_ANISOTROPY + uniform vec2 anisotropyVector; + #ifdef USE_ANISOTROPYMAP + uniform sampler2D anisotropyMap; + #endif +#endif +varying vec3 vViewPosition; +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +void main() { + vec4 diffuseColor = vec4( diffuse, opacity ); + #include + ReflectedLight reflectedLight = ReflectedLight( vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ) ); + vec3 totalEmissiveRadiance = emissive; + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + vec3 totalDiffuse = reflectedLight.directDiffuse + reflectedLight.indirectDiffuse; + vec3 totalSpecular = reflectedLight.directSpecular + reflectedLight.indirectSpecular; + #include + vec3 outgoingLight = totalDiffuse + totalSpecular + totalEmissiveRadiance; + #ifdef USE_SHEEN + + outgoingLight = outgoingLight + sheenSpecularDirect + sheenSpecularIndirect; + + #endif + #ifdef USE_CLEARCOAT + float dotNVcc = saturate( dot( geometryClearcoatNormal, geometryViewDir ) ); + vec3 Fcc = F_Schlick( material.clearcoatF0, material.clearcoatF90, dotNVcc ); + outgoingLight = outgoingLight * ( 1.0 - material.clearcoat * Fcc ) + ( clearcoatSpecularDirect + clearcoatSpecularIndirect ) * material.clearcoat; + #endif + #include + #include + #include + #include + #include + #include +}`,e_=`#define TOON +varying vec3 vViewPosition; +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +void main() { + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + vViewPosition = - mvPosition.xyz; + #include + #include + #include +}`,n_=`#define TOON +uniform vec3 diffuse; +uniform vec3 emissive; +uniform float opacity; +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +void main() { + vec4 diffuseColor = vec4( diffuse, opacity ); + #include + ReflectedLight reflectedLight = ReflectedLight( vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ) ); + vec3 totalEmissiveRadiance = emissive; + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + vec3 outgoingLight = reflectedLight.directDiffuse + reflectedLight.indirectDiffuse + totalEmissiveRadiance; + #include + #include + #include + #include + #include + #include +}`,i_=`uniform float size; +uniform float scale; +#include +#include +#include +#include +#include +#include +#ifdef USE_POINTS_UV + varying vec2 vUv; + uniform mat3 uvTransform; +#endif +void main() { + #ifdef USE_POINTS_UV + vUv = ( uvTransform * vec3( uv, 1 ) ).xy; + #endif + #include + #include + #include + #include + #include + #include + gl_PointSize = size; + #ifdef USE_SIZEATTENUATION + bool isPerspective = isPerspectiveMatrix( projectionMatrix ); + if ( isPerspective ) gl_PointSize *= ( scale / - mvPosition.z ); + #endif + #include + #include + #include + #include +}`,s_=`uniform vec3 diffuse; +uniform float opacity; +#include +#include +#include +#include +#include +#include +#include +#include +void main() { + vec4 diffuseColor = vec4( diffuse, opacity ); + #include + vec3 outgoingLight = vec3( 0.0 ); + #include + #include + #include + #include + #include + outgoingLight = diffuseColor.rgb; + #include + #include + #include + #include + #include +}`,r_=`#include +#include +#include +#include +#include +#include +#include +void main() { + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include +}`,o_=`uniform vec3 color; +uniform float opacity; +#include +#include +#include +#include +#include +#include +#include +void main() { + #include + gl_FragColor = vec4( color, opacity * ( 1.0 - getShadowMask() ) ); + #include + #include + #include + #include +}`,a_=`uniform float rotation; +uniform vec2 center; +#include +#include +#include +#include +#include +void main() { + #include + vec4 mvPosition = modelViewMatrix[ 3 ]; + vec2 scale = vec2( length( modelMatrix[ 0 ].xyz ), length( modelMatrix[ 1 ].xyz ) ); + #ifndef USE_SIZEATTENUATION + bool isPerspective = isPerspectiveMatrix( projectionMatrix ); + if ( isPerspective ) scale *= - mvPosition.z; + #endif + vec2 alignedPosition = ( position.xy - ( center - vec2( 0.5 ) ) ) * scale; + vec2 rotatedPosition; + rotatedPosition.x = cos( rotation ) * alignedPosition.x - sin( rotation ) * alignedPosition.y; + rotatedPosition.y = sin( rotation ) * alignedPosition.x + cos( rotation ) * alignedPosition.y; + mvPosition.xy += rotatedPosition; + gl_Position = projectionMatrix * mvPosition; + #include + #include + #include +}`,l_=`uniform vec3 diffuse; +uniform float opacity; +#include +#include +#include +#include +#include +#include +#include +#include +#include +void main() { + vec4 diffuseColor = vec4( diffuse, opacity ); + #include + vec3 outgoingLight = vec3( 0.0 ); + #include + #include + #include + #include + #include + outgoingLight = diffuseColor.rgb; + #include + #include + #include + #include +}`,kt={alphahash_fragment:Cp,alphahash_pars_fragment:Ip,alphamap_fragment:Pp,alphamap_pars_fragment:Lp,alphatest_fragment:Dp,alphatest_pars_fragment:Np,aomap_fragment:Up,aomap_pars_fragment:Fp,batching_pars_vertex:Bp,batching_vertex:Op,begin_vertex:zp,beginnormal_vertex:kp,bsdfs:Vp,iridescence_fragment:Hp,bumpmap_pars_fragment:Gp,clipping_planes_fragment:Wp,clipping_planes_pars_fragment:Xp,clipping_planes_pars_vertex:qp,clipping_planes_vertex:Yp,color_fragment:$p,color_pars_fragment:Zp,color_pars_vertex:Jp,color_vertex:Kp,common:jp,cube_uv_reflection_fragment:Qp,defaultnormal_vertex:tm,displacementmap_pars_vertex:em,displacementmap_vertex:nm,emissivemap_fragment:im,emissivemap_pars_fragment:sm,colorspace_fragment:rm,colorspace_pars_fragment:om,envmap_fragment:am,envmap_common_pars_fragment:lm,envmap_pars_fragment:cm,envmap_pars_vertex:hm,envmap_physical_pars_fragment:Mm,envmap_vertex:um,fog_vertex:dm,fog_pars_vertex:fm,fog_fragment:pm,fog_pars_fragment:mm,gradientmap_pars_fragment:gm,lightmap_pars_fragment:_m,lights_lambert_fragment:xm,lights_lambert_pars_fragment:vm,lights_pars_begin:ym,lights_toon_fragment:Sm,lights_toon_pars_fragment:bm,lights_phong_fragment:wm,lights_phong_pars_fragment:Em,lights_physical_fragment:Tm,lights_physical_pars_fragment:Am,lights_fragment_begin:Rm,lights_fragment_maps:Cm,lights_fragment_end:Im,lightprobes_pars_fragment:Pm,logdepthbuf_fragment:Lm,logdepthbuf_pars_fragment:Dm,logdepthbuf_pars_vertex:Nm,logdepthbuf_vertex:Um,map_fragment:Fm,map_pars_fragment:Bm,map_particle_fragment:Om,map_particle_pars_fragment:zm,metalnessmap_fragment:km,metalnessmap_pars_fragment:Vm,morphinstance_vertex:Hm,morphcolor_vertex:Gm,morphnormal_vertex:Wm,morphtarget_pars_vertex:Xm,morphtarget_vertex:qm,normal_fragment_begin:Ym,normal_fragment_maps:$m,normal_pars_fragment:Zm,normal_pars_vertex:Jm,normal_vertex:Km,normalmap_pars_fragment:jm,clearcoat_normal_fragment_begin:Qm,clearcoat_normal_fragment_maps:tg,clearcoat_pars_fragment:eg,iridescence_pars_fragment:ng,opaque_fragment:ig,packing:sg,premultiplied_alpha_fragment:rg,project_vertex:og,dithering_fragment:ag,dithering_pars_fragment:lg,roughnessmap_fragment:cg,roughnessmap_pars_fragment:hg,shadowmap_pars_fragment:ug,shadowmap_pars_vertex:dg,shadowmap_vertex:fg,shadowmask_pars_fragment:pg,skinbase_vertex:mg,skinning_pars_vertex:gg,skinning_vertex:_g,skinnormal_vertex:xg,specularmap_fragment:vg,specularmap_pars_fragment:yg,tonemapping_fragment:Mg,tonemapping_pars_fragment:Sg,transmission_fragment:bg,transmission_pars_fragment:wg,uv_pars_fragment:Eg,uv_pars_vertex:Tg,uv_vertex:Ag,worldpos_vertex:Rg,background_vert:Cg,background_frag:Ig,backgroundCube_vert:Pg,backgroundCube_frag:Lg,cube_vert:Dg,cube_frag:Ng,depth_vert:Ug,depth_frag:Fg,distance_vert:Bg,distance_frag:Og,equirect_vert:zg,equirect_frag:kg,linedashed_vert:Vg,linedashed_frag:Hg,meshbasic_vert:Gg,meshbasic_frag:Wg,meshlambert_vert:Xg,meshlambert_frag:qg,meshmatcap_vert:Yg,meshmatcap_frag:$g,meshnormal_vert:Zg,meshnormal_frag:Jg,meshphong_vert:Kg,meshphong_frag:jg,meshphysical_vert:Qg,meshphysical_frag:t_,meshtoon_vert:e_,meshtoon_frag:n_,points_vert:i_,points_frag:s_,shadow_vert:r_,shadow_frag:o_,sprite_vert:a_,sprite_frag:l_},pt={common:{diffuse:{value:new Dt(16777215)},opacity:{value:1},map:{value:null},mapTransform:{value:new Ut},alphaMap:{value:null},alphaMapTransform:{value:new Ut},alphaTest:{value:0}},specularmap:{specularMap:{value:null},specularMapTransform:{value:new Ut}},envmap:{envMap:{value:null},envMapRotation:{value:new Ut},reflectivity:{value:1},ior:{value:1.5},refractionRatio:{value:.98},dfgLUT:{value:null}},aomap:{aoMap:{value:null},aoMapIntensity:{value:1},aoMapTransform:{value:new Ut}},lightmap:{lightMap:{value:null},lightMapIntensity:{value:1},lightMapTransform:{value:new Ut}},bumpmap:{bumpMap:{value:null},bumpMapTransform:{value:new Ut},bumpScale:{value:1}},normalmap:{normalMap:{value:null},normalMapTransform:{value:new Ut},normalScale:{value:new zt(1,1)}},displacementmap:{displacementMap:{value:null},displacementMapTransform:{value:new Ut},displacementScale:{value:1},displacementBias:{value:0}},emissivemap:{emissiveMap:{value:null},emissiveMapTransform:{value:new Ut}},metalnessmap:{metalnessMap:{value:null},metalnessMapTransform:{value:new Ut}},roughnessmap:{roughnessMap:{value:null},roughnessMapTransform:{value:new Ut}},gradientmap:{gradientMap:{value:null}},fog:{fogDensity:{value:25e-5},fogNear:{value:1},fogFar:{value:2e3},fogColor:{value:new Dt(16777215)}},lights:{ambientLightColor:{value:[]},lightProbe:{value:[]},sunLights:{value:[],properties:{direction:{},color:{}}},sunLightShadows:{value:[],properties:{shadowIntensity:1,shadowBias:{},shadowNormalBias:{},shadowRadius:{},shadowMapSize:{}}},sunShadowMatrix:{value:[]},sunShadowCascade:{value:[]},directionalLights:{value:[],properties:{direction:{},color:{}}},directionalLightShadows:{value:[],properties:{shadowIntensity:1,shadowBias:{},shadowNormalBias:{},shadowRadius:{},shadowMapSize:{}}},directionalShadowMatrix:{value:[]},spotLights:{value:[],properties:{color:{},position:{},direction:{},distance:{},coneCos:{},penumbraCos:{},decay:{}}},spotLightShadows:{value:[],properties:{shadowIntensity:1,shadowBias:{},shadowNormalBias:{},shadowRadius:{},shadowMapSize:{}}},spotLightMap:{value:[]},spotLightMatrix:{value:[]},pointLights:{value:[],properties:{color:{},position:{},decay:{},distance:{}}},pointLightShadows:{value:[],properties:{shadowIntensity:1,shadowBias:{},shadowNormalBias:{},shadowRadius:{},shadowMapSize:{},shadowCameraNear:{},shadowCameraFar:{}}},pointShadowMatrix:{value:[]},hemisphereLights:{value:[],properties:{direction:{},skyColor:{},groundColor:{}}},rectAreaLights:{value:[],properties:{color:{},position:{},width:{},height:{}}},ltc_1:{value:null},ltc_2:{value:null},probesSH:{value:null},probesMin:{value:new C},probesMax:{value:new C},probesResolution:{value:new C}},points:{diffuse:{value:new Dt(16777215)},opacity:{value:1},size:{value:1},scale:{value:1},map:{value:null},alphaMap:{value:null},alphaMapTransform:{value:new Ut},alphaTest:{value:0},uvTransform:{value:new Ut}},sprite:{diffuse:{value:new Dt(16777215)},opacity:{value:1},center:{value:new zt(.5,.5)},rotation:{value:0},map:{value:null},mapTransform:{value:new Ut},alphaMap:{value:null},alphaMapTransform:{value:new Ut},alphaTest:{value:0}}},Dn={basic:{uniforms:Xe([pt.common,pt.specularmap,pt.envmap,pt.aomap,pt.lightmap,pt.fog]),vertexShader:kt.meshbasic_vert,fragmentShader:kt.meshbasic_frag},lambert:{uniforms:Xe([pt.common,pt.specularmap,pt.envmap,pt.aomap,pt.lightmap,pt.emissivemap,pt.bumpmap,pt.normalmap,pt.displacementmap,pt.fog,pt.lights,{emissive:{value:new Dt(0)},envMapIntensity:{value:1}}]),vertexShader:kt.meshlambert_vert,fragmentShader:kt.meshlambert_frag},phong:{uniforms:Xe([pt.common,pt.specularmap,pt.envmap,pt.aomap,pt.lightmap,pt.emissivemap,pt.bumpmap,pt.normalmap,pt.displacementmap,pt.fog,pt.lights,{emissive:{value:new Dt(0)},specular:{value:new Dt(1118481)},shininess:{value:30},envMapIntensity:{value:1}}]),vertexShader:kt.meshphong_vert,fragmentShader:kt.meshphong_frag},standard:{uniforms:Xe([pt.common,pt.envmap,pt.aomap,pt.lightmap,pt.emissivemap,pt.bumpmap,pt.normalmap,pt.displacementmap,pt.roughnessmap,pt.metalnessmap,pt.fog,pt.lights,{emissive:{value:new Dt(0)},roughness:{value:1},metalness:{value:0},envMapIntensity:{value:1}}]),vertexShader:kt.meshphysical_vert,fragmentShader:kt.meshphysical_frag},toon:{uniforms:Xe([pt.common,pt.aomap,pt.lightmap,pt.emissivemap,pt.bumpmap,pt.normalmap,pt.displacementmap,pt.gradientmap,pt.fog,pt.lights,{emissive:{value:new Dt(0)}}]),vertexShader:kt.meshtoon_vert,fragmentShader:kt.meshtoon_frag},matcap:{uniforms:Xe([pt.common,pt.bumpmap,pt.normalmap,pt.displacementmap,pt.fog,{matcap:{value:null}}]),vertexShader:kt.meshmatcap_vert,fragmentShader:kt.meshmatcap_frag},points:{uniforms:Xe([pt.points,pt.fog]),vertexShader:kt.points_vert,fragmentShader:kt.points_frag},dashed:{uniforms:Xe([pt.common,pt.fog,{scale:{value:1},dashSize:{value:1},totalSize:{value:2}}]),vertexShader:kt.linedashed_vert,fragmentShader:kt.linedashed_frag},depth:{uniforms:Xe([pt.common,pt.displacementmap]),vertexShader:kt.depth_vert,fragmentShader:kt.depth_frag},normal:{uniforms:Xe([pt.common,pt.bumpmap,pt.normalmap,pt.displacementmap,{opacity:{value:1}}]),vertexShader:kt.meshnormal_vert,fragmentShader:kt.meshnormal_frag},sprite:{uniforms:Xe([pt.sprite,pt.fog]),vertexShader:kt.sprite_vert,fragmentShader:kt.sprite_frag},background:{uniforms:{uvTransform:{value:new Ut},t2D:{value:null},backgroundIntensity:{value:1}},vertexShader:kt.background_vert,fragmentShader:kt.background_frag},backgroundCube:{uniforms:{envMap:{value:null},backgroundBlurriness:{value:0},backgroundIntensity:{value:1},backgroundRotation:{value:new Ut}},vertexShader:kt.backgroundCube_vert,fragmentShader:kt.backgroundCube_frag},cube:{uniforms:{tCube:{value:null},tFlip:{value:-1},opacity:{value:1}},vertexShader:kt.cube_vert,fragmentShader:kt.cube_frag},equirect:{uniforms:{tEquirect:{value:null}},vertexShader:kt.equirect_vert,fragmentShader:kt.equirect_frag},distance:{uniforms:Xe([pt.common,pt.displacementmap,{referencePosition:{value:new C},nearDistance:{value:1},farDistance:{value:1e3}}]),vertexShader:kt.distance_vert,fragmentShader:kt.distance_frag},shadow:{uniforms:Xe([pt.lights,pt.fog,{color:{value:new Dt(0)},opacity:{value:1}}]),vertexShader:kt.shadow_vert,fragmentShader:kt.shadow_frag}};Dn.physical={uniforms:Xe([Dn.standard.uniforms,{clearcoat:{value:0},clearcoatMap:{value:null},clearcoatMapTransform:{value:new Ut},clearcoatNormalMap:{value:null},clearcoatNormalMapTransform:{value:new Ut},clearcoatNormalScale:{value:new zt(1,1)},clearcoatRoughness:{value:0},clearcoatRoughnessMap:{value:null},clearcoatRoughnessMapTransform:{value:new Ut},dispersion:{value:0},retroreflectivity:{value:0},iridescence:{value:0},iridescenceMap:{value:null},iridescenceMapTransform:{value:new Ut},iridescenceIOR:{value:1.3},iridescenceThicknessMinimum:{value:100},iridescenceThicknessMaximum:{value:400},iridescenceThicknessMap:{value:null},iridescenceThicknessMapTransform:{value:new Ut},sheen:{value:0},sheenColor:{value:new Dt(0)},sheenColorMap:{value:null},sheenColorMapTransform:{value:new Ut},sheenRoughness:{value:1},sheenRoughnessMap:{value:null},sheenRoughnessMapTransform:{value:new Ut},transmission:{value:0},transmissionMap:{value:null},transmissionMapTransform:{value:new Ut},transmissionSamplerSize:{value:new zt},transmissionSamplerMap:{value:null},thickness:{value:0},thicknessMap:{value:null},thicknessMapTransform:{value:new Ut},attenuationDistance:{value:0},attenuationColor:{value:new Dt(0)},specularColor:{value:new Dt(1,1,1)},specularColorMap:{value:null},specularColorMapTransform:{value:new Ut},specularIntensity:{value:1},specularIntensityMap:{value:null},specularIntensityMapTransform:{value:new Ut},anisotropyVector:{value:new zt},anisotropyMap:{value:null},anisotropyMapTransform:{value:new Ut}}]),vertexShader:kt.meshphysical_vert,fragmentShader:kt.meshphysical_frag};var Sa={r:0,b:0,g:0},c_=new Ht,Gu=new Ut;Gu.set(-1,0,0,0,1,0,0,0,1);function h_(i,t,e,n,s,r){let o=new Dt(0),a=s===!0?0:1,l,c,h=null,d=0,u=null;function p(w){let R=w.isScene===!0?w.background:null;if(R&&R.isTexture){let S=w.backgroundBlurriness>0;R=t.get(R,S)}return R}function g(w){let R=!1,S=p(w);S===null?m(o,a):S&&S.isColor&&(m(S,1),R=!0);let b=i.xr.getEnvironmentBlendMode();b==="additive"?e.buffers.color.setClear(0,0,0,1,r):b==="alpha-blend"&&e.buffers.color.setClear(0,0,0,0,r),(i.autoClear||R)&&(e.buffers.depth.setTest(!0),e.buffers.depth.setMask(!0),e.buffers.color.setMask(!0),i.clear(i.autoClearColor,i.autoClearDepth,i.autoClearStencil))}function _(w,R){let S=p(R);S&&(S.isCubeTexture||S.mapping===Qs)?(c===void 0&&(c=new _e(new hn(1,1,1),new Ze({name:"BackgroundCubeMaterial",uniforms:Ii(Dn.backgroundCube.uniforms),vertexShader:Dn.backgroundCube.vertexShader,fragmentShader:Dn.backgroundCube.fragmentShader,side:Je,depthTest:!1,depthWrite:!1,fog:!1,allowOverride:!1})),c.geometry.deleteAttribute("normal"),c.geometry.deleteAttribute("uv"),c.onBeforeRender=function(b,y,T){this.matrixWorld.copyPosition(T.matrixWorld)},Object.defineProperty(c.material,"envMap",{get:function(){return this.uniforms.envMap.value}}),n.update(c)),c.material.uniforms.envMap.value=S,c.material.uniforms.backgroundBlurriness.value=R.backgroundBlurriness,c.material.uniforms.backgroundIntensity.value=R.backgroundIntensity,c.material.uniforms.backgroundRotation.value.setFromMatrix4(c_.makeRotationFromEuler(R.backgroundRotation)).transpose(),S.isCubeTexture&&S.isRenderTargetTexture===!1&&c.material.uniforms.backgroundRotation.value.premultiply(Gu),c.material.toneMapped=qt.getTransfer(S.colorSpace)!==te,(h!==S||d!==S.version||u!==i.toneMapping)&&(c.material.needsUpdate=!0,h=S,d=S.version,u=i.toneMapping),c.layers.enableAll(),w.unshift(c,c.geometry,c.material,0,0,null)):S&&S.isTexture&&(l===void 0&&(l=new _e(new Gn(2,2),new Ze({name:"BackgroundMaterial",uniforms:Ii(Dn.background.uniforms),vertexShader:Dn.background.vertexShader,fragmentShader:Dn.background.fragmentShader,side:ai,depthTest:!1,depthWrite:!1,fog:!1,allowOverride:!1})),l.geometry.deleteAttribute("normal"),Object.defineProperty(l.material,"map",{get:function(){return this.uniforms.t2D.value}}),n.update(l)),l.material.uniforms.t2D.value=S,l.material.uniforms.backgroundIntensity.value=R.backgroundIntensity,l.material.toneMapped=qt.getTransfer(S.colorSpace)!==te,S.matrixAutoUpdate===!0&&S.updateMatrix(),l.material.uniforms.uvTransform.value.copy(S.matrix),(h!==S||d!==S.version||u!==i.toneMapping)&&(l.material.needsUpdate=!0,h=S,d=S.version,u=i.toneMapping),l.layers.enableAll(),w.unshift(l,l.geometry,l.material,0,0,null))}function m(w,R){w.getRGB(Sa,tc(i)),e.buffers.color.setClear(Sa.r,Sa.g,Sa.b,R,r)}function f(){c!==void 0&&(c.geometry.dispose(),c.material.dispose(),c=void 0),l!==void 0&&(l.geometry.dispose(),l.material.dispose(),l=void 0)}return{getClearColor:function(){return o},setClearColor:function(w,R=1){o.set(w),a=R,m(o,a)},getClearAlpha:function(){return a},setClearAlpha:function(w){a=w,m(o,a)},render:g,addToRenderList:_,dispose:f}}function u_(i,t){let e=i.getParameter(i.MAX_VERTEX_ATTRIBS),n={},s=u(null),r=s,o=!1;function a(D,z,O,I,k){let X=!1,Y=d(D,I,O,z);r!==Y&&(r=Y,c(r.object)),X=p(D,I,O,k),X&&g(D,I,O,k),k!==null&&t.update(k,i.ELEMENT_ARRAY_BUFFER),(X||o)&&(o=!1,S(D,z,O,I),k!==null&&i.bindBuffer(i.ELEMENT_ARRAY_BUFFER,t.get(k).buffer))}function l(){return i.createVertexArray()}function c(D){return i.bindVertexArray(D)}function h(D){return i.deleteVertexArray(D)}function d(D,z,O,I){let k=I.wireframe===!0,X=n[z.id];X===void 0&&(X={},n[z.id]=X);let Y=D.isInstancedMesh===!0?D.id:0,B=X[Y];B===void 0&&(B={},X[Y]=B);let U=B[O.id];U===void 0&&(U={},B[O.id]=U);let H=U[k];return H===void 0&&(H=u(l()),U[k]=H),H}function u(D){let z=[],O=[],I=[];for(let k=0;k=0){let Z=k[U],rt=X[U];if(rt===void 0&&(U==="instanceMatrix"&&D.instanceMatrix&&(rt=D.instanceMatrix),U==="instanceColor"&&D.instanceColor&&(rt=D.instanceColor)),Z===void 0||Z.attribute!==rt||rt&&Z.data!==rt.data)return!0;Y++}return r.attributesNum!==Y||r.index!==I}function g(D,z,O,I){let k={},X=z.attributes,Y=0,B=O.getAttributes();for(let U in B)if(B[U].location>=0){let Z=X[U];Z===void 0&&(U==="instanceMatrix"&&D.instanceMatrix&&(Z=D.instanceMatrix),U==="instanceColor"&&D.instanceColor&&(Z=D.instanceColor));let rt={};rt.attribute=Z,Z&&Z.data&&(rt.data=Z.data),k[U]=rt,Y++}r.attributes=k,r.attributesNum=Y,r.index=I}function _(){let D=r.newAttributes;for(let z=0,O=D.length;z=0){let H=k[B];if(H===void 0&&(B==="instanceMatrix"&&D.instanceMatrix&&(H=D.instanceMatrix),B==="instanceColor"&&D.instanceColor&&(H=D.instanceColor)),H!==void 0){let Z=H.normalized,rt=H.itemSize,at=t.get(H);if(at===void 0)continue;let Ft=at.buffer,Nt=at.type,$t=at.bytesPerElement,J=Nt===i.INT||Nt===i.UNSIGNED_INT||H.gpuType===Fo;if(H.isInterleavedBufferAttribute){let et=H.data,xt=et.stride,Lt=H.offset;if(et.isInstancedInterleavedBuffer){for(let yt=0;yt0&&i.getShaderPrecisionFormat(i.FRAGMENT_SHADER,i.HIGH_FLOAT).precision>0)return"highp";T="mediump"}return T==="mediump"&&i.getShaderPrecisionFormat(i.VERTEX_SHADER,i.MEDIUM_FLOAT).precision>0&&i.getShaderPrecisionFormat(i.FRAGMENT_SHADER,i.MEDIUM_FLOAT).precision>0?"mediump":"lowp"}let c=e.precision!==void 0?e.precision:"highp",h=l(c);h!==c&&(Ct("WebGLRenderer:",c,"not supported, using",h,"instead."),c=h);let d=e.logarithmicDepthBuffer===!0,u=e.reversedDepthBuffer===!0&&t.has("EXT_clip_control");e.reversedDepthBuffer===!0&&u===!1&&Ct("WebGLRenderer: Unable to use reversed depth buffer due to missing EXT_clip_control extension. Fallback to default depth buffer.");let p=i.getParameter(i.MAX_TEXTURE_IMAGE_UNITS),g=i.getParameter(i.MAX_VERTEX_TEXTURE_IMAGE_UNITS),_=i.getParameter(i.MAX_TEXTURE_SIZE),m=i.getParameter(i.MAX_CUBE_MAP_TEXTURE_SIZE),f=i.getParameter(i.MAX_VERTEX_ATTRIBS),w=i.getParameter(i.MAX_VERTEX_UNIFORM_VECTORS),R=i.getParameter(i.MAX_VARYING_VECTORS),S=i.getParameter(i.MAX_FRAGMENT_UNIFORM_VECTORS),b=i.getParameter(i.MAX_SAMPLES),y=i.getParameter(i.SAMPLES);return{isWebGL2:!0,getMaxAnisotropy:r,getMaxPrecision:l,textureFormatReadable:o,textureTypeReadable:a,precision:c,logarithmicDepthBuffer:d,reversedDepthBuffer:u,maxTextures:p,maxVertexTextures:g,maxTextureSize:_,maxCubemapSize:m,maxAttributes:f,maxVertexUniforms:w,maxVaryings:R,maxFragmentUniforms:S,maxSamples:b,samples:y}}function p_(i){let t=this,e=null,n=0,s=!1,r=!1,o=new ge,a=new Ut,l={value:null,needsUpdate:!1};this.uniform=l,this.numPlanes=0,this.numIntersection=0,this.init=function(d,u){let p=d.length!==0||u||n!==0||s;return s=u,n=d.length,p},this.beginShadows=function(){r=!0,h(null)},this.endShadows=function(){r=!1},this.setGlobalState=function(d,u){e=h(d,u,0)},this.setState=function(d,u,p){let g=d.clippingPlanes,_=d.clipIntersection,m=d.clipShadows,f=i.get(d);if(!s||g===null||g.length===0||r&&!m)r?h(null):c();else{let w=r?0:n,R=w*4,S=f.clippingState||null;l.value=S,S=h(g,u,R,p);for(let b=0;b!==R;++b)S[b]=e[b];f.clippingState=S,this.numIntersection=_?this.numPlanes:0,this.numPlanes+=w}};function c(){l.value!==e&&(l.value=e,l.needsUpdate=n>0),t.numPlanes=n,t.numIntersection=0}function h(d,u,p,g){let _=d!==null?d.length:0,m=null;if(_!==0){if(m=l.value,g!==!0||m===null){let f=p+_*4,w=u.matrixWorldInverse;a.getNormalMatrix(w),(m===null||m.length0&&this._blur(l,0,0,e),this._applyPMREM(l),this._cleanup(l),l}fromEquirectangular(t,e=null){return this._fromTexture(t,e)}fromCubemap(t,e=null){return this._fromTexture(t,e)}compileCubemapShader(){this._cubemapMaterial===null&&(this._cubemapMaterial=wu(),this._compileMaterial(this._cubemapMaterial))}compileEquirectangularShader(){this._equirectMaterial===null&&(this._equirectMaterial=bu(),this._compileMaterial(this._equirectMaterial))}dispose(){this._dispose(),this._cubemapMaterial!==null&&this._cubemapMaterial.dispose(),this._equirectMaterial!==null&&this._equirectMaterial.dispose(),this._backgroundBox!==null&&(this._backgroundBox.geometry.dispose(),this._backgroundBox.material.dispose())}_setSize(t){this._lodMax=Math.floor(Math.log2(t)),this._cubeSize=Math.pow(2,this._lodMax)}_dispose(){this._blurMaterial!==null&&this._blurMaterial.dispose(),this._ggxMaterial!==null&&this._ggxMaterial.dispose(),this._pingPongRenderTarget!==null&&this._pingPongRenderTarget.dispose();for(let t=0;t2?b:0,b,b),d.setRenderTarget(s),f&&d.render(_,l),d.render(t,l)}d.toneMapping=p,d.autoClear=u,t.background=w}_textureToCubeUV(t,e){let n=this._renderer,s=t.mapping===li||t.mapping===Ci;s?(this._cubemapMaterial===null&&(this._cubemapMaterial=wu()),this._cubemapMaterial.uniforms.flipEnvMap.value=t.isRenderTargetTexture===!1?-1:1):this._equirectMaterial===null&&(this._equirectMaterial=bu());let r=s?this._cubemapMaterial:this._equirectMaterial,o=this._lodMeshes[0];o.material=r;let a=r.uniforms;a.envMap.value=t;let l=this._cubeSize;vs(e,0,0,3*l,2*l),n.setRenderTarget(e),n.render(o,ar)}_applyPMREM(t){let e=this._renderer,n=e.autoClear;e.autoClear=!1;let s=this._lodMeshes.length;for(let r=1;rg-ys?n-g+ys:0),f=4*(this._cubeSize-_);l.envMap.value=t.texture,l.roughness.value=p,l.mipInt.value=g-e,vs(r,m,f,3*_,2*_),s.setRenderTarget(r),s.render(a,ar),l.envMap.value=r.texture,l.roughness.value=0,l.mipInt.value=g-n,vs(t,m,f,3*_,2*_),s.setRenderTarget(t),s.render(a,ar)}_blur(t,e,n,s){let r=this._pingPongRenderTarget,o=Math.min(s,Math.PI)/Math.SQRT2;this._blurPass(t,r,e,n,o),this._blurPass(r,t,n,n,o)}_blurPass(t,e,n,s,r){let o=this._renderer,a=this._blurMaterial,l=this._lodMeshes[s];l.material=a;let c=a.uniforms;c.envMap.value=t.texture,c.sigma.value=r,c.mipInt.value=this._lodMax-n;let h=this._sizeLods[s],d=3*h*(s>this._lodMax-ys?s-this._lodMax+ys:0),u=4*(this._cubeSize-h);vs(e,d,u,3*h,2*h),o.setRenderTarget(e),o.render(l,ar)}};function v_(i){let t=[],e=[],n=i,s=i-ys+1+m_;for(let r=0;r2?0:-1,S=[w,R,0,w+2/3,R,0,w+2/3,R+1,0,w,R,0,w+2/3,R+1,0,w,R+1,0];g.set(S,p*u*f);for(let b=0;bys&&n--}return{lodMeshes:e,sizeLods:t}}function Su(i,t,e){let n=new Fe(i,t,e);return n.texture.mapping=Qs,n.texture.name="PMREM.cubeUv",n.scissorTest=!0,n}function vs(i,t,e,n,s){i.viewport.set(t,e,n,s),i.scissor.set(t,e,n,s)}function y_(i,t,e){return new Ze({name:"PMREMGGXConvolution",defines:{GGX_SAMPLES:__,CUBEUV_TEXEL_WIDTH:1/t,CUBEUV_TEXEL_HEIGHT:1/e,CUBEUV_MAX_MIP:`${i}.0`},uniforms:{envMap:{value:null},roughness:{value:0},mipInt:{value:0}},vertexShader:Ta(),fragmentShader:` + + precision highp float; + precision highp int; + + varying vec3 vOutputDirection; + + uniform sampler2D envMap; + uniform float roughness; + uniform float mipInt; + + #define ENVMAP_TYPE_CUBE_UV + #include + + #define PI 3.14159265359 + + // Van der Corput radical inverse + float radicalInverse_VdC(uint bits) { + bits = (bits << 16u) | (bits >> 16u); + bits = ((bits & 0x55555555u) << 1u) | ((bits & 0xAAAAAAAAu) >> 1u); + bits = ((bits & 0x33333333u) << 2u) | ((bits & 0xCCCCCCCCu) >> 2u); + bits = ((bits & 0x0F0F0F0Fu) << 4u) | ((bits & 0xF0F0F0F0u) >> 4u); + bits = ((bits & 0x00FF00FFu) << 8u) | ((bits & 0xFF00FF00u) >> 8u); + return float(bits) * 2.3283064365386963e-10; // / 0x100000000 + } + + // Hammersley sequence + vec2 hammersley(uint i, uint N) { + return vec2(float(i) / float(N), radicalInverse_VdC(i)); + } + + // GGX VNDF importance sampling (Eric Heitz 2018) + // "Sampling the GGX Distribution of Visible Normals" + // https://jcgt.org/published/0007/04/01/ + vec3 importanceSampleGGX_VNDF(vec2 Xi, vec3 V, float roughness) { + float alpha = roughness * roughness; + + // Section 4.1: Orthonormal basis + vec3 T1 = vec3(1.0, 0.0, 0.0); + vec3 T2 = cross(V, T1); + + // Section 4.2: Parameterization of projected area + float r = sqrt(Xi.x); + float phi = 2.0 * PI * Xi.y; + float t1 = r * cos(phi); + float t2 = r * sin(phi); + float s = 0.5 * (1.0 + V.z); + t2 = (1.0 - s) * sqrt(1.0 - t1 * t1) + s * t2; + + // Section 4.3: Reprojection onto hemisphere + vec3 Nh = t1 * T1 + t2 * T2 + sqrt(max(0.0, 1.0 - t1 * t1 - t2 * t2)) * V; + + // Section 3.4: Transform back to ellipsoid configuration + return normalize(vec3(alpha * Nh.x, alpha * Nh.y, max(0.0, Nh.z))); + } + + void main() { + vec3 N = normalize(vOutputDirection); + vec3 V = N; // Assume view direction equals normal for pre-filtering + + vec3 prefilteredColor = vec3(0.0); + float totalWeight = 0.0; + + // For very low roughness, just sample the environment directly + if (roughness < 0.001) { + gl_FragColor = vec4(bilinearCubeUV(envMap, N, mipInt), 1.0); + return; + } + + // Tangent space basis for VNDF sampling + vec3 up = abs(N.z) < 0.999 ? vec3(0.0, 0.0, 1.0) : vec3(1.0, 0.0, 0.0); + vec3 tangent = normalize(cross(up, N)); + vec3 bitangent = cross(N, tangent); + + for(uint i = 0u; i < uint(GGX_SAMPLES); i++) { + vec2 Xi = hammersley(i, uint(GGX_SAMPLES)); + + // For PMREM, V = N, so in tangent space V is always (0, 0, 1) + vec3 H_tangent = importanceSampleGGX_VNDF(Xi, vec3(0.0, 0.0, 1.0), roughness); + + // Transform H back to world space + vec3 H = normalize(tangent * H_tangent.x + bitangent * H_tangent.y + N * H_tangent.z); + vec3 L = normalize(2.0 * dot(V, H) * H - V); + + float NdotL = max(dot(N, L), 0.0); + + if(NdotL > 0.0) { + // Sample environment at fixed mip level + // VNDF importance sampling handles the distribution filtering + vec3 sampleColor = bilinearCubeUV(envMap, L, mipInt); + + // Weight by NdotL for the split-sum approximation + // VNDF PDF naturally accounts for the visible microfacet distribution + prefilteredColor += sampleColor * NdotL; + totalWeight += NdotL; + } + } + + if (totalWeight > 0.0) { + prefilteredColor = prefilteredColor / totalWeight; + } + + gl_FragColor = vec4(prefilteredColor, 1.0); + } + `,blending:Pn,depthTest:!1,depthWrite:!1})}function M_(i,t,e){return new Ze({name:"SphericalGaussianBlur",defines:{SAMPLES:g_,CUBEUV_TEXEL_WIDTH:1/t,CUBEUV_TEXEL_HEIGHT:1/e,CUBEUV_MAX_MIP:`${i}.0`},uniforms:{envMap:{value:null},sigma:{value:0},mipInt:{value:0}},vertexShader:Ta(),fragmentShader:` + + precision highp float; + precision highp int; + + varying vec3 vOutputDirection; + + uniform sampler2D envMap; + uniform float sigma; + uniform float mipInt; + + #define ENVMAP_TYPE_CUBE_UV + #include + + #define PI 3.14159265359 + #define GOLDEN_ANGLE 2.39996322973 + + void main() { + + if ( sigma == 0.0 ) { + + gl_FragColor = vec4( bilinearCubeUV( envMap, vOutputDirection, mipInt ), 1.0 ); + return; + + } + + vec3 outputDirection = normalize( vOutputDirection ); + + vec3 up = abs( outputDirection.z ) < 0.999 ? vec3( 0.0, 0.0, 1.0 ) : vec3( 1.0, 0.0, 0.0 ); + vec3 tangent = normalize( cross( up, outputDirection ) ); + vec3 bitangent = cross( outputDirection, tangent ); + + // Truncate the kernel at three standard deviations or at the antipode. + float thetaMax = min( 3.0 * sigma, PI ); + float truncation = 1.0 - exp( - 0.5 * thetaMax * thetaMax / ( sigma * sigma ) ); + + vec3 accumColor = vec3( 0.0 ); + float accumWeight = 0.0; + + for ( int i = 0; i < SAMPLES; i ++ ) { + + // Stratified inverse-CDF sampling of the Gaussian, placed on a golden-angle spiral. + float stratum = ( float( i ) + 0.5 ) / float( SAMPLES ); + float theta = sigma * sqrt( - 2.0 * log( 1.0 - stratum * truncation ) ); + float phi = float( i ) * GOLDEN_ANGLE; + + vec3 offset = cos( phi ) * tangent + sin( phi ) * bitangent; + vec3 sampleDirection = cos( theta ) * outputDirection + sin( theta ) * offset; + + // Correct the planar sample density to solid angle. + float weight = sin( theta ) / theta; + + accumColor += weight * bilinearCubeUV( envMap, sampleDirection, mipInt ); + accumWeight += weight; + + } + + gl_FragColor = vec4( accumColor / accumWeight, 1.0 ); + + } + `,blending:Pn,depthTest:!1,depthWrite:!1})}function bu(){return new Ze({name:"EquirectangularToCubeUV",uniforms:{envMap:{value:null}},vertexShader:Ta(),fragmentShader:` + + precision mediump float; + precision mediump int; + + varying vec3 vOutputDirection; + + uniform sampler2D envMap; + + #include + + void main() { + + vec3 outputDirection = normalize( vOutputDirection ); + vec2 uv = equirectUv( outputDirection ); + + gl_FragColor = vec4( texture2D ( envMap, uv ).rgb, 1.0 ); + + } + `,blending:Pn,depthTest:!1,depthWrite:!1})}function wu(){return new Ze({name:"CubemapToCubeUV",uniforms:{envMap:{value:null},flipEnvMap:{value:-1}},vertexShader:Ta(),fragmentShader:` + + precision mediump float; + precision mediump int; + + uniform float flipEnvMap; + + varying vec3 vOutputDirection; + + uniform samplerCube envMap; + + void main() { + + gl_FragColor = textureCube( envMap, vec3( flipEnvMap * vOutputDirection.x, vOutputDirection.yz ) ); + + } + `,blending:Pn,depthTest:!1,depthWrite:!1})}function Ta(){return` + + precision mediump float; + precision mediump int; + + attribute vec3 outputDirection; + + varying vec3 vOutputDirection; + + void main() { + + vOutputDirection = outputDirection; + gl_Position = vec4( position, 1.0 ); + + } + `}var Ea=class extends Fe{constructor(t=1,e={}){super(t,t,e),this.isWebGLCubeRenderTarget=!0;let n={width:t,height:t,depth:1},s=[n,n,n,n,n,n];this.texture=new Gs(s),this._setTextureOptions(e),this.texture.isRenderTargetTexture=!0}fromEquirectangularTexture(t,e){this.texture.type=e.type,this.texture.colorSpace=e.colorSpace,this.texture.generateMipmaps=e.generateMipmaps,this.texture.minFilter=e.minFilter,this.texture.magFilter=e.magFilter;let n={uniforms:{tEquirect:{value:null}},vertexShader:` + + varying vec3 vWorldDirection; + + vec3 transformDirection( in vec3 dir, in mat4 matrix ) { + + return normalize( ( matrix * vec4( dir, 0.0 ) ).xyz ); + + } + + void main() { + + vWorldDirection = transformDirection( position, modelMatrix ); + + #include + #include + + } + `,fragmentShader:` + + uniform sampler2D tEquirect; + + varying vec3 vWorldDirection; + + #include + + void main() { + + vec3 direction = normalize( vWorldDirection ); + + vec2 sampleUV = equirectUv( direction ); + + gl_FragColor = texture2D( tEquirect, sampleUV ); + + } + `},s=new hn(5,5,5),r=new Ze({name:"CubemapFromEquirect",uniforms:Ii(n.uniforms),vertexShader:n.vertexShader,fragmentShader:n.fragmentShader,side:Je,blending:Pn});r.uniforms.tEquirect.value=e;let o=new _e(s,r),a=e.minFilter;return e.minFilter===ci&&(e.minFilter=De),new Co(1,10,this).update(t,o),e.minFilter=a,o.geometry.dispose(),o.material.dispose(),this}clear(t,e=!0,n=!0,s=!0){let r=t.getRenderTarget();for(let o=0;o<6;o++)t.setRenderTarget(this,o),t.clear(e,n,s);t.setRenderTarget(r)}};function S_(i){let t=new WeakMap,e=new WeakMap,n=null;function s(u,p=!1){return u==null?null:p?o(u):r(u)}function r(u){if(u&&u.isTexture){let p=u.mapping;if(p===Do||p===No)if(t.has(u)){let g=t.get(u).texture;return a(g,u.mapping)}else{let g=u.image;if(g&&g.height>0){let _=new Ea(g.height);return _.fromEquirectangularTexture(i,u),t.set(u,_),u.addEventListener("dispose",c),a(_.texture,u.mapping)}else return null}}return u}function o(u){if(u&&u.isTexture){let p=u.mapping,g=p===Do||p===No,_=p===li||p===Ci;if(g||_){let m=e.get(u),f=m!==void 0?m.texture.pmremVersion:0;if(u.isRenderTargetTexture&&u.pmremVersion!==f)return n===null&&(n=new wa(i)),m=g?n.fromEquirectangular(u,m):n.fromCubemap(u,m),m.texture.pmremVersion=u.pmremVersion,e.set(u,m),m.texture;if(m!==void 0)return m.texture;{let w=u.image;return g&&w&&w.height>0||_&&w&&l(w)?(n===null&&(n=new wa(i)),m=g?n.fromEquirectangular(u):n.fromCubemap(u),m.texture.pmremVersion=u.pmremVersion,e.set(u,m),u.addEventListener("dispose",h),m.texture):null}}}return u}function a(u,p){return p===Do?u.mapping=li:p===No&&(u.mapping=Ci),u}function l(u){let p=0,g=6;for(let _=0;_=65535?Vs:ks)(u,1);m.version=_;let f=r.get(d);f&&t.remove(f),r.set(d,m)}function h(d){let u=r.get(d);if(u){let p=d.index;p!==null&&u.versiont.maxTextureSize&&(b=Math.ceil(S/t.maxTextureSize),S=t.maxTextureSize);let y=new Float32Array(S*b*4*d),T=new zs(y,S,b,d);T.type=We,T.needsUpdate=!0;let v=R*4;for(let P=0;P + #include + + void main() { + gl_FragColor = texture2D( tDiffuse, vUv ); + + #ifdef LINEAR_TONE_MAPPING + gl_FragColor.rgb = LinearToneMapping( gl_FragColor.rgb ); + #elif defined( REINHARD_TONE_MAPPING ) + gl_FragColor.rgb = ReinhardToneMapping( gl_FragColor.rgb ); + #elif defined( CINEON_TONE_MAPPING ) + gl_FragColor.rgb = CineonToneMapping( gl_FragColor.rgb ); + #elif defined( ACES_FILMIC_TONE_MAPPING ) + gl_FragColor.rgb = ACESFilmicToneMapping( gl_FragColor.rgb ); + #elif defined( AGX_TONE_MAPPING ) + gl_FragColor.rgb = AgXToneMapping( gl_FragColor.rgb ); + #elif defined( NEUTRAL_TONE_MAPPING ) + gl_FragColor.rgb = NeutralToneMapping( gl_FragColor.rgb ); + #elif defined( CUSTOM_TONE_MAPPING ) + gl_FragColor.rgb = CustomToneMapping( gl_FragColor.rgb ); + #endif + + #ifdef SRGB_TRANSFER + gl_FragColor = sRGBTransferOETF( gl_FragColor ); + #endif + }`,depthTest:!1,depthWrite:!1}),d=new _e(c,h),u=new In(-1,1,1,-1,0,1),p=null,g=null,_=!1,m,f=null,w=[],R=!1;this.setSize=function(S,b){o.setSize(S,b),a!==null&&a.setSize(S,b),l!==null&&l.setSize(S,b);for(let y=0;y0&&w[0].isRenderPass===!0;let b=o.width,y=o.height;w.length>0&&a===null&&(a=new Fe(b,y,{type:vn,depthBuffer:!1,stencilBuffer:!1}),l=new Fe(b,y,{type:vn,depthBuffer:!1,stencilBuffer:!1}));for(let T=0;T0)return i;let s=t*e,r=Eu[s];if(r===void 0&&(r=new Float32Array(s),Eu[s]=r),t!==0){n.toArray(r,0);for(let o=1,a=0;o!==t;++o)a+=e,i[o].toArray(r,a)}return r}function Te(i,t){if(i.length!==t.length)return!1;for(let e=0,n=i.length;e0&&(this.seq=s.concat(r))}setValue(t,e,n,s){let r=this.map[e];r!==void 0&&r.setValue(t,n,s)}setOptional(t,e,n){let s=e[n];s!==void 0&&this.setValue(t,n,s)}static upload(t,e,n,s){for(let r=0,o=e.length;r!==o;++r){let a=e[r],l=n[a.id];l.needsUpdate!==!1&&a.setValue(t,l.value,s)}}static seqWithValue(t,e){let n=[];for(let s=0,r=t.length;s!==r;++s){let o=t[s];o.id in e&&n.push(o)}return n}};function Pu(i,t,e){let n=i.createShader(t);return i.shaderSource(n,e),i.compileShader(n),n}var x0=37297,v0=0;function y0(i,t){let e=i.split(` +`),n=[],s=Math.max(t-6,0),r=Math.min(t+6,e.length);for(let o=s;o":" "} ${a}: ${e[o]}`)}return n.join(` +`)}var Lu=new Ut;function M0(i){qt._getMatrix(Lu,qt.workingColorSpace,i);let t=`mat3( ${Lu.elements.map(e=>e.toFixed(4))} )`;switch(qt.getTransfer(i)){case Bs:return[t,"LinearTransferOETF"];case te:return[t,"sRGBTransferOETF"];default:return Ct("WebGLProgram: Unsupported color space: ",i),[t,"LinearTransferOETF"]}}function Du(i,t,e){let n=i.getShaderParameter(t,i.COMPILE_STATUS),r=(i.getShaderInfoLog(t)||"").trim();if(n&&r==="")return"";let o=/ERROR: 0:(\d+)/.exec(r);if(o){let a=parseInt(o[1]);return e.toUpperCase()+` + +`+r+` + +`+y0(i.getShaderSource(t),a)}else return r}function S0(i,t){let e=M0(t);return[`vec4 ${i}( vec4 value ) {`,` return ${e[1]}( vec4( value.rgb * ${e[0]}, value.a ) );`,"}"].join(` +`)}var b0={[Bl]:"Linear",[Ol]:"Reinhard",[zl]:"Cineon",[kl]:"ACESFilmic",[Hl]:"AgX",[Gl]:"Neutral",[Vl]:"Custom"};function w0(i,t){let e=b0[t];return e===void 0?(Ct("WebGLProgram: Unsupported toneMapping:",t),"vec3 "+i+"( vec3 color ) { return LinearToneMapping( color ); }"):"vec3 "+i+"( vec3 color ) { return "+e+"ToneMapping( color ); }"}var ba=new C;function E0(){qt.getLuminanceCoefficients(ba);let i=ba.x.toFixed(4),t=ba.y.toFixed(4),e=ba.z.toFixed(4);return["float luminance( const in vec3 rgb ) {",` const vec3 weights = vec3( ${i}, ${t}, ${e} );`," return dot( weights, rgb );","}"].join(` +`)}function T0(i){return[i.extensionClipCullDistance?"#extension GL_ANGLE_clip_cull_distance : require":"",i.extensionMultiDraw?"#extension GL_ANGLE_multi_draw : require":""].filter(cr).join(` +`)}function A0(i){let t=[];for(let e in i){let n=i[e];n!==!1&&t.push("#define "+e+" "+n)}return t.join(` +`)}function R0(i,t){let e={},n=i.getProgramParameter(t,i.ACTIVE_ATTRIBUTES);for(let s=0;s/gm;function vc(i){return i.replace(C0,P0)}var I0=new Map;function P0(i,t){let e=kt[t];if(e===void 0){let n=I0.get(t);if(n!==void 0)e=kt[n],Ct('WebGLRenderer: Shader chunk "%s" has been deprecated. Use "%s" instead.',t,n);else throw new Error("THREE.WebGLProgram: Can not resolve #include <"+t+">")}return vc(e)}var L0=/#pragma unroll_loop_start\s+for\s*\(\s*int\s+i\s*=\s*(\d+)\s*;\s*i\s*<\s*(\d+)\s*;\s*i\s*\+\+\s*\)\s*{([\s\S]+?)}\s+#pragma unroll_loop_end/g;function Fu(i){return i.replace(L0,D0)}function D0(i,t,e,n){let s="";for(let r=parseInt(t);r0&&(m+=` +`),f=["#define SHADER_TYPE "+e.shaderType,"#define SHADER_NAME "+e.shaderName,g].filter(cr).join(` +`),f.length>0&&(f+=` +`)):(m=[Bu(e),"#define SHADER_TYPE "+e.shaderType,"#define SHADER_NAME "+e.shaderName,g,e.extensionClipCullDistance?"#define USE_CLIP_DISTANCE":"",e.batching?"#define USE_BATCHING":"",e.batchingColor?"#define USE_BATCHING_COLOR":"",e.instancing?"#define USE_INSTANCING":"",e.instancingColor?"#define USE_INSTANCING_COLOR":"",e.instancingMorph?"#define USE_INSTANCING_MORPH":"",e.useFog&&e.fog?"#define USE_FOG":"",e.useFog&&e.fogExp2?"#define FOG_EXP2":"",e.map?"#define USE_MAP":"",e.envMap?"#define USE_ENVMAP":"",e.envMap?"#define "+h:"",e.lightMap?"#define USE_LIGHTMAP":"",e.aoMap?"#define USE_AOMAP":"",e.bumpMap?"#define USE_BUMPMAP":"",e.normalMap?"#define USE_NORMALMAP":"",e.normalMapObjectSpace?"#define USE_NORMALMAP_OBJECTSPACE":"",e.normalMapTangentSpace?"#define USE_NORMALMAP_TANGENTSPACE":"",e.displacementMap?"#define USE_DISPLACEMENTMAP":"",e.emissiveMap?"#define USE_EMISSIVEMAP":"",e.anisotropy?"#define USE_ANISOTROPY":"",e.anisotropyMap?"#define USE_ANISOTROPYMAP":"",e.clearcoatMap?"#define USE_CLEARCOATMAP":"",e.clearcoatRoughnessMap?"#define USE_CLEARCOAT_ROUGHNESSMAP":"",e.clearcoatNormalMap?"#define USE_CLEARCOAT_NORMALMAP":"",e.iridescenceMap?"#define USE_IRIDESCENCEMAP":"",e.iridescenceThicknessMap?"#define USE_IRIDESCENCE_THICKNESSMAP":"",e.specularMap?"#define USE_SPECULARMAP":"",e.specularColorMap?"#define USE_SPECULAR_COLORMAP":"",e.specularIntensityMap?"#define USE_SPECULAR_INTENSITYMAP":"",e.roughnessMap?"#define USE_ROUGHNESSMAP":"",e.metalnessMap?"#define USE_METALNESSMAP":"",e.alphaMap?"#define USE_ALPHAMAP":"",e.alphaHash?"#define USE_ALPHAHASH":"",e.transmission?"#define USE_TRANSMISSION":"",e.transmissionMap?"#define USE_TRANSMISSIONMAP":"",e.thicknessMap?"#define USE_THICKNESSMAP":"",e.sheenColorMap?"#define USE_SHEEN_COLORMAP":"",e.sheenRoughnessMap?"#define USE_SHEEN_ROUGHNESSMAP":"",e.mapUv?"#define MAP_UV "+e.mapUv:"",e.alphaMapUv?"#define ALPHAMAP_UV "+e.alphaMapUv:"",e.lightMapUv?"#define LIGHTMAP_UV "+e.lightMapUv:"",e.aoMapUv?"#define AOMAP_UV "+e.aoMapUv:"",e.emissiveMapUv?"#define EMISSIVEMAP_UV "+e.emissiveMapUv:"",e.bumpMapUv?"#define BUMPMAP_UV "+e.bumpMapUv:"",e.normalMapUv?"#define NORMALMAP_UV "+e.normalMapUv:"",e.displacementMapUv?"#define DISPLACEMENTMAP_UV "+e.displacementMapUv:"",e.metalnessMapUv?"#define METALNESSMAP_UV "+e.metalnessMapUv:"",e.roughnessMapUv?"#define ROUGHNESSMAP_UV "+e.roughnessMapUv:"",e.anisotropyMapUv?"#define ANISOTROPYMAP_UV "+e.anisotropyMapUv:"",e.clearcoatMapUv?"#define CLEARCOATMAP_UV "+e.clearcoatMapUv:"",e.clearcoatNormalMapUv?"#define CLEARCOAT_NORMALMAP_UV "+e.clearcoatNormalMapUv:"",e.clearcoatRoughnessMapUv?"#define CLEARCOAT_ROUGHNESSMAP_UV "+e.clearcoatRoughnessMapUv:"",e.iridescenceMapUv?"#define IRIDESCENCEMAP_UV "+e.iridescenceMapUv:"",e.iridescenceThicknessMapUv?"#define IRIDESCENCE_THICKNESSMAP_UV "+e.iridescenceThicknessMapUv:"",e.sheenColorMapUv?"#define SHEEN_COLORMAP_UV "+e.sheenColorMapUv:"",e.sheenRoughnessMapUv?"#define SHEEN_ROUGHNESSMAP_UV "+e.sheenRoughnessMapUv:"",e.specularMapUv?"#define SPECULARMAP_UV "+e.specularMapUv:"",e.specularColorMapUv?"#define SPECULAR_COLORMAP_UV "+e.specularColorMapUv:"",e.specularIntensityMapUv?"#define SPECULAR_INTENSITYMAP_UV "+e.specularIntensityMapUv:"",e.transmissionMapUv?"#define TRANSMISSIONMAP_UV "+e.transmissionMapUv:"",e.thicknessMapUv?"#define THICKNESSMAP_UV "+e.thicknessMapUv:"",e.vertexTangents&&e.flatShading===!1?"#define USE_TANGENT":"",e.vertexNormals?"#define HAS_NORMAL":"",e.vertexColors?"#define USE_COLOR":"",e.vertexAlphas?"#define USE_COLOR_ALPHA":"",e.vertexUv1s?"#define USE_UV1":"",e.vertexUv2s?"#define USE_UV2":"",e.vertexUv3s?"#define USE_UV3":"",e.pointsUvs?"#define USE_POINTS_UV":"",e.flatShading?"#define FLAT_SHADED":"",e.skinning?"#define USE_SKINNING":"",e.morphTargets?"#define USE_MORPHTARGETS":"",e.morphNormals&&e.flatShading===!1?"#define USE_MORPHNORMALS":"",e.morphColors?"#define USE_MORPHCOLORS":"",e.morphTargetsCount>0?"#define MORPHTARGETS_TEXTURE_STRIDE "+e.morphTextureStride:"",e.morphTargetsCount>0?"#define MORPHTARGETS_COUNT "+e.morphTargetsCount:"",e.doubleSided?"#define DOUBLE_SIDED":"",e.flipSided?"#define FLIP_SIDED":"",e.shadowMapEnabled?"#define USE_SHADOWMAP":"",e.shadowMapEnabled?"#define "+l:"",e.sizeAttenuation?"#define USE_SIZEATTENUATION":"",e.numLightProbes>0?"#define USE_LIGHT_PROBES":"",e.logarithmicDepthBuffer?"#define USE_LOGARITHMIC_DEPTH_BUFFER":"",e.reversedDepthBuffer?"#define USE_REVERSED_DEPTH_BUFFER":"","uniform mat4 modelMatrix;","uniform mat4 modelViewMatrix;","uniform mat4 projectionMatrix;","uniform mat4 viewMatrix;","uniform mat3 normalMatrix;","uniform vec3 cameraPosition;","uniform bool isOrthographic;","#ifdef USE_INSTANCING"," attribute mat4 instanceMatrix;","#endif","#ifdef USE_INSTANCING_COLOR"," attribute vec3 instanceColor;","#endif","#ifdef USE_INSTANCING_MORPH"," uniform sampler2D morphTexture;","#endif","attribute vec3 position;","attribute vec3 normal;","attribute vec2 uv;","#ifdef USE_UV1"," attribute vec2 uv1;","#endif","#ifdef USE_UV2"," attribute vec2 uv2;","#endif","#ifdef USE_UV3"," attribute vec2 uv3;","#endif","#ifdef USE_TANGENT"," attribute vec4 tangent;","#endif","#if defined( USE_COLOR_ALPHA )"," attribute vec4 color;","#elif defined( USE_COLOR )"," attribute vec3 color;","#endif","#ifdef USE_SKINNING"," attribute vec4 skinIndex;"," attribute vec4 skinWeight;","#endif",` +`].filter(cr).join(` +`),f=[Bu(e),"#define SHADER_TYPE "+e.shaderType,"#define SHADER_NAME "+e.shaderName,g,e.useFog&&e.fog?"#define USE_FOG":"",e.useFog&&e.fogExp2?"#define FOG_EXP2":"",e.alphaToCoverage?"#define ALPHA_TO_COVERAGE":"",e.map?"#define USE_MAP":"",e.matcap?"#define USE_MATCAP":"",e.envMap?"#define USE_ENVMAP":"",e.envMap?"#define "+c:"",e.envMap?"#define "+h:"",e.envMap?"#define "+d:"",u?"#define CUBEUV_TEXEL_WIDTH "+u.texelWidth:"",u?"#define CUBEUV_TEXEL_HEIGHT "+u.texelHeight:"",u?"#define CUBEUV_MAX_MIP "+u.maxMip+".0":"",e.lightMap?"#define USE_LIGHTMAP":"",e.aoMap?"#define USE_AOMAP":"",e.bumpMap?"#define USE_BUMPMAP":"",e.normalMap?"#define USE_NORMALMAP":"",e.normalMapObjectSpace?"#define USE_NORMALMAP_OBJECTSPACE":"",e.normalMapTangentSpace?"#define USE_NORMALMAP_TANGENTSPACE":"",e.packedNormalMap?"#define USE_PACKED_NORMALMAP":"",e.emissiveMap?"#define USE_EMISSIVEMAP":"",e.anisotropy?"#define USE_ANISOTROPY":"",e.anisotropyMap?"#define USE_ANISOTROPYMAP":"",e.clearcoat?"#define USE_CLEARCOAT":"",e.clearcoatMap?"#define USE_CLEARCOATMAP":"",e.clearcoatRoughnessMap?"#define USE_CLEARCOAT_ROUGHNESSMAP":"",e.clearcoatNormalMap?"#define USE_CLEARCOAT_NORMALMAP":"",e.dispersion?"#define USE_DISPERSION":"",e.retroreflection?"#define USE_RETROREFLECTION":"",e.iridescence?"#define USE_IRIDESCENCE":"",e.iridescenceMap?"#define USE_IRIDESCENCEMAP":"",e.iridescenceThicknessMap?"#define USE_IRIDESCENCE_THICKNESSMAP":"",e.specularMap?"#define USE_SPECULARMAP":"",e.specularColorMap?"#define USE_SPECULAR_COLORMAP":"",e.specularIntensityMap?"#define USE_SPECULAR_INTENSITYMAP":"",e.roughnessMap?"#define USE_ROUGHNESSMAP":"",e.metalnessMap?"#define USE_METALNESSMAP":"",e.alphaMap?"#define USE_ALPHAMAP":"",e.alphaTest?"#define USE_ALPHATEST":"",e.alphaHash?"#define USE_ALPHAHASH":"",e.sheen?"#define USE_SHEEN":"",e.sheenColorMap?"#define USE_SHEEN_COLORMAP":"",e.sheenRoughnessMap?"#define USE_SHEEN_ROUGHNESSMAP":"",e.transmission?"#define USE_TRANSMISSION":"",e.transmissionMap?"#define USE_TRANSMISSIONMAP":"",e.thicknessMap?"#define USE_THICKNESSMAP":"",e.vertexTangents&&e.flatShading===!1?"#define USE_TANGENT":"",e.vertexColors||e.instancingColor?"#define USE_COLOR":"",e.vertexAlphas||e.batchingColor?"#define USE_COLOR_ALPHA":"",e.vertexUv1s?"#define USE_UV1":"",e.vertexUv2s?"#define USE_UV2":"",e.vertexUv3s?"#define USE_UV3":"",e.pointsUvs?"#define USE_POINTS_UV":"",e.gradientMap?"#define USE_GRADIENTMAP":"",e.flatShading?"#define FLAT_SHADED":"",e.doubleSided?"#define DOUBLE_SIDED":"",e.flipSided?"#define FLIP_SIDED":"",e.shadowMapEnabled?"#define USE_SHADOWMAP":"",e.shadowMapEnabled?"#define "+l:"",e.premultipliedAlpha?"#define PREMULTIPLIED_ALPHA":"",e.numLightProbes>0?"#define USE_LIGHT_PROBES":"",e.numLightProbeGrids>0?"#define USE_LIGHT_PROBES_GRID":"",e.decodeVideoTexture?"#define DECODE_VIDEO_TEXTURE":"",e.decodeVideoTextureEmissive?"#define DECODE_VIDEO_TEXTURE_EMISSIVE":"",e.logarithmicDepthBuffer?"#define USE_LOGARITHMIC_DEPTH_BUFFER":"",e.reversedDepthBuffer?"#define USE_REVERSED_DEPTH_BUFFER":"","uniform mat4 viewMatrix;","uniform vec3 cameraPosition;","uniform bool isOrthographic;",e.toneMapping!==_n?"#define TONE_MAPPING":"",e.toneMapping!==_n?kt.tonemapping_pars_fragment:"",e.toneMapping!==_n?w0("toneMapping",e.toneMapping):"",e.dithering?"#define DITHERING":"",e.opaque?"#define OPAQUE":"",kt.colorspace_pars_fragment,S0("linearToOutputTexel",e.outputColorSpace),E0(),e.useDepthPacking?"#define DEPTH_PACKING "+e.depthPacking:"",` +`].filter(cr).join(` +`)),o=vc(o),o=Nu(o,e),o=Uu(o,e),a=vc(a),a=Nu(a,e),a=Uu(a,e),o=Fu(o),a=Fu(a),e.isRawShaderMaterial!==!0&&(w=`#version 300 es +`,m=[p,"#define attribute in","#define varying out","#define texture2D texture"].join(` +`)+` +`+m,f=["#define varying in",e.glslVersion===Kl?"":"layout(location = 0) out highp vec4 pc_fragColor;",e.glslVersion===Kl?"":"#define gl_FragColor pc_fragColor","#define gl_FragDepthEXT gl_FragDepth","#define texture2D texture","#define textureCube texture","#define texture2DProj textureProj","#define texture2DLodEXT textureLod","#define texture2DProjLodEXT textureProjLod","#define textureCubeLodEXT textureLod","#define texture2DGradEXT textureGrad","#define texture2DProjGradEXT textureProjGrad","#define textureCubeGradEXT textureGrad"].join(` +`)+` +`+f);let R=w+m+o,S=w+f+a,b=Pu(s,s.VERTEX_SHADER,R),y=Pu(s,s.FRAGMENT_SHADER,S);s.attachShader(_,b),s.attachShader(_,y),e.index0AttributeName!==void 0?s.bindAttribLocation(_,0,e.index0AttributeName):e.hasPositionAttribute===!0&&s.bindAttribLocation(_,0,"position"),s.linkProgram(_);function T(D){if(i.debug.checkShaderErrors){let z=s.getProgramInfoLog(_)||"",O=s.getShaderInfoLog(b)||"",I=s.getShaderInfoLog(y)||"",k=z.trim(),X=O.trim(),Y=I.trim(),B=!0,U=!0;if(s.getProgramParameter(_,s.LINK_STATUS)===!1)if(B=!1,typeof i.debug.onShaderError=="function")i.debug.onShaderError(s,_,b,y);else{let H=Du(s,b,"vertex"),Z=Du(s,y,"fragment");Pt("WebGLProgram: Shader Error "+s.getError()+" - VALIDATE_STATUS "+s.getProgramParameter(_,s.VALIDATE_STATUS)+` + +Material Name: `+D.name+` +Material Type: `+D.type+` + +Program Info Log: `+k+` +`+H+` +`+Z)}else k!==""?Ct("WebGLProgram: Program Info Log:",k):(X===""||Y==="")&&(U=!1);U&&(D.diagnostics={runnable:B,programLog:k,vertexShader:{log:X,prefix:m},fragmentShader:{log:Y,prefix:f}})}s.deleteShader(b),s.deleteShader(y),v=new Ms(s,_),E=R0(s,_)}let v;this.getUniforms=function(){return v===void 0&&T(this),v};let E;this.getAttributes=function(){return E===void 0&&T(this),E};let P=e.rendererExtensionParallelShaderCompile===!1;return this.isReady=function(){return P===!1&&(P=s.getProgramParameter(_,x0)),P},this.destroy=function(){n.releaseStatesOfProgram(this),s.deleteProgram(_),this.program=void 0},this.type=e.shaderType,this.name=e.shaderName,this.id=v0++,this.cacheKey=t,this.usedTimes=1,this.program=_,this.vertexShader=b,this.fragmentShader=y,this}var W0=0,yc=class{constructor(){this.shaderCache=new Map,this.materialCache=new Map}update(t,e,n){let s=this._getShaderCacheForMaterial(t);return s.has(e)===!1&&(s.add(e),e.usedTimes++),s.has(n)===!1&&(s.add(n),n.usedTimes++),this}remove(t){let e=this.materialCache.get(t);for(let n of e)n.usedTimes--,n.usedTimes===0&&this.shaderCache.delete(n.code);return this.materialCache.delete(t),this}getVertexShaderStage(t){return this._getShaderStage(t.vertexShader)}getFragmentShaderStage(t){return this._getShaderStage(t.fragmentShader)}dispose(){this.shaderCache.clear(),this.materialCache.clear()}_getShaderCacheForMaterial(t){let e=this.materialCache,n=e.get(t);return n===void 0&&(n=new Set,e.set(t,n)),n}_getShaderStage(t){let e=this.shaderCache,n=e.get(t);return n===void 0&&(n=new Mc(t),e.set(t,n)),n}},Mc=class{constructor(t){this.id=W0++,this.code=t,this.usedTimes=0}};function X0(i){return i===ui||i===rr||i===or}function q0(i,t,e,n,s,r){let o=new as,a=new yc,l=new Set,c=[],h=new Map,d=n.logarithmicDepthBuffer,u=n.precision,p={MeshDepthMaterial:"depth",MeshDistanceMaterial:"distance",MeshNormalMaterial:"normal",MeshBasicMaterial:"basic",MeshLambertMaterial:"lambert",MeshPhongMaterial:"phong",MeshToonMaterial:"toon",MeshStandardMaterial:"physical",MeshPhysicalMaterial:"physical",MeshMatcapMaterial:"matcap",LineBasicMaterial:"basic",LineDashedMaterial:"dashed",PointsMaterial:"points",ShadowMaterial:"shadow",SpriteMaterial:"sprite"};function g(v){return l.add(v),v===0?"uv":`uv${v}`}function _(v,E,P,D,z,O){let I=D.fog,k=z.geometry,X=v.isMeshStandardMaterial||v.isMeshLambertMaterial||v.isMeshPhongMaterial?D.environment:null,Y=v.isMeshStandardMaterial||v.isMeshLambertMaterial&&!v.envMap||v.isMeshPhongMaterial&&!v.envMap,B=t.get(v.envMap||X,Y),U=B&&B.mapping===Qs?B.image.height:null,H=p[v.type];v.precision!==null&&(u=n.getMaxPrecision(v.precision),u!==v.precision&&Ct("WebGLProgram.getParameters:",v.precision,"not supported, using",u,"instead."));let Z=k.morphAttributes.position||k.morphAttributes.normal||k.morphAttributes.color,rt=Z!==void 0?Z.length:0,at=0;k.morphAttributes.position!==void 0&&(at=1),k.morphAttributes.normal!==void 0&&(at=2),k.morphAttributes.color!==void 0&&(at=3);let Ft,Nt,$t,J;if(H){let ae=Dn[H];Ft=ae.vertexShader,Nt=ae.fragmentShader}else{Ft=v.vertexShader,Nt=v.fragmentShader;let ae=a.getVertexShaderStage(v),jt=a.getFragmentShaderStage(v);a.update(v,ae,jt),$t=ae.id,J=jt.id}let et=i.getRenderTarget(),xt=i.state.buffers.depth.getReversed(),Lt=z.isInstancedMesh===!0,yt=z.isBatchedMesh===!0,Vt=!!v.map,Ee=!!v.matcap,Gt=!!B,Kt=!!v.aoMap,oe=!!v.lightMap,Xt=!!v.bumpMap&&v.wireframe===!1,fe=!!v.normalMap,Re=!!v.displacementMap,je=!!v.emissiveMap,me=!!v.metalnessMap,ve=!!v.roughnessMap,F=v.anisotropy>0,ze=v.clearcoat>0,ee=v.dispersion>0,A=v.retroreflectivity>0,x=v.iridescence>0,V=v.sheen>0,q=v.transmission>0,K=F&&!!v.anisotropyMap,ot=ze&&!!v.clearcoatMap,lt=ze&&!!v.clearcoatNormalMap,j=ze&&!!v.clearcoatRoughnessMap,tt=x&&!!v.iridescenceMap,ct=x&&!!v.iridescenceThicknessMap,Tt=V&&!!v.sheenColorMap,ft=V&&!!v.sheenRoughnessMap,ht=!!v.specularMap,At=!!v.specularColorMap,It=!!v.specularIntensityMap,Bt=q&&!!v.transmissionMap,N=q&&!!v.thicknessMap,ut=!!v.gradientMap,Q=!!v.alphaMap,dt=v.alphaTest>0,_t=!!v.alphaHash,it=!!v.extensions,Rt=_n;v.toneMapped&&(et===null||et.isXRRenderTarget===!0)&&(Rt=i.toneMapping);let wt={shaderID:H,shaderType:v.type,shaderName:v.name,vertexShader:Ft,fragmentShader:Nt,defines:v.defines,customVertexShaderID:$t,customFragmentShaderID:J,isRawShaderMaterial:v.isRawShaderMaterial===!0,glslVersion:v.glslVersion,precision:u,batching:yt,batchingColor:yt&&z._colorsTexture!==null,instancing:Lt,instancingColor:Lt&&z.instanceColor!==null,instancingMorph:Lt&&z.morphTexture!==null,outputColorSpace:et===null?i.outputColorSpace:et.isXRRenderTarget===!0?et.texture.colorSpace:qt.workingColorSpace,alphaToCoverage:!!v.alphaToCoverage,map:Vt,matcap:Ee,envMap:Gt,envMapMode:Gt&&B.mapping,envMapCubeUVHeight:U,aoMap:Kt,lightMap:oe,bumpMap:Xt,normalMap:fe,displacementMap:Re,emissiveMap:je,normalMapObjectSpace:fe&&v.normalMapType===su,normalMapTangentSpace:fe&&v.normalMapType===va,packedNormalMap:fe&&v.normalMapType===va&&X0(v.normalMap.format),metalnessMap:me,roughnessMap:ve,anisotropy:F,anisotropyMap:K,clearcoat:ze,clearcoatMap:ot,clearcoatNormalMap:lt,clearcoatRoughnessMap:j,dispersion:ee,retroreflection:A,iridescence:x,iridescenceMap:tt,iridescenceThicknessMap:ct,sheen:V,sheenColorMap:Tt,sheenRoughnessMap:ft,specularMap:ht,specularColorMap:At,specularIntensityMap:It,transmission:q,transmissionMap:Bt,thicknessMap:N,gradientMap:ut,opaque:v.transparent===!1&&v.blending===ms&&v.alphaToCoverage===!1,alphaMap:Q,alphaTest:dt,alphaHash:_t,combine:v.combine,mapUv:Vt&&g(v.map.channel),aoMapUv:Kt&&g(v.aoMap.channel),lightMapUv:oe&&g(v.lightMap.channel),bumpMapUv:Xt&&g(v.bumpMap.channel),normalMapUv:fe&&g(v.normalMap.channel),displacementMapUv:Re&&g(v.displacementMap.channel),emissiveMapUv:je&&g(v.emissiveMap.channel),metalnessMapUv:me&&g(v.metalnessMap.channel),roughnessMapUv:ve&&g(v.roughnessMap.channel),anisotropyMapUv:K&&g(v.anisotropyMap.channel),clearcoatMapUv:ot&&g(v.clearcoatMap.channel),clearcoatNormalMapUv:lt&&g(v.clearcoatNormalMap.channel),clearcoatRoughnessMapUv:j&&g(v.clearcoatRoughnessMap.channel),iridescenceMapUv:tt&&g(v.iridescenceMap.channel),iridescenceThicknessMapUv:ct&&g(v.iridescenceThicknessMap.channel),sheenColorMapUv:Tt&&g(v.sheenColorMap.channel),sheenRoughnessMapUv:ft&&g(v.sheenRoughnessMap.channel),specularMapUv:ht&&g(v.specularMap.channel),specularColorMapUv:At&&g(v.specularColorMap.channel),specularIntensityMapUv:It&&g(v.specularIntensityMap.channel),transmissionMapUv:Bt&&g(v.transmissionMap.channel),thicknessMapUv:N&&g(v.thicknessMap.channel),alphaMapUv:Q&&g(v.alphaMap.channel),vertexTangents:!!k.attributes.tangent&&(fe||F),vertexNormals:!!k.attributes.normal,vertexColors:v.vertexColors,vertexAlphas:v.vertexColors===!0&&!!k.attributes.color&&k.attributes.color.itemSize===4,pointsUvs:z.isPoints===!0&&!!k.attributes.uv&&(Vt||Q),fog:!!I,useFog:v.fog===!0,fogExp2:!!I&&I.isFogExp2,flatShading:v.wireframe===!1&&(v.flatShading===!0||k.attributes.normal===void 0&&fe===!1&&(v.isMeshLambertMaterial||v.isMeshPhongMaterial||v.isMeshStandardMaterial||v.isMeshPhysicalMaterial)),sizeAttenuation:v.sizeAttenuation===!0,logarithmicDepthBuffer:d,reversedDepthBuffer:xt,skinning:z.isSkinnedMesh===!0,hasPositionAttribute:k.attributes.position!==void 0,morphTargets:k.morphAttributes.position!==void 0,morphNormals:k.morphAttributes.normal!==void 0,morphColors:k.morphAttributes.color!==void 0,morphTargetsCount:rt,morphTextureStride:at,numSunLights:E.sun.length,numDirLights:E.directional.length,numPointLights:E.point.length,numSpotLights:E.spot.length,numSpotLightMaps:E.spotLightMap.length,numRectAreaLights:E.rectArea.length,numHemiLights:E.hemi.length,numSunLightShadows:E.sunShadowMap.length,numDirLightShadows:E.directionalShadowMap.length,numPointLightShadows:E.pointShadowMap.length,numSpotLightShadows:E.spotShadowMap.length,numSpotLightShadowsWithMaps:E.numSpotLightShadowsWithMaps,numLightProbes:E.numLightProbes,numLightProbeGrids:O.length,numClippingPlanes:r.numPlanes,numClipIntersection:r.numIntersection,dithering:v.dithering,shadowMapEnabled:i.shadowMap.enabled&&P.length>0,shadowMapType:i.shadowMap.type,toneMapping:Rt,decodeVideoTexture:Vt&&v.map.isVideoTexture===!0&&qt.getTransfer(v.map.colorSpace)===te,decodeVideoTextureEmissive:je&&v.emissiveMap.isVideoTexture===!0&&qt.getTransfer(v.emissiveMap.colorSpace)===te,premultipliedAlpha:v.premultipliedAlpha,doubleSided:v.side===Be,flipSided:v.side===Je,useDepthPacking:v.depthPacking>=0,depthPacking:v.depthPacking||0,index0AttributeName:v.index0AttributeName,extensionClipCullDistance:it&&v.extensions.clipCullDistance===!0&&e.has("WEBGL_clip_cull_distance"),extensionMultiDraw:(it&&v.extensions.multiDraw===!0||yt)&&e.has("WEBGL_multi_draw"),rendererExtensionParallelShaderCompile:e.has("KHR_parallel_shader_compile"),customProgramCacheKey:v.customProgramCacheKey()};return wt.vertexUv1s=l.has(1),wt.vertexUv2s=l.has(2),wt.vertexUv3s=l.has(3),l.clear(),wt}function m(v){let E=[];if(v.shaderID?E.push(v.shaderID):(E.push(v.customVertexShaderID),E.push(v.customFragmentShaderID)),v.defines!==void 0)for(let P in v.defines)E.push(P),E.push(v.defines[P]);return v.isRawShaderMaterial===!1&&(f(E,v),w(E,v),E.push(i.outputColorSpace)),E.push(v.customProgramCacheKey),E.join()}function f(v,E){v.push(E.precision),v.push(E.outputColorSpace),v.push(E.envMapMode),v.push(E.envMapCubeUVHeight),v.push(E.mapUv),v.push(E.alphaMapUv),v.push(E.lightMapUv),v.push(E.aoMapUv),v.push(E.bumpMapUv),v.push(E.normalMapUv),v.push(E.displacementMapUv),v.push(E.emissiveMapUv),v.push(E.metalnessMapUv),v.push(E.roughnessMapUv),v.push(E.anisotropyMapUv),v.push(E.clearcoatMapUv),v.push(E.clearcoatNormalMapUv),v.push(E.clearcoatRoughnessMapUv),v.push(E.iridescenceMapUv),v.push(E.iridescenceThicknessMapUv),v.push(E.sheenColorMapUv),v.push(E.sheenRoughnessMapUv),v.push(E.specularMapUv),v.push(E.specularColorMapUv),v.push(E.specularIntensityMapUv),v.push(E.transmissionMapUv),v.push(E.thicknessMapUv),v.push(E.combine),v.push(E.fogExp2),v.push(E.sizeAttenuation),v.push(E.morphTargetsCount),v.push(E.morphAttributeCount),v.push(E.numSunLights),v.push(E.numDirLights),v.push(E.numPointLights),v.push(E.numSpotLights),v.push(E.numSpotLightMaps),v.push(E.numHemiLights),v.push(E.numRectAreaLights),v.push(E.numSunLightShadows),v.push(E.numDirLightShadows),v.push(E.numPointLightShadows),v.push(E.numSpotLightShadows),v.push(E.numSpotLightShadowsWithMaps),v.push(E.numLightProbes),v.push(E.shadowMapType),v.push(E.toneMapping),v.push(E.numClippingPlanes),v.push(E.numClipIntersection),v.push(E.depthPacking)}function w(v,E){o.disableAll(),E.instancing&&o.enable(0),E.instancingColor&&o.enable(1),E.instancingMorph&&o.enable(2),E.matcap&&o.enable(3),E.envMap&&o.enable(4),E.normalMapObjectSpace&&o.enable(5),E.normalMapTangentSpace&&o.enable(6),E.clearcoat&&o.enable(7),E.iridescence&&o.enable(8),E.alphaTest&&o.enable(9),E.vertexColors&&o.enable(10),E.vertexAlphas&&o.enable(11),E.vertexUv1s&&o.enable(12),E.vertexUv2s&&o.enable(13),E.vertexUv3s&&o.enable(14),E.vertexTangents&&o.enable(15),E.anisotropy&&o.enable(16),E.alphaHash&&o.enable(17),E.batching&&o.enable(18),E.dispersion&&o.enable(19),E.retroreflection&&o.enable(24),E.batchingColor&&o.enable(20),E.gradientMap&&o.enable(21),E.packedNormalMap&&o.enable(22),E.vertexNormals&&o.enable(23),v.push(o.mask),o.disableAll(),E.fog&&o.enable(0),E.useFog&&o.enable(1),E.flatShading&&o.enable(2),E.logarithmicDepthBuffer&&o.enable(3),E.reversedDepthBuffer&&o.enable(4),E.skinning&&o.enable(5),E.morphTargets&&o.enable(6),E.morphNormals&&o.enable(7),E.morphColors&&o.enable(8),E.premultipliedAlpha&&o.enable(9),E.shadowMapEnabled&&o.enable(10),E.doubleSided&&o.enable(11),E.flipSided&&o.enable(12),E.useDepthPacking&&o.enable(13),E.dithering&&o.enable(14),E.transmission&&o.enable(15),E.sheen&&o.enable(16),E.opaque&&o.enable(17),E.pointsUvs&&o.enable(18),E.decodeVideoTexture&&o.enable(19),E.decodeVideoTextureEmissive&&o.enable(20),E.alphaToCoverage&&o.enable(21),E.numLightProbeGrids>0&&o.enable(22),E.hasPositionAttribute&&o.enable(23),v.push(o.mask)}function R(v){let E=p[v.type],P;if(E){let D=Dn[E];P=xu.clone(D.uniforms)}else P=v.uniforms;return P}function S(v,E){let P=h.get(E);return P!==void 0?++P.usedTimes:(P=new G0(i,E,v,s),c.push(P),h.set(E,P)),P}function b(v){if(--v.usedTimes===0){let E=c.indexOf(v);c[E]=c[c.length-1],c.pop(),h.delete(v.cacheKey),v.destroy()}}function y(v){a.remove(v)}function T(){a.dispose()}return{getParameters:_,getProgramCacheKey:m,getUniforms:R,acquireProgram:S,releaseProgram:b,releaseShaderCache:y,programs:c,dispose:T}}function Y0(){let i=new WeakMap;function t(o){return i.has(o)}function e(o){let a=i.get(o);return a===void 0&&(a={},i.set(o,a)),a}function n(o){i.delete(o)}function s(o,a,l){i.get(o)[a]=l}function r(){i=new WeakMap}return{has:t,get:e,remove:n,update:s,dispose:r}}function $0(i,t){return i.groupOrder!==t.groupOrder?i.groupOrder-t.groupOrder:i.renderOrder!==t.renderOrder?i.renderOrder-t.renderOrder:i.material.id!==t.material.id?i.material.id-t.material.id:i.materialVariant!==t.materialVariant?i.materialVariant-t.materialVariant:i.z!==t.z?i.z-t.z:i.id-t.id}function Ou(i,t){return i.groupOrder!==t.groupOrder?i.groupOrder-t.groupOrder:i.renderOrder!==t.renderOrder?i.renderOrder-t.renderOrder:i.z!==t.z?t.z-i.z:i.id-t.id}function zu(){let i=[],t=0,e=[],n=[],s=[];function r(){t=0,e.length=0,n.length=0,s.length=0}function o(u){let p=0;return u.isInstancedMesh&&(p+=2),u.isSkinnedMesh&&(p+=1),p}function a(u,p,g,_,m,f){let w=i[t];return w===void 0?(w={id:u.id,object:u,geometry:p,material:g,materialVariant:o(u),groupOrder:_,renderOrder:u.renderOrder,z:m,group:f},i[t]=w):(w.id=u.id,w.object=u,w.geometry=p,w.material=g,w.materialVariant=o(u),w.groupOrder=_,w.renderOrder=u.renderOrder,w.z=m,w.group=f),t++,w}function l(u,p,g,_,m,f,w){w.reversedDepth===!0&&(m=-m);let R=a(u,p,g,_,m,f);g.transmission>0?n.push(R):g.transparent===!0?s.push(R):e.push(R)}function c(u,p,g,_,m,f){let w=a(u,p,g,_,m,f);g.transmission>0?n.unshift(w):g.transparent===!0?s.unshift(w):e.unshift(w)}function h(u,p){e.length>1&&e.sort(u||$0),n.length>1&&n.sort(p||Ou),s.length>1&&s.sort(p||Ou)}function d(){for(let u=t,p=i.length;u=r.length?(o=new zu,r.push(o)):o=r[s],o}function e(){i=new WeakMap}return{get:t,dispose:e}}function J0(){let i={};return{get:function(t){if(i[t.id]!==void 0)return i[t.id];let e;switch(t.type){case"SunLight":case"DirectionalLight":e={direction:new C,color:new Dt};break;case"SpotLight":e={position:new C,direction:new C,color:new Dt,distance:0,coneCos:0,penumbraCos:0,decay:0};break;case"PointLight":e={position:new C,color:new Dt,distance:0,decay:0};break;case"HemisphereLight":e={direction:new C,skyColor:new Dt,groundColor:new Dt};break;case"RectAreaLight":e={color:new Dt,position:new C,halfWidth:new C,halfHeight:new C};break}return i[t.id]=e,e}}}function K0(){let i={};return{get:function(t){if(i[t.id]!==void 0)return i[t.id];let e;switch(t.type){case"SunLight":case"DirectionalLight":e={shadowIntensity:1,shadowBias:0,shadowNormalBias:0,shadowRadius:1,shadowMapSize:new zt};break;case"SpotLight":e={shadowIntensity:1,shadowBias:0,shadowNormalBias:0,shadowRadius:1,shadowMapSize:new zt};break;case"PointLight":e={shadowIntensity:1,shadowBias:0,shadowNormalBias:0,shadowRadius:1,shadowMapSize:new zt,shadowCameraNear:1,shadowCameraFar:1e3};break}return i[t.id]=e,e}}}var j0=0;function Q0(i,t){return(t.castShadow?2:0)-(i.castShadow?2:0)+(t.map?1:0)-(i.map?1:0)}function tx(i){let t=new J0,e=K0(),n={version:0,hash:{sunLength:-1,directionalLength:-1,pointLength:-1,spotLength:-1,rectAreaLength:-1,hemiLength:-1,numSunShadows:-1,numDirectionalShadows:-1,numPointShadows:-1,numSpotShadows:-1,numSpotMaps:-1,numLightProbes:-1},ambient:[0,0,0],probe:[],sun:[],sunShadow:[],sunShadowMap:[],sunShadowMatrix:[],sunShadowCascade:[],directional:[],directionalShadow:[],directionalShadowMap:[],directionalShadowMatrix:[],spot:[],spotLightMap:[],spotShadow:[],spotShadowMap:[],spotLightMatrix:[],rectArea:[],rectAreaLTC1:null,rectAreaLTC2:null,point:[],pointShadow:[],pointShadowMap:[],pointShadowMatrix:[],hemi:[],numSpotLightShadowsWithMaps:0,numLightProbes:0};for(let c=0;c<9;c++)n.probe.push(new C);let s=new C,r=new Ht,o=new Ht;function a(c){let h=0,d=0,u=0;for(let z=0;z<9;z++)n.probe[z].set(0,0,0);let p=0,g=0,_=0,m=0,f=0,w=0,R=0,S=0,b=0,y=0,T=0,v=0,E=0,P=0;c.sort(Q0);for(let z=0,O=c.length;z0&&(i.has("OES_texture_float_linear")===!0?(n.rectAreaLTC1=pt.LTC_FLOAT_1,n.rectAreaLTC2=pt.LTC_FLOAT_2):(n.rectAreaLTC1=pt.LTC_HALF_1,n.rectAreaLTC2=pt.LTC_HALF_2)),n.ambient[0]=h,n.ambient[1]=d,n.ambient[2]=u;let D=n.hash;(D.sunLength!==p||D.directionalLength!==m||D.pointLength!==f||D.spotLength!==w||D.rectAreaLength!==R||D.hemiLength!==S||D.numSunShadows!==g||D.numDirectionalShadows!==b||D.numPointShadows!==y||D.numSpotShadows!==T||D.numSpotMaps!==v||D.numLightProbes!==P)&&(n.sun.length=p,n.directional.length=m,n.spot.length=w,n.rectArea.length=R,n.point.length=f,n.hemi.length=S,n.sunShadow.length=g,n.sunShadowMap.length=g,n.sunShadowMatrix.length=_,n.sunShadowCascade.length=_,n.directionalShadow.length=b,n.directionalShadowMap.length=b,n.directionalShadowMatrix.length=b,n.pointShadow.length=y,n.pointShadowMap.length=y,n.pointShadowMatrix.length=y,n.spotShadow.length=T,n.spotShadowMap.length=T,n.spotLightMatrix.length=T+v-E,n.spotLightMap.length=v,n.numSpotLightShadowsWithMaps=E,n.numLightProbes=P,D.sunLength=p,D.directionalLength=m,D.pointLength=f,D.spotLength=w,D.rectAreaLength=R,D.hemiLength=S,D.numSunShadows=g,D.numDirectionalShadows=b,D.numPointShadows=y,D.numSpotShadows=T,D.numSpotMaps=v,D.numLightProbes=P,n.version=j0++)}function l(c,h){let d=0,u=0,p=0,g=0,_=0,m=0,f=h.matrixWorldInverse;for(let w=0,R=c.length;w=o.length?(a=new ku(i),o.push(a)):a=o[r],a}function n(){t=new WeakMap}return{get:e,dispose:n}}var nx=`void main() { + gl_Position = vec4( position, 1.0 ); +}`,ix=`uniform sampler2D shadow_pass; +uniform vec2 resolution; +uniform float radius; +void main() { + const float samples = float( VSM_SAMPLES ); + float mean = 0.0; + float squared_mean = 0.0; + float uvStride = samples <= 1.0 ? 0.0 : 2.0 / ( samples - 1.0 ); + float uvStart = samples <= 1.0 ? 0.0 : - 1.0; + for ( float i = 0.0; i < samples; i ++ ) { + float uvOffset = uvStart + i * uvStride; + #ifdef HORIZONTAL_PASS + vec2 distribution = texture2D( shadow_pass, ( gl_FragCoord.xy + vec2( uvOffset, 0.0 ) * radius ) / resolution ).rg; + mean += distribution.x; + squared_mean += distribution.y * distribution.y + distribution.x * distribution.x; + #else + float depth = texture2D( shadow_pass, ( gl_FragCoord.xy + vec2( 0.0, uvOffset ) * radius ) / resolution ).r; + mean += depth; + squared_mean += depth * depth; + #endif + } + mean = mean / samples; + squared_mean = squared_mean / samples; + float std_dev = sqrt( max( 0.0, squared_mean - mean * mean ) ); + gl_FragColor = vec4( mean, std_dev, 0.0, 1.0 ); +}`,sx=[new C(1,0,0),new C(-1,0,0),new C(0,1,0),new C(0,-1,0),new C(0,0,1),new C(0,0,-1)],rx=[new C(0,-1,0),new C(0,-1,0),new C(0,0,1),new C(0,0,-1),new C(0,-1,0),new C(0,-1,0)],Vu=new Ht,lr=new C,pc=new C;function ox(i,t,e){let n=new Cn,s=new zt,r=new zt,o=new pe,a=new go,l=new _o,c={},h=e.maxTextureSize,d={[ai]:Je,[Je]:ai,[Be]:Be},u=new Ze({defines:{VSM_SAMPLES:8},uniforms:{shadow_pass:{value:null},resolution:{value:new zt},radius:{value:4}},vertexShader:nx,fragmentShader:ix}),p=u.clone();p.defines.HORIZONTAL_PASS=1;let g=new re;g.setAttribute("position",new we(new Float32Array([-1,-1,.5,3,-1,.5,-1,3,.5]),3));let _=new _e(g,u),m=this;this.enabled=!1,this.autoUpdate=!0,this.needsUpdate=!1,this.type=js;let f=this.type;this.render=function(y,T,v){if(m.enabled===!1||m.autoUpdate===!1&&m.needsUpdate===!1||y.length===0)return;this.type===Uh&&(Ct("WebGLShadowMap: PCFSoftShadowMap has been removed. Using PCFShadowMap instead."),this.type=js);let E=i.getRenderTarget(),P=i.getActiveCubeFace(),D=i.getActiveMipmapLevel(),z=i.state;z.setBlending(Pn),z.buffers.depth.getReversed()===!0?z.buffers.color.setClear(0,0,0,0):z.buffers.color.setClear(1,1,1,1),z.buffers.depth.setTest(!0),z.setScissorTest(!1);let O=f!==this.type;O&&T.traverse(function(I){I.material&&(Array.isArray(I.material)?I.material.forEach(k=>k.needsUpdate=!0):I.material.needsUpdate=!0)});for(let I=0,k=y.length;Ih||s.y>h)&&(s.x>h&&(r.x=Math.floor(h/B.x),s.x=r.x*B.x,Y.mapSize.x=r.x),s.y>h&&(r.y=Math.floor(h/B.y),s.y=r.y*B.y,Y.mapSize.y=r.y));let U=i.state.buffers.depth.getReversed();if(Y.camera._reversedDepth=U,Y.map===null||O===!0){if(Y.map!==null&&(Y.map.depthTexture!==null&&(Y.map.depthTexture.dispose(),Y.map.depthTexture=null),Y.map.dispose()),this.type===ps){if(X.isPointLight){Ct("WebGLShadowMap: VSM shadow maps are not supported for PointLights. Use PCF or BasicShadowMap instead.");continue}Y.map=new Fe(s.x,s.y,{format:ui,type:vn,minFilter:De,magFilter:De,generateMipmaps:!1}),Y.map.texture.name=X.name+".shadowMap",Y.map.depthTexture=new ii(s.x,s.y,We),Y.map.depthTexture.name=X.name+".shadowMapDepth",Y.map.depthTexture.format=Tn,Y.map.depthTexture.compareFunction=null,Y.map.depthTexture.minFilter=Ie,Y.map.depthTexture.magFilter=Ie}else X.isPointLight?(Y.map=new Ea(s.x),Y.map.depthTexture=new po(s.x,xn)):(Y.map=new Fe(s.x,s.y),Y.map.depthTexture=new ii(s.x,s.y,xn)),Y.map.depthTexture.name=X.name+".shadowMap",Y.map.depthTexture.format=Tn,this.type===js?(Y.map.depthTexture.compareFunction=U?Ma:ya,Y.map.depthTexture.minFilter=De,Y.map.depthTexture.magFilter=De):(Y.map.depthTexture.compareFunction=null,Y.map.depthTexture.minFilter=Ie,Y.map.depthTexture.magFilter=Ie);Y.camera.updateProjectionMatrix()}Y.map.isWebGLCubeRenderTarget!==!0&&(Y.map.width!==s.x||Y.map.height!==s.y)&&Y.map.setSize(s.x,s.y);let H=Y.map.isWebGLCubeRenderTarget?6:Y.getViewportCount();X.isPointLight!==!0&&Y.updateMatrices(X,v);for(let Z=0;Z0||T.map&&T.alphaTest>0||T.alphaToCoverage===!0){let z=P.uuid,O=T.uuid,I=c[z];I===void 0&&(I={},c[z]=I);let k=I[O];k===void 0&&(k=P.clone(),I[O]=k,T.addEventListener("dispose",b)),P=k}if(P.visible=T.visible,P.wireframe=T.wireframe,E===ps?P.side=T.shadowSide!==null?T.shadowSide:T.side:P.side=T.shadowSide!==null?T.shadowSide:d[T.side],P.alphaMap=T.alphaMap,P.alphaTest=T.alphaToCoverage===!0?.5:T.alphaTest,P.map=T.map,P.clipShadows=T.clipShadows,P.clippingPlanes=T.clippingPlanes,P.clipIntersection=T.clipIntersection,P.displacementMap=T.displacementMap,P.displacementScale=T.displacementScale,P.displacementBias=T.displacementBias,P.wireframeLinewidth=T.wireframeLinewidth,P.linewidth=T.linewidth,v.isPointLight===!0&&P.isMeshDistanceMaterial===!0){let z=i.properties.get(P);z.light=v}return P}function S(y,T,v,E,P){if(y.visible===!1)return;if(y.layers.test(T.layers)&&(y.isMesh||y.isLine||y.isPoints)&&(y.castShadow||y.receiveShadow&&P===ps)&&(!y.frustumCulled||y.intersectsFrustum(n))){y.modelViewMatrix.multiplyMatrices(v.matrixWorldInverse,y.matrixWorld);let O=t.update(y),I=y.material;if(Array.isArray(I)){let k=O.groups;for(let X=0,Y=k.length;X=1):U.indexOf("OpenGL ES")!==-1&&(B=parseFloat(/^OpenGL ES (\d)/.exec(U)[1]),Y=B>=2);let H=null,Z={},rt=i.getParameter(i.SCISSOR_BOX),at=i.getParameter(i.VIEWPORT),Ft=new pe().fromArray(rt),Nt=new pe().fromArray(at);function $t(N,ut,Q,dt){let _t=new Uint8Array(4),it=i.createTexture();i.bindTexture(N,it),i.texParameteri(N,i.TEXTURE_MIN_FILTER,i.NEAREST),i.texParameteri(N,i.TEXTURE_MAG_FILTER,i.NEAREST);for(let Rt=0;Rt"u"?!1:/OculusBrowser/g.test(navigator.userAgent),c=new zt,h=new WeakMap,d=new Set,u,p=new WeakMap,g=!1;try{g=typeof OffscreenCanvas<"u"&&new OffscreenCanvas(1,1).getContext("2d")!==null}catch{}function _(A,x){return g?new OffscreenCanvas(A,x):Os("canvas")}function m(A,x,V){let q=1,K=ee(A);if((K.width>V||K.height>V)&&(q=V/Math.max(K.width,K.height)),q<1)if(typeof HTMLImageElement<"u"&&A instanceof HTMLImageElement||typeof HTMLCanvasElement<"u"&&A instanceof HTMLCanvasElement||typeof ImageBitmap<"u"&&A instanceof ImageBitmap||typeof VideoFrame<"u"&&A instanceof VideoFrame){let ot=Math.floor(q*K.width),lt=Math.floor(q*K.height);u===void 0&&(u=_(ot,lt));let j=x?_(ot,lt):u;return j.width=ot,j.height=lt,j.getContext("2d").drawImage(A,0,0,ot,lt),Ct("WebGLRenderer: Texture has been resized from ("+K.width+"x"+K.height+") to ("+ot+"x"+lt+")."),j}else return"data"in A&&Ct("WebGLRenderer: Image in DataTexture is too big ("+K.width+"x"+K.height+")."),A;return A}function f(A){return A.generateMipmaps}function w(A){i.generateMipmap(A)}function R(A){return A.isWebGLCubeRenderTarget?i.TEXTURE_CUBE_MAP:A.isWebGL3DRenderTarget?i.TEXTURE_3D:A.isWebGLArrayRenderTarget||A.isCompressedArrayTexture?i.TEXTURE_2D_ARRAY:i.TEXTURE_2D}function S(A,x,V,q,K,ot=!1){if(A!==null){if(i[A]!==void 0)return i[A];Ct("WebGLRenderer: Attempt to use non-existing WebGL internal format '"+A+"'")}let lt;q&&(lt=t.get("EXT_texture_norm16"),lt||Ct("WebGLRenderer: Unable to use normalized textures without EXT_texture_norm16 extension"));let j=x;if(x===i.RED&&(V===i.FLOAT&&(j=i.R32F),V===i.HALF_FLOAT&&(j=i.R16F),V===i.UNSIGNED_BYTE&&(j=i.R8),V===i.UNSIGNED_SHORT&<&&(j=lt.R16_EXT),V===i.SHORT&<&&(j=lt.R16_SNORM_EXT)),x===i.RED_INTEGER&&(V===i.UNSIGNED_BYTE&&(j=i.R8UI),V===i.UNSIGNED_SHORT&&(j=i.R16UI),V===i.UNSIGNED_INT&&(j=i.R32UI),V===i.BYTE&&(j=i.R8I),V===i.SHORT&&(j=i.R16I),V===i.INT&&(j=i.R32I)),x===i.RG&&(V===i.FLOAT&&(j=i.RG32F),V===i.HALF_FLOAT&&(j=i.RG16F),V===i.UNSIGNED_BYTE&&(j=i.RG8),V===i.UNSIGNED_SHORT&<&&(j=lt.RG16_EXT),V===i.SHORT&<&&(j=lt.RG16_SNORM_EXT)),x===i.RG_INTEGER&&(V===i.UNSIGNED_BYTE&&(j=i.RG8UI),V===i.UNSIGNED_SHORT&&(j=i.RG16UI),V===i.UNSIGNED_INT&&(j=i.RG32UI),V===i.BYTE&&(j=i.RG8I),V===i.SHORT&&(j=i.RG16I),V===i.INT&&(j=i.RG32I)),x===i.RGB_INTEGER&&(V===i.UNSIGNED_BYTE&&(j=i.RGB8UI),V===i.UNSIGNED_SHORT&&(j=i.RGB16UI),V===i.UNSIGNED_INT&&(j=i.RGB32UI),V===i.BYTE&&(j=i.RGB8I),V===i.SHORT&&(j=i.RGB16I),V===i.INT&&(j=i.RGB32I)),x===i.RGBA_INTEGER&&(V===i.UNSIGNED_BYTE&&(j=i.RGBA8UI),V===i.UNSIGNED_SHORT&&(j=i.RGBA16UI),V===i.UNSIGNED_INT&&(j=i.RGBA32UI),V===i.BYTE&&(j=i.RGBA8I),V===i.SHORT&&(j=i.RGBA16I),V===i.INT&&(j=i.RGBA32I)),x===i.RGB&&(V===i.UNSIGNED_SHORT&<&&(j=lt.RGB16_EXT),V===i.SHORT&<&&(j=lt.RGB16_SNORM_EXT),V===i.UNSIGNED_INT_5_9_9_9_REV&&(j=i.RGB9_E5),V===i.UNSIGNED_INT_10F_11F_11F_REV&&(j=i.R11F_G11F_B10F)),x===i.RGBA){let tt=ot?Bs:qt.getTransfer(K);V===i.FLOAT&&(j=i.RGBA32F),V===i.HALF_FLOAT&&(j=i.RGBA16F),V===i.UNSIGNED_BYTE&&(j=tt===te?i.SRGB8_ALPHA8:i.RGBA8),V===i.UNSIGNED_SHORT&<&&(j=lt.RGBA16_EXT),V===i.SHORT&<&&(j=lt.RGBA16_SNORM_EXT),V===i.UNSIGNED_SHORT_4_4_4_4&&(j=i.RGBA4),V===i.UNSIGNED_SHORT_5_5_5_1&&(j=i.RGB5_A1)}return(j===i.R16F||j===i.R32F||j===i.RG16F||j===i.RG32F||j===i.RGBA16F||j===i.RGBA32F)&&t.get("EXT_color_buffer_float"),j}function b(A,x){let V;return A?x===null||x===xn||x===_s?V=i.DEPTH24_STENCIL8:x===We?V=i.DEPTH32F_STENCIL8:x===gs&&(V=i.DEPTH24_STENCIL8,Ct("DepthTexture: 16 bit depth attachment is not supported with stencil. Using 24-bit attachment.")):x===null||x===xn||x===_s?V=i.DEPTH_COMPONENT24:x===We?V=i.DEPTH_COMPONENT32F:x===gs&&(V=i.DEPTH_COMPONENT16),V}function y(A,x){return f(A)===!0||A.isFramebufferTexture&&A.minFilter!==Ie&&A.minFilter!==De?Math.log2(Math.max(x.width,x.height))+1:A.mipmaps!==void 0&&A.mipmaps.length>0?A.mipmaps.length:A.isCompressedTexture&&Array.isArray(A.image)?x.mipmaps.length:1}function T(A){let x=A.target;x.removeEventListener("dispose",T),E(x),x.isVideoTexture&&h.delete(x),x.isHTMLTexture&&d.delete(x)}function v(A){let x=A.target;x.removeEventListener("dispose",v),D(x)}function E(A){let x=n.get(A);if(x.__webglInit===void 0)return;let V=A.source,q=p.get(V);if(q){let K=q[x.__cacheKey];K.usedTimes--,K.usedTimes===0&&P(A),Object.keys(q).length===0&&p.delete(V)}n.remove(A)}function P(A){let x=n.get(A);i.deleteTexture(x.__webglTexture);let V=A.source,q=p.get(V);delete q[x.__cacheKey],o.memory.textures--}function D(A){let x=n.get(A);if(A.depthTexture&&(A.depthTexture.dispose(),n.remove(A.depthTexture)),A.isWebGLCubeRenderTarget)for(let q=0;q<6;q++){if(Array.isArray(x.__webglFramebuffer[q]))for(let K=0;K=s.maxTextures&&Ct("WebGLTextures: Trying to use "+(A+1)+" texture units while this GPU supports only "+s.maxTextures),z+=1,A}function Y(A){let x=[];return x.push(A.wrapS),x.push(A.wrapT),x.push(A.wrapR||0),x.push(A.magFilter),x.push(A.minFilter),x.push(A.anisotropy),x.push(A.internalFormat),x.push(A.format),x.push(A.type),x.push(A.generateMipmaps),x.push(A.premultiplyAlpha),x.push(A.flipY),x.push(A.unpackAlignment),x.push(A.colorSpace),x.join()}function B(A,x){let V=n.get(A);if(A.isVideoTexture&&F(A),A.isRenderTargetTexture===!1&&A.isExternalTexture!==!0&&A.version>0&&V.__version!==A.version){let q=A.image;if(q===null)Ct("WebGLRenderer: Texture marked for update but no image data found.");else if(q.complete===!1)Ct("WebGLRenderer: Texture marked for update but image is incomplete");else{xt(V,A,x);return}}else A.isExternalTexture&&(V.__webglTexture=A.sourceTexture?A.sourceTexture:null);e.bindTexture(i.TEXTURE_2D,V.__webglTexture,i.TEXTURE0+x)}function U(A,x){let V=n.get(A);if(A.isRenderTargetTexture===!1&&A.version>0&&V.__version!==A.version){xt(V,A,x);return}else A.isExternalTexture&&(V.__webglTexture=A.sourceTexture?A.sourceTexture:null);e.bindTexture(i.TEXTURE_2D_ARRAY,V.__webglTexture,i.TEXTURE0+x)}function H(A,x){let V=n.get(A);if(A.isRenderTargetTexture===!1&&A.version>0&&V.__version!==A.version){xt(V,A,x);return}e.bindTexture(i.TEXTURE_3D,V.__webglTexture,i.TEXTURE0+x)}function Z(A,x){let V=n.get(A);if(A.isCubeDepthTexture!==!0&&A.version>0&&V.__version!==A.version){Lt(V,A,x);return}e.bindTexture(i.TEXTURE_CUBE_MAP,V.__webglTexture,i.TEXTURE0+x)}let rt={[ns]:i.REPEAT,[En]:i.CLAMP_TO_EDGE,[oo]:i.MIRRORED_REPEAT},at={[Ie]:i.NEAREST,[nu]:i.NEAREST_MIPMAP_NEAREST,[tr]:i.NEAREST_MIPMAP_LINEAR,[De]:i.LINEAR,[Uo]:i.LINEAR_MIPMAP_NEAREST,[ci]:i.LINEAR_MIPMAP_LINEAR},Ft={[ou]:i.NEVER,[uu]:i.ALWAYS,[au]:i.LESS,[ya]:i.LEQUAL,[lu]:i.EQUAL,[Ma]:i.GEQUAL,[cu]:i.GREATER,[hu]:i.NOTEQUAL};function Nt(A,x){if(x.type===We&&t.has("OES_texture_float_linear")===!1&&(x.magFilter===De||x.magFilter===Uo||x.magFilter===tr||x.magFilter===ci||x.minFilter===De||x.minFilter===Uo||x.minFilter===tr||x.minFilter===ci)&&Ct("WebGLRenderer: Unable to use linear filtering with floating point textures. OES_texture_float_linear not supported on this device."),i.texParameteri(A,i.TEXTURE_WRAP_S,rt[x.wrapS]),i.texParameteri(A,i.TEXTURE_WRAP_T,rt[x.wrapT]),(A===i.TEXTURE_3D||A===i.TEXTURE_2D_ARRAY)&&i.texParameteri(A,i.TEXTURE_WRAP_R,rt[x.wrapR]),i.texParameteri(A,i.TEXTURE_MAG_FILTER,at[x.magFilter]),i.texParameteri(A,i.TEXTURE_MIN_FILTER,at[x.minFilter]),x.compareFunction&&(i.texParameteri(A,i.TEXTURE_COMPARE_MODE,i.COMPARE_REF_TO_TEXTURE),i.texParameteri(A,i.TEXTURE_COMPARE_FUNC,Ft[x.compareFunction])),t.has("EXT_texture_filter_anisotropic")===!0){if(x.magFilter===Ie||x.minFilter!==tr&&x.minFilter!==ci||x.type===We&&t.has("OES_texture_float_linear")===!1)return;if(x.anisotropy>1||n.get(x).__currentAnisotropy){let V=t.get("EXT_texture_filter_anisotropic");i.texParameterf(A,V.TEXTURE_MAX_ANISOTROPY_EXT,Math.min(x.anisotropy,s.getMaxAnisotropy())),n.get(x).__currentAnisotropy=x.anisotropy}}}function $t(A,x){let V=!1;A.__webglInit===void 0&&(A.__webglInit=!0,x.addEventListener("dispose",T));let q=x.source,K=p.get(q);K===void 0&&(K={},p.set(q,K));let ot=Y(x);if(ot!==A.__cacheKey){K[ot]===void 0&&(K[ot]={texture:i.createTexture(),usedTimes:0},o.memory.textures++,V=!0),K[ot].usedTimes++;let lt=K[A.__cacheKey];lt!==void 0&&(K[A.__cacheKey].usedTimes--,lt.usedTimes===0&&P(x)),A.__cacheKey=ot,A.__webglTexture=K[ot].texture}return V}function J(A,x,V){return Math.floor(Math.floor(A/V)/x)}function et(A,x,V,q){let ot=A.updateRanges;if(ot.length===0)e.texSubImage2D(i.TEXTURE_2D,0,0,0,x.width,x.height,V,q,x.data);else{ot.sort((Tt,ft)=>Tt.start-ft.start);let lt=0;for(let Tt=1;Tt0){It&&Bt&&e.texStorage2D(i.TEXTURE_2D,ut,ft,At[0].width,At[0].height);for(let Q=0,dt=At.length;Q0){let _t=ic(ht.width,ht.height,x.format,x.type);for(let it of x.layerUpdates){let Rt=ht.data.subarray(it*_t/ht.data.BYTES_PER_ELEMENT,(it+1)*_t/ht.data.BYTES_PER_ELEMENT);e.compressedTexSubImage3D(i.TEXTURE_2D_ARRAY,Q,0,0,it,ht.width,ht.height,1,ct,Rt)}}else e.compressedTexSubImage3D(i.TEXTURE_2D_ARRAY,Q,0,0,0,ht.width,ht.height,tt.depth,ct,ht.data)}else e.compressedTexImage3D(i.TEXTURE_2D_ARRAY,Q,ft,ht.width,ht.height,tt.depth,0,ht.data,0,0);else Ct("WebGLRenderer: Attempt to load unsupported compressed texture format in .uploadTexture()");else It?N&&e.texSubImage3D(i.TEXTURE_2D_ARRAY,Q,0,0,0,ht.width,ht.height,tt.depth,ct,Tt,ht.data):e.texImage3D(i.TEXTURE_2D_ARRAY,Q,ft,ht.width,ht.height,tt.depth,0,ct,Tt,ht.data);x.layerUpdates.size>0&&x.clearLayerUpdates()}else{It&&Bt&&e.texStorage2D(i.TEXTURE_2D,ut,ft,At[0].width,At[0].height);for(let Q=0,dt=At.length;Q0){let Q=ic(tt.width,tt.height,x.format,x.type);for(let dt of x.layerUpdates){let _t=tt.data.subarray(dt*Q/tt.data.BYTES_PER_ELEMENT,(dt+1)*Q/tt.data.BYTES_PER_ELEMENT);e.texSubImage3D(i.TEXTURE_2D_ARRAY,0,0,0,dt,tt.width,tt.height,1,ct,Tt,_t)}x.clearLayerUpdates()}else e.texSubImage3D(i.TEXTURE_2D_ARRAY,0,0,0,0,tt.width,tt.height,tt.depth,ct,Tt,tt.data)}else e.texImage3D(i.TEXTURE_2D_ARRAY,0,ft,tt.width,tt.height,tt.depth,0,ct,Tt,tt.data);else if(x.isData3DTexture)It?(Bt&&e.texStorage3D(i.TEXTURE_3D,ut,ft,tt.width,tt.height,tt.depth),N&&e.texSubImage3D(i.TEXTURE_3D,0,0,0,0,tt.width,tt.height,tt.depth,ct,Tt,tt.data)):e.texImage3D(i.TEXTURE_3D,0,ft,tt.width,tt.height,tt.depth,0,ct,Tt,tt.data);else if(x.isFramebufferTexture){if(Bt)if(It)e.texStorage2D(i.TEXTURE_2D,ut,ft,tt.width,tt.height);else{let Q=tt.width,dt=tt.height;for(let _t=0;_t>=1,dt>>=1}}else if(x.isHTMLTexture){if("texElementImage2D"in i){let Q=i.canvas;if(Q.hasAttribute("layoutsubtree")||Q.setAttribute("layoutsubtree","true"),tt.parentNode!==Q){Q.appendChild(tt),d.add(x),Q.onpaint=dt=>{let _t=dt.changedElements;for(let it of d)_t.includes(it.image)&&(it.needsUpdate=!0)},Q.requestPaint();return}if(i.texElementImage2D.length===3)i.texElementImage2D(i.TEXTURE_2D,i.RGBA8,tt);else{let _t=i.RGBA,it=i.RGBA,Rt=i.UNSIGNED_BYTE;i.texElementImage2D(i.TEXTURE_2D,0,_t,it,Rt,tt)}i.texParameteri(i.TEXTURE_2D,i.TEXTURE_MIN_FILTER,i.LINEAR),i.texParameteri(i.TEXTURE_2D,i.TEXTURE_WRAP_S,i.CLAMP_TO_EDGE),i.texParameteri(i.TEXTURE_2D,i.TEXTURE_WRAP_T,i.CLAMP_TO_EDGE)}}else if(At.length>0){if(It&&Bt){let Q=ee(At[0]);e.texStorage2D(i.TEXTURE_2D,ut,ft,Q.width,Q.height)}for(let Q=0,dt=At.length;Q0&&dt++;let it=ee(ft[0]);e.texStorage2D(i.TEXTURE_CUBE_MAP,dt,Bt,it.width,it.height)}for(let it=0;it<6;it++)if(Tt){N?Q&&e.texSubImage2D(i.TEXTURE_CUBE_MAP_POSITIVE_X+it,0,0,0,ft[it].width,ft[it].height,At,It,ft[it].data):e.texImage2D(i.TEXTURE_CUBE_MAP_POSITIVE_X+it,0,Bt,ft[it].width,ft[it].height,0,At,It,ft[it].data);for(let Rt=0;Rt<_t.length;Rt++){let ae=_t[Rt].image[it].image;N?Q&&e.texSubImage2D(i.TEXTURE_CUBE_MAP_POSITIVE_X+it,Rt+1,0,0,ae.width,ae.height,At,It,ae.data):e.texImage2D(i.TEXTURE_CUBE_MAP_POSITIVE_X+it,Rt+1,Bt,ae.width,ae.height,0,At,It,ae.data)}}else{N?Q&&e.texSubImage2D(i.TEXTURE_CUBE_MAP_POSITIVE_X+it,0,0,0,At,It,ft[it]):e.texImage2D(i.TEXTURE_CUBE_MAP_POSITIVE_X+it,0,Bt,At,It,ft[it]);for(let Rt=0;Rt<_t.length;Rt++){let wt=_t[Rt];N?Q&&e.texSubImage2D(i.TEXTURE_CUBE_MAP_POSITIVE_X+it,Rt+1,0,0,At,It,wt.image[it]):e.texImage2D(i.TEXTURE_CUBE_MAP_POSITIVE_X+it,Rt+1,Bt,At,It,wt.image[it])}}}f(x)&&w(i.TEXTURE_CUBE_MAP),ot.__version=K.version,x.onUpdate&&x.onUpdate(x)}A.__version=x.version}function yt(A,x,V,q,K,ot){let lt=r.convert(V.format,V.colorSpace),j=r.convert(V.type),tt=S(V.internalFormat,lt,j,V.normalized,V.colorSpace),ct=n.get(x),Tt=n.get(V);if(Tt.__renderTarget=x,!ct.__hasExternalTextures){let ft=Math.max(1,x.width>>ot),ht=Math.max(1,x.height>>ot);K===i.TEXTURE_3D||K===i.TEXTURE_2D_ARRAY?e.texImage3D(K,ot,tt,ft,ht,x.depth,0,lt,j,null):e.texImage2D(K,ot,tt,ft,ht,0,lt,j,null)}e.bindFramebuffer(i.FRAMEBUFFER,A),ve(x)?a.framebufferTexture2DMultisampleEXT(i.FRAMEBUFFER,q,K,Tt.__webglTexture,0,me(x)):(K===i.TEXTURE_2D||K>=i.TEXTURE_CUBE_MAP_POSITIVE_X&&K<=i.TEXTURE_CUBE_MAP_NEGATIVE_Z)&&i.framebufferTexture2D(i.FRAMEBUFFER,q,K,Tt.__webglTexture,ot),e.bindFramebuffer(i.FRAMEBUFFER,null)}function Vt(A,x,V){if(i.bindRenderbuffer(i.RENDERBUFFER,A),x.depthBuffer){let q=x.depthTexture,K=q&&q.isDepthTexture?q.type:null,ot=b(x.stencilBuffer,K),lt=x.stencilBuffer?i.DEPTH_STENCIL_ATTACHMENT:i.DEPTH_ATTACHMENT;ve(x)?a.renderbufferStorageMultisampleEXT(i.RENDERBUFFER,me(x),ot,x.width,x.height):V?i.renderbufferStorageMultisample(i.RENDERBUFFER,me(x),ot,x.width,x.height):i.renderbufferStorage(i.RENDERBUFFER,ot,x.width,x.height),i.framebufferRenderbuffer(i.FRAMEBUFFER,lt,i.RENDERBUFFER,A)}else{let q=x.textures;for(let K=0;K{delete x.__boundDepthTexture,delete x.__depthDisposeCallback,q.removeEventListener("dispose",K)};q.addEventListener("dispose",K),x.__depthDisposeCallback=K}x.__boundDepthTexture=q}if(A.depthTexture&&!x.__autoAllocateDepthBuffer)if(V)for(let q=0;q<6;q++)Ee(x.__webglFramebuffer[q],A,q);else{let q=A.texture.mipmaps;q&&q.length>0?Ee(x.__webglFramebuffer[0],A,0):Ee(x.__webglFramebuffer,A,0)}else if(V){x.__webglDepthbuffer=[];for(let q=0;q<6;q++)if(e.bindFramebuffer(i.FRAMEBUFFER,x.__webglFramebuffer[q]),x.__webglDepthbuffer[q]===void 0)x.__webglDepthbuffer[q]=i.createRenderbuffer(),Vt(x.__webglDepthbuffer[q],A,!1);else{let K=A.stencilBuffer?i.DEPTH_STENCIL_ATTACHMENT:i.DEPTH_ATTACHMENT,ot=x.__webglDepthbuffer[q];i.bindRenderbuffer(i.RENDERBUFFER,ot),i.framebufferRenderbuffer(i.FRAMEBUFFER,K,i.RENDERBUFFER,ot)}}else{let q=A.texture.mipmaps;if(q&&q.length>0?e.bindFramebuffer(i.FRAMEBUFFER,x.__webglFramebuffer[0]):e.bindFramebuffer(i.FRAMEBUFFER,x.__webglFramebuffer),x.__webglDepthbuffer===void 0)x.__webglDepthbuffer=i.createRenderbuffer(),Vt(x.__webglDepthbuffer,A,!1);else{let K=A.stencilBuffer?i.DEPTH_STENCIL_ATTACHMENT:i.DEPTH_ATTACHMENT,ot=x.__webglDepthbuffer;i.bindRenderbuffer(i.RENDERBUFFER,ot),i.framebufferRenderbuffer(i.FRAMEBUFFER,K,i.RENDERBUFFER,ot)}}e.bindFramebuffer(i.FRAMEBUFFER,null)}function Kt(A,x,V){let q=n.get(A);x!==void 0&&yt(q.__webglFramebuffer,A,A.texture,i.COLOR_ATTACHMENT0,i.TEXTURE_2D,0),V!==void 0&&Gt(A)}function oe(A){let x=A.texture,V=n.get(A),q=n.get(x);A.addEventListener("dispose",v);let K=A.textures,ot=A.isWebGLCubeRenderTarget===!0,lt=K.length>1;if(lt||(q.__webglTexture===void 0&&(q.__webglTexture=i.createTexture()),q.__version=x.version,o.memory.textures++),ot){V.__webglFramebuffer=[];for(let j=0;j<6;j++)if(x.mipmaps&&x.mipmaps.length>0){V.__webglFramebuffer[j]=[];for(let tt=0;tt0){V.__webglFramebuffer=[];for(let j=0;j0&&ve(A)===!1){V.__webglMultisampledFramebuffer=i.createFramebuffer(),V.__webglColorRenderbuffer=[],e.bindFramebuffer(i.FRAMEBUFFER,V.__webglMultisampledFramebuffer);for(let j=0;j0)for(let tt=0;tt0)for(let tt=0;tt0){if(ve(A)===!1){let x=A.textures,V=A.width,q=A.height,K=i.COLOR_BUFFER_BIT,ot=A.stencilBuffer?i.DEPTH_STENCIL_ATTACHMENT:i.DEPTH_ATTACHMENT,lt=n.get(A),j=x.length>1;if(j)for(let ct=0;ct0?e.bindFramebuffer(i.DRAW_FRAMEBUFFER,lt.__webglFramebuffer[0]):e.bindFramebuffer(i.DRAW_FRAMEBUFFER,lt.__webglFramebuffer);for(let ct=0;ct0&&t.has("WEBGL_multisampled_render_to_texture")===!0&&x.__useRenderToTexture!==!1}function F(A){let x=o.render.frame;h.get(A)!==x&&(h.set(A,x),A.update())}function ze(A,x){let V=A.colorSpace,q=A.format,K=A.type;return A.isCompressedTexture===!0||A.isVideoTexture===!0||V!==Fs&&V!==Wn&&(qt.getTransfer(V)===te?(q!==tn||K!==Qe)&&Ct("WebGLTextures: sRGB encoded textures have to use RGBAFormat and UnsignedByteType."):Pt("WebGLTextures: Unsupported texture color space:",V)),x}function ee(A){return typeof HTMLImageElement<"u"&&A instanceof HTMLImageElement?(c.width=A.naturalWidth||A.width,c.height=A.naturalHeight||A.height):typeof VideoFrame<"u"&&A instanceof VideoFrame?(c.width=A.displayWidth,c.height=A.displayHeight):(c.width=A.width,c.height=A.height),c}this.allocateTextureUnit=X,this.resetTextureUnits=O,this.getTextureUnits=I,this.setTextureUnits=k,this.setTexture2D=B,this.setTexture2DArray=U,this.setTexture3D=H,this.setTextureCube=Z,this.rebindTextures=Kt,this.setupRenderTarget=oe,this.updateRenderTargetMipmap=Xt,this.updateMultisampleRenderTarget=je,this.setupDepthRenderbuffer=Gt,this.setupFrameBufferTexture=yt,this.useMultisampledRTT=ve,this.isReversedDepthBuffer=function(){return e.buffers.depth.getReversed()}}function cx(i,t){function e(n,s=Wn){let r,o=qt.getTransfer(s);if(n===Qe)return i.UNSIGNED_BYTE;if(n===Bo)return i.UNSIGNED_SHORT_4_4_4_4;if(n===Oo)return i.UNSIGNED_SHORT_5_5_5_1;if(n===Yl)return i.UNSIGNED_INT_5_9_9_9_REV;if(n===$l)return i.UNSIGNED_INT_10F_11F_11F_REV;if(n===Xl)return i.BYTE;if(n===ql)return i.SHORT;if(n===gs)return i.UNSIGNED_SHORT;if(n===Fo)return i.INT;if(n===xn)return i.UNSIGNED_INT;if(n===We)return i.FLOAT;if(n===vn)return i.HALF_FLOAT;if(n===Zl)return i.ALPHA;if(n===Jl)return i.RGB;if(n===tn)return i.RGBA;if(n===Tn)return i.DEPTH_COMPONENT;if(n===hi)return i.DEPTH_STENCIL;if(n===zo)return i.RED;if(n===ko)return i.RED_INTEGER;if(n===ui)return i.RG;if(n===Vo)return i.RG_INTEGER;if(n===Ho)return i.RGBA_INTEGER;if(n===er||n===nr||n===ir||n===sr)if(o===te)if(r=t.get("WEBGL_compressed_texture_s3tc_srgb"),r!==null){if(n===er)return r.COMPRESSED_SRGB_S3TC_DXT1_EXT;if(n===nr)return r.COMPRESSED_SRGB_ALPHA_S3TC_DXT1_EXT;if(n===ir)return r.COMPRESSED_SRGB_ALPHA_S3TC_DXT3_EXT;if(n===sr)return r.COMPRESSED_SRGB_ALPHA_S3TC_DXT5_EXT}else return null;else if(r=t.get("WEBGL_compressed_texture_s3tc"),r!==null){if(n===er)return r.COMPRESSED_RGB_S3TC_DXT1_EXT;if(n===nr)return r.COMPRESSED_RGBA_S3TC_DXT1_EXT;if(n===ir)return r.COMPRESSED_RGBA_S3TC_DXT3_EXT;if(n===sr)return r.COMPRESSED_RGBA_S3TC_DXT5_EXT}else return null;if(n===Go||n===Wo||n===Xo||n===qo)if(r=t.get("WEBGL_compressed_texture_pvrtc"),r!==null){if(n===Go)return r.COMPRESSED_RGB_PVRTC_4BPPV1_IMG;if(n===Wo)return r.COMPRESSED_RGB_PVRTC_2BPPV1_IMG;if(n===Xo)return r.COMPRESSED_RGBA_PVRTC_4BPPV1_IMG;if(n===qo)return r.COMPRESSED_RGBA_PVRTC_2BPPV1_IMG}else return null;if(n===Yo||n===$o||n===Zo||n===Jo||n===Ko||n===rr||n===jo)if(r=t.get("WEBGL_compressed_texture_etc"),r!==null){if(n===Yo||n===$o)return o===te?r.COMPRESSED_SRGB8_ETC2:r.COMPRESSED_RGB8_ETC2;if(n===Zo)return o===te?r.COMPRESSED_SRGB8_ALPHA8_ETC2_EAC:r.COMPRESSED_RGBA8_ETC2_EAC;if(n===Jo)return r.COMPRESSED_R11_EAC;if(n===Ko)return r.COMPRESSED_SIGNED_R11_EAC;if(n===rr)return r.COMPRESSED_RG11_EAC;if(n===jo)return r.COMPRESSED_SIGNED_RG11_EAC}else return null;if(n===Qo||n===ta||n===ea||n===na||n===ia||n===sa||n===ra||n===oa||n===aa||n===la||n===ca||n===ha||n===ua||n===da)if(r=t.get("WEBGL_compressed_texture_astc"),r!==null){if(n===Qo)return o===te?r.COMPRESSED_SRGB8_ALPHA8_ASTC_4x4_KHR:r.COMPRESSED_RGBA_ASTC_4x4_KHR;if(n===ta)return o===te?r.COMPRESSED_SRGB8_ALPHA8_ASTC_5x4_KHR:r.COMPRESSED_RGBA_ASTC_5x4_KHR;if(n===ea)return o===te?r.COMPRESSED_SRGB8_ALPHA8_ASTC_5x5_KHR:r.COMPRESSED_RGBA_ASTC_5x5_KHR;if(n===na)return o===te?r.COMPRESSED_SRGB8_ALPHA8_ASTC_6x5_KHR:r.COMPRESSED_RGBA_ASTC_6x5_KHR;if(n===ia)return o===te?r.COMPRESSED_SRGB8_ALPHA8_ASTC_6x6_KHR:r.COMPRESSED_RGBA_ASTC_6x6_KHR;if(n===sa)return o===te?r.COMPRESSED_SRGB8_ALPHA8_ASTC_8x5_KHR:r.COMPRESSED_RGBA_ASTC_8x5_KHR;if(n===ra)return o===te?r.COMPRESSED_SRGB8_ALPHA8_ASTC_8x6_KHR:r.COMPRESSED_RGBA_ASTC_8x6_KHR;if(n===oa)return o===te?r.COMPRESSED_SRGB8_ALPHA8_ASTC_8x8_KHR:r.COMPRESSED_RGBA_ASTC_8x8_KHR;if(n===aa)return o===te?r.COMPRESSED_SRGB8_ALPHA8_ASTC_10x5_KHR:r.COMPRESSED_RGBA_ASTC_10x5_KHR;if(n===la)return o===te?r.COMPRESSED_SRGB8_ALPHA8_ASTC_10x6_KHR:r.COMPRESSED_RGBA_ASTC_10x6_KHR;if(n===ca)return o===te?r.COMPRESSED_SRGB8_ALPHA8_ASTC_10x8_KHR:r.COMPRESSED_RGBA_ASTC_10x8_KHR;if(n===ha)return o===te?r.COMPRESSED_SRGB8_ALPHA8_ASTC_10x10_KHR:r.COMPRESSED_RGBA_ASTC_10x10_KHR;if(n===ua)return o===te?r.COMPRESSED_SRGB8_ALPHA8_ASTC_12x10_KHR:r.COMPRESSED_RGBA_ASTC_12x10_KHR;if(n===da)return o===te?r.COMPRESSED_SRGB8_ALPHA8_ASTC_12x12_KHR:r.COMPRESSED_RGBA_ASTC_12x12_KHR}else return null;if(n===fa||n===pa||n===ma)if(r=t.get("EXT_texture_compression_bptc"),r!==null){if(n===fa)return o===te?r.COMPRESSED_SRGB_ALPHA_BPTC_UNORM_EXT:r.COMPRESSED_RGBA_BPTC_UNORM_EXT;if(n===pa)return r.COMPRESSED_RGB_BPTC_SIGNED_FLOAT_EXT;if(n===ma)return r.COMPRESSED_RGB_BPTC_UNSIGNED_FLOAT_EXT}else return null;if(n===ga||n===_a||n===or||n===xa)if(r=t.get("EXT_texture_compression_rgtc"),r!==null){if(n===ga)return r.COMPRESSED_RED_RGTC1_EXT;if(n===_a)return r.COMPRESSED_SIGNED_RED_RGTC1_EXT;if(n===or)return r.COMPRESSED_RED_GREEN_RGTC2_EXT;if(n===xa)return r.COMPRESSED_SIGNED_RED_GREEN_RGTC2_EXT}else return null;return n===_s?i.UNSIGNED_INT_24_8:i[n]!==void 0?i[n]:null}return{convert:e}}var hx=` +void main() { + + gl_Position = vec4( position, 1.0 ); + +}`,ux=` +uniform sampler2DArray depthColor; +uniform float depthWidth; +uniform float depthHeight; + +void main() { + + vec2 coord = vec2( gl_FragCoord.x / depthWidth, gl_FragCoord.y / depthHeight ); + + if ( coord.x >= 1.0 ) { + + gl_FragDepth = texture( depthColor, vec3( coord.x - 1.0, coord.y, 1 ) ).r; + + } else { + + gl_FragDepth = texture( depthColor, vec3( coord.x, coord.y, 0 ) ).r; + + } + +}`,Sc=class{constructor(){this.texture=null,this.mesh=null,this.depthNear=0,this.depthFar=0}init(t,e){if(this.texture===null){let n=new Xs(t.texture);(t.depthNear!==e.depthNear||t.depthFar!==e.depthFar)&&(this.depthNear=t.depthNear,this.depthFar=t.depthFar),this.texture=n}}getMesh(t){if(this.texture!==null&&this.mesh===null){let e=t.cameras[0].viewport,n=new Ze({vertexShader:hx,fragmentShader:ux,uniforms:{depthColor:{value:this.texture},depthWidth:{value:e.z},depthHeight:{value:e.w}}});this.mesh=new _e(new Gn(20,20),n)}return this.mesh}reset(){this.texture=null,this.mesh=null}getDepthTexture(){return this.texture}},bc=class extends An{constructor(t,e){super();let n=this,s=null,r=1,o=null,a="local-floor",l=1,c=null,h=null,d=null,u=null,p=null,g=null,_=typeof XRWebGLBinding<"u",m=new Sc,f={},w=e.getContextAttributes(),R=null,S=null,b=[],y=[],T=new zt,v=null,E=null,P=new Ge;P.viewport=new pe;let D=new Ge;D.viewport=new pe;let z=[P,D],O=new Io,I=null,k=null;this.cameraAutoUpdate=!0,this.enabled=!1,this.isPresenting=!1,this.getController=function(J){let et=b[J];return et===void 0&&(et=new ls,b[J]=et),et.getTargetRaySpace()},this.getControllerGrip=function(J){let et=b[J];return et===void 0&&(et=new ls,b[J]=et),et.getGripSpace()},this.getHand=function(J){let et=b[J];return et===void 0&&(et=new ls,b[J]=et),et.getHandSpace()};function X(J){let et=y.indexOf(J.inputSource);if(et===-1)return;let xt=b[et];xt!==void 0&&(xt.update(J.inputSource,J.frame,c||o),xt.dispatchEvent({type:J.type,data:J.inputSource}))}function Y(){s.removeEventListener("select",X),s.removeEventListener("selectstart",X),s.removeEventListener("selectend",X),s.removeEventListener("squeeze",X),s.removeEventListener("squeezestart",X),s.removeEventListener("squeezeend",X),s.removeEventListener("end",Y),s.removeEventListener("inputsourceschange",B);for(let J=0;J=0&&(y[Lt]=null,b[Lt].disconnect(xt))}for(let et=0;et=y.length){y.push(xt),Lt=Vt;break}else if(y[Vt]===null){y[Vt]=xt,Lt=Vt;break}if(Lt===-1)break}let yt=b[Lt];yt&&yt.connect(xt)}}let U=new C,H=new C;function Z(J,et,xt){U.setFromMatrixPosition(et.matrixWorld),H.setFromMatrixPosition(xt.matrixWorld);let Lt=U.distanceTo(H),yt=et.projectionMatrix.elements,Vt=xt.projectionMatrix.elements,Ee=yt[14]/(yt[10]-1),Gt=yt[14]/(yt[10]+1),Kt=(yt[9]+1)/yt[5],oe=(yt[9]-1)/yt[5],Xt=(yt[8]-1)/yt[0],fe=(Vt[8]+1)/Vt[0],Re=Ee*Xt,je=Ee*fe,me=Lt/(-Xt+fe),ve=me*-Xt;if(et.matrixWorld.decompose(J.position,J.quaternion,J.scale),J.translateX(ve),J.translateZ(me),J.matrixWorld.compose(J.position,J.quaternion,J.scale),J.matrixWorldInverse.copy(J.matrixWorld).invert(),yt[10]===-1)J.projectionMatrix.copy(et.projectionMatrix),J.projectionMatrixInverse.copy(et.projectionMatrixInverse);else{let F=Ee+me,ze=Gt+me,ee=Re-ve,A=je+(Lt-ve),x=Kt*Gt/ze*F,V=oe*Gt/ze*F;J.projectionMatrix.makePerspective(ee,A,x,V,F,ze),J.projectionMatrixInverse.copy(J.projectionMatrix).invert()}}function rt(J,et){et===null?J.matrixWorld.copy(J.matrix):J.matrixWorld.multiplyMatrices(et.matrixWorld,J.matrix),J.matrixWorldInverse.copy(J.matrixWorld).invert()}this.updateCamera=function(J){if(s===null)return;let et=J.near,xt=J.far;m.texture!==null&&(m.depthNear>0&&(et=m.depthNear),m.depthFar>0&&(xt=m.depthFar)),O.near=D.near=P.near=et,O.far=D.far=P.far=xt,(I!==O.near||k!==O.far)&&(s.updateRenderState({depthNear:O.near,depthFar:O.far}),I=O.near,k=O.far),O.layers.mask=J.layers.mask|6,P.layers.mask=O.layers.mask&-5,D.layers.mask=O.layers.mask&-3;let Lt=J.parent,yt=O.cameras;rt(O,Lt);for(let Vt=0;Vt0&&(m.alphaTest.value=f.alphaTest);let w=t.get(f),R=w.envMap,S=w.envMapRotation;R&&(m.envMap.value=R,m.envMapRotation.value.setFromMatrix4(dx.makeRotationFromEuler(S)).transpose(),R.isCubeTexture&&R.isRenderTargetTexture===!1&&m.envMapRotation.value.premultiply($u),m.reflectivity.value=f.reflectivity,m.ior.value=f.ior,m.refractionRatio.value=f.refractionRatio),f.lightMap&&(m.lightMap.value=f.lightMap,m.lightMapIntensity.value=f.lightMapIntensity,e(f.lightMap,m.lightMapTransform)),f.aoMap&&(m.aoMap.value=f.aoMap,m.aoMapIntensity.value=f.aoMapIntensity,e(f.aoMap,m.aoMapTransform))}function o(m,f){m.diffuse.value.copy(f.color),m.opacity.value=f.opacity,f.map&&(m.map.value=f.map,e(f.map,m.mapTransform))}function a(m,f){m.dashSize.value=f.dashSize,m.totalSize.value=f.dashSize+f.gapSize,m.scale.value=f.scale}function l(m,f,w,R){m.diffuse.value.copy(f.color),m.opacity.value=f.opacity,m.size.value=f.size*w,m.scale.value=R*.5,f.map&&(m.map.value=f.map,e(f.map,m.uvTransform)),f.alphaMap&&(m.alphaMap.value=f.alphaMap,e(f.alphaMap,m.alphaMapTransform)),f.alphaTest>0&&(m.alphaTest.value=f.alphaTest)}function c(m,f){m.diffuse.value.copy(f.color),m.opacity.value=f.opacity,m.rotation.value=f.rotation,f.map&&(m.map.value=f.map,e(f.map,m.mapTransform)),f.alphaMap&&(m.alphaMap.value=f.alphaMap,e(f.alphaMap,m.alphaMapTransform)),f.alphaTest>0&&(m.alphaTest.value=f.alphaTest)}function h(m,f){m.specular.value.copy(f.specular),m.shininess.value=Math.max(f.shininess,1e-4)}function d(m,f){f.gradientMap&&(m.gradientMap.value=f.gradientMap)}function u(m,f){m.metalness.value=f.metalness,f.metalnessMap&&(m.metalnessMap.value=f.metalnessMap,e(f.metalnessMap,m.metalnessMapTransform)),m.roughness.value=f.roughness,f.roughnessMap&&(m.roughnessMap.value=f.roughnessMap,e(f.roughnessMap,m.roughnessMapTransform)),f.envMap&&(m.envMapIntensity.value=f.envMapIntensity)}function p(m,f,w){m.ior.value=f.ior,f.sheen>0&&(m.sheenColor.value.copy(f.sheenColor).multiplyScalar(f.sheen),m.sheenRoughness.value=f.sheenRoughness,f.sheenColorMap&&(m.sheenColorMap.value=f.sheenColorMap,e(f.sheenColorMap,m.sheenColorMapTransform)),f.sheenRoughnessMap&&(m.sheenRoughnessMap.value=f.sheenRoughnessMap,e(f.sheenRoughnessMap,m.sheenRoughnessMapTransform))),f.clearcoat>0&&(m.clearcoat.value=f.clearcoat,m.clearcoatRoughness.value=f.clearcoatRoughness,f.clearcoatMap&&(m.clearcoatMap.value=f.clearcoatMap,e(f.clearcoatMap,m.clearcoatMapTransform)),f.clearcoatRoughnessMap&&(m.clearcoatRoughnessMap.value=f.clearcoatRoughnessMap,e(f.clearcoatRoughnessMap,m.clearcoatRoughnessMapTransform)),f.clearcoatNormalMap&&(m.clearcoatNormalMap.value=f.clearcoatNormalMap,e(f.clearcoatNormalMap,m.clearcoatNormalMapTransform),m.clearcoatNormalScale.value.copy(f.clearcoatNormalScale),f.side===Je&&m.clearcoatNormalScale.value.negate())),f.dispersion>0&&(m.dispersion.value=f.dispersion),f.retroreflectivity>0&&(m.retroreflectivity.value=f.retroreflectivity),f.iridescence>0&&(m.iridescence.value=f.iridescence,m.iridescenceIOR.value=f.iridescenceIOR,m.iridescenceThicknessMinimum.value=f.iridescenceThicknessRange[0],m.iridescenceThicknessMaximum.value=f.iridescenceThicknessRange[1],f.iridescenceMap&&(m.iridescenceMap.value=f.iridescenceMap,e(f.iridescenceMap,m.iridescenceMapTransform)),f.iridescenceThicknessMap&&(m.iridescenceThicknessMap.value=f.iridescenceThicknessMap,e(f.iridescenceThicknessMap,m.iridescenceThicknessMapTransform))),f.transmission>0&&(m.transmission.value=f.transmission,m.transmissionSamplerMap.value=w.texture,m.transmissionSamplerSize.value.set(w.width,w.height),f.transmissionMap&&(m.transmissionMap.value=f.transmissionMap,e(f.transmissionMap,m.transmissionMapTransform)),m.thickness.value=f.thickness,f.thicknessMap&&(m.thicknessMap.value=f.thicknessMap,e(f.thicknessMap,m.thicknessMapTransform)),m.attenuationDistance.value=f.attenuationDistance,m.attenuationColor.value.copy(f.attenuationColor)),f.anisotropy>0&&(m.anisotropyVector.value.set(f.anisotropy*Math.cos(f.anisotropyRotation),f.anisotropy*Math.sin(f.anisotropyRotation)),f.anisotropyMap&&(m.anisotropyMap.value=f.anisotropyMap,e(f.anisotropyMap,m.anisotropyMapTransform))),m.specularIntensity.value=f.specularIntensity,m.specularColor.value.copy(f.specularColor),f.specularColorMap&&(m.specularColorMap.value=f.specularColorMap,e(f.specularColorMap,m.specularColorMapTransform)),f.specularIntensityMap&&(m.specularIntensityMap.value=f.specularIntensityMap,e(f.specularIntensityMap,m.specularIntensityMapTransform))}function g(m,f){f.matcap&&(m.matcap.value=f.matcap)}function _(m,f){let w=t.get(f).light;m.referencePosition.value.setFromMatrixPosition(w.matrixWorld),m.nearDistance.value=w.shadow.camera.near,m.farDistance.value=w.shadow.camera.far}return{refreshFogUniforms:n,refreshMaterialUniforms:s}}function px(i,t,e,n){let s={},r={},o=[],a=i.getParameter(i.MAX_UNIFORM_BUFFER_BINDINGS);function l(S,b){let y=b.program;n.uniformBlockBinding(S,y)}function c(S,b){let y=s[S.id];y===void 0&&(m(S),y=h(S),s[S.id]=y,S.addEventListener("dispose",w));let T=b.program;n.updateUBOMapping(S,T);let v=t.render.frame;r[S.id]!==v&&(u(S),r[S.id]=v)}function h(S){let b=d();S.__bindingPointIndex=b;let y=i.createBuffer(),T=S.__size,v=S.usage;return i.bindBuffer(i.UNIFORM_BUFFER,y),i.bufferData(i.UNIFORM_BUFFER,T,v),i.bindBuffer(i.UNIFORM_BUFFER,null),i.bindBufferBase(i.UNIFORM_BUFFER,b,y),y}function d(){for(let S=0;S0&&(y+=T-v),S.__size=y,S.__cache={},this}function f(S){let b={boundary:0,storage:0};return typeof S=="number"||typeof S=="boolean"?(b.boundary=4,b.storage=4):S.isVector2?(b.boundary=8,b.storage=8):S.isVector3||S.isColor?(b.boundary=16,b.storage=12):S.isVector4?(b.boundary=16,b.storage=16):S.isMatrix3?(b.boundary=48,b.storage=48):S.isMatrix4?(b.boundary=64,b.storage=64):S.isTexture?Ct("WebGLRenderer: Texture samplers can not be part of an uniforms group."):ArrayBuffer.isView(S)?(b.boundary=16,b.storage=S.byteLength):Ct("WebGLRenderer: Unsupported uniform value type.",S),b}function w(S){let b=S.target;b.removeEventListener("dispose",w);let y=o.indexOf(b.__bindingPointIndex);o.splice(y,1),i.deleteBuffer(s[b.id]),delete s[b.id],delete r[b.id]}function R(){for(let S in s)i.deleteBuffer(s[S]);o=[],s={},r={}}return{bind:l,update:c,dispose:R}}var mx=new Uint16Array([12469,15057,12620,14925,13266,14620,13807,14376,14323,13990,14545,13625,14713,13328,14840,12882,14931,12528,14996,12233,15039,11829,15066,11525,15080,11295,15085,10976,15082,10705,15073,10495,13880,14564,13898,14542,13977,14430,14158,14124,14393,13732,14556,13410,14702,12996,14814,12596,14891,12291,14937,11834,14957,11489,14958,11194,14943,10803,14921,10506,14893,10278,14858,9960,14484,14039,14487,14025,14499,13941,14524,13740,14574,13468,14654,13106,14743,12678,14818,12344,14867,11893,14889,11509,14893,11180,14881,10751,14852,10428,14812,10128,14765,9754,14712,9466,14764,13480,14764,13475,14766,13440,14766,13347,14769,13070,14786,12713,14816,12387,14844,11957,14860,11549,14868,11215,14855,10751,14825,10403,14782,10044,14729,9651,14666,9352,14599,9029,14967,12835,14966,12831,14963,12804,14954,12723,14936,12564,14917,12347,14900,11958,14886,11569,14878,11247,14859,10765,14828,10401,14784,10011,14727,9600,14660,9289,14586,8893,14508,8533,15111,12234,15110,12234,15104,12216,15092,12156,15067,12010,15028,11776,14981,11500,14942,11205,14902,10752,14861,10393,14812,9991,14752,9570,14682,9252,14603,8808,14519,8445,14431,8145,15209,11449,15208,11451,15202,11451,15190,11438,15163,11384,15117,11274,15055,10979,14994,10648,14932,10343,14871,9936,14803,9532,14729,9218,14645,8742,14556,8381,14461,8020,14365,7603,15273,10603,15272,10607,15267,10619,15256,10631,15231,10614,15182,10535,15118,10389,15042,10167,14963,9787,14883,9447,14800,9115,14710,8665,14615,8318,14514,7911,14411,7507,14279,7198,15314,9675,15313,9683,15309,9712,15298,9759,15277,9797,15229,9773,15166,9668,15084,9487,14995,9274,14898,8910,14800,8539,14697,8234,14590,7790,14479,7409,14367,7067,14178,6621,15337,8619,15337,8631,15333,8677,15325,8769,15305,8871,15264,8940,15202,8909,15119,8775,15022,8565,14916,8328,14804,8009,14688,7614,14569,7287,14448,6888,14321,6483,14088,6171,15350,7402,15350,7419,15347,7480,15340,7613,15322,7804,15287,7973,15229,8057,15148,8012,15046,7846,14933,7611,14810,7357,14682,7069,14552,6656,14421,6316,14251,5948,14007,5528,15356,5942,15356,5977,15353,6119,15348,6294,15332,6551,15302,6824,15249,7044,15171,7122,15070,7050,14949,6861,14818,6611,14679,6349,14538,6067,14398,5651,14189,5311,13935,4958,15359,4123,15359,4153,15356,4296,15353,4646,15338,5160,15311,5508,15263,5829,15188,6042,15088,6094,14966,6001,14826,5796,14678,5543,14527,5287,14377,4985,14133,4586,13869,4257,15360,1563,15360,1642,15358,2076,15354,2636,15341,3350,15317,4019,15273,4429,15203,4732,15105,4911,14981,4932,14836,4818,14679,4621,14517,4386,14359,4156,14083,3795,13808,3437,15360,122,15360,137,15358,285,15355,636,15344,1274,15322,2177,15281,2765,15215,3223,15120,3451,14995,3569,14846,3567,14681,3466,14511,3305,14344,3121,14037,2800,13753,2467,15360,0,15360,1,15359,21,15355,89,15346,253,15325,479,15287,796,15225,1148,15133,1492,15008,1749,14856,1882,14685,1886,14506,1783,14324,1608,13996,1398,13702,1183]),Ln=null;function gx(){return Ln===null&&(Ln=new ei(mx,16,16,ui,vn),Ln.name="DFG_LUT",Ln.minFilter=De,Ln.magFilter=De,Ln.wrapS=En,Ln.wrapT=En,Ln.generateMipmaps=!1,Ln.needsUpdate=!0),Ln}var Ss=class{constructor(t={}){let{canvas:e=fu(),context:n=null,depth:s=!0,stencil:r=!1,alpha:o=!1,antialias:a=!1,premultipliedAlpha:l=!0,preserveDrawingBuffer:c=!1,powerPreference:h="default",failIfMajorPerformanceCaveat:d=!1,reversedDepthBuffer:u=!1,outputBufferType:p=Qe}=t;this.isWebGLRenderer=!0;let g;if(n!==null){if(typeof WebGLRenderingContext<"u"&&n instanceof WebGLRenderingContext)throw new Error("THREE.WebGLRenderer: WebGL 1 is not supported since r163.");g=n.getContextAttributes().alpha}else g=o;let _=p,m=new Set([Ho,Vo,ko]),f=new Set([Qe,xn,gs,_s,Bo,Oo]),w=new Uint32Array(4),R=new Int32Array(4),S=new C,b=null,y=null,T=[],v=[],E=null;this.domElement=e,this.debug={checkShaderErrors:!0,diagnostics:{keywords:!1},onShaderError:null},this.autoClear=!0,this.autoClearColor=!0,this.autoClearDepth=!0,this.autoClearStencil=!0,this.sortObjects=!0,this.clippingPlanes=[],this.localClippingEnabled=!1,this.toneMapping=_n,this.toneMappingExposure=1,this.transmissionResolutionScale=1;let P=this,D=!1,z=null,O=null,I=null,k=null;this._outputColorSpace=Se;let X=0,Y=0,B=null,U=-1,H=null,Z=new pe,rt=new pe,at=null,Ft=new Dt(0),Nt=0,$t=e.width,J=e.height,et=1,xt=null,Lt=null,yt=new pe(0,0,$t,J),Vt=new pe(0,0,$t,J),Ee=!1,Gt=new Cn,Kt=!1,oe=!1,Xt=new Ht,fe=new C,Re=new pe,je={background:null,fog:null,environment:null,overrideMaterial:null,isScene:!0},me=!1;function ve(){return B===null?et:1}let F=n;function ze(M,L){return e.getContext(M,L)}let ee,A,x,V,q,K,ot,lt,j,tt,ct,Tt,ft,ht,At,It,Bt,N,ut,Q,dt,_t,it;try{let M={alpha:!0,depth:s,stencil:r,antialias:a,premultipliedAlpha:l,preserveDrawingBuffer:c,powerPreference:h,failIfMajorPerformanceCaveat:d};if("setAttribute"in e&&e.setAttribute("data-engine",`three.js r${"186"}`),e.addEventListener("webglcontextlost",ae,!1),e.addEventListener("webglcontextrestored",jt,!1),e.addEventListener("webglcontextcreationerror",un,!1),F===null){let L="webgl2";if(F=ze(L,M),F===null)throw ze(L)?new Error("THREE.WebGLRenderer: Error creating WebGL context with your selected attributes."):new Error("THREE.WebGLRenderer: Error creating WebGL context.")}Rt()}catch(M){throw e.removeEventListener("webglcontextlost",ae,!1),e.removeEventListener("webglcontextrestored",jt,!1),e.removeEventListener("webglcontextcreationerror",un,!1),Pt("WebGLRenderer: "+M.message),M}function Rt(){ee=new b_(F),ee.init(),dt=new cx(F,ee),A=new f_(F,ee,t,dt),x=new ax(F,ee),A.reversedDepthBuffer&&u&&x.buffers.depth.setReversed(!0),O=F.createFramebuffer(),I=F.createFramebuffer(),k=F.createFramebuffer(),V=new T_(F),q=new Y0,K=new lx(F,ee,x,q,A,dt,V),ot=new S_(P),lt=new Rp(F),_t=new u_(F,lt),j=new w_(F,lt,V,_t),tt=new R_(F,j,lt,_t,V),N=new A_(F,A,K),At=new p_(q),ct=new q0(P,ot,ee,A,_t,At),Tt=new fx(P,q),ft=new Z0,ht=new ex(ee),Bt=new h_(P,ot,x,tt,g,l),It=new ox(P,tt,A),it=new px(F,V,A,x),ut=new d_(F,ee,V),Q=new E_(F,ee,V),V.programs=ct.programs,P.capabilities=A,P.extensions=ee,P.properties=q,P.renderLists=ft,P.shadowMap=It,P.state=x,P.info=V}_!==Qe&&(E=new I_(_,e.width,e.height,a,s,r));let wt=new bc(P,F);this.xr=wt,this.getContext=function(){return F},this.getContextAttributes=function(){return F.getContextAttributes()},this.forceContextLoss=function(){let M=ee.get("WEBGL_lose_context");M&&M.loseContext()},this.forceContextRestore=function(){let M=ee.get("WEBGL_lose_context");M&&M.restoreContext()},this.getPixelRatio=function(){return et},this.setPixelRatio=function(M){M!==void 0&&(et=M,this.setSize($t,J,!1))},this.getSize=function(M){return M.set($t,J)},this.setSize=function(M,L,$=!0){if(wt.isPresenting){Ct("WebGLRenderer: Can't change size while VR device is presenting.");return}$t=M,J=L,e.width=Math.floor(M*et),e.height=Math.floor(L*et),$===!0&&(e.style.width=M+"px",e.style.height=L+"px"),E!==null&&E.setSize(e.width,e.height),this.setViewport(0,0,M,L)},this.getDrawingBufferSize=function(M){return M.set($t*et,J*et).floor()},this.setDrawingBufferSize=function(M,L,$){$t=M,J=L,et=$,e.width=Math.floor(M*$),e.height=Math.floor(L*$),this.setViewport(0,0,M,L)},this.setEffects=function(M){if(_===Qe){Pt("WebGLRenderer: setEffects() requires outputBufferType set to HalfFloatType or FloatType.");return}if(M){for(let L=0;L{function gt(){if(G.forEach(function(Mt){let St=q.get(Mt).currentProgram;(St===void 0||St.isReady())&&G.delete(Mt)}),G.size===0){W(M);return}setTimeout(gt,10)}ee.get("KHR_parallel_shader_compile")!==null?gt():setTimeout(gt,10)})};let Za=null;function Ef(M){Za&&Za(M)}function Wc(){mi.stop()}function Xc(){mi.start()}let mi=new Hu;mi.setAnimationLoop(Ef),typeof self<"u"&&mi.setContext(self),this.setAnimationLoop=function(M){Za=M,wt.setAnimationLoop(M),M===null?mi.stop():mi.start()},wt.addEventListener("sessionstart",Wc),wt.addEventListener("sessionend",Xc),this.render=function(M,L){if(L!==void 0&&L.isCamera!==!0){Pt("WebGLRenderer.render: camera is not an instance of THREE.Camera.");return}if(D===!0)return;z!==null&&z.renderStart(M,L);let $=wt.enabled===!0&&wt.isPresenting===!0,G=E!==null&&(B===null||$)&&E.begin(P,B);if(M.matrixWorldAutoUpdate===!0&&M.updateMatrixWorld(),L.parent===null&&L.matrixWorldAutoUpdate===!0&&L.updateMatrixWorld(),wt.enabled===!0&&wt.isPresenting===!0&&(E===null||E.isCompositing()===!1)&&(wt.cameraAutoUpdate===!0&&wt.updateCamera(L),L=wt.getCamera()),M.isScene===!0&&M.onBeforeRender(P,M,L,B),y=ht.get(M,v.length),y.init(L),y.state.textureUnits=K.getTextureUnits(),v.push(y),Xt.multiplyMatrices(L.projectionMatrix,L.matrixWorldInverse),Gt.setFromProjectionMatrix(Xt,mn,L.reversedDepth),oe=this.localClippingEnabled,Kt=At.init(this.clippingPlanes,oe),b=ft.get(M,T.length),b.init(),T.push(b),wt.enabled===!0&&wt.isPresenting===!0){let Mt=P.xr.getDepthSensingMesh();Mt!==null&&Ja(Mt,L,-1/0,P.sortObjects)}Ja(M,L,0,P.sortObjects),b.finish(),z!==null&&z.updateLights(y.state.lightsArray),P.sortObjects===!0&&b.sort(xt,Lt),me=wt.enabled===!1||wt.isPresenting===!1||wt.hasDepthSensing()===!1,me&&Bt.addToRenderList(b,M),this.info.render.frame++,this.info.autoReset===!0&&this.info.reset(),Kt===!0&&At.beginShadows();let W=y.state.shadowsArray;if(It.render(W,M,L),Kt===!0&&At.endShadows(),(G&&E.hasRenderPass())===!1){let Mt=b.opaque,mt=b.transmissive;if(y.setupLights(),L.isArrayCamera){let St=L.cameras;if(mt.length>0)for(let Et=0,Ot=St.length;Et0&&Yc(Mt,mt,M,L),me&&Bt.render(M),qc(b,M,L)}B!==null&&Y===0&&(K.updateMultisampleRenderTarget(B),K.updateRenderTargetMipmap(B)),G&&E.end(P),M.isScene===!0&&M.onAfterRender(P,M,L),_t.resetDefaultState(),U=-1,H=null,v.pop(),v.length>0?(y=v[v.length-1],K.setTextureUnits(y.state.textureUnits),Kt===!0&&At.setGlobalState(P.clippingPlanes,y.state.camera)):y=null,T.pop(),T.length>0?b=T[T.length-1]:b=null,z!==null&&z.renderEnd()};function Ja(M,L,$,G){if(M.visible===!1)return;if(M.layers.test(L.layers)){if(M.isGroup)$=M.renderOrder;else if(M.isLOD)M.autoUpdate===!0&&M.update(L);else if(M.isLightProbeGrid)y.pushLightProbeGrid(M);else if(M.isLight)y.pushLight(M),M.castShadow&&y.pushShadow(M);else if(M.isSprite){if(!M.frustumCulled||M.intersectsFrustum(Gt)){G&&Re.setFromMatrixPosition(M.matrixWorld).applyMatrix4(Xt);let Mt=tt.update(M),mt=M.material;mt.visible&&b.push(M,Mt,mt,$,Re.z,null,L)}}else if((M.isMesh||M.isLine||M.isPoints)&&(!M.frustumCulled||M.intersectsFrustum(Gt))){let Mt=tt.update(M),mt=M.material;if(G&&(M.boundingSphere!==void 0?(M.boundingSphere===null&&M.computeBoundingSphere(),Re.copy(M.boundingSphere.center)):(Mt.boundingSphere===null&&Mt.computeBoundingSphere(),Re.copy(Mt.boundingSphere.center)),Re.applyMatrix4(M.matrixWorld).applyMatrix4(Xt)),Array.isArray(mt)){let St=Mt.groups;for(let Et=0,Ot=St.length;Et0&&Mr(W,L,$),gt.length>0&&Mr(gt,L,$),Mt.length>0&&Mr(Mt,L,$),x.buffers.depth.setTest(!0),x.buffers.depth.setMask(!0),x.buffers.color.setMask(!0),x.setPolygonOffset(!1)}function Yc(M,L,$,G){if(($.isScene===!0?$.overrideMaterial:null)!==null)return;if(y.state.transmissionRenderTarget[G.id]===void 0){let bt=ee.has("EXT_color_buffer_half_float")||ee.has("EXT_color_buffer_float");y.state.transmissionRenderTarget[G.id]=new Fe(1,1,{generateMipmaps:!0,type:bt?vn:Qe,minFilter:ci,samples:Math.max(4,A.samples),stencilBuffer:r,resolveDepthBuffer:!1,resolveStencilBuffer:!1,storeMultisampledDepthBuffer:!1,storeMultisampledStencilBuffer:!1,colorSpace:qt.workingColorSpace})}let gt=y.state.transmissionRenderTarget[G.id],Mt=G.viewport||Z;gt.setSize(Mt.z*P.transmissionResolutionScale,Mt.w*P.transmissionResolutionScale);let mt=P.getRenderTarget(),St=P.getActiveCubeFace(),Et=P.getActiveMipmapLevel();P.setRenderTarget(gt),P.getClearColor(Ft),Nt=P.getClearAlpha(),Nt<1&&P.setClearColor(16777215,.5),P.clear(),me&&Bt.render($);let Ot=P.toneMapping;P.toneMapping=_n;let Wt=G.viewport;if(G.viewport!==void 0&&(G.viewport=void 0),y.setupLightsView(G),Kt===!0&&At.setGlobalState(P.clippingPlanes,G),Mr(M,$,G),K.updateMultisampleRenderTarget(gt),K.updateRenderTargetMipmap(gt),ee.has("WEBGL_multisampled_render_to_texture")===!1){let bt=!1;for(let Qt=0,ye=L.length;Qt0,G.currentProgram=Wt,G.uniformsList=null,Wt}function Zc(M){if(M.uniformsList===null){let L=M.currentProgram.getUniforms();M.uniformsList=Ms.seqWithValue(L.seq,M.uniforms)}return M.uniformsList}function Jc(M,L){let $=q.get(M);$.outputColorSpace=L.outputColorSpace,$.batching=L.batching,$.batchingColor=L.batchingColor,$.instancing=L.instancing,$.instancingColor=L.instancingColor,$.instancingMorph=L.instancingMorph,$.skinning=L.skinning,$.morphTargets=L.morphTargets,$.morphNormals=L.morphNormals,$.morphColors=L.morphColors,$.morphTargetsCount=L.morphTargetsCount,$.numClippingPlanes=L.numClippingPlanes,$.numIntersection=L.numClipIntersection,$.vertexAlphas=L.vertexAlphas,$.vertexTangents=L.vertexTangents,$.toneMapping=L.toneMapping}function Tf(M,L){if(M.length===0)return null;if(M.length===1)return M[0].texture!==null?M[0]:null;S.setFromMatrixPosition(L.matrixWorld);for(let $=0,G=M.length;$0),bt=!!$.morphAttributes.position,Qt=!!$.morphAttributes.normal,ye=!!$.morphAttributes.color,he=_n;G.toneMapped&&(B===null||B.isXRRenderTarget===!0)&&(he=P.toneMapping);let ie=$.morphAttributes.position||$.morphAttributes.normal||$.morphAttributes.color,ke=ie!==void 0?ie.length:0,vt=q.get(G),Ye=y.state.lights;if(Kt===!0&&(oe===!0||M!==H)){let le=M===H&&G.id===U;At.setState(G,M,le)}let Jt=!1;G.version===vt.__version?(vt.needsLights&&vt.lightsStateVersion!==Ye.state.version||vt.outputColorSpace!==mt||W.isBatchedMesh&&vt.batching===!1||!W.isBatchedMesh&&vt.batching===!0||W.isBatchedMesh&&vt.batchingColor===!0&&W._colorsTexture===null||W.isBatchedMesh&&vt.batchingColor===!1&&W._colorsTexture!==null||W.isInstancedMesh&&vt.instancing===!1||!W.isInstancedMesh&&vt.instancing===!0||W.isSkinnedMesh&&vt.skinning===!1||!W.isSkinnedMesh&&vt.skinning===!0||W.isInstancedMesh&&vt.instancingColor===!0&&W.instanceColor===null||W.isInstancedMesh&&vt.instancingColor===!1&&W.instanceColor!==null||W.isInstancedMesh&&vt.instancingMorph===!0&&W.morphTexture===null||W.isInstancedMesh&&vt.instancingMorph===!1&&W.morphTexture!==null||vt.envMap!==Et||G.fog===!0&&vt.fog!==gt||vt.numClippingPlanes!==void 0&&(vt.numClippingPlanes!==At.numPlanes||vt.numIntersection!==At.numIntersection)||vt.vertexAlphas!==Ot||vt.vertexTangents!==Wt||vt.morphTargets!==bt||vt.morphNormals!==Qt||vt.morphColors!==ye||vt.toneMapping!==he||vt.morphTargetsCount!==ke||!!vt.lightProbeGrid!=y.state.lightProbeGridArray.length>0)&&(Jt=!0):(Jt=!0,vt.__version=G.version);let an=vt.currentProgram;Jt===!0&&(an=Sr(G,L,W),z&&G.isNodeMaterial&&z.onUpdateProgram(G,an,vt));let bn=!1,qn=!1,Fi=!1,ne=an.getUniforms(),xe=vt.uniforms;if(x.useProgram(an.program)&&(bn=!0,qn=!0,Fi=!0),G.id!==U&&(U=G.id,qn=!0),vt.needsLights){let le=Tf(y.state.lightProbeGridArray,W);vt.lightProbeGrid!==le&&(vt.lightProbeGrid=le,qn=!0)}if(bn||H!==M){x.buffers.depth.getReversed()&&M.reversedDepth!==!0&&(M._reversedDepth=!0,M.updateProjectionMatrix()),ne.setValue(F,"projectionMatrix",M.projectionMatrix),ne.setValue(F,"viewMatrix",M.matrixWorldInverse);let $n=ne.map.cameraPosition;$n!==void 0&&$n.setValue(F,fe.setFromMatrixPosition(M.matrixWorld)),A.logarithmicDepthBuffer&&ne.setValue(F,"logDepthBufFC",2/(Math.log(M.far+1)/Math.LN2)),(G.isMeshPhongMaterial||G.isMeshToonMaterial||G.isMeshLambertMaterial||G.isMeshBasicMaterial||G.isMeshStandardMaterial||G.isShaderMaterial)&&ne.setValue(F,"isOrthographic",M.isOrthographicCamera===!0),H!==M&&(H=M,qn=!0,Fi=!0)}if(vt.needsLights&&(Ye.state.sunShadowMap.length>0&&ne.setValue(F,"sunShadowMap",Ye.state.sunShadowMap,K),Ye.state.directionalShadowMap.length>0&&ne.setValue(F,"directionalShadowMap",Ye.state.directionalShadowMap,K),Ye.state.spotShadowMap.length>0&&ne.setValue(F,"spotShadowMap",Ye.state.spotShadowMap,K),Ye.state.pointShadowMap.length>0&&ne.setValue(F,"pointShadowMap",Ye.state.pointShadowMap,K)),W.isSkinnedMesh){ne.setOptional(F,W,"bindMatrix"),ne.setOptional(F,W,"bindMatrixInverse");let le=W.skeleton;le&&(le.boneTexture===null&&le.computeBoneTexture(),ne.setValue(F,"boneTexture",le.boneTexture,K))}W.isBatchedMesh&&(ne.setOptional(F,W,"batchingTexture"),ne.setValue(F,"batchingTexture",W._matricesTexture,K),ne.setOptional(F,W,"batchingIdTexture"),ne.setValue(F,"batchingIdTexture",W._indirectTexture,K),ne.setOptional(F,W,"batchingColorTexture"),W._colorsTexture!==null&&ne.setValue(F,"batchingColorTexture",W._colorsTexture,K));let Yn=$.morphAttributes;if((Yn.position!==void 0||Yn.normal!==void 0||Yn.color!==void 0)&&N.update(W,$,an),(qn||vt.receiveShadow!==W.receiveShadow)&&(vt.receiveShadow=W.receiveShadow,ne.setValue(F,"receiveShadow",W.receiveShadow)),(G.isMeshStandardMaterial||G.isMeshLambertMaterial||G.isMeshPhongMaterial)&&G.envMap===null&&L.environment!==null&&(xe.envMapIntensity.value=L.environmentIntensity),xe.dfgLUT!==void 0&&(xe.dfgLUT.value=gx()),qn){if(ne.setValue(F,"toneMappingExposure",P.toneMappingExposure),vt.needsLights&&Rf(xe,Fi),gt&&G.fog===!0&&Tt.refreshFogUniforms(xe,gt),Tt.refreshMaterialUniforms(xe,G,et,J,y.state.transmissionRenderTarget[M.id]),vt.needsLights&&vt.lightProbeGrid){let le=vt.lightProbeGrid;xe.probesSH.value=le.texture,xe.probesMin.value.copy(le.boundingBox.min),xe.probesMax.value.copy(le.boundingBox.max),xe.probesResolution.value.copy(le.resolution)}Ms.upload(F,Zc(vt),xe,K)}if(G.isShaderMaterial&&G.uniformsNeedUpdate===!0&&(Ms.upload(F,Zc(vt),xe,K),G.uniformsNeedUpdate=!1),G.isSpriteMaterial&&ne.setValue(F,"center",W.center),ne.setValue(F,"modelViewMatrix",W.modelViewMatrix),ne.setValue(F,"normalMatrix",W.normalMatrix),ne.setValue(F,"modelMatrix",W.matrixWorld),G.uniformsGroups!==void 0){let le=G.uniformsGroups;for(let $n=0,Bi=le.length;$n0&&K.useMultisampledRTT(M)===!1?G=q.get(M).__webglMultisampledFramebuffer:Array.isArray(Et)?G=Et[$]:G=Et,Z.copy(M.viewport),rt.copy(M.scissor),at=M.scissorTest}else Z.copy(yt).multiplyScalar(et).floor(),rt.copy(Vt).multiplyScalar(et).floor(),at=Ee;if($!==0&&(G=O),x.bindFramebuffer(F.FRAMEBUFFER,G)&&x.drawBuffers(M,G),x.viewport(Z),x.scissor(rt),x.setScissorTest(at),W){let mt=q.get(M.texture);F.framebufferTexture2D(F.FRAMEBUFFER,F.COLOR_ATTACHMENT0,F.TEXTURE_CUBE_MAP_POSITIVE_X+L,mt.__webglTexture,$)}else if(gt){let mt=L;for(let St=0;St1&&F.readBuffer(F.COLOR_ATTACHMENT0+mt);let bt=Kc(Et);if(bt.__formatReadable===!1){Pt("WebGLRenderer.readRenderTargetPixels: renderTarget is not in RGBA or implementation defined format.");return}if(bt.__typeReadable===!1){Pt("WebGLRenderer.readRenderTargetPixels: renderTarget is not in UnsignedByteType or implementation defined type.");return}L>=0&&L<=M.width-G&&$>=0&&$<=M.height-W&&F.readPixels(L,$,G,W,dt.convert(Ot),dt.convert(Wt),gt)}finally{let Et=B!==null?q.get(B).__webglFramebuffer:null;x.bindFramebuffer(F.FRAMEBUFFER,Et)}}},this.readRenderTargetPixelsAsync=async function(M,L,$,G,W,gt,Mt,mt=0){if(!(M&&M.isWebGLRenderTarget))throw new Error("THREE.WebGLRenderer.readRenderTargetPixels: renderTarget is not THREE.WebGLRenderTarget.");let St=q.get(M).__webglFramebuffer;if(M.isWebGLCubeRenderTarget&&Mt!==void 0&&(St=St[Mt]),St)if(L>=0&&L<=M.width-G&&$>=0&&$<=M.height-W){x.bindFramebuffer(F.FRAMEBUFFER,St);let Et=M.textures[mt],Ot=Et.format,Wt=Et.type;M.textures.length>1&&F.readBuffer(F.COLOR_ATTACHMENT0+mt);let bt=Kc(Et);if(bt.__formatReadable===!1)throw new Error("THREE.WebGLRenderer.readRenderTargetPixelsAsync: renderTarget is not in RGBA or implementation defined format.");if(bt.__typeReadable===!1)throw new Error("THREE.WebGLRenderer.readRenderTargetPixelsAsync: renderTarget is not in UnsignedByteType or implementation defined type.");let Qt=F.createBuffer();F.bindBuffer(F.PIXEL_PACK_BUFFER,Qt),F.bufferData(F.PIXEL_PACK_BUFFER,gt.byteLength,F.STREAM_READ),F.readPixels(L,$,G,W,dt.convert(Ot),dt.convert(Wt),0),F.bindBuffer(F.PIXEL_PACK_BUFFER,null);let ye=B!==null?q.get(B).__webglFramebuffer:null;x.bindFramebuffer(F.FRAMEBUFFER,ye);let he=F.fenceSync(F.SYNC_GPU_COMMANDS_COMPLETE,0);return F.flush(),await mu(F,he,4),F.bindBuffer(F.PIXEL_PACK_BUFFER,Qt),F.getBufferSubData(F.PIXEL_PACK_BUFFER,0,gt),F.bindBuffer(F.PIXEL_PACK_BUFFER,null),F.deleteBuffer(Qt),F.deleteSync(he),gt}else throw new Error("THREE.WebGLRenderer.readRenderTargetPixelsAsync: requested read bounds are out of range.")},this.copyFramebufferToTexture=function(M,L=null,$=0){let G=Math.pow(2,-$),W=Math.floor(M.image.width*G),gt=Math.floor(M.image.height*G),Mt=L!==null?L.x:0,mt=L!==null?L.y:0;K.setTexture2D(M,0),F.copyTexSubImage2D(F.TEXTURE_2D,$,0,0,Mt,mt,W,gt),x.unbindTexture()},this.copyTextureToTexture=function(M,L,$=null,G=null,W=0,gt=0){let Mt,mt,St,Et,Ot,Wt,bt,Qt,ye,he=M.isCompressedTexture?M.mipmaps[gt]:M.image;if($!==null)Mt=$.max.x-$.min.x,mt=$.max.y-$.min.y,St=$.isBox3?$.max.z-$.min.z:1,Et=$.min.x,Ot=$.min.y,Wt=$.isBox3?$.min.z:0;else{let xe=Math.pow(2,-W);Mt=Math.floor(he.width*xe),mt=Math.floor(he.height*xe),M.isDataArrayTexture?St=he.depth:M.isData3DTexture?St=Math.floor(he.depth*xe):St=1,Et=0,Ot=0,Wt=0}G!==null?(bt=G.x,Qt=G.y,ye=G.z):(bt=0,Qt=0,ye=0);let ie=dt.convert(L.format),ke=dt.convert(L.type),vt;L.isData3DTexture?(K.setTexture3D(L,0),vt=F.TEXTURE_3D):L.isDataArrayTexture||L.isCompressedArrayTexture?(K.setTexture2DArray(L,0),vt=F.TEXTURE_2D_ARRAY):(K.setTexture2D(L,0),vt=F.TEXTURE_2D),x.activeTexture(F.TEXTURE0),x.pixelStorei(F.UNPACK_FLIP_Y_WEBGL,L.flipY),x.pixelStorei(F.UNPACK_PREMULTIPLY_ALPHA_WEBGL,L.premultiplyAlpha),x.pixelStorei(F.UNPACK_ALIGNMENT,L.unpackAlignment);let Ye=x.getParameter(F.UNPACK_ROW_LENGTH),Jt=x.getParameter(F.UNPACK_IMAGE_HEIGHT),an=x.getParameter(F.UNPACK_SKIP_PIXELS),bn=x.getParameter(F.UNPACK_SKIP_ROWS),qn=x.getParameter(F.UNPACK_SKIP_IMAGES);x.pixelStorei(F.UNPACK_ROW_LENGTH,he.width),x.pixelStorei(F.UNPACK_IMAGE_HEIGHT,he.height),x.pixelStorei(F.UNPACK_SKIP_PIXELS,Et),x.pixelStorei(F.UNPACK_SKIP_ROWS,Ot),x.pixelStorei(F.UNPACK_SKIP_IMAGES,Wt);let Fi=M.isDataArrayTexture||M.isData3DTexture,ne=L.isDataArrayTexture||L.isData3DTexture;if(M.isDepthTexture){let xe=q.get(M),Yn=q.get(L),le=q.get(xe.__renderTarget),$n=q.get(Yn.__renderTarget);x.bindFramebuffer(F.READ_FRAMEBUFFER,le.__webglFramebuffer),x.bindFramebuffer(F.DRAW_FRAMEBUFFER,$n.__webglFramebuffer);for(let Bi=0;Bi=0&&s.n>=0&&s.o%o===0&&s.o+s.n*o<=e.length))throw new Li(`View ${JSON.stringify(s)} is outside the ${e.length} bytes of binary data.`);return new r(e.buffer,s.o,s.n)},releaseBinary(){e=null},props(s){return n??(n=JSON.parse(Ju(i,"ioviewer-props"))),n[s]??null}}}var Qu=new Ht,td=new Cn,ed=32;function nd(i,t=new Float64Array(24)){return Qu.multiplyMatrices(i.projectionMatrix,i.matrixWorldInverse),td.setFromProjectionMatrix(Qu,i.coordinateSystem,i.reversedDepth),td.planes.forEach(({normal:e,constant:n},s)=>t.set([e.x,e.y,e.z,n],s*4)),t}function id(i,t,e,n,s){for(let r=0;r<24;r+=4)if(i[r]*t+i[r+1]*e+i[r+2]*n+i[r+3]<-s)return!0;return!1}function wc(i,t,e=new Float64Array(8)){let n=i.matrixWorld.elements,s=new C(-n[8],-n[9],-n[10]).normalize();return e.set([n[12],n[13],n[14],s.x,s.y,s.z,i.projectionMatrix.elements[5]*t/2,i.isPerspectiveCamera?1:0]),e}function sd(i,t,e){let n=new Float64Array(ed),s=0;for(let a=0;a=0;a--)if(n[a]!==0){if(r+=n[a],r>e)return o?a===0?0:2**(a/2-4):2**((a+1)/2-4);o=!1}return 0}var Xn=.0009765625,Ec=32767,hr=i=>i<0?-1:1;function rd(i){let t=Math.min(Math.max(i,-1),1)*Ec;return hr(t)*Math.round(Math.abs(t))}function ur(i,t,e,n,s){let r=Math.abs(e)+Math.abs(n)+Math.abs(s);if(!(r>0)){i[t]=i[t+1]=0;return}let o=e/r,a=n/r;s<0&&([o,a]=[(1-Math.abs(a))*hr(o),(1-Math.abs(o))*hr(a)]),i[t]=rd(o),i[t+1]=rd(a)}function od(i,t,e,n){let s=Math.max(e/Ec,-1),r=Math.max(n/Ec,-1),o=1-Math.abs(s)-Math.abs(r);o<0&&([s,r]=[(1-Math.abs(r))*hr(s),(1-Math.abs(s))*hr(r)]);let a=Math.sqrt(s*s+r*r+o*o);i[t]=s/a,i[t+1]=r/a,i[t+2]=o/a}function ad(i,t){let e=i.view(t.positions);if(!t.q){if(!(e instanceof Float32Array))throw new Error(`Geometry positions of type '${t.positions.t}' need a "q" origin and step.`);return e}if(!(e instanceof Uint16Array))throw new Error(`Quantized geometry positions must be u16, got '${t.positions.t}'.`);return e}function ld(i,t){if(!t.normals)return null;let e=i.view(t.normals);if(!(e instanceof Float32Array||e instanceof Int16Array))throw new Error(`Geometry normals must be i16 or f32, got '${t.normals.t}'.`);return e}var cd=32768,hd=0,vx=1,ws=2,ud=65535;function dd(i,t,e,n){let s=[],r=[Uint32Array.from(e)];for(;r.length;){let o=r.pop(),a=0;for(let c of o)a+=t[c];if(a<=n||o.length<=1){o.length&&s.push(o);continue}fd(o,i);let l=0;for(let c=0;ls[d]&&(s[d]=u))}let r=0;for(let h=1;h<3;h++)s[h]-n[h]>s[r]-n[r]&&(r=h);let o=s[r]-n[r],a=2**20-1,l=new Float64Array(i.length);for(let h=0;h0&&Number.isFinite(u)?Math.round((u-n[r])/o*a):0;l[h]=p*2**32+d}let c=0;e<0?l.sort():c=yx(l,e);for(let h=0;hr)return i.subarray(e,n+1).sort(),a+l*Math.ceil(Math.log2(l));a+=l,s=Math.imul(s,1664525)+1013904223>>>0;let c=i[e+s%(n-e+1)],h=e,d=n;for(;h<=d;){for(;i[h]c;)d--;if(h<=d){let u=i[h];i[h++]=i[d],i[d--]=u}}if(t<=d)n=d;else if(t>=h)e=h;else return a}return a}function pd(i,t,e,n=1/0){let s=[];s.scanned=0;let r=[Uint32Array.from(t)];for(;r.length;){let o=r.pop(),a=Math.ceil(o.length/e);if(o.length<=1||a<=1&&Mx(o,i)<=n){o.length&&s.push(o);continue}let l=a>1?Math.ceil(o.length*Math.floor(a/2)/a):Math.ceil(o.length/2);s.scanned+=fd(o,i,l),r.push(o.subarray(l),o.subarray(0,l))}return s}function Mx(i,t){let e=1/0,n=1/0,s=1/0,r=-1/0,o=-1/0,a=-1/0;for(let l of i){let c=t[l*3],h=t[l*3+1],d=t[l*3+2];cr&&(r=c),ho&&(o=h),da&&(a=d)}return Math.hypot(r-e,o-n,a-s)/2}var Ac=384*2**20;function md(i,{cap:t=Ac,minRepeats:e=16,perCell:n=1024,maxDraws:s=1024}={}){let r=new Uint8Array(i.length),o=0;for(let h of i)o+=h.k*h.bytes;if(o<=t)return r;let a=h=>(i[h].k-1)*i[h].bytes,l=[...i.keys()].filter(h=>i[h].k>=e);l.sort((h,d)=>a(d)-a(h)||i[d].k-i[h].k||h-d);let c=0;for(let h of l){let d=i[h].cells??Math.ceil(i[h].k/n);c+d>s||(r[h]=1,c+=d)}return r}function Ra(i,t){return i>65535?new Uint32Array(t):new Uint16Array(t)}function Rc(i,t){return i.sort((e,n)=>t[n]-t[e]||e-n)}function gd(i,t,e,n){for(;t>>1;i[s]>=n?t=s+1:e=s}return t}function _d(i,t,e){let n=i[t];return[n,e>t?i[e]-n:0]}function xd(i,t,e,n){let s=i.subarray(t*12,t*12+12);if(s[0]===1&&s[1]===0&&s[2]===0&&s[3]===0&&s[4]===1&&s[5]===0&&s[6]===0&&s[7]===0&&s[8]===1&&e.q&&e.q.s===Xn){for(let a=0;a<3;a++)n[a]=s[9+a]+e.q.o[a];return hd}let o=!0;for(let a=0;a<3;a++){let l=s[9+a],c=s[9+a];for(let h=0;h<3;h++){let d=s[h*3+a]*e.min[h],u=s[h*3+a]*e.max[h];l+=Math.min(d,u),c+=Math.max(d,u)}n[a]=Math.floor(l/Xn)*Xn,Math.ceil((c-n[a])/Xn)<=ud||(o=!1)}return o||n.fill(0),o?vx:ws}function vd(i,t){let e=i.length,n=0,s=0;for(let m of i){let f=t.source(t.geometryOf[m]);n+=f.vertices,s+=f.indices.length}let r=t.kind[i[0]]===ws,o=r?new Float32Array(n*4):new Uint16Array(n*4),a=new Int16Array(n*2),l=t.textured?new Float32Array(n*2):null,c=Ra(n,s),h=new Uint32Array(e+1),d=new Float32Array(e*3),u=new Float32Array(e),p=new Float64Array(3),g=0,_=0;for(let m=0;mMath.min(Math.max(Math.round(i/Xn),0),ud);function yd(i,t,e,n,s){let r=i.length/3,o=[1/0,1/0,1/0],a=[-1/0,-1/0,-1/0];for(let c=0;ca[h]&&(a[h]=d)}let l=e instanceof Int16Array?e:null;return{positions:i,q:t,oct:l,normals:l?null:e,indices:n,uvs:s,vertices:r,min:o,max:a}}function Ca(i){if(!i.normals&&i.oct){i.normals=new Float32Array(i.vertices*3);for(let t=0;tt?t.o[o]+i[r*3+o]*t.s:i[r*3+o],s=new Float32Array(i.length);for(let r=0;rn(a,u)-n(o,u)),h=[0,1,2].map(u=>n(l,u)-n(o,u)),d=[c[1]*h[2]-c[2]*h[1],c[2]*h[0]-c[0]*h[2],c[0]*h[1]-c[1]*h[0]];for(let u of[o,a,l])for(let p=0;p<3;p++)s[u*3+p]+=d[p]}for(let r=0;r= 0.0 ? - t : t, n.y >= 0.0 ? - t : t ); + return n; +}`,Ic=` +int slot = int( slotBase ) + int( q.w ); +ivec2 st = ivec2( slot & 2047, slot >> 11 ); +vec4 info = texelFetch( slotInfo, st, 0 ); +vec4 state = texelFetch( slotState, st, 0 ); +vec3 slotPosition = info.xyz + q.xyz * info.w;`,Ex=` +#ifndef IO_NO_HIDE_BRANCH +if ( state.a < 0.5 ) gl_Position = vec4( 0.0, 0.0, 2.0, 1.0 ); +#endif`,Tx=` +vec3 transformedNormal = objectNormal; +#ifdef USE_INSTANCING +mat3 im = mat3( instanceMatrix ); +transformedNormal = mat3( cross( im[ 1 ], im[ 2 ] ), cross( im[ 2 ], im[ 0 ] ), cross( im[ 0 ], im[ 1 ] ) ) * transformedNormal + * ( dot( im[ 0 ], cross( im[ 1 ], im[ 2 ] ) ) < 0.0 ? -1.0 : 1.0 ); +#endif +transformedNormal = normalMatrix * transformedNormal; +#ifdef FLIP_SIDED +transformedNormal = - transformedNormal; +#endif`;function di(i,t,e){let n=i.indexOf(t);if(n<0||i.indexOf(t,n+1)>=0)throw new Error(`The shader must contain '${t}' exactly once.`);return i.slice(0,n)+e+i.slice(n+t.length)}function Ax(i){Object.assign(i.uniforms,this.userData.slotUniforms);let t=di(i.vertexShader,"#include ",`#include +${Cc}`);t=di(t,"#include ",`${Ic} +vTint = state.rgb;`),t=di(t,"#include ","vec3 objectNormal = ioOct( normal.xy );"),t=di(t,"#include ",Tx),t=di(t,"#include ","vec3 transformed = slotPosition;"),t=di(t,"#include ",`#include +${Ex}`),i.vertexShader=t;let e=di(i.fragmentShader,"#include ",`#include +varying vec3 vTint;`);e=di(e,"#include ","diffuseColor.rgb *= vTint;"),i.fragmentShader=e}function Pc(i,t,e=!1){return i.userData.slotUniforms=t,i.onBeforeCompile=Ax,i.index0AttributeName="q",e&&(i.defines={IO_NO_HIDE_BRANCH:""}),i}var Nc=["materials","textures","white","wireframe"],Rx=[245,158,11],Cx=[235,235,237],dr=4294967295,Di=2048,Lc=256,Ix=16,Md=1024,Px=.1,Lx=8192,Dx=1.4;function Nx(i,t){let[e,n,s,r,o,a,l,c,h]=i.subarray(t*12,t*12+9);return e*(o*h-a*c)-r*(n*h-s*c)+l*(n*a-s*o)<0}function Ux(){this.array=null}function yn(i,t,e=!1,n=we){return new n(i,t,e).onUpload(Ux)}var fr=class extends RangeError{constructor(t,e){super(`${t} drawable instances exceed the limit of ${e}.`),this.name="ModelTooLargeError",this.count=t,this.limit=e}},Sd=(i,t)=>{for(let e=t*6;e{let t=i.getHex(Se);return[t>>16&255,t>>8&255,t&255]},pr=class{constructor(t,e={}){let{meta:n}=t,s=n.instances,r=s.count,o=t.view(s.geometry),a=t.view(s.material),l=t.view(s.matrix);this.root=new be,this.elementCount=n.elements.length,this._elements=n.elements,this._meta=n,this._mode="materials",this._opacity=1,this._picking=!1,this._textures=new Map,this._elementOf=t.view(s.element).slice(),this._bucketOf=new Uint32Array(r),this._visible=new Uint8Array(r).fill(1),this._layers=new Map,this._layerHides=new Uint8Array(r),this._highlight=new Uint8Array(r),this._highlighted=[],this._tint=new Map,this._boxes=new Float32Array(r*6),this._minPixels=0,this._viewHeight=1,this.visibleCost=0;let c=!!e.noHideBranch;this.slotUniforms={slotInfo:{value:null},slotState:{value:null}},this._buckets=this._createBuckets(n,a,c),this._indexElements();let h=new Ht,d=new se;for(let p=0;pthis._shown(e)?1:0)}instancesOf(t){return this._checkElement(t),this._elementInstances.subarray(this._elementStart[t],this._elementStart[t+1])}isVisible(t){this._checkElement(t);for(let e=this._elementStart[t];e=0&&t<=1))throw new RangeError(`Opacity must be within 0..1, got ${t}.`);this._opacity=t;for(let e of this._buckets)this._applyOpacity(e);this._changed("appearance")}setDetail(t,e=1){this._minPixels=t,this._viewHeight=e;for(let s of this._cut)this._showUnit(s);this._cut.length=0;let n=this._unitScale;!(t>0)||!n||(this._unitScale=null,this._cutInView=0,this._cutFrame=null,this._units.forEach((s,r)=>{!(n[r]>=0)||s.shown===0||(s.dirty&&this._span(s),!(this._slotRadius[s.slotBase+s.first]*n[r]>=t)&&(s.mesh.visible=!1,s.lines&&(s.lines.visible=!1),this._cut.push(s),this._cutInView+=Dc(s,s.first,s.last+1)))}))}get totalCost(){return this._totalCost}detailThreshold(t,e,n){if(this.visibleCost=this._totalCost,this._totalCost<=n)return 0;t.updateMatrixWorld();let s=nd(t,this._planes),r=wc(t,e,this._detailView),o=this._pixels.fill(-1),a=this._state.image.data,l=this._unitScale=new Float64Array(this._units.length).fill(-1),c=0;for(let h=0;h0?r[6]/p:1/0:r[6];l[h]=g;for(let _=d.slotBase;_o-a)}memory(){let t=this._units[this._chunkCount]?.slotBase??this._slotInstance.length;return{gpuBytes:this._mergedBytes+this._instancedBytes+this._lineBytes+this._stateBytes,mergedBytes:this._mergedBytes,instancedBytes:this._instancedBytes,stateBytes:this._stateBytes,lineBytes:this._lineBytes,chunks:this._chunkCount,cells:this._units.length-this._chunkCount,mergedInstances:t,instancedInstances:this._slotInstance.length-t,triangles:this._triangles}}setClipping(t,e){for(let n of this._buckets)for(let s of[n.material,n.wire])s.clippingPlanes=t.length>0?t:null,s.clipIntersection=e,s.needsUpdate=!0;this._changed("clipping")}_build(t,e,n,s,r){let o=e.length,a=this._meta,l=new Map,c=B=>{let U=l.get(B);if(!U){let H=a.geometries[B],Z=t.view(H.indices),rt=H.uvs?t.view(H.uvs):null;U=yd(ad(t,H),H.q??null,ld(t,H),Z,rt),l.set(B,U)}return U},h=new Uint8Array(o),d=new Float64Array(o*3),u=new Float32Array(o*3),p=new Uint32Array(o),g=new Float32Array(o),_=new Uint8Array(o),m=new Map,f=this._boxes;for(let B=0;BB-U).map(B=>{let U=m.get(B),H=c(e[U[0]]),Z=this._buckets[this._bucketOf[U[0]]].texture!==null,rt=[0,1,2].every(Ft=>(H.max[Ft]-H.min[Ft])/Xn<=65535),at=H.vertices*((rt?12:20)+(Z?8:0))+H.indices.length*(H.vertices>65535?4:2);return{k:U.length,bytes:at,members:U,textured:Z}}),S=s.mergedCap??Ac,b=s.minRepeats??Ix;if(R.reduce((B,U)=>B+U.k*U.bytes,0)>S){let B=this.visibleBox(),U=B.isEmpty()?1/0:B.min.distanceTo(B.max)*(s.cellExtent??Px);for(let H of R)H.k{if(y[U]){for(let H of B.leaves)E.push({shape:B,members:Rc(H,g)});return}for(let H of B.members){v[e[H]]++;let Z=this._bucketOf[H]*2+(h[H]===ws?1:0),rt=T.get(Z);rt||T.set(Z,rt=[]),rt.push(H)}});let P=[];for(let B of[...T.keys()].sort((U,H)=>U-H))for(let U of dd(u,p,T.get(B),s.chunkVertices??cd))P.push(Rc(U,g));let D=[...P,...E.map(B=>B.members)].reduce((B,U)=>B+U.length,0);if(D>r)throw new fr(D,r);let z=Math.max(1,Math.ceil(D/Di)),O=new Float32Array(Di*z*4);this._state=new ei(new Uint8Array(Di*z*4),Di,z),this._state.colorSpace=Se,this._state.onUpdate=()=>{this._fullPending=!1,this._pendingRanges=0},this._fullPending=!0,this._pendingRanges=0,this._dirty=new Uint32Array(Lc),this._dirtyCount=0;let I=new ei(O,Di,z,tn,We);I.needsUpdate=!0,I.onUpdate=()=>I.image.data=null,this.slotUniforms.slotInfo.value=I,this.slotUniforms.slotState.value=this._state,this._stateBytes=Di*z*20,this._slotOf=new Uint32Array(o).fill(dr),this._slotInstance=new Uint32Array(D),this._slotUnit=new Uint32Array(D),this._slotRadius=new Float32Array(D),this._slotCost=new Float32Array(D),this._pixels=new Float32Array(D),this._planes=new Float64Array(24),this._detailView=new Float64Array(8),this._frameView=new Float64Array(8),this._frameViewAt=NaN,this._units=[],this._cut=[],this._unitScale=null,this._cutInView=0,this._cutFrame=-1,this._mergedBytes=0,this._instancedBytes=0,this._lineBytes=0;let k={matrices:n,geometryOf:e,mirrored:_,kind:h,origins:d,source:c,textured:!1};this._lineSources=new Map;let X=0;for(let B of P){let U=this._buckets[this._bucketOf[B[0]]];k.textured=U.texture!==null;let H=vd(B,k);for(let Z=0;ZB+U,0);let Y=new Map;for(let{shape:B,members:U}of E){let H=U[0],Z=c(e[H]),rt=Y.get(B);rt||Y.set(B,rt=this._shape(Z,_[H]===1,B.textured));for(let at=0;atB+U,0)}_shape(t,e,n){let s=t.vertices,r=t.q?new Uint16Array(s*4):new Float32Array(s*4);for(let h=0;hn+g),1,!1,Hn)),a.setIndex(s.index);let l=wd(this._boxes,e),c=new Le(new C(l[0],l[1],l[2]),l[3]);a.boundingSphere=c;let h=new cs(a,t.material,0),d=new Float32Array(o*16);for(let p=0;pthis._range(u,p,_,h),h.visible=!1,this.root.add(h),u}_chunk(t,e,n,s,r,o){let a=new fs;a.instanceCount=1,a.setAttribute("q",yn(s.q,4)),a.setAttribute("normal",yn(s.o,2,!0)),s.uv&&a.setAttribute("uv",yn(s.uv,2)),a.setAttribute("slotBase",yn(new Float32Array([n]),1,!1,Hn)),a.setIndex(yn(s.index,1));let l=wd(this._boxes,e);a.boundingSphere=new Le(new C(l[0],l[1],l[2]),l[3]),this._mergedBytes+=s.q.byteLength+s.o.byteLength+(s.uv?.byteLength??0)+s.index.byteLength;let c=new _e(a,t.material),h={bucket:t,mesh:c,lines:null,slotBase:n,n:e.length,sphere:l,shown:0,first:0,last:-1,end:0,dirty:!1,cell:!1,starts:s.indexStart,vertices:s.q.length/4,f32:s.q instanceof Float32Array,members:e.slice(),lastFrame:-1/0};return c.onBeforeRender=(d,u,p)=>this._range(h,d,p,c),c.visible=!1,this.root.add(c),h}_buildLines(){if(!this._lineSources)return;let{mirrored:t,geometryOf:e}=this._lineMembers;for(let n of this._units){if(n.cell){let h=n.mesh.geometry.index;n.lines=new cs(n.mesh.geometry,n.bucket.wire,0),n.lines.instanceMatrix=n.mesh.instanceMatrix,n.lines.boundingSphere=n.mesh.boundingSphere,n.lines.onBeforeRender=(d,u,p)=>this._range(n,d,p,n.lines),this._lineBytes+=h.count*2*h.array.BYTES_PER_ELEMENT,this.root.add(n.lines);continue}let s=n.starts[n.n],r=Ra(n.vertices,s*2),o=0,a=0;for(let h of n.members){let d=this._lineSources.get(e[h]),u=t[h]===1;for(let p=0;pthis._range(n,h,u,n.lines),this.root.add(n.lines),n.members=null}this._lineSources=null,this._lineMembers=null}_range(t,e,n,s){t.dirty&&this._span(t);let r=t.last+1;if(this._minPixels>0&&r>t.first){let o=e.info.render.frame;o!==this._frameViewAt&&(wc(n,this._viewHeight,this._frameView),this._frameViewAt=o);let a=this._frameView,l=bd(a,t.sphere),c=a[7]?l>0?this._minPixels*l/a[6]:0:this._minPixels/a[6];r=Math.min(r,gd(this._slotRadius,t.slotBase+t.first,t.slotBase+r,c)-t.slotBase)}if(t.cell)s.count=Math.max(r,0);else{let o=s.isLineSegments?2:1,[a,l]=_d(t.starts,t.first,r);s.geometry.setDrawRange(a*o,l*o)}t.end=r,t.lastFrame=e.info.render.frame,this._cutFrame===null&&this._minPixels>0&&(this._cutFrame=t.lastFrame)}_span(t){let e=this._state.image.data,n=0;for(;n=n&&e[(t.slotBase+s)*4+3]===0;)s--;t.first=n0&&!e,t.lines&&(t.lines.visible=t.shown>0&&e)}_createBuckets(t,e,n){let s=new Map,r=[];for(let o=0;oMath.round(h*255)),opacity:c.opacity,texture:c.texture,material:Pc(new ds({side:Be}),this.slotUniforms,n),wire:Pc(new ds({side:Be,wireframe:!0}),this.slotUniforms,n)},this._applyOpacity(l),s.set(a,l),r.push(l)}this._bucketOf[o]=l.index}return r}_indexElements(){let t=new Uint32Array(this.elementCount+1);for(let s of this._elementOf)s>=0&&t[s+1]++;for(let s=0;s{s>=0&&(e[n[s]++]=r)}),this._elementStart=t,this._elementInstances=e}_texture(t){let e=this._textures.get(t);if(!e){let n=this._meta.textures[t],s=new Image;e=new Ue(s),e.colorSpace=Se,e.wrapS=e.wrapT=ns,e.repeat.set(1/n.sx,1/n.sy),s.onload=()=>{e.needsUpdate=!0,this._changed("appearance")},s.src=`data:${n.mime};base64,${n.data}`,this._textures.set(t,e)}return e}_applyOpacity(t){let e=t.opacity*this._opacity,n=e<1;for(let s of[t.material,t.wire])s.transparent!==n&&(s.needsUpdate=!0),s.opacity=e,s.transparent=n,s.depthWrite=!n}_colorOf(t){return this._highlight[t]?Rx:this._tint.get(t)??(this._mode==="white"?Cx:this._buckets[this._bucketOf[t]].bytes)}_applyColor(t){let e=this._slotOf[t];e!==dr&&(this._state.image.data.set(this._colorOf(t),e*4),this._markDirty(e))}_setInstanceVisible(t,e){this._visible[t]=e?1:0,this._draw(t)}_setLayerFlag(t,e,n){t[e]!==(n?1:0)&&(t[e]=n?1:0,this._layerHides[e]+=n?1:-1,this._draw(e))}_shown(t){return this._visible[t]===1&&this._layerHides[t]===0}_draw(t){let e=this._slotOf[t];if(e===dr)return;let n=this._shown(t)?255:0,s=this._state.image.data;if(s[e*4+3]===n)return;s[e*4+3]=n,this._markDirty(e);let r=this._units[this._slotUnit[e]];r.shown+=n?1:-1,r.dirty=!0,this._showUnit(r)}_markDirty(t){this._dirtyCountLc)e.clearUpdateRanges(),this._fullPending=!0;else{for(let n=0;n=this.elementCount)throw new RangeError(`Element index ${t} is out of range 0..${this.elementCount-1}.`)}_expandBox(t,e){if(!Sd(this._boxes,e))return;let n=this._boxes,s=e*6;t.min.set(Math.min(t.min.x,n[s]),Math.min(t.min.y,n[s+1]),Math.min(t.min.z,n[s+2])),t.max.set(Math.max(t.max.x,n[s+3]),Math.max(t.max.y,n[s+4]),Math.max(t.max.z,n[s+5]))}_changed(t){this._flush(),this.root.dispatchEvent({type:"change",detail:{kind:t}})}};function Dc(i,t,e){return e<=t?0:i.cell?(e-t)*i.triangles:(i.starts[e]-i.starts[t])/3}function bd(i,t){return(t[0]-i[0])*i[3]+(t[1]-i[1])*i[4]+(t[2]-i[2])*i[5]-t[3]}function wd(i,t){let e=[1/0,1/0,1/0],n=[-1/0,-1/0,-1/0];for(let s of t)for(let r=0;r<3;r++)e[r]=Math.min(e[r],i[s*6+r]),n[r]=Math.max(n[r],i[s*6+3+r]);return Float64Array.of((e[0]+n[0])/2,(e[1]+n[1])/2,(e[2]+n[2])/2,Math.hypot(n[0]-e[0],n[1]-e[1],n[2]-e[2])/2)}var Bx={ru:{elements:"\u042D\u043B\u0435\u043C\u0435\u043D\u0442\u043E\u0432",noGeometry:"\u041D\u0435\u0442 \u0433\u0435\u043E\u043C\u0435\u0442\u0440\u0438\u0438",contextLost:"\u0412\u0438\u0434\u0435\u043E\u043A\u0430\u0440\u0442\u0430 \u0441\u0431\u0440\u043E\u0448\u0435\u043D\u0430 \u2014 \u043F\u0435\u0440\u0435\u0437\u0430\u0433\u0440\u0443\u0437\u0438\u0442\u0435 \u0441\u0442\u0440\u0430\u043D\u0438\u0446\u0443",noWebGL:"\u041D\u0443\u0436\u0435\u043D \u0431\u0440\u0430\u0443\u0437\u0435\u0440 \u0441 \u043F\u043E\u0434\u0434\u0435\u0440\u0436\u043A\u043E\u0439 WebGL 2",badData:"\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u043F\u0440\u043E\u0447\u0438\u0442\u0430\u0442\u044C \u0434\u0430\u043D\u043D\u044B\u0435 \u043C\u043E\u0434\u0435\u043B\u0438",tooLarge:"\u041C\u043E\u0434\u0435\u043B\u044C \u0441\u043B\u0438\u0448\u043A\u043E\u043C \u0431\u043E\u043B\u044C\u0448\u0430\u044F \u0434\u043B\u044F \u044D\u0442\u043E\u0433\u043E \u043F\u0440\u043E\u0441\u043C\u043E\u0442\u0440\u0449\u0438\u043A\u0430: \u043E\u0431\u044A\u0435\u043A\u0442\u043E\u0432 \u0441 \u0433\u0435\u043E\u043C\u0435\u0442\u0440\u0438\u0435\u0439 \u2014 {count}, \u043F\u0440\u0435\u0434\u0435\u043B \u2014 {limit}",selected:"\u0412\u044B\u0431\u0440\u0430\u043D\u043E",close:"\u0417\u0430\u043A\u0440\u044B\u0442\u044C",elementGroup:"\u042D\u043B\u0435\u043C\u0435\u043D\u0442",id:"Id",category:"\u041A\u0430\u0442\u0435\u0433\u043E\u0440\u0438\u044F",type:"\u0422\u0438\u043F",level:"\u0423\u0440\u043E\u0432\u0435\u043D\u044C",link:"\u0421\u0432\u044F\u0437\u0430\u043D\u043D\u044B\u0439 \u0444\u0430\u0439\u043B",panels:"\u041F\u0430\u043D\u0435\u043B\u0438","panel.model":"\u041C\u043E\u0434\u0435\u043B\u044C","panel.views":"\u0412\u0438\u0434\u044B","panel.sections":"\u0421\u0435\u0447\u0435\u043D\u0438\u044F","panel.clashes":"\u041A\u043E\u043B\u043B\u0438\u0437\u0438\u0438","panel.timeliner":"4D-\u0433\u0440\u0430\u0444\u0438\u043A","panel.links":"\u0421\u0432\u044F\u0437\u0430\u043D\u043D\u044B\u0435 \u0444\u0430\u0439\u043B\u044B","group.navigation":"\u041D\u0430\u0432\u0438\u0433\u0430\u0446\u0438\u044F","group.sections":"\u0421\u0435\u0447\u0435\u043D\u0438\u044F","group.measure":"\u0418\u0437\u043C\u0435\u0440\u0435\u043D\u0438\u044F","group.display":"\u041E\u0442\u043E\u0431\u0440\u0430\u0436\u0435\u043D\u0438\u0435","group.view":"\u0412\u0438\u0434","tool.orbit":"\u0412\u0440\u0430\u0449\u0435\u043D\u0438\u0435","tool.pan":"\u041F\u0430\u043D\u043E\u0440\u0430\u043C\u0430","tool.fit":"\u0412\u043F\u0438\u0441\u0430\u0442\u044C \u0432\u0441\u0451","tool.projection":"\u041F\u0435\u0440\u0441\u043F\u0435\u043A\u0442\u0438\u0432\u0430 / \u043E\u0440\u0442\u043E\u0433\u0440\u0430\u0444\u0438\u044F","tool.plane":"\u041F\u043B\u043E\u0441\u043A\u043E\u0441\u0442\u044C \u0441\u0435\u0447\u0435\u043D\u0438\u044F","tool.box":"\u0411\u043E\u043A\u0441 \u0441\u0435\u0447\u0435\u043D\u0438\u044F","tool.resetSections":"\u0421\u0431\u0440\u043E\u0441\u0438\u0442\u044C \u0441\u0435\u0447\u0435\u043D\u0438\u044F","tool.distance":"\u0420\u0430\u0441\u0441\u0442\u043E\u044F\u043D\u0438\u0435","tool.displayMode":"\u0420\u0435\u0436\u0438\u043C \u043E\u0442\u043E\u0431\u0440\u0430\u0436\u0435\u043D\u0438\u044F","tool.grids":"\u041E\u0441\u0438","tool.opacity":"\u041F\u0440\u043E\u0437\u0440\u0430\u0447\u043D\u043E\u0441\u0442\u044C","tool.fullscreen":"\u041F\u043E\u043B\u043D\u044B\u0439 \u044D\u043A\u0440\u0430\u043D","menu.properties":"\u0421\u0432\u043E\u0439\u0441\u0442\u0432\u0430","menu.hide":"\u0421\u043A\u0440\u044B\u0442\u044C","menu.isolate":"\u0418\u0437\u043E\u043B\u0438\u0440\u043E\u0432\u0430\u0442\u044C","menu.showAll":"\u041F\u043E\u043A\u0430\u0437\u0430\u0442\u044C \u0432\u0441\u0451","menu.zoomTo":"\u041F\u0440\u0438\u0431\u043B\u0438\u0437\u0438\u0442\u044C \u043A \u043E\u0431\u044A\u0435\u043A\u0442\u0443","menu.sectionBox":"\u0411\u043E\u043A\u0441 \u0441\u0435\u0447\u0435\u043D\u0438\u044F \u043F\u043E \u043E\u0431\u044A\u0435\u043A\u0442\u0443","menu.copyId":"\u041A\u043E\u043F\u0438\u0440\u043E\u0432\u0430\u0442\u044C Id","menu.fitAll":"\u0412\u043F\u0438\u0441\u0430\u0442\u044C \u0432\u0441\u0451","menu.resetSections":"\u0421\u0431\u0440\u043E\u0441\u0438\u0442\u044C \u0441\u0435\u0447\u0435\u043D\u0438\u044F",searchPlaceholder:"\u041F\u043E\u0438\u0441\u043A \u043F\u043E \u0438\u043C\u0435\u043D\u0438, \u0442\u0438\u043F\u0443, Id\u2026",noCategory:"\u0411\u0435\u0437 \u043A\u0430\u0442\u0435\u0433\u043E\u0440\u0438\u0438",noMatches:"\u041D\u0438\u0447\u0435\u0433\u043E \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D\u043E",idCopied:"Id \u0441\u043A\u043E\u043F\u0438\u0440\u043E\u0432\u0430\u043D",copyFailed:"\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u0441\u043A\u043E\u043F\u0438\u0440\u043E\u0432\u0430\u0442\u044C Id",fullscreenUnavailable:"\u041F\u043E\u043B\u043D\u043E\u044D\u043A\u0440\u0430\u043D\u043D\u044B\u0439 \u0440\u0435\u0436\u0438\u043C \u043D\u0435\u0434\u043E\u0441\u0442\u0443\u043F\u0435\u043D","face.front":"\u0421\u043F\u0435\u0440\u0435\u0434\u0438","face.back":"\u0421\u0437\u0430\u0434\u0438","face.left":"\u0421\u043B\u0435\u0432\u0430","face.right":"\u0421\u043F\u0440\u0430\u0432\u0430","face.top":"\u0421\u0432\u0435\u0440\u0445\u0443","face.bottom":"\u0421\u043D\u0438\u0437\u0443","cube.title":"\u041A\u0443\u0431 \u0432\u0438\u0434\u0430","cube.up":"\u041F\u043E\u0432\u0435\u0440\u043D\u0443\u0442\u044C \u0432\u0432\u0435\u0440\u0445","cube.down":"\u041F\u043E\u0432\u0435\u0440\u043D\u0443\u0442\u044C \u0432\u043D\u0438\u0437","cube.left":"\u041F\u043E\u0432\u0435\u0440\u043D\u0443\u0442\u044C \u0432\u043B\u0435\u0432\u043E","cube.right":"\u041F\u043E\u0432\u0435\u0440\u043D\u0443\u0442\u044C \u0432\u043F\u0440\u0430\u0432\u043E","cube.home":"\u0418\u0441\u0445\u043E\u0434\u043D\u044B\u0439 \u0432\u0438\u0434","sections.mode":"\u0420\u0435\u0436\u0438\u043C \u0441\u0435\u0447\u0435\u043D\u0438\u044F","sections.planes":"\u041F\u043B\u043E\u0441\u043A\u043E\u0441\u0442\u0438","sections.box":"\u0411\u043E\u043A\u0441","sections.off":"\u0412\u044B\u043A\u043B","sections.axis":"\u041E\u0441\u044C","sections.addPlane":"\u0414\u043E\u0431\u0430\u0432\u0438\u0442\u044C \u043F\u043B\u043E\u0441\u043A\u043E\u0441\u0442\u044C","sections.noPlanes":"\u041F\u043B\u043E\u0441\u043A\u043E\u0441\u0442\u0435\u0439 \u043D\u0435\u0442","sections.rotated":"\u043F\u043E\u0432\u0451\u0440\u043D\u0443\u0442\u0430","sections.position":"\u041F\u043E\u043B\u043E\u0436\u0435\u043D\u0438\u0435 \u043F\u043B\u043E\u0441\u043A\u043E\u0441\u0442\u0438","sections.flip":"\u0421\u043C\u0435\u043D\u0438\u0442\u044C \u0441\u0442\u043E\u0440\u043E\u043D\u0443","sections.enabled":"\u0412\u043A\u043B","sections.delete":"\u0423\u0434\u0430\u043B\u0438\u0442\u044C \u043F\u043B\u043E\u0441\u043A\u043E\u0441\u0442\u044C","sections.keepInside":"\u041F\u043E\u043A\u0430\u0437\u0430\u0442\u044C \u0432\u043D\u0443\u0442\u0440\u0438","sections.cutInside":"\u0412\u044B\u0440\u0435\u0437\u0430\u0442\u044C \u0432\u043D\u0443\u0442\u0440\u0438","sections.fitSelection":"\u041F\u043E \u0432\u044B\u0434\u0435\u043B\u0435\u043D\u043D\u043E\u043C\u0443","sections.showGizmo":"\u041F\u043E\u043A\u0430\u0437\u044B\u0432\u0430\u0442\u044C \u043C\u0430\u043D\u0438\u043F\u0443\u043B\u044F\u0442\u043E\u0440","sections.maxPlanes":"\u041D\u0435 \u0431\u043E\u043B\u044C\u0448\u0435 6 \u043F\u043B\u043E\u0441\u043A\u043E\u0441\u0442\u0435\u0439","unit.m":"\u043C","display.materials":"\u041C\u0430\u0442\u0435\u0440\u0438\u0430\u043B\u044B","display.textures":"\u0422\u0435\u043A\u0441\u0442\u0443\u0440\u044B","display.white":"\u0411\u0435\u043B\u044B\u0439","display.wireframe":"\u041A\u0430\u0440\u043A\u0430\u0441","opacity.label":"\u041D\u0435\u043F\u0440\u043E\u0437\u0440\u0430\u0447\u043D\u043E\u0441\u0442\u044C","opacity.value":"{value} %","views.tour":"\u0422\u0443\u0440 \u043F\u043E \u0432\u0438\u0434\u0430\u043C","views.stop":"\u041E\u0441\u0442\u0430\u043D\u043E\u0432\u0438\u0442\u044C \u0442\u0443\u0440","clash.New":"\u041D\u043E\u0432\u0430\u044F","clash.Active":"\u0410\u043A\u0442\u0438\u0432\u043D\u0430\u044F","clash.Reviewed":"\u041F\u0440\u043E\u0432\u0435\u0440\u0435\u043D\u0430","clash.Approved":"\u0423\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043D\u0430","clash.Resolved":"\u0423\u0441\u0442\u0440\u0430\u043D\u0435\u043D\u0430","task.construct":"\u0421\u0442\u0440\u043E\u0438\u0442\u0435\u043B\u044C\u0441\u0442\u0432\u043E","task.demolish":"\u0421\u043D\u043E\u0441","task.temporary":"\u0412\u0440\u0435\u043C\u0435\u043D\u043D\u043E\u0435","player.title":"4D-\u043F\u0440\u043E\u0438\u0433\u0440\u044B\u0432\u0430\u0442\u0435\u043B\u044C","player.play":"\u0412\u043E\u0441\u043F\u0440\u043E\u0438\u0437\u0432\u0435\u0441\u0442\u0438","player.pause":"\u041F\u0430\u0443\u0437\u0430","player.date":"\u0414\u0430\u0442\u0430 \u0433\u0440\u0430\u0444\u0438\u043A\u0430","player.speed":"\u0421\u043A\u043E\u0440\u043E\u0441\u0442\u044C",truncated:"\u041F\u043E\u043A\u0430\u0437\u0430\u043D\u043E {meshes} \u0438\u0437 {total} \u043C\u0435\u0448\u0435\u0439: \u043C\u043E\u0434\u0435\u043B\u044C \u043E\u0431\u0440\u0435\u0437\u0430\u043D\u0430 \u043D\u0430\u0441\u0442\u0440\u043E\u0439\u043A\u043E\u0439 MaxMeshVertices"},en:{elements:"Elements",noGeometry:"No geometry",contextLost:"The graphics context was lost \u2014 reload the page",noWebGL:"This page needs a browser with WebGL 2",badData:"The model data could not be read",tooLarge:"The model is too large for this viewer: {count} objects with geometry, the limit is {limit}",selected:"Selected",close:"Close",elementGroup:"Element",id:"Id",category:"Category",type:"Type",level:"Level",link:"Link",panels:"Panels","panel.model":"Model","panel.views":"Views","panel.sections":"Sections","panel.clashes":"Clashes","panel.timeliner":"4D schedule","panel.links":"Linked files","group.navigation":"Navigation","group.sections":"Sections","group.measure":"Measure","group.display":"Display","group.view":"View","tool.orbit":"Orbit","tool.pan":"Pan","tool.fit":"Fit all","tool.projection":"Perspective / orthographic","tool.plane":"Section plane","tool.box":"Section box","tool.resetSections":"Reset sections","tool.distance":"Distance","tool.displayMode":"Display mode","tool.grids":"Grids","tool.opacity":"Transparency","tool.fullscreen":"Full screen","menu.properties":"Properties","menu.hide":"Hide","menu.isolate":"Isolate","menu.showAll":"Show all","menu.zoomTo":"Zoom to object","menu.sectionBox":"Section box around object","menu.copyId":"Copy Id","menu.fitAll":"Fit all","menu.resetSections":"Reset sections",searchPlaceholder:"Search by name, type, Id\u2026",noCategory:"No category",noMatches:"No matches",idCopied:"Id copied",copyFailed:"Could not copy Id",fullscreenUnavailable:"Fullscreen is not available","face.front":"Front","face.back":"Back","face.left":"Left","face.right":"Right","face.top":"Top","face.bottom":"Bottom","cube.title":"View cube","cube.up":"Rotate up","cube.down":"Rotate down","cube.left":"Rotate left","cube.right":"Rotate right","cube.home":"Home view","sections.mode":"Section mode","sections.planes":"Planes","sections.box":"Box","sections.off":"Off","sections.axis":"Axis","sections.addPlane":"Add plane","sections.noPlanes":"No planes","sections.rotated":"rotated","sections.position":"Plane position","sections.flip":"Flip side","sections.enabled":"On","sections.delete":"Delete plane","sections.keepInside":"Show inside","sections.cutInside":"Cut inside","sections.fitSelection":"Fit to selection","sections.showGizmo":"Show gizmo","sections.maxPlanes":"At most 6 planes","unit.m":"m","display.materials":"Materials","display.textures":"Textures","display.white":"White","display.wireframe":"Wireframe","opacity.label":"Opacity","opacity.value":"{value}%","views.tour":"Play tour","views.stop":"Stop tour","clash.New":"New","clash.Active":"Active","clash.Reviewed":"Reviewed","clash.Approved":"Approved","clash.Resolved":"Resolved","task.construct":"Construct","task.demolish":"Demolish","task.temporary":"Temporary","player.title":"4D player","player.play":"Play","player.pause":"Pause","player.date":"Schedule date","player.speed":"Speed",truncated:"Showing {meshes} of {total} meshes: the model was truncated by the MaxMeshVertices setting"}},mr=(globalThis.navigator?.language??"").toLowerCase().startsWith("ru")?"ru":"en";function st(i,t=mr){let e=Bx[t][i];if(e===void 0)throw new Error(`Missing UI string '${i}'.`);return e}var Ed=new C(0,0,1),Ia=Math.PI/2-.01;function Ad(i,t,e=4){return Math.hypot(t.x-i.x,t.y-i.y)<=e}function Rd(i,t,e,n,s,r){let o=i.clone().sub(e),a=t.clone().sub(e),l=d=>(o.applyQuaternion(d),a.applyQuaternion(d));l(new Ne().setFromAxisAngle(Ed,-n*r));let c=a.clone().sub(o).normalize(),h=c.clone().cross(Ed);if(h.lengthSq()>1e-12){let d=Math.asin(Oe.clamp(c.z,-1,1)),u=Oe.clamp(s*r,d-Ia,d+Ia);l(new Ne().setFromAxisAngle(h.normalize(),-u))}return{position:o.add(e),target:a.add(e)}}function Cd(i,t,e,n){let s=r=>r.clone().sub(e).multiplyScalar(n).add(e);return{position:s(i),target:s(t)}}var Ox=.85,zx=100/3;function Id(i,t,e){let n=i*(t===1?zx:t===2?e:1);return Oe.clamp(Ox**(-n/100),.5,2)}var Uc=(i,t)=>t>=i?"window":"crossing";function Pd(i,t,e){let n=Td(i.position,i.target),s=Td(t.position,t.target),r=n.azimuth??s.azimuth??0,o=s.azimuth??r,a=Oe.euclideanModulo(o-r+Math.PI,2*Math.PI)-Math.PI,l=(_,m)=>_+(m-_)*e,c=r+a*e,h=Oe.clamp(l(n.elevation,s.elevation),-Ia,Ia),d=l(n.distance,s.distance),u=i.target.clone().lerp(t.target,e),p=Math.cos(h)*d;return{position:u.clone().add(new C(p*Math.cos(c),p*Math.sin(c),Math.sin(h)*d)),target:u}}function Td(i,t){let e=i.clone().sub(t),n=e.length(),s=Math.hypot(e.x,e.y);return{distance:n,elevation:n>1e-12?Math.asin(Oe.clamp(e.z/n,-1,1)):0,azimuth:s>1e-9*Math.max(n,1e-12)?Math.atan2(e.y,e.x):null}}var kx=.005,Vx=new C(1,-1,.8).normalize(),Fc=(i,t)=>new CustomEvent(i,{detail:t}),Pa=class extends EventTarget{constructor(t,e,{pick:n=()=>null,visibleBox:s=()=>new se}={}){super(),this.dom=e,this.pick=n,this.visibleBox=s,this.target=new C,this.leftButtonMode="orbit",this._perspective=t,this._orthographic=new In(-1,1,1,-1),this.camera=t,this._drag=null,this._wheelPick=null,this._animation=0,this._overlay=null,this._bounds=null,this._extraBounds=new Map,e.style.touchAction="none",e.addEventListener("pointerdown",r=>this._down(r)),e.addEventListener("pointermove",r=>this._move(r)),e.addEventListener("pointerup",r=>this._up(r)),e.addEventListener("pointercancel",()=>this._end()),e.addEventListener("lostpointercapture",r=>this._lostCapture(r)),window.addEventListener("blur",()=>this._end()),e.addEventListener("wheel",r=>this._wheel(r),{passive:!1}),e.addEventListener("contextmenu",r=>r.preventDefault())}setLeftButtonMode(t){if(t!=="orbit"&&t!=="pan")throw new RangeError(`Unknown left button mode: ${t}`);this.leftButtonMode=t}setProjection(t){if(t!=="perspective"&&t!=="orthographic")throw new RangeError(`Unknown projection: ${t}`);let e=t==="perspective"?this._perspective:this._orthographic,n=this.camera;if(e!==n){e.position.copy(n.position),e.quaternion.copy(n.quaternion),e.up.copy(n.up),n.parent?.add(e),n.parent?.remove(n);for(let s of[...n.children])e.add(s);this.camera=e,this.resize()}}resize(){let t=this.camera,e=this.dom.clientWidth/Math.max(this.dom.clientHeight,1),n=Math.max(t.position.distanceTo(this.target),1e-6);if(t.isPerspectiveCamera)t.aspect=e;else{let r=n*Math.tan(Oe.degToRad(this._perspective.fov/2));Object.assign(t,{top:r,bottom:-r,left:-r*e,right:r*e})}let s=this._rangeSphere();if(s){let r=t.position.distanceTo(s.center);t.far=(r+s.radius)*1.05,t.near=Math.max((r-s.radius)*.95,t.far/5e3)}t.updateProjectionMatrix(),this.dispatchEvent(new Event("change"))}setExtraBounds(t,e){e&&!e.isEmpty()?this._extraBounds.set(t,e.clone()):this._extraBounds.delete(t),this.resize()}_rangeSphere(){let t=this._visibleBounds()?.sphere??null;if(this._extraBounds.size===0)return t;let e=t?this._bounds.box.clone():new se;for(let n of this._extraBounds.values())e.union(n);return e.getBoundingSphere(new Le)}invalidateBounds(){this._bounds=null}_visibleBounds(){if(!this._bounds){let t=this.visibleBox();this._bounds={box:t,sphere:t.isEmpty()?null:t.getBoundingSphere(new Le)}}return this._bounds.sphere&&this._bounds}fitBox(t,e=!0){if(t.isEmpty())return;let n=t.getBoundingSphere(new Le),s=Math.max(n.radius,.001),r=Oe.degToRad(this._perspective.fov/2),o=this.dom.clientWidth/Math.max(this.dom.clientHeight,1),a=s/Math.sin(Math.min(r,Math.atan(Math.tan(r)*o))),l=this.camera.position.clone().sub(this.target);l.lengthSq()<1e-12&&l.copy(Vx);let c=l.normalize().multiplyScalar(a).add(n.center);e?this.animateTo({position:c,target:n.center}):this._set(c,n.center)}zoomToBox(t){this.fitBox(t,!0)}animateTo({position:t,target:e},n=400){this._stopAnimation();let s={position:this.camera.position.clone(),target:this.target.clone()},r={position:t??s.position,target:e??s.target},o=performance.now(),a=l=>{let c=Math.min((l-o)/n,1),h=Pd(s,r,c*c*(3-2*c));this._set(h.position,h.target),this._animation=c<1?requestAnimationFrame(a):0};this._animation=requestAnimationFrame(a)}get animating(){return this._animation!==0}_stopAnimation(){cancelAnimationFrame(this._animation),this._animation=0}_set(t,e){this.camera.position.copy(t),this.target.copy(e),this.camera.lookAt(e),this.resize()}_down(t){this._end(),this._stopAnimation(),this.dom.setPointerCapture(t.pointerId),t.preventDefault();let e=t.button===0&&t.shiftKey?"box":t.button===0&&this.leftButtonMode==="orbit"?"orbit":"pan",n={x:t.clientX,y:t.clientY},s=null;e==="orbit"&&(s=this.pick(n.x,n.y),s||(s=this._visibleBounds()?.sphere.center.clone()??this.target.clone())),this._drag={id:t.pointerId,button:t.button,mode:e,pivot:s,down:n,last:n,moved:!1}}_move(t){let e=this._drag;if(!e||t.pointerId!==e.id)return;if(t.buttons===0)return this._end();let n={x:t.clientX,y:t.clientY};if(!e.moved&&Ad(e.down,n))return;e.moved=!0;let s=n.x-e.last.x,r=n.y-e.last.y;if(e.last=n,e.mode==="box")this._drawRect(e.down,n);else if(e.mode==="orbit"){let o=Rd(this.camera.position,this.target,e.pivot,s,r,kx);this._set(o.position,o.target)}else this._pan(s,r)}_pan(t,e){let n=this.camera,s=Math.max(this.dom.clientHeight,1),r=n.isPerspectiveCamera?2*n.position.distanceTo(this.target)*Math.tan(Oe.degToRad(n.fov/2))/s:(n.top-n.bottom)/s,o=new C(1,0,0).applyQuaternion(n.quaternion),a=new C(0,1,0).applyQuaternion(n.quaternion),l=o.multiplyScalar(-t*r).addScaledVector(a,e*r);this._set(n.position.clone().add(l),this.target.clone().add(l))}_up(t){let e=this._drag;if(!e||t.pointerId!==e.id||t.button!==e.button)return;let n={x:t.clientX,y:t.clientY};if(this._end(),e.mode==="box"&&e.moved){let s={x:Math.min(e.down.x,n.x),y:Math.min(e.down.y,n.y),width:Math.abs(n.x-e.down.x),height:Math.abs(n.y-e.down.y)},r=Uc(e.down.x,n.x);this.dispatchEvent(Fc("boxselect",{rect:s,mode:r,ctrl:t.ctrlKey,shift:t.shiftKey}))}else!e.moved&&e.button===0?this.dispatchEvent(Fc("click",{x:n.x,y:n.y,button:0,ctrl:t.ctrlKey,shift:t.shiftKey})):!e.moved&&e.button===2&&this.dispatchEvent(Fc("contextmenu",{x:n.x,y:n.y}))}_lostCapture(t){this._drag&&t.buttons?this.dom.setPointerCapture(this._drag.id):this._end()}_end(){this._drag=null,this._overlay?.remove(),this._overlay=null}_drawRect(t,e){this._overlay||(this._overlay=document.createElement("div"),Object.assign(this._overlay.style,{position:"fixed",pointerEvents:"none",border:"1px solid #2563eb",background:"rgba(37,99,235,0.12)"}),(this.dom.parentElement??document.body).appendChild(this._overlay)),Object.assign(this._overlay.style,{borderStyle:Uc(t.x,e.x)==="window"?"solid":"dashed",left:`${Math.min(t.x,e.x)}px`,top:`${Math.min(t.y,e.y)}px`,width:`${Math.abs(e.x-t.x)}px`,height:`${Math.abs(e.y-t.y)}px`})}_wheel(t){if(t.preventDefault(),t.deltaY===0)return;this._stopAnimation(),this._wheelPick||(this._wheelPick={point:this.pick(t.clientX,t.clientY)},requestAnimationFrame(()=>this._wheelPick=null));let e=this._wheelPick.point??this.target,n=Cd(this.camera.position,this.target,e,Id(t.deltaY,t.deltaMode,this.dom.clientHeight));this._set(n.position,n.target)}};function nt(i,t,e){let n=document.createElement(i);return t&&(n.className=t),e!==void 0&&(n.textContent=e),n}function qe(i,t,e){let n=nt("button",i);return n.type="button",n.innerHTML=t,n.title=e,n.setAttribute("aria-label",e),n}function La(i){i.preventDefault(),i.stopPropagation()}function Da(i){La(i);let t=()=>{window.removeEventListener("pointerup",e,!0),setTimeout(()=>window.removeEventListener("click",La,!0))},e=n=>{La(n),t()};window.addEventListener("pointerup",e,!0),window.addEventListener("click",La,!0)}function Na(i,t,e,n){let s=t.clone().applyMatrix4(e.matrixWorldInverse).z<0,r=t.clone().project(e);i.hidden=!(s&&Math.abs(r.x)<=1&&Math.abs(r.y)<=1),!i.hidden&&(i.style.left=`${n.left+(r.x+1)/2*n.width}px`,i.style.top=`${n.top+(1-r.y)/2*n.height}px`)}var de=i=>``,Ld='',Zt={model:de(''),views:de(''),sections:de(''),clashes:de(''),timeliner:de(''),links:de(''),orbit:de(''),pan:de(''),fit:de(''),projection:de(Ld),plane:de(''),box:de(`${Ld}`),resetSections:de(''),distance:de(''),displayMode:de(''),grids:de(''),opacity:de(''),fullscreen:de(''),close:de(''),chevron:de(''),flip:de(''),remove:de(''),play:de(''),pause:de(''),home:de('')};var gr=new C(0,0,1),on={right:new C(1,0,0),left:new C(-1,0,0),back:new C(0,1,0),front:new C(0,-1,0),top:new C(0,0,1),bottom:new C(0,0,-1)},Hx={right:gr,left:gr,back:gr,front:gr,top:on.back,bottom:on.front},Ni=.7,Gx=Oe.degToRad(1),Dd=.01,Nd=96,Ua=1.75;function Ud(i){let t=i.split("-"),e=new C;for(let n of t){if(!Object.hasOwn(on,n))throw new RangeError(`Unknown view cube region: ${i}`);e.add(on[n])}if(new Set(t).size!==t.length||e.lengthSq()!==t.length)throw new RangeError(`Unknown view cube region: ${i}`);return e.normalize()}function Wx(i){let t=[];return Math.abs(i.z)>=Ni&&t.push(i.z>0?"top":"bottom"),Math.abs(i.y)>=Ni&&t.push(i.y>0?"back":"front"),Math.abs(i.x)>=Ni&&t.push(i.x>0?"right":"left"),t.join("-")}function Fd(i,t=Gx){let e=i.clone().normalize(),n=Math.cos(t);return Object.keys(on).find(s=>e.dot(on[s])>=n)??null}function Bd(i){let t=new C(i.x,i.y,0);if(t.lengthSq()<1e-24)return"front";t.normalize();let e=["front","right","back","left"],n=Math.max(...e.map(s=>t.dot(on[s])));return e.find(s=>t.dot(on[s])>=n-1e-9)}function Bc(i,t){return on[Bd(t)].clone().multiplyScalar(Math.sin(Dd)).addScaledVector(on[i],Math.cos(Dd))}function Xx(i,t){let e=Fd(i);if(!e)return null;let n=e==="top"||e==="bottom";if(t==="left"||t==="right")return(n?Bc(e,i):Ud(e)).applyAxisAngle(gr,t==="right"?Math.PI/2:-Math.PI/2);if(t!=="up"&&t!=="down")throw new RangeError(`Unknown arrow: ${t}`);if(!n)return Bc(t==="up"?"top":"bottom",i);let s=on[Bd(i)];return t==="up"==(e==="top")?s.clone().negate():s.clone()}function qx(i,t){let e=document.createElement("canvas");e.width=e.height=128;let n=e.getContext("2d");n.fillStyle="#2a2f38",n.fillRect(0,0,128,128),n.fillStyle="#cbd5e1",n.textAlign="center",n.textBaseline="middle",n.font=`600 26px ${t}`;let s=n.measureText(i).width;s>112&&(n.font=`600 ${Math.floor(2912/s)}px ${t}`),n.fillText(i,64,64);let r=new Ws(e);return r.colorSpace=Se,r}var Fa=class{constructor(t,e,n){this.controls=n,this.home={position:e.position.clone(),target:n.target.clone()},this.element=nt("div","ioviewer-viewcube"),this.canvas=nt("canvas","ioviewer-viewcube-canvas"),this.canvas.title=st("cube.title"),this.canvas.setAttribute("aria-label",st("cube.title")),this.element.append(this.canvas),this.error=null,this.renderer=this._createRenderer(),this.scene=new Mi,this.camera=new In(-Ua,Ua,Ua,-Ua,.1,10),this.faces=new be;let s=getComputedStyle(t).fontFamily;for(let[a,l]of Object.entries(on)){let c=st(`face.${a}`),h=new _e(new Gn(2,2),new cn({map:qx(c,s)})),d=Hx[a];h.quaternion.setFromRotationMatrix(new Ht().makeBasis(d.clone().cross(l),d,l)),h.position.copy(l),h.name=a,h.userData.label=c,this.faces.add(h)}let r=new gn(new Ti(new hn(2,2,2)),new sn({color:"#5b6575"}));this.highlight=new _e(new hn(1,1,1),new cn({color:"#2563eb",transparent:!0,opacity:.6})),this.highlight.visible=!1,this.scene.add(this.faces,r,this.highlight),this._raycaster=new Ai,this.hovered=null,this.canvas.addEventListener("pointermove",a=>this._hover(this.regionAtClient(a.clientX,a.clientY))),this.canvas.addEventListener("pointerleave",()=>this._hover(null)),this.canvas.addEventListener("click",a=>{let l=this.regionAtClient(a.clientX,a.clientY);if(!l)return;let c=l==="top"||l==="bottom";this._look(c?Bc(l,n.camera.position.clone().sub(n.target)):Ud(l))}),this.arrows=["up","down","left","right"].map(a=>{let l=qe("ioviewer-viewcube-arrow",Zt.chevron,st(`cube.${a}`));return l.dataset.arrow=a,l.addEventListener("click",()=>this._arrow(a)),l});let o=qe("ioviewer-viewcube-home",Zt.home,st("cube.home"));o.addEventListener("click",()=>n.animateTo({position:this.home.position.clone(),target:this.home.target.clone()})),this.element.append(...this.arrows,o),t.append(this.element),this.update()}_createRenderer(){this.canvas.addEventListener("webglcontextlost",e=>{e.stopImmediatePropagation(),this._fail(new Error("The view cube lost its WebGL context."))});let t=this.canvas.getContext("webgl2",{alpha:!0,antialias:!0});if(!t)return this._fail(new Error("WebGL 2 is not available for the view cube."));try{let e=new Ss({canvas:this.canvas,context:t,alpha:!0,antialias:!0});return e.setPixelRatio(window.devicePixelRatio),e.setSize(Nd,Nd),e}catch(e){return this._fail(e)}}_fail(t){return this.error=t,this.renderer=null,this.element.hidden=!0,null}_render(){if(this.renderer)try{this.renderer.render(this.scene,this.camera)}catch(t){this._fail(t)}}update(){if(!this.renderer)return;let t=this.controls.camera;this.camera.quaternion.copy(t.quaternion),this.camera.position.set(0,0,5).applyQuaternion(t.quaternion),this.camera.updateMatrixWorld();let e=Fd(t.position.clone().sub(this.controls.target))!==null;for(let n of this.arrows)n.hidden=!e;this._render()}regionAtClient(t,e){let n=this.canvas.getBoundingClientRect(),s=new zt((t-n.left)/n.width*2-1,1-(e-n.top)/n.height*2);this._raycaster.setFromCamera(s,this.camera);let r=this._raycaster.intersectObjects(this.faces.children)[0];return r?Wx(r.point):null}_hover(t){if(t!==this.hovered){if(this.hovered=t,this.highlight.visible=!!t,t){let e=t.split("-").reduce((a,l)=>a.add(on[l]),new C),n=a=>a===0?[-Ni,Ni]:a>0?[Ni,1.02]:[-1.02,-Ni],[s,r,o]=e.toArray().map(n);this.highlight.scale.set(s[1]-s[0],r[1]-r[0],o[1]-o[0]),this.highlight.position.set((s[0]+s[1])/2,(r[0]+r[1])/2,(o[0]+o[1])/2)}this._render()}}_arrow(t){let n=this.controls.camera.position.clone().sub(this.controls.target),s=Xx(n,t);s&&this._look(s)}_look(t){let{controls:e}=this,n=e.camera.position.distanceTo(e.target);e.animateTo({position:t.clone().multiplyScalar(n).add(e.target),target:e.target.clone()})}};var Yx=` +#include +${Cc} +#include +varying vec3 vPos; +varying vec2 vSlot; +void main() { + ${Ic} + vec3 transformed = slotPosition; + #include + #include + vec4 wp = vec4( transformed, 1.0 ); + #ifdef USE_INSTANCING + wp = instanceMatrix * wp; + #endif + vPos = ( modelMatrix * wp ).xyz; + vSlot = vec2( float( slot >> 12 ), float( slot & 4095 ) ); + if ( state.a < 0.5 ) gl_Position = vec4( 0.0, 0.0, 2.0, 1.0 ); +}`,$x=` +#include +#include +#include +varying vec3 vPos; +varying vec2 vSlot; +void main() { + #include + float id = floor( vSlot.x + 0.5 ) * 4096.0 + floor( vSlot.y + 0.5 ) + 1.0; + #if defined( IO_PICK_DEPTH ) + gl_FragColor = packDepthToRGBA( gl_FragCoord.z ); + #elif defined( IO_PICK_RGBA8 ) + gl_FragColor = vec4( mod( id, 256.0 ), mod( floor( id / 256.0 ), 256.0 ), mod( floor( id / 65536.0 ), 256.0 ), floor( id / 16777216.0 ) ) / 255.0; + #else + gl_FragColor = vec4( vPos, id ); + #endif +}`,Od=i=>i&&{element:i.element,point:i.point.clone()};function Oc(i,t){let e=new Ze({vertexShader:Yx,fragmentShader:$x,uniforms:i,defines:t?{[t]:""}:{},side:Be,clipping:!0});return e.index0AttributeName="q",e}var Ba=class{constructor(t,e,n,s={}){this.renderer=t,this.scene=e,this.camera=n,this._lost=!1,t.domElement.addEventListener("webglcontextlost",()=>this._lost=!0),this._last=null,this._key=new Float64Array(36),e.root.addEventListener("change",o=>o.detail.kind!=="appearance"&&(this._last=null));let r=e.slotUniforms;this._float=s.pickFloat!==!1&&t.extensions.has("EXT_color_buffer_float"),this._float?(this._material=Oc(r,null),this._target=new Fe(1,1,{type:We}),this._pixel=new Float32Array(4)):(this._material=Oc(r,"IO_PICK_RGBA8"),this._depthMaterial=Oc(r,"IO_PICK_DEPTH"),this._target=new Fe(1,1),this._pixel=new Uint8Array(4))}pick(t,e){let n=this.renderer.domElement.getBoundingClientRect(),s=Math.floor(t-n.left),r=Math.floor(e-n.top);if(this._lost||!(s>=0&&r>=0&&sh===a[d]))return Od(l.hit);let c=this._pick(s,r,n);return this._last={camera:o,key:a.slice(),hit:c},Od(c)}_pick(t,e,n){let s=this._render(this._material,t,e,n),r=(this._float?Math.round(s[3]):new Uint32Array(s.buffer)[0])-1;if(r<0)return null;let o=this.scene.slotElement(r);if(this._float)return{element:o,point:new C(s[0],s[1],s[2])};let[a,l,c,h]=this._render(this._depthMaterial,t,e,n),d=a/256+l/65536+c/16777216+h/255/16777216,u=new C((t+.5)/n.width*2-1,1-(e+.5)/n.height*2,d*2-1);return{element:o,point:u.unproject(this.camera)}}_render(t,e,n,s){let{renderer:r,camera:o}=this,a={target:r.getRenderTarget(),color:r.getClearColor(new Dt),alpha:r.getClearAlpha()},l=this.scene._buckets[0]?.material;t.clippingPlanes=l?.clippingPlanes??null,t.clipIntersection=l?.clipIntersection??!1,o.updateMatrixWorld(),o.setViewOffset(s.width,s.height,e,n,1,1);try{r.setRenderTarget(this._target),r.setClearColor(0,0),this.scene.withMaterial(t,()=>r.render(this.scene.root,o)),r.readRenderTargetPixels(this._target,0,0,1,1,this._pixel)}finally{r.setRenderTarget(a.target),r.setClearColor(a.color,a.alpha),o.clearViewOffset()}return this._pixel}};var zd=33.333333333333336,Zx=200,kd=1e6,Oa=class{constructor(t,e,n=1e7){this.scene=t,this.budget=n,this.minPixels=0,this._requestRender=e,this._last=new Float64Array(32),this._seen=!1,this._requestedAt=null,this._frame=null,this._cost=0,this._settle=0}requested(t){this._requestedAt??(this._requestedAt=t)}begin(t,e,n){t.updateMatrixWorld();let s=this._requestedAt===null?0:Math.max(n-this._requestedAt,0);this._requestedAt=null;let r=this._moved(t);this._frame=r?{start:n,waited:s}:null,this.minPixels=r?this.scene.detailThreshold(t,e,this.budget):0,this.scene.setDetail(this.minPixels,e),r&&(clearTimeout(this._settle),this.minPixels>0&&(this._settle=setTimeout(this._requestRender,Zx)))}end(t){this.scene.setDetail(0),this._frame&&this._adapt(this._frame.waited+(t-this._frame.start)),this._frame=null}_adapt(t){this._cost=this._cost?this._cost*.8+t*.2:t,this._cost>zd*1.1?this.budget=Math.max(this.budget*.9,kd):this._cost0&&(this.budget=Math.max(Math.min(this.budget*1.05,this.scene.visibleCost),kd))}_moved(t){let e=this._last,n=[...t.matrixWorld.elements,...t.projectionMatrix.elements],s=(o,a)=>a!==26&&a!==30&&Math.abs(o-e[a])>1e-9*Math.max(1,Math.abs(o)),r=this._seen&&n.some(s);return this._seen=!0,e.set(n),r}};var Mn,Es,za,ka=class extends EventTarget{constructor(){super(...arguments);ja(this,Es);ja(this,Mn,new Set)}get size(){return Zn(this,Mn).size}has(e){return Zn(this,Mn).has(e)}values(){return Zn(this,Mn).values()}set(e){th(this,Mn,new Set(e)),br(this,Es,za).call(this)}toggle(e){Zn(this,Mn).delete(e)||Zn(this,Mn).add(e),br(this,Es,za).call(this)}clear(){Zn(this,Mn).size!==0&&(Zn(this,Mn).clear(),br(this,Es,za).call(this))}};Mn=new WeakMap,Es=new WeakSet,za=function(){this.dispatchEvent(new Event("change"))};function Vd(i,t,e){if(t!=="window"&&t!=="crossing")throw new RangeError(`Unknown box select mode: ${t}`);let n=i.x+i.width,s=i.y+i.height,r=t==="window"?o=>o.minX>=i.x&&o.maxX<=n&&o.minY>=i.y&&o.maxY<=s:o=>o.maxX>=i.x&&o.minX<=n&&o.maxY>=i.y&&o.minY<=s;return e.filter(r).map(o=>o.index)}function Hd(i,t,e){let n=new Ht().multiplyMatrices(t.projectionMatrix,t.matrixWorldInverse).elements,{left:s,top:r,width:o,height:a}=e,l=new se,c=[];for(let h=0;hn===l?r(l):o(l));let m=[...t.children].find(b=>Gd.indexOf(b.dataset.panel)>g);t.insertBefore(_,m??null);let f=nt("section","ioviewer-side-panel ioviewer-floating");f.dataset.panel=l,f.hidden=!0,f.setAttribute("aria-label",h);let w=qe("ioviewer-panel-close",Zt.close,st("close"));w.addEventListener("click",()=>r(l));let R=nt("header","ioviewer-panel-header");R.append(nt("h2","",h),w);let S=nt("div","ioviewer-panel-body");f.append(R,S),i.append(f),e.set(l,{button:_,panel:f,onClose:p}),d(S)}return{rail:t,openPanel:o,closePanel:r,registerPanel:a}}var Xd=[{id:"navigation",items:["orbit","pan","fit","projection"]},{id:"sections",items:["plane","box","resetSections"]},{id:"measure",items:["distance"]},{id:"display",items:["displayMode","grids","opacity"]},{id:"view",items:["fullscreen"]}];function qd(i,t){let e=nt("div","ioviewer-toolbar ioviewer-floating");e.setAttribute("role","toolbar");let n=new Map;for(let o of t){let a=nt("div","ioviewer-toolbar-group");a.setAttribute("role","group"),a.setAttribute("aria-label",st(`group.${o.id}`));let l=nt("div","ioviewer-toolbar-row");for(let c of o.items){let h=qe("ioviewer-tool",Zt[c],st(`tool.${c}`));h.dataset.tool=c,h.setAttribute("aria-disabled","true");let d={button:h,handler:null};h.addEventListener("click",()=>{h.getAttribute("aria-disabled")!=="true"&&d.handler?.()}),n.set(c,d),l.append(h)}a.append(l,nt("div","ioviewer-toolbar-label",st(`group.${o.id}`))),e.append(a)}i.append(e);function s(o){let a=n.get(o);if(!a)throw new RangeError(`Unknown tool: ${o}`);return a}function r(o,a){s(o).button.setAttribute("aria-disabled",String(!a))}return{element:e,setHandler(o,a){s(o).handler=a,r(o,!0)},setEnabled:r,setActive(o,a){let{button:l}=s(o);l.classList.toggle("active",a),l.setAttribute("aria-pressed",String(a))}}}var Ui=24,Jx=8;function Kx(i,t,e,n,s){let r=Math.min(n,Math.max(0,Math.floor(i/t)-s)),o=Math.min(n,Math.ceil((i+e)/t)+s);return{start:r,end:Math.max(r,o)}}function jx(i){let t=null;return e=>{let n=e.trim().toLowerCase();t??(t=i.map(r=>`${r.name??""} +${r.type??""} +${r.id}`.toLowerCase()));let s=[];for(let r=0;r{let s=e.category??"";t.has(s)||t.set(s,[]),t.get(s).push(n)}),[...t].map(([e,n])=>({name:e,indices:n})).sort((e,n)=>e.name.localeCompare(n.name))}function Yd(i,{elements:t,selection:e,scene:n,activate:s}){let r=Qx(t),o=new Int32Array(t.length);r.forEach((b,y)=>b.indices.forEach(T=>o[T]=y));let a=new Uint8Array(r.length),l=jx(t),c="",h=new Int32Array(0),d=-1,u=nt("input","ioviewer-tree-search");u.type="search",u.placeholder=st("searchPlaceholder"),u.setAttribute("aria-label",st("searchPlaceholder"));let p=nt("div","ioviewer-tree");p.setAttribute("role","tree"),p.setAttribute("aria-label",st("panel.model"));let g=nt("div","ioviewer-tree-spacer");p.append(g);let _=nt("div","ioviewer-tree-empty",st("noMatches"));i.append(u,p,_);function m(){if(c)h=Int32Array.from(l(c));else{let b=r.length;r.forEach((T,v)=>a[v]&&(b+=T.indices.length)),h=new Int32Array(b);let y=0;r.forEach((T,v)=>{if(h[y++]=-(v+1),a[v])for(let E of T.indices)h[y++]=E})}g.style.height=`${h.length*Ui}px`,_.hidden=h.length>0||!c,f()}function f(){let{start:b,end:y}=Kx(p.scrollTop,Ui,p.clientHeight,h.length,Jx),T=[];for(let v=b;vp.scrollTop+y&&(p.scrollTop=T+Ui-y)}function S(b){let y=o[b];!c&&!a[y]&&(a[y]=1,m());let T=h.indexOf(b);T>=0&&R(T),f()}u.addEventListener("input",()=>{c=u.value.trim(),p.scrollTop=0,m()}),p.addEventListener("scroll",f),p.addEventListener("click",b=>{let y=b.target.closest(".tree-row");if(!y)return;let T=h[Number(y.dataset.row)];if(T>=0){s(T);return}a[-T-1]^=1,m()}),new ResizeObserver(()=>{d>=0&&p.clientHeight>0&&(R(d),d=-1),f()}).observe(p),e.addEventListener("change",()=>{let b=-1;for(let y of e.values())b=y;b>=0?S(b):f()}),n.root.addEventListener("change",b=>b.detail.kind==="visibility"&&f()),m()}var $d={element:["properties","hide","isolate","showAll","-","zoomTo","sectionBox","-","copyId"],empty:["showAll","fitAll","resetSections"]},tv={hide:"H",isolate:"I",showAll:"Shift+H",zoomTo:"F"},Va=4;function Zd(i){let t=nt("div","ioviewer-context-menu");t.setAttribute("role","menu"),t.hidden=!0,i.append(t);let e=new Map(Object.values($d).flat().filter(l=>l!=="-").map(l=>[l,{enabled:!1,handler:null}]));function n(){t.hidden||(t.hidden=!0,t.replaceChildren(),window.removeEventListener("pointerdown",r,!0),window.removeEventListener("wheel",n,!0),window.removeEventListener("scroll",s,!0),window.removeEventListener("resize",n))}function s(l){i.contains(l.target)||n()}function r(l){t.contains(l.target)||(n(),Da(l))}function o(l,c,h,d){let u=$d[l];if(!u)throw new RangeError(`Unknown menu: ${l}`);n();for(let _ of u){if(_==="-"){t.append(nt("div","ioviewer-menu-separator"));continue}let m=e.get(_),f=nt("button","ioviewer-menu-item");f.type="button",f.dataset.item=_,f.setAttribute("role","menuitem"),f.disabled=!m.enabled,f.append(nt("span","",st(`menu.${_}`)),nt("kbd","",tv[_]??"")),f.addEventListener("click",()=>{n(),m.handler(d)}),t.append(f)}t.hidden=!1;let{width:p,height:g}=t.getBoundingClientRect();t.style.left=`${Math.max(Va,Math.min(c,innerWidth-p-Va))}px`,t.style.top=`${Math.max(Va,Math.min(h,innerHeight-g-Va))}px`,window.addEventListener("pointerdown",r,!0),window.addEventListener("wheel",n,!0),window.addEventListener("scroll",s,!0),window.addEventListener("resize",n)}function a(l,{enabled:c=!0,handler:h}){let d=e.get(l);if(!d)throw new RangeError(`Unknown menu item: ${l}`);h&&(d.handler=h),d.enabled=c&&!!d.handler}return{element:t,open:o,close:n,setItem:a,get isOpen(){return!t.hidden}}}var ev="input, textarea, select, [contenteditable]";function nv(i){if(i.ctrlKey||i.altKey||i.metaKey||i.repeat||i.target?.closest?.(ev))return null;switch(i.code){case"KeyH":return i.shiftKey?"showAll":"hide";case"KeyI":return i.shiftKey?null:"isolate";case"KeyF":return i.shiftKey?null:"zoomTo";case"Escape":return"escape";default:return null}}function Jd(i,t){i.addEventListener("keydown",e=>{let n=nv(e);n&&(e.preventDefault(),t[n]())})}var iv=1600;function Kd(i,t,e){let n=()=>i.textContent=`${st("selected")}: ${t.size} \xB7 ${st("elements")}: ${e}`;t.addEventListener("change",n),n()}function jd(i,t){if(!t)return;let e=nt("div","ioviewer-banner ioviewer-floating");e.setAttribute("role","status");let n=st("truncated").replace("{meshes}",t.meshes).replace("{total}",t.totalMeshes),s=qe("ioviewer-panel-close",Zt.close,st("close"));s.addEventListener("click",()=>e.remove()),e.append(nt("span","",n),s),i.append(e)}function Ts(i,t){let e=nt("div","ioviewer-toast",t);e.setAttribute("role","status"),i.append(e),setTimeout(()=>e.remove(),iv)}function Qd(i,t){let e=nt("table");for(let[s,r]of t)e.insertRow().append(nt("th","",s),nt("td","",String(r??"")));let n=nt("section");return n.append(nt("h3","",i),e),n}function tf(i,t,e){let n=nt("h2","ioviewer-properties-title"),s=nt("button","ioviewer-properties-close","\xD7");s.type="button",s.title=st("close"),s.setAttribute("aria-label",st("close")),s.addEventListener("click",()=>t.clear());let r=nt("header");r.append(n,s);let o=nt("div","ioviewer-properties-body");i.replaceChildren(r,o),i.hidden=!0,t.addEventListener("change",()=>{if(i.hidden=t.size===0,o.replaceChildren(),t.size!==1){n.textContent=`${st("selected")}: ${t.size}`;return}let[a]=t.values(),l=e.meta.elements[a];n.textContent=l.name||`#${l.id}`;let c=[[st("id"),l.id],[st("category"),l.category],[st("type"),l.type],[st("level"),l.level]];l.link&&c.push([st("link"),l.link]),o.append(Qd(st("elementGroup"),c));for(let[h,d]of Object.entries(e.props(l.id)??{}))o.append(Qd(h,Object.entries(d)))})}async function sv(i){if(await navigator.clipboard?.writeText(i).then(()=>!0,()=>!1))return!0;let t=document.createElement("textarea");t.value=i,t.setAttribute("readonly",""),t.className="ioviewer-offscreen",document.body.append(t),t.select();try{return document.execCommand("copy")}finally{t.remove()}}function ef({root:i,data:t,scene:e,controls:n,picker:s,selection:r,measure:o}){let{meta:a}=t,l=()=>n.fitBox(e.visibleBox()),c={hide:()=>r.size&&e.setVisible([...r.values()],!1),isolate:()=>r.size&&e.isolate([...r.values()]),showAll:()=>e.showAll(),zoomTo:()=>{if(!r.size)return l();let[m,...f]=r.values(),w=e.elementBox(m);for(let R of f)w.union(e.elementBox(R));n.zoomToBox(w)},fitAll:l},h=Wd(i);h.registerPanel("model",{icon:Zt.model,title:st("panel.model"),render:m=>Yd(m,{elements:a.elements,selection:r,scene:e,activate:f=>{r.set([f]),n.zoomToBox(e.elementBox(f))}})});let d=document.createElement("aside");d.id="ioviewer-properties",d.className="ioviewer-floating",i.append(d),tf(d,r,t);let u=qd(i,Xd),p=m=>{n.setLeftButtonMode(m),u.setActive("orbit",m==="orbit"),u.setActive("pan",m==="pan")};u.setHandler("orbit",()=>p("orbit")),u.setHandler("pan",()=>p("pan")),p(n.leftButtonMode),u.setHandler("fit",l),u.setHandler("projection",()=>{let m=!n.camera.isOrthographicCamera;n.setProjection(m?"orthographic":"perspective"),u.setActive("projection",m)}),u.setActive("projection",!1),document.fullscreenEnabled&&(u.setHandler("fullscreen",()=>{(document.fullscreenElement?document.exitFullscreen():document.documentElement.requestFullscreen()).catch(()=>{u.setActive("fullscreen",!!document.fullscreenElement),Ts(i,st("fullscreenUnavailable"))})}),document.addEventListener("fullscreenchange",()=>u.setActive("fullscreen",!!document.fullscreenElement)));let g=Zd(i);g.setItem("properties",{handler:m=>r.set([m])});for(let m of["hide","isolate","showAll","zoomTo","fitAll"])g.setItem(m,{handler:c[m]});g.setItem("copyId",{handler:m=>sv(String(a.elements[m].id)).then(f=>Ts(i,st(f?"idCopied":"copyFailed")),()=>Ts(i,st("copyFailed")))}),n.addEventListener("contextmenu",m=>{let{x:f,y:w}=m.detail;if(o.active){let[S]=r.values();return S===void 0?g.open("empty",f,w,null):g.open("element",f,w,S)}let R=s.pick(f,w);if(!R||R.element<0)return g.open("empty",f,w,null);r.has(R.element)||r.set([R.element]),g.open("element",f,w,R.element)});let _=[];return Jd(window,{...c,escape:()=>{if(g.isOpen)return g.close();_.some(m=>m())||r.clear()}}),Kd(document.getElementById("ioviewer-status"),r,e.elementCount),jd(i,a.truncated),{shell:h,toolbar:u,contextMenu:g,onEscape:m=>_.push(m)}}var rv={x:new C(1,0,0),y:new C(0,1,0),z:new C(0,0,1)};function zc(i){let t=rv[i];if(!t)throw new RangeError(`Unknown axis: ${i}`);return t.clone()}function nf(i,t,e,n){let s=i.clone().normalize(),r=s.dot(t)+e;return n?new ge(s,-r):new ge(s.negate(),r)}function sf(i,t){if(t!=="keepInside"&&t!=="cutInside")throw new RangeError(`Unknown box mode: ${t}`);let{min:e,max:n}=i,s=[new ge(new C(1,0,0),-e.x),new ge(new C(-1,0,0),n.x),new ge(new C(0,1,0),-e.y),new ge(new C(0,-1,0),n.y),new ge(new C(0,0,1),-e.z),new ge(new C(0,0,-1),n.z)];return t==="keepInside"?{planes:s,intersection:!1}:{planes:s.map(r=>r.negate()),intersection:!0}}function rf(i){let{min:t,max:e}=i;return Array.from({length:8},(n,s)=>new C(s&1?e.x:t.x,s&2?e.y:t.y,s&4?e.z:t.z))}function Ha(i,t,e){if(t.length===0||i.isEmpty())return!1;let n=rf(i),s=r=>n.every(o=>r.distanceToPoint(o)<0);return e?t.every(s):t.some(s)}function of(i,t){let e=i.getSize(new C).multiplyScalar(.5);return Math.abs(t.x)*e.x+Math.abs(t.y)*e.y+Math.abs(t.z)*e.z}function _r(i,t,e){let n=t.clone().sub(i.origin),s=e.dot(i.direction),r=1-s*s;return r<.03?null:(s*i.direction.dot(n)-e.dot(n))/r}function af(i,t,e){let n=new C(1,0,0).applyQuaternion(t),s=new C(0,1,0).applyQuaternion(t),[r,o,a,l]=[1/0,-1/0,1/0,-1/0];for(let h of rf(i)){h.sub(e);let d=h.dot(n),u=h.dot(s);[r,o,a,l]=[Math.min(r,d),Math.max(o,d),Math.min(a,u),Math.max(l,u)]}return{center:e.clone().addScaledVector(n,(r+o)/2).addScaledVector(s,(a+l)/2),width:(o-r)*1.1,height:(l-a)*1.1}}var xr=6,ov=["off","planes","box"],av=.05,lf=new C(0,0,1),Ga=class extends EventTarget{constructor(t,e){super(),this.scene=t,this.modelBox=e.clone(),this.center=e.getCenter(new C),this.planes=[],this.active=-1,this.box=null,this.boxMode="keepInside",this.gizmoVisible=!0,this._mode="off"}get mode(){return this._mode}setMode(t){if(!ov.includes(t))throw new RangeError(`Unknown section mode: ${t}`);t==="box"&&!this.box&&this.fitBoxToShown()||(this._mode=t,this._changed(!0))}addPlane(t){let e=zc(t);if(this.planes.length>=xr||this.modelBox.isEmpty())return null;let n=new Ne().setFromUnitVectors(lf,e);return this.planes.push({axis:t,quaternion:n,offset:0,flipped:!1,enabled:!0,rotated:!1}),this.active=this.planes.length-1,this._mode="planes",this._changed(!0),this.active}removePlane(t){this._plane(t),this.planes.splice(t,1),(this.active>=this.planes.length||this.active>t)&&this.active--,this._changed(!0)}updatePlane(t,{offset:e,flipped:n,enabled:s,quaternion:r}){let o=this._plane(t);if(e!==void 0){if(!Number.isFinite(e))throw new RangeError(`Offset must be a finite number, got ${e}.`);o.offset=e}n!==void 0&&(o.flipped=!!n),s!==void 0&&(o.enabled=!!s),r&&(o.quaternion.copy(r).normalize(),o.rotated=this.normal(t).angleTo(zc(o.axis))>1e-6),this._changed(!0)}setActivePlane(t){this._plane(t),this.active=t,this._changed(!1)}normal(t){return lf.clone().applyQuaternion(this._plane(t).quaternion)}extent(t){return of(this.modelBox,this.normal(t))}origin(t){return this.center.clone().addScaledVector(this.normal(t),this._plane(t).offset)}clipPlane(t){let e=this._plane(t);return nf(this.normal(t),this.center,e.offset,e.flipped)}setBox(t){if(t.isEmpty())throw new RangeError("The section box is empty.");this.box=t.clone(),this._mode="box",this._changed(!0)}setBoxMode(t){if(t!=="keepInside"&&t!=="cutInside")throw new RangeError(`Unknown box mode: ${t}`);this.boxMode=t,this._changed(!0)}fitBoxToElements(t){let e=new se;for(let s of t)e.union(this.scene.elementBox(s));if(e.isEmpty())return!1;let n=e.getSize(new C);return this.setBox(e.expandByScalar(av*Math.max(n.x,n.y,n.z))),!0}fitBoxToShown(){let t=this.scene.visibleBox();if(t.isEmpty())return!1;let e=t.getSize(new C);return this.setBox(t.expandByScalar(.001*Math.max(e.x,e.y,e.z))),!0}setGizmoVisible(t){this.gizmoVisible=!!t,this._changed(!1)}reset(){this.planes=[],this.active=-1,this.box=null,this._mode="off",this._changed(!0)}clipping(){return this._mode==="planes"?{planes:this.planes.flatMap((e,n)=>e.enabled?[this.clipPlane(n)]:[]),intersection:!1}:this._mode==="box"&&this.box?sf(this.box,this.boxMode):{planes:[],intersection:!1}}isCut(t){let{planes:e,intersection:n}=this.clipping();return Ha(t,e,n)}_plane(t){let e=this.planes[t];if(!Number.isInteger(t)||!e)throw new RangeError(`Plane index ${t} is out of range 0..${this.planes.length-1}.`);return e}_changed(t){if(t){let{planes:e,intersection:n}=this.clipping();this.scene.setClipping(e,n)}this.dispatchEvent(new Event("change"))}};var lv=80,cv=8,fi={x:"#ef4444",y:"#22c55e",z:"#3b82f6",centre:"#e2e8f0",hover:"#facc15",frame:"#93c5fd",fill:"#60a5fa"},Wa=["x","y","z"],hv=Math.cos(Oe.degToRad(12)),cf=new C(0,0,1),uv=i=>new cn({color:i,depthTest:!1,depthWrite:!1,transparent:!0,opacity:.9}),dv=new cn({visible:!1});function Xa(i,t,e,n){let s=new be,r=uv(t);for(let a of e)s.add(new _e(a,r));let o=new _e(n,dv);return o.userData.handle=s,s.add(o),s.userData={kind:i,color:t,material:r,proxy:o},s.traverse(a=>a.renderOrder=1e3),s}var qa=class extends EventTarget{constructor(t,e){super(),this.sections=t,this.dom=e,this.camera=null,this.group=new be,this.group.name="sections",this._raycaster=new Ai,this._drag=null,this._hovered=null,this._buildPlanes(),this._buildPlaneGizmo(),this._buildBox(),t.addEventListener("change",()=>this._sync()),(e.parentElement??e).addEventListener("pointerdown",n=>this._down(n),!0),e.addEventListener("pointermove",n=>this._move(n)),e.addEventListener("pointerup",n=>this._up(n)),e.addEventListener("pointercancel",()=>this._end()),e.addEventListener("lostpointercapture",()=>this._end()),this._sync()}get dragging(){return this._drag!==null}update(t){this.camera=t;let e=this.planeGizmo;e.visible&&(e.scale.setScalar(this._worldPerPixel(e.position)*lv),this.arrow.visible=this._across(cf.clone().applyQuaternion(e.quaternion),e.position));for(let n of this.boxHandles){let{shown:s,axis:r}=n.userData;n.visible=s&&(!r||this._across(new C().setComponent(Wa.indexOf(r),1),n.position)),n.visible&&n.scale.setScalar(this._worldPerPixel(n.position)*cv)}}_across(t,e){let n=this.camera,s=n.isOrthographicCamera?n.getWorldDirection(new C):e.clone().sub(n.position).normalize();return Math.abs(t.dot(s))new C(r/2,o/2,0))),n=new cn({color:fi.fill,transparent:!0,opacity:.12,side:Be,depthWrite:!1}),s=new sn({color:fi.frame});this.planeVisuals=Array.from({length:xr},()=>{let r=new be;return r.add(new _e(t,n),new Hs(e,s)),this.group.add(r),r})}_buildPlaneGizmo(){let t=(s,r)=>s.rotateX(Math.PI/2).translate(0,0,r),e=Xa("move",fi.z,[t(new Ei(.025,.025,1),.5),t(new qs(.08,.25),1.125)],t(new Ei(.12,.12,1.25),.625));e.name="arrow",this.arrow=e;let n=(s,r,o)=>{let a=Xa("rotate",fi[r],[o(new us(.8,.018,8,64))],o(new us(.8,.09,8,48)));return a.name=s,a.userData.axis=r,a};this.planeGizmo=new be,this.planeGizmo.add(e,n("ringX","x",s=>s.rotateY(Math.PI/2)),n("ringY","y",s=>s.rotateX(Math.PI/2))),this.group.add(this.planeGizmo)}_buildBox(){this.boxWire=new gn(new Ti(new hn(1,1,1)),new sn({color:fi.frame})),this.group.add(this.boxWire);let t=new hs(1,16,12),e=new hs(1.6,8,6);this.boxHandles=[];for(let s of Wa)for(let r of[-1,1]){let o=Xa("face",fi[s],[t],e);o.name=`face${r>0?"+":"-"}${s}`,Object.assign(o.userData,{axis:s,sign:r}),this.boxHandles.push(o)}let n=Xa("centre",fi.centre,[new hn(1.4,1.4,1.4)],e);n.name="centre",this.boxHandles.push(n),this.group.add(...this.boxHandles)}_sync(){let t=this.sections,e=t.mode==="planes";this.planeVisuals.forEach((r,o)=>{let a=t.planes[o];if(r.visible=e&&!!a?.enabled,!r.visible)return;let l=af(t.modelBox,a.quaternion,t.origin(o));r.position.copy(l.center),r.quaternion.copy(a.quaternion),r.scale.set(l.width,l.height,1)});let n=t.planes[t.active];this.planeGizmo.visible=e&&t.gizmoVisible&&!!n?.enabled,this.planeGizmo.visible&&(this.planeGizmo.position.copy(t.origin(t.active)),this.planeGizmo.quaternion.copy(n.quaternion));let s=t.mode==="box"?t.box:null;this.boxWire.visible=!!s;for(let r of this.boxHandles)r.visible=r.userData.shown=!!s&&t.gizmoVisible;if(s){let r=s.getCenter(new C),o=s.getSize(new C);this.boxWire.position.copy(r),this.boxWire.scale.copy(o).max(new C(1e-6,1e-6,1e-6));for(let a of this.boxHandles){a.position.copy(r);let{axis:l,sign:c}=a.userData;l&&(a.position[l]=c>0?s.max[l]:s.min[l])}}}_worldPerPixel(t){let e=this.camera,n=Math.max(this.dom.clientHeight,1);if(e.isOrthographicCamera)return(e.top-e.bottom)/e.zoom/n;let s=t.clone().sub(e.position).dot(e.getWorldDirection(new C));return 2*Math.max(s,e.near)*Math.tan(Oe.degToRad(e.fov/2))/n}_ray(t){let e=this.dom.getBoundingClientRect(),n=new zt((t.clientX-e.left)/e.width*2-1,1-(t.clientY-e.top)/e.height*2);return this.camera.updateMatrixWorld(),this._raycaster.setFromCamera(n,this.camera),this._raycaster.ray.clone()}_hit(t){if(!this.camera)return null;this.update(this.camera),this.group.updateMatrixWorld(!0);let e=r=>{for(;r&&r!==this.group;r=r.parent)if(!r.visible)return!1;return!0},n=[];this.group.traverse(r=>r.userData.handle&&e(r)&&n.push(r)),this._ray(t);let s=this._raycaster.intersectObjects(n,!1)[0];return s?s.object.userData.handle:null}_down(t){if(t.target!==this.dom||t.button!==0)return;let e=this._hit(t),n=e&&this._start(e,this._ray(t));n&&(t.stopPropagation(),t.preventDefault(),this.dom.setPointerCapture(t.pointerId),this._drag={id:t.pointerId,handle:e,...n},this._highlight(e))}_start(t,e){let n=this.sections,{kind:s}=t.userData;if(s==="move"||s==="rotate"){let c=n.active,h=n.origin(c),d=n.planes[c].quaternion.clone();if(s==="move"){let _=n.normal(c),m=_r(e,h,_);return m===null?null:{index:c,origin:h,normal:_,offset:n.planes[c].offset,s0:m}}let u=new C().setComponent(Wa.indexOf(t.userData.axis),1).applyQuaternion(d),p=new ge().setFromNormalAndCoplanarPoint(u,h),g=e.intersectPlane(p,new C)?.sub(h);return g?{index:c,origin:h,quaternion:d,axis:u,plane:p,from:g}:null}let r=n.box.clone();if(s==="face"){let{axis:c,sign:h}=t.userData,d=new C().setComponent(Wa.indexOf(c),h),u=t.position.clone(),p=_r(e,u,d);return p===null?null:{box:r,normal:d,at:u,s0:p}}let o=this.camera.getWorldDirection(new C),a=new ge().setFromNormalAndCoplanarPoint(o,r.getCenter(new C)),l=e.intersectPlane(a,new C);return l?{box:r,plane:a,from:l}:null}_move(t){let e=this._drag;if(!e)return this._hover(t);if(t.pointerId!==e.id)return;if(t.buttons===0)return this._end();let n=this._ray(t),s=this.sections,{kind:r}=e.handle.userData;if(r==="move"){let o=_r(n,e.origin,e.normal);o!==null&&s.updatePlane(e.index,{offset:e.offset+o-e.s0})}else if(r==="rotate"){let o=n.intersectPlane(e.plane,new C)?.sub(e.origin);if(!o)return;let a=Math.atan2(e.axis.dot(new C().crossVectors(e.from,o)),e.from.dot(o)),l=new Ne().setFromAxisAngle(e.axis,a).multiply(e.quaternion),c=cf.clone().applyQuaternion(l);s.updatePlane(e.index,{quaternion:l,offset:c.dot(e.origin.clone().sub(s.center))})}else if(r==="face"){let o=_r(n,e.at,e.normal);if(o===null)return;let{axis:a,sign:l}=e.handle.userData,c=e.box.clone(),h=.001*Math.max(...s.modelBox.getSize(new C).toArray(),.001),d=(l>0?e.box.max[a]:e.box.min[a])+l*(o-e.s0);l>0?c.max[a]=Math.max(d,c.min[a]+h):c.min[a]=Math.min(d,c.max[a]-h),s.setBox(c)}else{let o=n.intersectPlane(e.plane,new C);o&&s.setBox(e.box.clone().translate(o.sub(e.from)))}}_up(t){this._drag&&t.pointerId===this._drag.id&&this._end()}_end(){this._drag&&(this._drag=null,this._highlight(null))}_hover(t){t.buttons===0&&this._highlight(this._hit(t))}_highlight(t){if(this._hovered!==t){for(let e of[this._hovered,t])e&&e.userData.material.color.set(e===t?fi.hover:e.userData.color);this._hovered=t,this.dom.style.cursor=t?"grab":"",this.dispatchEvent(new Event("change"))}}};function vr(i,t,e){let n=nt("div","ioviewer-segmented");n.setAttribute("role","group"),n.setAttribute("aria-label",i);let s=t.map(([o,a])=>{let l=nt("button","",a);return l.type="button",l.dataset.value=o,l.addEventListener("click",()=>e(o)),n.append(l),l});return{element:n,update:o=>s.forEach(a=>a.setAttribute("aria-pressed",String(a.dataset.value===o)))}}function hf(i,t){let e=nt("label","ioviewer-check"),n=nt("input");return n.type="checkbox",n.addEventListener("change",()=>t(n.checked)),e.append(n,nt("span","",i)),{element:e,input:n}}function fv(i,t){let e=nt("li","ioviewer-section-plane");e.addEventListener("pointerdown",()=>i.active!==t&&i.setActivePlane(t));let n=nt("span","ioviewer-section-name"),s=qe("ioviewer-icon-button",Zt.flip,st("sections.flip"));s.addEventListener("click",()=>i.updatePlane(t,{flipped:!i.planes[t].flipped}));let r=hf(st("sections.enabled"),h=>i.updatePlane(t,{enabled:h})),o=qe("ioviewer-icon-button",Zt.remove,st("sections.delete"));o.addEventListener("click",()=>i.removePlane(t));let a=nt("input","ioviewer-section-slider");a.type="range",a.step="any",a.setAttribute("aria-label",st("sections.position")),a.addEventListener("input",()=>i.updatePlane(t,{offset:Number(a.value)}));let l=nt("div","ioviewer-section-head");return l.append(n,s,r.element,o),e.append(l,a),{element:e,update:()=>{let h=i.planes[t],d=h.rotated?` (${st("sections.rotated")})`:"";n.textContent=`${t+1}. ${h.axis.toUpperCase()}${d}`,e.classList.toggle("active",i.active===t),s.setAttribute("aria-pressed",String(h.flipped)),r.input.checked=h.enabled;let u=i.extent(t);a.min=String(-u),a.max=String(u),a.value=String(h.offset)}}}function uf({root:i,shell:t,toolbar:e,contextMenu:n,sections:s,selection:r}){let o="z",a=()=>{s.addPlane(o)===null&&(s.setMode("planes"),Ts(i,st("sections.maxPlanes")))},l=()=>[...r.values()],c=()=>s.fitBoxToElements(l()),h=vr(st("sections.mode"),[["planes",st("sections.planes")],["box",st("sections.box")],["off",st("sections.off")]],E=>s.setMode(E)),d=vr(st("sections.axis"),["x","y","z"].map(E=>[E,E.toUpperCase()]),E=>d.update(o=E));d.update(o);let u=nt("button","ioviewer-text-button",st("sections.addPlane"));u.type="button",u.addEventListener("click",a);let p=nt("ul","ioviewer-section-list"),g=nt("div","ioviewer-section-empty",st("sections.noPlanes")),_=nt("div","ioviewer-section-block"),m=nt("div","ioviewer-section-row");m.append(nt("span","",st("sections.axis")),d.element,u),_.append(m,p,g);let f=vr(st("sections.box"),[["keepInside",st("sections.keepInside")],["cutInside",st("sections.cutInside")]],E=>s.setBoxMode(E)),w=nt("button","ioviewer-text-button",st("sections.fitSelection"));w.type="button",w.addEventListener("click",c);let R=nt("div","ioviewer-section-block");R.append(f.element,w);let S=hf(st("sections.showGizmo"),E=>s.setGizmoVisible(E)),b=[],y=()=>{h.update(s.mode),_.hidden=s.mode!=="planes",R.hidden=s.mode!=="box",u.disabled=s.planes.length>=xr,b.length!==s.planes.length&&(b=s.planes.map((E,P)=>fv(s,P)),p.replaceChildren(...b.map(E=>E.element))),b.forEach(E=>E.update()),g.hidden=b.length>0,f.update(s.boxMode),S.input.checked=s.gizmoVisible,e.setActive("plane",s.mode==="planes"),e.setActive("box",s.mode==="box")},T=()=>w.disabled=r.size===0;if(t.registerPanel("sections",{icon:Zt.sections,title:st("panel.sections"),visible:!s.modelBox.isEmpty(),render:E=>E.append(h.element,_,R,S.element)}),s.addEventListener("change",y),r.addEventListener("change",T),y(),T(),e.setActive("plane",!1),e.setActive("box",!1),s.modelBox.isEmpty())return;let v=()=>t.openPanel("sections");e.setHandler("plane",()=>{a(),v()}),e.setHandler("box",()=>{!(r.size&&c())&&!s.fitBoxToShown()||v()}),e.setHandler("resetSections",()=>s.reset()),n.setItem("sectionBox",{handler:E=>{s.fitBoxToElements(r.has(E)?l():[E]),v()}}),n.setItem("resetSections",{handler:()=>s.reset()})}var pv="#facc15",mv=9;function kc(i,t=mr){return`${new Intl.NumberFormat(t,{minimumFractionDigits:2,maximumFractionDigits:2,useGrouping:!1}).format(i)} ${st("unit.m",t)}`}var Ya=class extends EventTarget{constructor(t){super(),this.active=!1,this.points=[],this.group=new be,this.group.name="measure";let e=new re;e.setAttribute("position",new we(new Float32Array(6),3));let n={color:pv,depthTest:!1,depthWrite:!1,transparent:!0};this._line=new bi(e,new sn(n)),this._markers=new wi(e,new ni({...n,size:mv,sizeAttenuation:!1}));for(let s of[this._line,this._markers])s.frustumCulled=!1,s.renderOrder=1001,this.group.add(s);this.label=nt("div","ioviewer-measure-label"),this.label.hidden=!0,t.prepend(this.label),this._sync()}get distance(){return this.points.length===2?this.points[0].distanceTo(this.points[1]):null}get box(){return new se().setFromPoints(this.points)}setActive(t){this.active=!!t,this.active||(this.points=[]),this._sync()}add(t){this.active&&(this.points=this.points.length===1?[this.points[0],t.clone()]:[t.clone()],this._sync())}clear(){return this.points.length===0?!1:(this.points=[],this._sync(),!0)}update(t,e){this.distance!==null&&Na(this.label,this.points[0].clone().lerp(this.points[1],.5),t,e)}_sync(){let t=this._line.geometry.attributes.position;this.points.forEach((e,n)=>t.setXYZ(n,e.x,e.y,e.z)),t.needsUpdate=!0,this._line.geometry.setDrawRange(0,this.points.length),this._line.visible=this.points.length===2,this._markers.visible=this.points.length>0,this.label.hidden=this.distance===null,this.distance!==null&&(this.label.textContent=kc(this.distance)),this.dispatchEvent(new Event("change"))}};var $a=class extends EventTarget{constructor(t,e){super(),this.count=e.length,this.group=new gn(new re().setAttribute("position",new ce(e.flatMap(s=>[...s.start,...s.end]),3)),new sn({color:"#93c5fd",transparent:!0,opacity:.55,depthTest:!1,depthWrite:!1})),this.group.name="grids",this.group.geometry.computeBoundingBox(),this.box=this.group.geometry.boundingBox,this.group.renderOrder=999,this._labels=e.flatMap(s=>[s.start,s.end].map(r=>({element:nt("div","ioviewer-grid-label",s.name),point:new C(...r)})));let n=nt("div","ioviewer-grid-labels");n.append(...this._labels.map(s=>s.element)),t.prepend(n),this._layer=n,this.setVisible(this.count>0)}get visible(){return this.group.visible}setVisible(t){this.group.visible=!!t,this._layer.hidden=!t,this.dispatchEvent(new Event("change"))}update(t,e){if(this.visible)for(let{element:n,point:s}of this._labels)Na(n,s,t,e)}};var gv=8,df=4;function Vc(i,t,e,n="dialog"){let s=nt("div","ioviewer-popover ioviewer-floating");s.setAttribute("role",n),s.setAttribute("aria-label",e),s.hidden=!0,t.setAttribute("aria-haspopup",n),t.setAttribute("aria-expanded","false"),i.append(s);function r(l){s.contains(l.target)||(a(),Da(l))}s.addEventListener("keydown",l=>{l.key==="Escape"&&(l.preventDefault(),l.stopPropagation(),a(),t.focus())});function o(){s.hidden=!1,t.setAttribute("aria-expanded","true");let l=t.getBoundingClientRect(),{width:c}=s.getBoundingClientRect();s.style.left=`${Math.max(df,Math.min(l.left+l.width/2-c/2,innerWidth-c-df))}px`,s.style.bottom=`${innerHeight-l.top+gv}px`,window.addEventListener("pointerdown",r,!0),window.addEventListener("resize",a)}function a(){return s.hidden?!1:(s.hidden=!0,t.setAttribute("aria-expanded","false"),window.removeEventListener("pointerdown",r,!0),window.removeEventListener("resize",a),!0)}return{element:s,close:a,toggle:()=>s.hidden?o():a(),get isOpen(){return!s.hidden}}}function _v(i){return i?.initialWireframe?"wireframe":i?.initialWhiteMode?"white":"materials"}function ff({root:i,toolbar:t,scene:e,meta:n,grids:s}){let r=_=>t.element.querySelector(`[data-tool="${_}"]`),o=Vc(i,r("displayMode"),st("tool.displayMode"),"menu");o.element.classList.add("ioviewer-display-modes");let a=(n.textures??[]).length>0,l=Nc.map(_=>{let m=nt("button","ioviewer-menu-item",st(`display.${_}`));return m.type="button",m.dataset.mode=_,m.setAttribute("role","menuitemradio"),m.disabled=_==="textures"&&!a,m.addEventListener("click",()=>{c(_),o.close()}),o.element.append(m),m}),c=_=>{e.setDisplayMode(_);for(let m of l)m.setAttribute("aria-checked",String(m.dataset.mode===_))};c(_v(n.options)),t.setHandler("displayMode",o.toggle),t.setActive("grids",s.visible),s.count>0&&t.setHandler("grids",()=>{s.setVisible(!s.visible),t.setActive("grids",s.visible)});let h=Vc(i,r("opacity"),st("tool.opacity"));h.element.classList.add("ioviewer-opacity");let d=nt("input","ioviewer-opacity-slider");Object.assign(d,{type:"range",min:"10",max:"100",step:"1",value:"100"}),d.setAttribute("aria-label",st("opacity.label"));let u=nt("output","ioviewer-opacity-value"),p=()=>u.textContent=st("opacity.value").replace("{value}",d.value);d.addEventListener("input",()=>{e.setOpacity(Number(d.value)/100),p()}),p();let g=nt("div","ioviewer-opacity-head");return g.append(nt("span","",st("opacity.label")),u),h.element.append(g,d),t.setHandler("opacity",h.toggle),[o,h]}var xv=3e3;function Hc(i,t){let e=nt("button",i);return e.type="button",e.setAttribute("title",t),e.append(nt("span","ioviewer-row-name",t)),e}function pf({shell:i,toolbar:t,controls:e,views:n}){let s=null,r=nt("button","ioviewer-text-button ioviewer-tour");r.type="button";function o(g){let _=g.camera.projection==="orthographic";e.setProjection(_?"orthographic":"perspective"),t.setActive("projection",_),e.animateTo({position:new C(...g.camera.position),target:new C(...g.camera.target)})}function a(){o(n[s.next]),s.next=(s.next+1)%n.length;let g=()=>{e.animating?s.frame=requestAnimationFrame(g):s.timer=setTimeout(a,xv)};s.frame=requestAnimationFrame(g)}let l=g=>r.contains(g.target),c=g=>!l(g)&&d(),h=g=>!(l(g)&&(g.key==="Enter"||g.key===" "))&&d();function d(){s&&(cancelAnimationFrame(s.frame),clearTimeout(s.timer),s=null,window.removeEventListener("pointerdown",c,!0),window.removeEventListener("wheel",d,!0),window.removeEventListener("keydown",h,!0),p())}function u(){s={next:0,frame:0,timer:0},window.addEventListener("pointerdown",c,!0),window.addEventListener("wheel",d,!0),window.addEventListener("keydown",h,!0),p(),a()}function p(){let g=st(s?"views.stop":"views.tour");r.replaceChildren(),r.insertAdjacentHTML("afterbegin",s?Zt.pause:Zt.play),r.append(nt("span","",g)),r.setAttribute("aria-pressed",String(!!s))}r.addEventListener("click",()=>s?d():u()),p(),i.registerPanel("views",{icon:Zt.views,title:st("panel.views"),visible:n.length>0,render:g=>{let _=nt("div","ioviewer-row-list");for(let m of n){let f=Hc("ioviewer-row ioviewer-view",String(m.name??""));f.addEventListener("click",()=>o(m)),_.append(f)}g.append(r,_)}})}function mf(i,t){let e=[];for(let[n,s]of[[i.element1Id,i.element1Name],[i.element2Id,i.element2Name]]){let r=n!=null?t.findIndex(o=>o.id===n&&!o.mesh):vv(t,s);r>=0&&!e.includes(r)&&e.push(r)}return e}function vv(i,t){if(t==null)return-1;let e=i.findIndex(n=>n.name===t);return e>=0&&i.findIndex((n,s)=>s>e&&n.name===t)<0?e:-1}var yv=["New","Active","Reviewed","Approved","Resolved"],Mv=14,Sv=2,bv=new C(1,-1,.8).normalize();function gf({shell:i,tests:t,elements:e,scene:n,controls:s,selection:r}){let o=new be;o.name="clash-marker";let a=new re;a.setAttribute("position",new we(new Float32Array(3),3));let l={color:"#ef4444",depthTest:!1,depthWrite:!1,transparent:!0},c=new wi(a,new ni({...l,size:Mv,sizeAttenuation:!1}));c.frustumCulled=!1,c.renderOrder=1002,o.add(c),o.visible=!1;let h=null;function d(){o.visible=!1,h?.removeAttribute("aria-current"),h=null,s.setExtraBounds("clash",null)}function u(_,m){let f=mf(_,e);f.length?r.set(f):r.clear();let w=new C(..._.point),R=0;for(let y of f){let T=n.elementBox(y);T.isEmpty()||(R=Math.max(R,...T.getSize(new C).toArray()))}let S=s.camera.position.clone().sub(s.target);S.lengthSq()<1e-12&&S.copy(bv);let b=Math.max(3*R,Sv);s.animateTo({position:S.normalize().multiplyScalar(b).add(w),target:w}),a.attributes.position.setXYZ(0,w.x,w.y,w.z),a.attributes.position.needsUpdate=!0,o.visible=!0,h?.removeAttribute("aria-current"),h=m,m.setAttribute("aria-current","true"),s.setExtraBounds("clash",new se(w.clone(),w.clone()))}function p(_){let m=Hc("ioviewer-row ioviewer-clash",String(_.name??""));m.setAttribute("title",`${_.name??""}: ${_.element1Name??""} \u2014 ${_.element2Name??""}`);let f=yv.includes(_.status),w=nt("span","ioviewer-clash-status",f?st(`clash.${_.status}`):String(_.status??""));return f&&(w.dataset.status=_.status),m.append(w,nt("span","ioviewer-clash-distance",kc(_.distance))),m.addEventListener("click",()=>u(_,m)),m}function g(_){let m=nt("div","ioviewer-clash-test"),f=nt("button","ioviewer-row ioviewer-clash-header");f.type="button",f.setAttribute("aria-expanded","true");let w=`${_.name??""} (${_.clashes.length})`;f.setAttribute("title",w),f.insertAdjacentHTML("afterbegin",Zt.chevron),f.append(nt("span","ioviewer-row-name",w));let R=nt("div","ioviewer-row-list");return R.append(..._.clashes.map(p)),f.addEventListener("click",()=>{R.hidden=!R.hidden,f.setAttribute("aria-expanded",String(!R.hidden))}),m.append(f,R),m}return i.registerPanel("clashes",{icon:Zt.clashes,title:st("panel.clashes"),visible:t.length>0,render:_=>_.append(...t.map(g)),onClose:()=>o.visible&&d()}),{group:o}}var wv=["construct","demolish","temporary"],Ev=Date.parse("0002-01-01T00:00:00Z");function _f(i){let t=typeof i=="string"?Date.parse(i):NaN;return t>=Ev?t:NaN}function xf(i,t){let e=new Map(t.flatMap((o,a)=>o.mesh?[]:[[o.id,a]])),n=[];for(let o of Array.isArray(i?.tasks)?i.tasks:[]){let a=_f(o?.start),l=_f(o?.end);if(!wv.includes(o?.type)||!(a<=l))continue;let c=Array.isArray(o.attachedElements)?o.attachedElements:[];n.push({name:o.name,type:o.type,start:a,end:l,elements:c.filter(h=>e.has(h)).map(h=>e.get(h))})}let s=n.reduce((o,a)=>Math.min(o,a.start),1/0),r=n.reduce((o,a)=>Math.max(o,a.end),-1/0);return{start:s,end:r,tasks:n}}function vf(i,t){let e=new Set,n=new Map;for(let s of i){let r=ts.end,a=s.type==="construct"?r:s.type==="demolish"?o:r||o;for(let l of s.elements)a?e.add(l):!r&&!o&&n.set(l,s.type)}for(let s of e)n.delete(s);return{hidden:e,active:n}}var Tv=864e5,Av=["1","5","15"],Rv={construct:"#22c55e",demolish:"#ef4444",temporary:"#a855f7"},Cv="156px";function yf({root:i,shell:t,timeliner:e,elements:n,scene:s}){let r=xf(e,n);if(r.tasks.length===0)return t.registerPanel("timeliner",{icon:Zt.timeliner,title:st("panel.timeliner"),visible:!1,render:()=>{}}),null;let o=new Intl.DateTimeFormat(void 0,{dateStyle:"short",timeZone:"UTC"}),a=new Intl.DateTimeFormat(void 0,{dateStyle:"medium",timeZone:"UTC"}),l=r.start,c=1,h=0,d=0,u=!1,p=new Set,g=new Map,_=nt("div","ioviewer-player ioviewer-floating");_.setAttribute("role","group"),_.setAttribute("aria-label",st("player.title")),_.hidden=!0;let m=qe("ioviewer-icon-button ioviewer-player-play",Zt.play,st("player.play"));m.addEventListener("click",()=>h?D():P());let f=nt("output","ioviewer-player-date"),w=nt("input","ioviewer-player-slider");Object.assign(w,{type:"range",min:String(r.start),max:String(r.end),step:"any"}),w.setAttribute("aria-label",st("player.date")),w.addEventListener("input",()=>y(Number(w.value)));let R=vr(st("player.speed"),Av.map(O=>[O,`${O}\xD7`]),O=>{c=Number(O),R.update(O)});R.update(String(c));let S=qe("ioviewer-panel-close",Zt.close,st("close"));S.addEventListener("click",v),_.append(m,f,w,R.element,S),i.append(_);function b(){let O=vf(r.tasks,l),I=[...p].filter(B=>!O.hidden.has(B)),k=[...O.hidden].filter(B=>!p.has(B));p=O.hidden;let X=[...g.keys()].filter(B=>O.active.get(B)!==g.get(B)),Y=new Map;for(let[B,U]of O.active)g.get(B)!==U&&(Y.has(U)||Y.set(U,[]),Y.get(U).push(B));g=O.active,I.length&&s.setLayerHidden("schedule",I,!1),k.length&&s.setLayerHidden("schedule",k,!0),X.length&&s.setTint(X,null);for(let[B,U]of Y)s.setTint(U,Rv[B])}function y(O){l=Math.min(Math.max(O,r.start),r.end),w.value=String(l),f.textContent=a.format(l),u&&b()}function T(){u||(u=!0,_.hidden=!1,i.style.setProperty("--toolbar-space",Cv),y(r.start))}function v(){u&&(D(),u=!1,s.setTint([...g.keys()],null),g=new Map,p=new Set,s.clearLayer("schedule"),_.hidden=!0,i.style.removeProperty("--toolbar-space"))}function E(O){y(l+Math.max(O-d,0)/1e3*c*Tv),d=O,l>=r.end?D():h=requestAnimationFrame(E)}function P(){T(),!h&&(l>=r.end&&y(r.start),d=performance.now(),h=requestAnimationFrame(E),z())}function D(){cancelAnimationFrame(h),h=0,z()}function z(){let O=st(h?"player.pause":"player.play");m.innerHTML=h?Zt.pause:Zt.play,m.title=O,m.setAttribute("aria-label",O)}return t.registerPanel("timeliner",{icon:Zt.timeliner,title:st("panel.timeliner"),render:O=>{let I=nt("button","ioviewer-text-button ioviewer-player-open");I.type="button",I.insertAdjacentHTML("afterbegin",Zt.play),I.append(nt("span","",st("player.play"))),I.addEventListener("click",P);let k=nt("div","ioviewer-row-list");for(let X of r.tasks){let Y=String(X.name??""),B=nt("div","ioviewer-row ioviewer-task");B.dataset.type=X.type,B.setAttribute("title",`${Y} \xB7 ${st(`task.${X.type}`)}`);let U=`${o.format(X.start)} \u2013 ${o.format(X.end)}`;B.append(nt("span","ioviewer-row-name",Y),nt("span","ioviewer-task-dates",U)),k.append(B)}O.append(I,k)}}),{open:T,close:v,play:P,pause:D,setDate:y,get date(){return l},get isOpen(){return u}}}function Mf({shell:i,files:t,elements:e,scene:n}){let s=t.map((o,a)=>({file:o,layer:`link:${a}`,members:e.flatMap((l,c)=>l.link!=null&&(l.link===o.name||l.link===o.path)?[c]:[])})),r=(o,a)=>a?n.clearLayer(o.layer):n.setLayerHidden(o.layer,o.members,!0);i.registerPanel("links",{icon:Zt.links,title:st("panel.links"),visible:s.length>0,render:o=>{let a=nt("div","ioviewer-row-list");for(let l of s){let c=String(l.file.name??""),h=nt("label","ioviewer-row ioviewer-check ioviewer-link");h.setAttribute("title",`${c} (${l.file.path??""})`);let d=nt("input");d.type="checkbox",d.checked=l.members.length===0||l.file.visible!==!1,d.disabled=l.members.length===0,d.addEventListener("change",()=>r(l,d.checked)),d.checked||r(l,!1),h.append(d,nt("span","ioviewer-row-name",c),nt("span","ioviewer-row-count",String(l.members.length))),a.append(h)}o.append(a)}})}document.documentElement.lang=mr;var pi=document.getElementById("ioviewer-canvas");function yr(i){let t=document.getElementById("ioviewer-message");t.textContent=i,t.hidden=!1}var Sf=pi.getContext("webgl2",{antialias:!0});if(!Sf)yr(st("noWebGL"));else try{Iv(ju(),Sf)}catch(i){if(!(i instanceof Li))throw i;yr(st("badData"))}function Iv(i,t){i.meta.title&&(document.title=i.meta.title);let e=new Ss({canvas:pi,context:t,antialias:!0});e.setPixelRatio(window.devicePixelRatio),e.localClippingEnabled=!0;let n=new Mi;n.background=new Dt("#1f2228");let s=new Zs("#ffffff","#8a8f99",1.6);s.position.set(0,0,1),n.add(s);let r=new Ge(45,1,.1,1e3);r.up.set(0,0,1),r.position.set(1,-1,.8);let o=new Ks("#ffffff",1.4);o.position.set(.3,.6,0),r.add(o,o.target),o.target.position.set(0,0,-1),n.add(r);let a=window.IOVIEWER_TEST??{},l=_();i.releaseBinary();let c=new Oa(l,S,Math.min(l.totalCost,2e7));n.add(l.root);let h=new Ga(l,l.visibleBox()),d=new qa(h,pi);n.add(d.group);let u=document.getElementById("ioviewer-ui"),p=new Ya(u),g=new $a(u,i.meta.grids??[]);n.add(p.group,g.group);function _(){try{return new pr(i,{...a,maxTextureSize:e.capabilities.maxTextureSize})}catch(I){if(!(I instanceof fr))throw I;yr(st("tooLarge").replace("{count}",I.count).replace("{limit}",I.limit));let k=Y=>({o:0,n:0,t:Y}),X={count:0,geometry:k("u32"),material:k("u32"),element:k("i32"),matrix:k("f32")};return new pr({...i,meta:{...i.meta,instances:X}})}}i.meta.instances.count===0&&yr(st("noGeometry"));let m=!1;pi.addEventListener("webglcontextlost",()=>{m=!0,yr(st("contextLost"))});let f,w=new Promise(I=>f=I),R=!1;function S(){R||m||(R=!0,c.requested(performance.now()),requestAnimationFrame(()=>{if(R=!1,m)return;let I=pi.getBoundingClientRect();y.camera.updateMatrixWorld(),d.update(y.camera),p.update(y.camera,I),g.update(y.camera,I),c.begin(y.camera,I.height,performance.now()),e.render(n,y.camera),c.end(performance.now()),O.update(),f()}))}l.root.addEventListener("change",S),h.addEventListener("change",S),d.addEventListener("change",S),p.addEventListener("change",S),g.addEventListener("change",S);let b=new Ba(e,l,r,{pickFloat:a.pickFloat}),y=new Pa(r,pi,{pick:(I,k)=>b.pick(I,k)?.point??null,visibleBox:()=>l.visibleBox()});y.addEventListener("change",()=>{b.camera=y.camera,S()}),l.root.addEventListener("change",I=>{I.detail.kind==="visibility"&&(y.invalidateBounds(),y.resize())});let T=new ka;T.addEventListener("change",()=>l.setHighlighted([...T.values()]));let v=ef({root:u,data:i,scene:l,controls:y,picker:b,selection:T,measure:p});uf({root:u,...v,sections:h,selection:T});let{meta:E}=i;pf({...v,controls:y,views:E.viewpoints??[]});let P=gf({...v,tests:E.clashTests??[],elements:E.elements,scene:l,controls:y,selection:T});n.add(P.group);let D=yf({root:u,...v,timeliner:E.timeliner,elements:E.elements,scene:l});Mf({...v,files:E.linkedFiles??[],elements:E.elements,scene:l});for(let I of ff({root:u,toolbar:v.toolbar,scene:l,meta:i.meta,grids:g}))v.onEscape(I.close);v.toolbar.setHandler("distance",()=>{p.setActive(!p.active),v.toolbar.setActive("distance",p.active)}),v.toolbar.setActive("distance",!1),v.onEscape(()=>p.clear());let z=()=>{y.setExtraBounds("grids",g.visible?g.box:null),y.setExtraBounds("measure",p.box)};g.addEventListener("change",z),p.addEventListener("change",z),z(),y.addEventListener("click",I=>{let{x:k,y:X,ctrl:Y,shift:B}=I.detail,U=b.pick(k,X),H=U?.element??-1;p.active?U&&p.add(U.point):Y?H>=0&&T.toggle(H):H>=0?T.set([H]):B||T.clear()}),y.addEventListener("boxselect",I=>{if(p.active)return;let{rect:k,mode:X,ctrl:Y,shift:B}=I.detail;if(k.width<4||k.height<4){!Y&&!B&&T.clear();return}let U=new se,H=Vd(k,X,Hd(l,y.camera,pi.getBoundingClientRect())),{planes:Z,intersection:rt}=h.clipping(),at=H.filter(Ft=>!Ha(l.elementBox(Ft,U),Z,rt));T.set(Y?[...T.values(),...at]:at)}),window.addEventListener("resize",()=>{e.setSize(window.innerWidth,window.innerHeight,!1),y.resize()}),e.setSize(window.innerWidth,window.innerHeight,!1),y.resize(),y.fitBox(l.visibleBox(),!1);let O=new Fa(document.getElementById("ioviewer-ui"),r,y);window.addEventListener("pointerdown",I=>{(I.target===pi||O.element.contains(I.target))&&document.activeElement?.blur()},!0),window.__ioviewer={scene:l,motionDetail:c,controls:y,picker:b,selection:T,ui:v,viewCube:O,sections:h,sectionGizmo:d,measure:p,grids:g,clashes:P,timeliner:D,ready:w,get camera(){return y.camera}}}})(); +/*! Bundled license information: + +three/build/three.core.js: +three/build/three.module.js: + (** + * @license + * Copyright 2010-2026 Three.js Authors + * SPDX-License-Identifier: MIT + *) +*/ diff --git a/src/IOViewer/Viewer/index.html b/src/IOViewer/Viewer/index.html new file mode 100644 index 0000000..da16835 --- /dev/null +++ b/src/IOViewer/Viewer/index.html @@ -0,0 +1,20 @@ + + + + + +IOViewer + + + + +
+ +
+
+ + + + + + diff --git a/src/IOViewer/Viewer/package-lock.json b/src/IOViewer/Viewer/package-lock.json new file mode 100644 index 0000000..5e7558a --- /dev/null +++ b/src/IOViewer/Viewer/package-lock.json @@ -0,0 +1,552 @@ +{ + "name": "ioviewer-viewer", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ioviewer-viewer", + "dependencies": { + "three": "0.186.1" + }, + "devDependencies": { + "@playwright/test": "1.64.1", + "esbuild": "0.28.2" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@playwright/test": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz", + "integrity": "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.64.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/playwright": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz", + "integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.64.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz", + "integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/three": { + "version": "0.186.1", + "resolved": "https://registry.npmjs.org/three/-/three-0.186.1.tgz", + "integrity": "sha512-blFeqb49wRCSGUGj7gtpfnSGHy2lwDk94RhUmS1c/hTby70kvChbWpkJ4Pm1390LqzzvTmzgXKHPEafJwCb8jA==", + "license": "MIT" + } + } +} diff --git a/src/IOViewer/Viewer/package.json b/src/IOViewer/Viewer/package.json new file mode 100644 index 0000000..3afb620 --- /dev/null +++ b/src/IOViewer/Viewer/package.json @@ -0,0 +1,18 @@ +{ + "name": "ioviewer-viewer", + "private": true, + "type": "module", + "scripts": { + "build": "esbuild src/main.js --bundle --minify --format=iife --target=es2020 --outfile=dist/viewer.js", + "test": "node --test test/unit/*.test.js", + "e2e": "npm run build && playwright test", + "perf": "npm run build && playwright test --config playwright.perf.config.js" + }, + "dependencies": { + "three": "0.186.1" + }, + "devDependencies": { + "@playwright/test": "1.64.1", + "esbuild": "0.28.2" + } +} diff --git a/src/IOViewer/Viewer/playwright.config.js b/src/IOViewer/Viewer/playwright.config.js new file mode 100644 index 0000000..9affecf --- /dev/null +++ b/src/IOViewer/Viewer/playwright.config.js @@ -0,0 +1,15 @@ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: 'test/e2e', + // Keep Playwright's own artifacts in a subfolder: it empties outputDir, and global setup writes pages to test-results/. + outputDir: 'test-results/output', + globalSetup: './test/e2e/global-setup.js', + forbidOnly: !!process.env.CI, + reporter: 'list', + use: { + browserName: 'chromium', + locale: 'ru-RU', + viewport: { width: 1280, height: 800 }, + }, +}); diff --git a/src/IOViewer/Viewer/playwright.perf.config.js b/src/IOViewer/Viewer/playwright.perf.config.js new file mode 100644 index 0000000..ccac9a3 --- /dev/null +++ b/src/IOViewer/Viewer/playwright.perf.config.js @@ -0,0 +1,24 @@ +import { defineConfig } from '@playwright/test'; + +// Performance harness (`npm run perf`), never part of `npm run e2e`. Headed: hidden windows pause requestAnimationFrame. +// PERF_GPU=integrated asks Chromium for the low-power GPU, PERF_GPU=swiftshader renders on the CPU (worst case). +// Frames follow the display refresh as for users; PERF_UNCAPPED=1 unties them so fps above the refresh rate shows. +const UNCAPPED = ['--disable-gpu-vsync', '--disable-frame-rate-limit']; +const GPU_FLAGS = { integrated: ['--force_low_power_gpu'], swiftshader: ['--use-angle=swiftshader'] }; + +export default defineConfig({ + testDir: 'test/perf', + testMatch: '*.perf.js', + outputDir: 'test-results/perf-output', + timeout: 30 * 60 * 1000, + workers: 1, + reporter: 'list', + use: { + browserName: 'chromium', + headless: false, + locale: 'ru-RU', + viewport: { width: 1920, height: 1080 }, + deviceScaleFactor: 1, + launchOptions: { args: ['--enable-precise-memory-info', ...(process.env.PERF_UNCAPPED ? UNCAPPED : []), ...(GPU_FLAGS[process.env.PERF_GPU] ?? [])] }, + }, +}); diff --git a/src/IOViewer/Viewer/src/data.js b/src/IOViewer/Viewer/src/data.js new file mode 100644 index 0000000..95b6cc1 --- /dev/null +++ b/src/IOViewer/Viewer/src/data.js @@ -0,0 +1,75 @@ +// Reads the data blocks of an exported page (format 2 or 3): META and PROPS as JSON, BIN as one base64 blob. + +const VIEW_TYPES = { f32: Float32Array, u16: Uint16Array, i16: Int16Array, u32: Uint32Array, i32: Int32Array }; +const CHUNK_CHARS = 4 * 1024 * 1024; // multiple of 4, so every chunk decodes on its own + +function block(doc, id) { + const element = doc.getElementById(id); + if (!element) throw new Error(`Data block '${id}' is missing.`); + return element.textContent; +} + +// Decodes the BIN block's base64 text. It is read from its text nodes in pieces: as one string, a block over the +// JavaScript string limit (about 512 M characters in Chromium, ~380 MB of binary) would come back empty. +function decodeBase64(element) { + let total = 0; + for (const node of element.childNodes) total += node.length; + const bytes = new Uint8Array(Math.ceil(total / 4) * 3); // at most; padding and whitespace make it less + let offset = 0; + let carry = ''; // characters past the last whole group of 4 + for (const node of element.childNodes) { + for (let start = 0; start < node.length; start += CHUNK_CHARS) { + const text = carry + node.substringData(start, CHUNK_CHARS).trim(); + const whole = text.length - (text.length % 4); + offset = decodeInto(bytes, offset, text.slice(0, whole)); + carry = text.slice(whole); + } + } + return bytes.subarray(0, decodeInto(bytes, offset, carry)); // an unpadded tail, as atob accepts it +} + +// Writes the bytes of base64 `text` into `bytes` at `offset`; returns the offset after them. Uses the native decoder +// where the browser has one (several times faster), else atob. +function decodeInto(bytes, offset, text) { + if (bytes.setFromBase64) return offset + bytes.subarray(offset).setFromBase64(text).written; + const binary = atob(text); + for (let i = 0; i < binary.length; i++) bytes[offset++] = binary.charCodeAt(i); + return offset; +} + +// The page's data blocks are missing, malformed or do not fit together, as in a truncated or damaged file. +export class ModelDataError extends Error {} + +export function loadModelData(doc = document) { + let meta, bin; + try { + meta = JSON.parse(block(doc, 'ioviewer-meta')); + const binElement = doc.getElementById('ioviewer-bin'); + if (!binElement) throw new Error("Data block 'ioviewer-bin' is missing."); + bin = decodeBase64(binElement); + } catch (error) { + throw new ModelDataError(error.message, { cause: error }); + } + let props = null; + return { + meta, + view(v) { + if (!bin) throw new Error('The binary data has been released.'); + const Type = VIEW_TYPES[v.t]; + if (!Type) throw new ModelDataError(`Unknown view type '${v.t}'.`); + const size = Type.BYTES_PER_ELEMENT; + if (!(Number.isInteger(v.o) && Number.isInteger(v.n) && v.o >= 0 && v.n >= 0 && v.o % size === 0 && v.o + v.n * size <= bin.length)) { + throw new ModelDataError(`View ${JSON.stringify(v)} is outside the ${bin.length} bytes of binary data.`); + } + return new Type(bin.buffer, v.o, v.n); + }, + // Frees the decoded BIN once the scene holds its own copies; view() throws afterwards. + releaseBinary() { + bin = null; + }, + props(elementId) { + props ??= JSON.parse(block(doc, 'ioviewer-props')); + return props[elementId] ?? null; + }, + }; +} diff --git a/src/IOViewer/Viewer/src/engine/chunks.js b/src/IOViewer/Viewer/src/engine/chunks.js new file mode 100644 index 0000000..7a50d4c --- /dev/null +++ b/src/IOViewer/Viewer/src/engine/chunks.js @@ -0,0 +1,400 @@ +// Merged spatial chunks: which instances share a chunk, the order of their slots, and the baked vertex data. Pure +// typed-array code (f64 maths, no per-vertex objects), so it runs and is tested in Node. +import { GRID, octDecodeInto, octEncodeInto } from './quantize.js'; + +export const CHUNK_VERTICES = 32768; // measured best: fewer draws than 16384, less overdraw per draw than 65536 +export const VERBATIM = 0; // u16 slot whose positions and normals are copied as exported (translation-only instance) +export const GENERAL = 1; // u16 slot re-quantized onto the global grid from the transformed vertices +export const F32 = 2; // slot over 65535 grid steps: f32 positions in RTC, origin 0, step 1 +const U16_MAX = 65535; + +/** + * Splits `members` (instance numbers) into leaves of at most `limit` summed `weight` (vertices) each, or of a single + * instance: a recursive split at the weighted median of the longest axis of the member centres (3 per instance). + * Leaves come out depth-first, so neighbouring leaves are neighbours in space. The leaves are views of one copy. + */ +export function splitCells(centres, weight, members, limit) { + const leaves = []; + const stack = [Uint32Array.from(members)]; + while (stack.length) { + const m = stack.pop(); + let total = 0; + for (const i of m) total += weight[i]; + if (total <= limit || m.length <= 1) { + if (m.length) leaves.push(m); + continue; + } + sortAlongLongestAxis(m, centres); + let k = 0; + for (let sum = 0; k < m.length - 1 && sum + weight[m[k]] <= total / 2; k++) sum += weight[m[k]]; + k = Math.max(k, 1); + stack.push(m.subarray(k), m.subarray(0, k)); // the lower half is emitted first + } + return leaves; +} + +// Sorts m by its centre coordinate on the axis where the centres spread most: the coordinate is quantized into the +// high bits of one float64 and the instance number fills the low 32, so a plain numeric sort does it (ties by number). +// A non-finite coordinate sorts as level 0, so no key is NaN and no instance is lost or doubled. With `split`, only +// puts the `split` first in that order before the others (in linear time), each part in no particular order. +function sortAlongLongestAxis(m, centres, split = -1) { + const min = [Infinity, Infinity, Infinity]; + const max = [-Infinity, -Infinity, -Infinity]; + for (const i of m) { + for (let a = 0; a < 3; a++) { + const c = centres[i * 3 + a]; + if (!Number.isFinite(c)) continue; + if (c < min[a]) min[a] = c; + if (c > max[a]) max[a] = c; + } + } + let axis = 0; + for (let a = 1; a < 3; a++) if (max[a] - min[a] > max[axis] - min[axis]) axis = a; + const span = max[axis] - min[axis]; // NaN when no centre is finite: every level 0 + const levels = 2 ** 20 - 1; + const keys = new Float64Array(m.length); + for (let k = 0; k < m.length; k++) { + const i = m[k]; + const c = centres[i * 3 + axis]; + const level = span > 0 && Number.isFinite(c) ? Math.round(((c - min[axis]) / span) * levels) : 0; + keys[k] = level * 2 ** 32 + i; + } + let scanned = 0; + if (split < 0) keys.sort(); + else scanned = select(keys, split); + for (let k = 0; k < m.length; k++) m[k] = keys[k] % 2 ** 32; + return scanned; +} + +// Rearranges distinct keys so that keys[0..k) are the k smallest; returns how many keys its rounds scanned (about 3n). +// Quickselect with a pseudo-random pivot (a fixed-pivot rule goes quadratic on run-like orders, e.g. two ascending +// runs); after 2 log2(n) + 8 rounds it sorts the rest, so it never takes more than n log n. +export function select(keys, k) { + let lo = 0; + let hi = keys.length - 1; + let seed = 0x9e3779b9; + const limit = 2 * Math.ceil(Math.log2(keys.length + 1)) + 8; + let rounds = 0; + let scanned = 0; + while (lo < hi) { + const n = hi - lo + 1; + if (++rounds > limit) { + keys.subarray(lo, hi + 1).sort(); + return scanned + n * Math.ceil(Math.log2(n)); + } + scanned += n; + seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0; + const pivot = keys[lo + (seed % (hi - lo + 1))]; + let i = lo; + let j = hi; + while (i <= j) { + while (keys[i] < pivot) i++; + while (keys[j] > pivot) j--; + if (i <= j) { + const t = keys[i]; + keys[i++] = keys[j]; + keys[j--] = t; + } + } + if (k <= j) hi = j; + else if (k >= i) lo = i; + else return scanned; + } + return scanned; +} + +/** + * Splits `members` into leaves of at most `limit` instances whose centres lie within `maxRadius` of their middle (half + * the diagonal of the centres' box), or of one instance, nearby instances together: a recursive split along the + * longest axis of the member centres. Above `limit` the lower part takes its exact share of ceil(n / limit) leaves (so + * without a radius bound there are exactly that many); a leaf that is only too wide is halved. Leaves come out + * depth-first. The leaves are views of one copy; `leaves.scanned` counts the keys the partitions scanned (see select). + */ +export function splitByCount(centres, members, limit, maxRadius = Infinity) { + const leaves = []; + leaves.scanned = 0; + const stack = [Uint32Array.from(members)]; + while (stack.length) { + const m = stack.pop(); + const cells = Math.ceil(m.length / limit); + if (m.length <= 1 || (cells <= 1 && spread(m, centres) <= maxRadius)) { + if (m.length) leaves.push(m); + continue; + } + const k = cells > 1 ? Math.ceil((m.length * Math.floor(cells / 2)) / cells) : Math.ceil(m.length / 2); + leaves.scanned += sortAlongLongestAxis(m, centres, k); + stack.push(m.subarray(k), m.subarray(0, k)); // the lower part is emitted first + } + return leaves; +} + +// Half the diagonal of the box around the centres of instances m. +function spread(m, centres) { + let x0 = Infinity, y0 = Infinity, z0 = Infinity, x1 = -Infinity, y1 = -Infinity, z1 = -Infinity; + for (const i of m) { + const x = centres[i * 3], y = centres[i * 3 + 1], z = centres[i * 3 + 2]; + if (x < x0) x0 = x; + if (x > x1) x1 = x; + if (y < y0) y0 = y; + if (y > y1) y1 = y; + if (z < z0) z0 = z; + if (z > z1) z1 = z; + } + return Math.hypot(x1 - x0, y1 - y0, z1 - z0) / 2; +} + +export const MERGED_CAP = 384 * 2 ** 20; // merged GPU bytes above which the repeated shapes are drawn instanced + +/** + * Which shapes (one geometry, mirrored or not, with one material; `k` instances of `bytes` merged GPU bytes each) are + * drawn as instanced cells instead of being merged. None while the merged total fits `cap`. Otherwise every shape of + * at least `minRepeats` instances, those that save most first ((k - 1) * bytes; ties by more instances, then by their + * order in `shapes`), skipping a shape whose cells (its `cells` when given, else ceil(k / perCell)) would take the + * cells past `maxDraws`. + * Returns 1 per instanced shape. + * Not just enough shapes to fit the cap: measured at 1M elements on an integrated GPU, keeping the cap's worth merged + * drew the frame 6 % faster (199 vs 212 ms) but took 5x the GPU memory, a 0.7 GB higher GPU-process and 0.45 GB higher + * page peak, and 0.9 s longer to open. + */ +export function planShapes(shapes, { cap = MERGED_CAP, minRepeats = 16, perCell = 1024, maxDraws = 1024 } = {}) { + const instanced = new Uint8Array(shapes.length); + let merged = 0; + for (const s of shapes) merged += s.k * s.bytes; + if (merged <= cap) return instanced; + const saving = (s) => (shapes[s].k - 1) * shapes[s].bytes; + const order = [...shapes.keys()].filter((s) => shapes[s].k >= minRepeats); + order.sort((a, b) => saving(b) - saving(a) || shapes[b].k - shapes[a].k || a - b); + let draws = 0; + for (const s of order) { + const cells = shapes[s].cells ?? Math.ceil(shapes[s].k / perCell); + if (draws + cells > maxDraws) continue; + instanced[s] = 1; + draws += cells; + } + return instanced; +} + +// Index array of `length` entries for a chunk of `vertices` vertices: u16 only while every index is below 65535, since +// WebGL2 always restarts primitives at 0xFFFF in u16 indices (a 65536-vertex chunk needs u32). +export function indexArray(vertices, length) { + return vertices > 65535 ? new Uint32Array(length) : new Uint16Array(length); +} + +// Sorts members in place by radius (indexed by instance) descending, ties by instance number ascending. +export function sortByRadius(members, radius) { + return members.sort((a, b) => radius[b] - radius[a] || a - b); +} + +// First k in [lo, hi] such that radius[lo..k) >= rMin and radius[k] < rMin, for radius descending over [lo, hi). +export function prefixByRadius(radius, lo, hi, rMin) { + while (lo < hi) { + const mid = (lo + hi) >>> 1; + if (radius[mid] >= rMin) lo = mid + 1; + else hi = mid; + } + return lo; +} + +// [start, count] in a chunk's triangle index of the slots first..end-1 (indexStart holds one start per slot + end). +export function drawSpan(indexStart, first, end) { + const start = indexStart[first]; + return [start, end > first ? indexStart[end] - start : 0]; +} + +/** + * How instance i is stored (VERBATIM, GENERAL or F32) and its slot origin in `origin` (3 doubles, RTC). `source` is the + * instance's geometry as from bakeSource: u16+q positions of step GRID under a translation-only matrix are copied as + * they are (origin = translation + q.o); otherwise the box of the transformed geometry box decides: up to 65535 steps + * of GRID from origin = that box's minimum snapped down to the grid, else f32. + */ +export function placeSlot(matrices, i, source, origin) { + const m = matrices.subarray(i * 12, i * 12 + 12); + const identity = m[0] === 1 && m[1] === 0 && m[2] === 0 && m[3] === 0 && m[4] === 1 && m[5] === 0 && m[6] === 0 && m[7] === 0 && m[8] === 1; + if (identity && source.q && source.q.s === GRID) { + for (let a = 0; a < 3; a++) origin[a] = m[9 + a] + source.q.o[a]; + return VERBATIM; + } + let fits = true; + for (let a = 0; a < 3; a++) { + // Row a of the transform over the 8 corners of the local box: its range is the sum of the per-axis ranges. + let lo = m[9 + a]; + let hi = m[9 + a]; + for (let c = 0; c < 3; c++) { + const u = m[c * 3 + a] * source.min[c]; + const v = m[c * 3 + a] * source.max[c]; + lo += Math.min(u, v); + hi += Math.max(u, v); + } + origin[a] = Math.floor(lo / GRID) * GRID; + if (!(Math.ceil((hi - origin[a]) / GRID) <= U16_MAX)) fits = false; + } + if (!fits) origin.fill(0); + return fits ? GENERAL : F32; +} + +/** + * Bakes one chunk: the slots of `members` in this order (slot j = members[j]), every vertex in world (RTC) space. + * ctx = { matrices (12 per instance), geometryOf, mirrored (Uint8Array), kind (per instance, from placeSlot), + * origins (Float64Array, 3 per instance), source(g) (see bakeSource), textured }. + * Returns q (u16 x4, or f32 x4 when the members are F32: xyz position steps, w the slot within the chunk), o (oct + * normals, i16 x2), uv (f32 x2, textured only, zeros where the geometry has none), index (u16, u32 from 65536 + * vertices; mirrored triangles written a, c, b), indexStart (per slot + end) and per slot origins (f32 x3) and steps. + */ +export function bakeChunk(members, ctx) { + const n = members.length; + let vertices = 0; + let indices = 0; + for (const i of members) { + const s = ctx.source(ctx.geometryOf[i]); + vertices += s.vertices; + indices += s.indices.length; + } + const f32 = ctx.kind[members[0]] === F32; + const q = f32 ? new Float32Array(vertices * 4) : new Uint16Array(vertices * 4); + const o = new Int16Array(vertices * 2); + const uv = ctx.textured ? new Float32Array(vertices * 2) : null; + const index = indexArray(vertices, indices); + const indexStart = new Uint32Array(n + 1); + const origins = new Float32Array(n * 3); + const steps = new Float32Array(n); + const normal = new Float64Array(3); + let base = 0; + let at = 0; + for (let j = 0; j < n; j++) { + const i = members[j]; + const s = ctx.source(ctx.geometryOf[i]); + const kind = ctx.kind[i]; + if ((kind === F32) !== f32) throw new Error('A chunk mixes f32 and u16 slots.'); + const m = ctx.matrices.subarray(i * 12, i * 12 + 12); + for (let a = 0; a < 3; a++) origins[j * 3 + a] = ctx.origins[i * 3 + a]; + steps[j] = f32 ? 1 : GRID; + if (kind === VERBATIM) copyVertices(s, q, o, base, j); + else transformVertices(s, m, kind, ctx.origins.subarray(i * 3, i * 3 + 3), q, o, base, j, normal); + if (uv && s.uvs) uv.set(s.uvs, base * 2); + indexStart[j] = at; + const src = s.indices; + if (ctx.mirrored[i]) { + for (let t = 0; t < src.length; t += 3) { + index[at++] = src[t] + base; + index[at++] = src[t + 2] + base; + index[at++] = src[t + 1] + base; + } + } else for (let t = 0; t < src.length; t++) index[at++] = src[t] + base; + base += s.vertices; + } + indexStart[n] = at; + return { q, o, uv, index, indexStart, origins, steps }; +} + +// Translation-only u16 instance: the exported steps and oct normals as they are. +function copyVertices(s, q, o, base, slot) { + const p = s.positions; + for (let v = 0; v < s.vertices; v++) { + const k = (base + v) * 4; + q[k] = p[v * 3]; + q[k + 1] = p[v * 3 + 1]; + q[k + 2] = p[v * 3 + 2]; + q[k + 3] = slot; + } + if (s.oct) o.set(s.oct, base * 2); + else { + const normals = unitNormals(s); + for (let v = 0; v < s.vertices; v++) octEncodeInto(o, (base + v) * 2, normals[v * 3], normals[v * 3 + 1], normals[v * 3 + 2]); + } +} + +// world = M·p in f64; q = steps of GRID from the slot origin (GENERAL) or the f32 world position (F32). Normals by the +// cofactor matrix times sign(det), i.e. the inverse transpose up to scale: right for mirroring and shear. +function transformVertices(s, m, kind, origin, q, o, base, slot, normal) { + const [a0, a1, a2, b0, b1, b2, c0, c1, c2, t0, t1, t2] = m; + // Cofactor columns: b×c, c×a, a×b. + const det = a0 * (b1 * c2 - b2 * c1) - b0 * (a1 * c2 - a2 * c1) + c0 * (a1 * b2 - a2 * b1); + const sg = det < 0 ? -1 : 1; + const k00 = (b1 * c2 - b2 * c1) * sg, k01 = (b2 * c0 - b0 * c2) * sg, k02 = (b0 * c1 - b1 * c0) * sg; + const k10 = (c1 * a2 - c2 * a1) * sg, k11 = (c2 * a0 - c0 * a2) * sg, k12 = (c0 * a1 - c1 * a0) * sg; + const k20 = (a1 * b2 - a2 * b1) * sg, k21 = (a2 * b0 - a0 * b2) * sg, k22 = (a0 * b1 - a1 * b0) * sg; + const p = s.positions; + const qo = s.q ? s.q.o : null; + const qs = s.q ? s.q.s : 1; + const normals = unitNormals(s); + for (let v = 0; v < s.vertices; v++) { + let x = p[v * 3], y = p[v * 3 + 1], z = p[v * 3 + 2]; + if (qo) { + x = qo[0] + x * qs; + y = qo[1] + y * qs; + z = qo[2] + z * qs; + } + const wx = a0 * x + b0 * y + c0 * z + t0; + const wy = a1 * x + b1 * y + c1 * z + t1; + const wz = a2 * x + b2 * y + c2 * z + t2; + const k = (base + v) * 4; + if (kind === F32) { + q[k] = wx; + q[k + 1] = wy; + q[k + 2] = wz; + } else { + q[k] = step(wx - origin[0]); + q[k + 1] = step(wy - origin[1]); + q[k + 2] = step(wz - origin[2]); + } + q[k + 3] = slot; + const nx = normals[v * 3], ny = normals[v * 3 + 1], nz = normals[v * 3 + 2]; + normal[0] = k00 * nx + k10 * ny + k20 * nz; + normal[1] = k01 * nx + k11 * ny + k21 * nz; + normal[2] = k02 * nx + k12 * ny + k22 * nz; + octEncodeInto(o, (base + v) * 2, normal[0], normal[1], normal[2]); + } +} + +const step = (d) => Math.min(Math.max(Math.round(d / GRID), 0), U16_MAX); + +/** + * A geometry as bakeChunk and placeSlot read it: positions (u16 steps with q = {o, s}, or f32), oct normals `oct` + * (i16 pairs, or null), f32 `normals` as written (or null), indices, uvs (or null), the vertex count and the local box + * (min, max) of the decoded positions. Unit f32 normals are made only when a transformed slot needs them (unitNormals): + * translation-only slots copy the oct pairs, and a large model of own geometry must not hold a decoded copy of all. + */ +export function bakeSource(positions, q, normals, indices, uvs) { + const vertices = positions.length / 3; + const min = [Infinity, Infinity, Infinity]; + const max = [-Infinity, -Infinity, -Infinity]; + for (let v = 0; v < vertices; v++) { + for (let a = 0; a < 3; a++) { + const c = q ? q.o[a] + positions[v * 3 + a] * q.s : positions[v * 3 + a]; + if (c < min[a]) min[a] = c; + if (c > max[a]) max[a] = c; + } + } + const oct = normals instanceof Int16Array ? normals : null; + return { positions, q, oct, normals: oct ? null : normals, indices, uvs, vertices, min, max }; +} + +// Unit f32 normals of a bakeSource (3 per vertex), made on first use and kept on it: decoded from the oct pairs, or +// computed from the faces when the geometry has none. +export function unitNormals(s) { + if (!s.normals && s.oct) { + s.normals = new Float32Array(s.vertices * 3); + for (let v = 0; v < s.vertices; v++) octDecodeInto(s.normals, v * 3, s.oct[v * 2], s.oct[v * 2 + 1]); + } else if (!s.normals) s.normals = vertexNormals(s.positions, s.q, s.indices); + return s.normals; +} + +// Area-weighted vertex normals from the faces, as three's computeVertexNormals. +function vertexNormals(positions, q, indices) { + const p = (v, a) => (q ? q.o[a] + positions[v * 3 + a] * q.s : positions[v * 3 + a]); + const out = new Float32Array(positions.length); + for (let t = 0; t < indices.length; t += 3) { + const [a, b, c] = [indices[t], indices[t + 1], indices[t + 2]]; + const e1 = [0, 1, 2].map((k) => p(b, k) - p(a, k)); + const e2 = [0, 1, 2].map((k) => p(c, k) - p(a, k)); + const n = [e1[1] * e2[2] - e1[2] * e2[1], e1[2] * e2[0] - e1[0] * e2[2], e1[0] * e2[1] - e1[1] * e2[0]]; + for (const v of [a, b, c]) for (let k = 0; k < 3; k++) out[v * 3 + k] += n[k]; + } + for (let v = 0; v < out.length; v += 3) { + const l = Math.hypot(out[v], out[v + 1], out[v + 2]) || 1; + out[v] /= l; + out[v + 1] /= l; + out[v + 2] /= l; + } + return out; +} diff --git a/src/IOViewer/Viewer/src/engine/culling.js b/src/IOViewer/Viewer/src/engine/culling.js new file mode 100644 index 0000000..5184a42 --- /dev/null +++ b/src/IOViewer/Viewer/src/engine/culling.js @@ -0,0 +1,66 @@ +import * as THREE from 'three'; + +const matrix = new THREE.Matrix4(); +const frustum = new THREE.Frustum(); +const BINS = 32; // half-octave bins of the on-screen radius, from 1/16 px up + +// The camera's six frustum planes as [nx, ny, nz, d] x 6 in `out`; a point p is inside a plane when n·p + d >= 0. +export function frustumPlanes(camera, out = new Float64Array(24)) { + matrix.multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse); + frustum.setFromProjectionMatrix(matrix, camera.coordinateSystem, camera.reversedDepth); + frustum.planes.forEach(({ normal, constant }, k) => out.set([normal.x, normal.y, normal.z, constant], k * 4)); + return out; +} + +// True when the sphere (x, y, z, r) lies entirely outside one of the planes from frustumPlanes. +export function outsideFrustum(p, x, y, z, r) { + for (let k = 0; k < 24; k += 4) if (p[k] * x + p[k + 1] * y + p[k + 2] * z + p[k + 3] < -r) return true; + return false; +} + +/** + * How to get a sphere's depth and on-screen radius for a camera drawn `viewHeight` CSS pixels high, in `out`: + * [eye x, y, z, forward x, y, z, scale, perspective (1|0)]. Depth = (centre - eye)·forward; the radius in pixels is + * r * scale / depth for a perspective camera and r * scale for an orthographic one (see screenRadius). + */ +export function viewMetrics(camera, viewHeight, out = new Float64Array(8)) { + const e = camera.matrixWorld.elements; + // The camera looks down its local -Z, the third column of its world matrix negated. + const f = new THREE.Vector3(-e[8], -e[9], -e[10]).normalize(); + out.set([e[12], e[13], e[14], f.x, f.y, f.z, (camera.projectionMatrix.elements[5] * viewHeight) / 2, camera.isPerspectiveCamera ? 1 : 0]); + return out; +} + +// On-screen radius in pixels of a sphere at `depth` (Infinity when the eye is inside it). +export function screenRadius(m, r, depth) { + if (!m[7]) return r * m[6]; + return depth > r ? (r * m[6]) / depth : Infinity; +} + +/** + * The smallest on-screen radius (px) to draw so that the summed cost of what is drawn, largest first, stays within + * `budget`; 0 when everything fits. pixels[i] < 0 means i is not drawn anyway; cost[i] is what drawing i costs. + * Radii are binned by half octaves, so the result is a bin edge and the drawn cost may fall below the budget; the + * largest bin is always drawn, so whatever is in view never disappears entirely. + */ +export function detailThreshold(pixels, cost, budget) { + const bins = new Float64Array(BINS); + let total = 0; + for (let i = 0; i < pixels.length; i++) { + const p = pixels[i]; + if (p < 0) continue; + total += cost[i]; + bins[31 - Math.clz32(Math.max(1, Math.min(p * p * 256, 0x7fffffff)))] += cost[i]; // octave of (16 p)^2 + } + if (total <= budget) return 0; + let sum = 0; + let top = true; + for (let b = BINS - 1; b >= 0; b--) { + if (bins[b] === 0) continue; + sum += bins[b]; + // The largest bin is drawn even when it alone is over the budget: never an empty view. + if (sum > budget) return top ? (b === 0 ? 0 : 2 ** (b / 2 - 4)) : 2 ** ((b + 1) / 2 - 4); + top = false; + } + return 0; +} diff --git a/src/IOViewer/Viewer/src/engine/modelScene.js b/src/IOViewer/Viewer/src/engine/modelScene.js new file mode 100644 index 0000000..dc8d380 --- /dev/null +++ b/src/IOViewer/Viewer/src/engine/modelScene.js @@ -0,0 +1,985 @@ +import * as THREE from 'three'; +import { detailThreshold, frustumPlanes, outsideFrustum, viewMetrics } from './culling.js'; +import { GRID, normalsView, octEncodeInto, positionsView } from './quantize.js'; +import { + CHUNK_VERTICES, F32, MERGED_CAP, bakeChunk, bakeSource, drawSpan, indexArray, placeSlot, planShapes, prefixByRadius, sortByRadius, + splitByCount, splitCells, unitNormals, +} from './chunks.js'; +import { patchLambert } from './slotShader.js'; + +export const DISPLAY_MODES = ['materials', 'textures', 'white', 'wireframe']; +const HIGHLIGHT = [245, 158, 11]; // #f59e0b +const WHITE = [235, 235, 237]; // sRGB (0.92, 0.92, 0.93) +const NO_SLOT = 0xffffffff; // an instance without vertices has no slot +const ROW = 2048; // slots per slot-texture row (the shaders use slot & 2047, slot >> 11) +const RANGE_LIMIT = 256; // up to this many changed slots are uploaded one by one, more as the whole texture +const MIN_REPEATS = 16; // fewer instances of a shape stay merged even above the cap +const PER_CELL = 1024; // instances per remainder cell +// A cell's centres lie within this share of the model's diagonal: a cell spread over the site is drawn whole by the +// 1-pixel pick pass and sized by its nearest point by the detail cut. Measured at 1M synthetic elements on an +// integrated GPU (cells / pick / CPU per still frame): unbounded 1024 / 61 ms / 4 ms; 0.1: 4766 / 20 ms / 23 ms; +// 0.06: over 8192 cells, 9.5 ms. Each draw costs three ~5 us of CPU, so merged-chunk extents (0.035) are out of reach. +const CELL_EXTENT = 0.1; +const MAX_CELLS = 8192; // remainder draws at most +const INSTANCED_COST = 1.4; // an instanced triangle against a merged one (measured 1.58 / 1.16 ns per triangle) + +// True when the linear part of instance i (12 floats per instance, columns first) has a negative determinant. +export function isMirrored(matrices, i) { + const [a, b, c, d, e, f, g, h, k] = matrices.subarray(i * 12, i * 12 + 9); + return a * (e * k - f * h) - d * (b * k - c * h) + g * (b * f - c * e) < 0; +} + +// The CPU copy of a GPU array is dropped once three has uploaded it (three's documented onUpload pattern). +function release() { + this.array = null; +} +function attr(array, itemSize, normalized = false, Type = THREE.BufferAttribute) { + return new Type(array, itemSize, normalized).onUpload(release); +} +// A model with more drawable instances (count) than this viewer and GPU can address (limit). +export class ModelTooLargeError extends RangeError { + constructor(count, limit) { + super(`${count} drawable instances exceed the limit of ${limit}.`); + this.name = 'ModelTooLargeError'; + this.count = count; + this.limit = limit; + } +} + +const finiteBox = (boxes, i) => { + for (let k = i * 6; k < i * 6 + 6; k++) if (!Number.isFinite(boxes[k])) return false; + return true; +}; +const colorBytes = (color) => { + const hex = color.getHex(THREE.SRGBColorSpace); + return [(hex >> 16) & 255, (hex >> 8) & 255, hex & 255]; +}; + +/** + * The model as merged spatial chunks: per material, the instances are split into chunks of up to CHUNK_VERTICES + * vertices of nearby instances, baked into world space once (quantized positions, octahedral normals) and drawn with + * one call each. Every instance with vertices owns a slot; its colour and shown flag live in a slot texture the shader + * reads, so selecting, hiding or tinting rewrites a few texels and never rebuilds geometry. + * Elements are addressed by their index in META `elements` (0..n-1). Every visual change dispatches + * `{type: 'change', detail: {kind}}` on `root`. Visibility has layers: the user's (setVisible, isolate, showAll) and + * named ones (setLayerHidden, e.g. the 4D schedule or a linked file); an instance is drawn when the user shows it and + * no named layer hides it. + * Within a chunk the slots are ordered by size, largest first, and each frame draws the index range from the first to + * the last shown slot (hidden slots inside it are dropped by the shader); setDetail can also cut that range where the + * slots get smaller on screen than a given radius, which the viewer uses while the camera moves (detailThreshold + * picks it for a cost budget in triangles). + * A model whose merged data would exceed MERGED_CAP bytes draws its repeated shapes (a geometry with one material, + * mirrored or not, used at least 16 times) as instanced cells instead: three's InstancedMesh over the shape's own + * vertex data with the same slots, slot textures and material, up to 1024 nearby instances within a tenth of the + * model's diagonal (one draw) per cell. + * options (tests and the performance harness, from window.IOVIEWER_TEST): chunkVertices, noHideBranch, slotLimit, + * mergedCap, minRepeats, cellExtent; and maxTextureSize (the renderer's). A model over the slot limit throws + * ModelTooLargeError. + * Instances with a non-finite box (NaN or infinite matrix or geometry box) get no slot and take no part in the bounds. + */ +export class ModelScene { + constructor(data, options = {}) { + const { meta } = data; + const instances = meta.instances; + const count = instances.count; + const geometryOf = data.view(instances.geometry); + const materialOf = data.view(instances.material); + const matrices = data.view(instances.matrix); + + this.root = new THREE.Group(); + this.elementCount = meta.elements.length; + this._elements = meta.elements; + this._meta = meta; + this._mode = 'materials'; + this._opacity = 1; + this._picking = false; + this._textures = new Map(); + this._elementOf = data.view(instances.element).slice(); // own copy: the BIN block is released after load + this._bucketOf = new Uint32Array(count); + this._visible = new Uint8Array(count).fill(1); // the user's layer + this._layers = new Map(); // name -> Uint8Array, 1 = hidden by that layer + this._layerHides = new Uint8Array(count); // how many named layers hide the instance + this._highlight = new Uint8Array(count); + this._highlighted = []; + this._tint = new Map(); // instance -> sRGB bytes [r, g, b] + this._boxes = new Float32Array(count * 6); + this._minPixels = 0; + this._viewHeight = 1; + this.visibleCost = 0; + const noHideBranch = !!options.noHideBranch; + this.slotUniforms = { slotInfo: { value: null }, slotState: { value: null } }; + this._buckets = this._createBuckets(meta, materialOf, noHideBranch); + this._indexElements(); + + const matrix = new THREE.Matrix4(); + const box = new THREE.Box3(); + for (let i = 0; i < count; i++) { + const m = matrices.subarray(i * 12, i * 12 + 12); + matrix.set(m[0], m[3], m[6], m[9], m[1], m[4], m[7], m[10], m[2], m[5], m[8], m[11], 0, 0, 0, 1); + const local = meta.geometries[geometryOf[i]].bbox; + box.min.fromArray(local.min); + box.max.fromArray(local.max); + box.applyMatrix4(matrix); + box.min.toArray(this._boxes, i * 6); + box.max.toArray(this._boxes, i * 6 + 3); + } + const limit = Math.min(2 ** 24 - 1, ROW * (options.maxTextureSize ?? Infinity), options.slotLimit ?? Infinity); + this._build(data, geometryOf, matrices, options, limit); + for (let i = 0; i < count; i++) { + this._applyColor(i); + this._draw(i); + } + this._flush(); + } + + elementId(index) { + this._checkElement(index); + return this._elements[index].id; + } + + setVisible(indices, visible) { + for (const index of indices) { + for (const i of this.instancesOf(index)) this._setInstanceVisible(i, visible); + } + this._changed('visibility'); + } + + showAll() { + for (let i = 0; i < this._visible.length; i++) this._setInstanceVisible(i, true); + this._changed('visibility'); + } + + isolate(indices) { + const keep = new Uint8Array(this._visible.length); + for (const index of indices) { + for (const i of this.instancesOf(index)) keep[i] = 1; + } + for (let i = 0; i < keep.length; i++) this._setInstanceVisible(i, keep[i] === 1); + this._changed('visibility'); + } + + setHighlighted(indices) { + const previous = this._highlighted; + this._highlighted = []; + for (const i of previous) this._highlight[i] = 0; + for (const index of indices) { + for (const i of this.instancesOf(index)) { + this._highlight[i] = 1; + this._highlighted.push(i); + } + } + for (const i of previous) this._applyColor(i); + for (const i of this._highlighted) this._applyColor(i); + this._changed('appearance'); + } + + // Colour override for elements (a THREE.Color or CSS colour; null removes it); the selection highlight still wins. + setTint(indices, color) { + const tint = color === null ? null : colorBytes(new THREE.Color(color)); + for (const index of indices) { + for (const i of this.instancesOf(index)) { + if (tint) this._tint.set(i, tint); + else this._tint.delete(i); + this._applyColor(i); + } + } + this._changed('appearance'); + } + + // Hides (hidden = true) or unhides elements in the named layer `layer`, independently of the user's layer. + setLayerHidden(layer, indices, hidden) { + let flags = this._layers.get(layer); + if (!flags) this._layers.set(layer, (flags = new Uint8Array(this._visible.length))); + for (const index of indices) { + for (const i of this.instancesOf(index)) this._setLayerFlag(flags, i, hidden); + } + this._changed('visibility'); + } + + // Unhides everything the named layer hides. + clearLayer(layer) { + const flags = this._layers.get(layer); + if (!flags) return; + for (let i = 0; i < flags.length; i++) this._setLayerFlag(flags, i, false); + this._layers.delete(layer); + this._changed('visibility'); + } + + // Which instances are drawn now (1 = drawn), all layers combined. + visibilityState() { + return this._visible.map((_, i) => (this._shown(i) ? 1 : 0)); + } + + // Instance numbers (0..instanceCount-1) of an element. + instancesOf(index) { + this._checkElement(index); + return this._elementInstances.subarray(this._elementStart[index], this._elementStart[index + 1]); + } + + // True when at least one instance of the element is drawn (all visibility layers combined). + isVisible(index) { + this._checkElement(index); + for (let k = this._elementStart[index]; k < this._elementStart[index + 1]; k++) { + if (this._shown(this._elementInstances[k])) return true; + } + return false; + } + + get instanceCount() { + return this._slotOf.length; + } + + // Where instance i is drawn (its chunk mesh and slot; null and -1 without vertices) and its element (-1 = none). + instance(i) { + const slot = this._slotOf[i]; + if (slot === NO_SLOT) return { mesh: null, element: this._elementOf[i], slot: -1 }; + return { mesh: this._units[this._slotUnit[slot]].mesh, element: this._elementOf[i], slot }; + } + + // The colour instance i is drawn with (highlight, tint, white mode or material), linear, in `target`. + instanceColor(i, target = new THREE.Color()) { + const slot = this._slotOf[i]; + const [r, g, b] = slot === NO_SLOT ? this._colorOf(i) : this._state.image.data.subarray(slot * 4, slot * 4 + 3); + return target.setRGB(r / 255, g / 255, b / 255, THREE.SRGBColorSpace); + } + + // Element of a slot, -1 for an instance without one. + slotElement(slot) { + return this._elementOf[this._slotInstance[slot]]; + } + + // Runs fn with `material` on every chunk's triangles (and triangles shown in wireframe mode), e.g. for picking. + withMaterial(material, fn) { + this._picking = true; + for (const unit of this._units) { + unit.mesh.material = material; + this._showUnit(unit); + } + try { + return fn(); + } finally { + this._picking = false; + for (const unit of this._units) { + unit.mesh.material = unit.bucket.material; + this._showUnit(unit); + } + } + } + + // Box of all the element's instances, in `box` when given (callers looping over many elements reuse one). + elementBox(index, box = new THREE.Box3()) { + this._checkElement(index); + box.makeEmpty(); + for (let k = this._elementStart[index]; k < this._elementStart[index + 1]; k++) this._expandBox(box, this._elementInstances[k]); + return box; + } + + visibleBox() { + const box = new THREE.Box3(); + for (let i = 0; i < this._visible.length; i++) { + if (this._shown(i)) this._expandBox(box, i); + } + return box; + } + + setDisplayMode(mode) { + if (!DISPLAY_MODES.includes(mode)) throw new Error(`Unknown display mode '${mode}'.`); + const white = (mode === 'white') !== (this._mode === 'white'); + this._mode = mode; + for (const bucket of this._buckets) { + const map = mode === 'textures' && bucket.texture !== null ? this._texture(bucket.texture) : null; + for (const material of [bucket.material, bucket.wire]) { + if (material.map === map) continue; + material.map = map; + material.needsUpdate = true; + } + } + if (mode === 'wireframe') this._buildLines(); + for (const unit of this._units) this._showUnit(unit); + if (white) for (let i = 0; i < this._slotOf.length; i++) this._applyColor(i); + this._changed('appearance'); + } + + setOpacity(opacity) { + if (!(opacity >= 0 && opacity <= 1)) throw new RangeError(`Opacity must be within 0..1, got ${opacity}.`); + this._opacity = opacity; + for (const bucket of this._buckets) this._applyOpacity(bucket); + this._changed('appearance'); + } + + // While minPixels > 0, renders leave out slots whose bounding sphere is smaller on screen than minPixels (radius, CSS + // px, taken at the nearest point of its chunk) in a view `viewHeight` CSS px high; 0 draws everything shown again. + // A unit whose largest shown slot is below the cut (as detailThreshold measured it for this frame) is hidden for the + // frame, so three skips it whole instead of submitting an empty draw (a few microseconds each, thousands of cells). + setDetail(minPixels, viewHeight = 1) { + this._minPixels = minPixels; + this._viewHeight = viewHeight; + for (const unit of this._cut) this._showUnit(unit); + this._cut.length = 0; + const scales = this._unitScale; + if (!(minPixels > 0) || !scales) return; + this._unitScale = null; // measured for this frame only + this._cutInView = 0; + this._cutFrame = null; // the frame that draws with this cut, set by the first unit it draws + this._units.forEach((unit, u) => { + if (!(scales[u] >= 0) || unit.shown === 0) return; + if (unit.dirty) this._span(unit); + if (this._slotRadius[unit.slotBase + unit.first] * scales[u] >= minPixels) return; + unit.mesh.visible = false; + if (unit.lines) unit.lines.visible = false; + this._cut.push(unit); + this._cutInView += unitTriangles(unit, unit.first, unit.last + 1); + }); + } + + // Cost (triangles) of drawing everything. + get totalCost() { + return this._totalCost; + } + + // The radius for setDetail that keeps the triangles drawn of what `camera` sees within `budget`; 0 when everything + // fits. Sets visibleCost to the cost of all that is in view (the whole model when it fits the budget anyway). + detailThreshold(camera, viewHeight, budget) { + this.visibleCost = this._totalCost; + if (this._totalCost <= budget) return 0; + camera.updateMatrixWorld(); + const planes = frustumPlanes(camera, this._planes); + const view = viewMetrics(camera, viewHeight, this._detailView); + const pixels = this._pixels.fill(-1); + const alpha = this._state.image.data; + const scales = (this._unitScale = new Float64Array(this._units.length).fill(-1)); // for setDetail + let visible = 0; + for (let u = 0; u < this._units.length; u++) { + const unit = this._units[u]; + const s = unit.sphere; + if (unit.shown === 0 || outsideFrustum(planes, s[0], s[1], s[2], s[3])) continue; + const near = nearDepth(view, s); + const scale = view[7] ? (near > 0 ? view[6] / near : Infinity) : view[6]; + scales[u] = scale; + for (let slot = unit.slotBase; slot < unit.slotBase + unit.n; slot++) { + if (alpha[slot * 4 + 3] === 0) continue; + pixels[slot] = this._slotRadius[slot] * scale; + visible += this._slotCost[slot]; + } + } + this.visibleCost = visible; + return detailThreshold(pixels, this._slotCost, budget); + } + + // What the latest render drew, in triangles: { drawn, inView, draws } over the units it drew (inView: their shown + // span; drawn: after the detail cut). + drawStats() { + let latest = -Infinity; + for (const unit of this._units) latest = Math.max(latest, unit.lastFrame); + let drawn = 0; + let inView = this._cutFrame === latest ? this._cutInView : 0; // units the cut left out whole + let draws = 0; + for (const unit of this._units) { + if (unit.lastFrame !== latest) continue; + inView += unitTriangles(unit, unit.first, Math.max(unit.last + 1, unit.first)); + drawn += unitTriangles(unit, unit.first, Math.max(unit.end, unit.first)); + draws++; + } + return { drawn, inView, draws }; + } + + // Test helper: the instances a render with `camera` would draw (three's sphere test per chunk, then its range), + // ascending. + drawnInstances(camera) { + camera.updateMatrixWorld(); + const frustum = new THREE.Frustum().setFromProjectionMatrix(new THREE.Matrix4().multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse)); + const renderer = { info: { render: { frame: -1 } } }; + const sphere = new THREE.Sphere(); + const drawn = []; + for (const unit of this._units) { + const object = this._mode === 'wireframe' && !this._picking ? unit.lines : unit.mesh; + if (!object?.visible || !frustum.intersectsSphere(sphere.set(sphere.center.fromArray(unit.sphere), unit.sphere[3]))) continue; + object.onBeforeRender(renderer, null, camera, object.geometry, object.material, null); + for (let k = unit.first; k < unit.end; k++) { + const slot = unit.slotBase + k; + if (this._state.image.data[slot * 4 + 3]) drawn.push(this._slotInstance[slot]); + } + } + return drawn.sort((a, b) => a - b); + } + + // GPU and CPU-mirror sizes in bytes, and counts. + memory() { + const merged = this._units[this._chunkCount]?.slotBase ?? this._slotInstance.length; // the cells' slots come last + return { + gpuBytes: this._mergedBytes + this._instancedBytes + this._lineBytes + this._stateBytes, + mergedBytes: this._mergedBytes, + instancedBytes: this._instancedBytes, + stateBytes: this._stateBytes, + lineBytes: this._lineBytes, + chunks: this._chunkCount, + cells: this._units.length - this._chunkCount, + mergedInstances: merged, + instancedInstances: this._slotInstance.length - merged, + triangles: this._triangles, + }; + } + + setClipping(planes, intersection) { + for (const bucket of this._buckets) { + for (const material of [bucket.material, bucket.wire]) { + material.clippingPlanes = planes.length > 0 ? planes : null; + material.clipIntersection = intersection; + material.needsUpdate = true; + } + } + this._changed('clipping'); + } + + // Slots (numbered unit by unit: chunks, then cells), the chunks with their baked GPU data, the instanced cells, and + // the slot textures. + _build(data, geometryOf, matrices, options, limit) { + const count = geometryOf.length; + const meta = this._meta; + const sources = new Map(); + const source = (g) => { + let s = sources.get(g); + if (!s) { + const geometry = meta.geometries[g]; + const indices = data.view(geometry.indices); + const uvs = geometry.uvs ? data.view(geometry.uvs) : null; + s = bakeSource(positionsView(data, geometry), geometry.q ?? null, normalsView(data, geometry), indices, uvs); + sources.set(g, s); + } + return s; + }; + const kind = new Uint8Array(count); + const origins = new Float64Array(count * 3); + const centres = new Float32Array(count * 3); + const weight = new Uint32Array(count); + const radius = new Float32Array(count); + const mirrored = new Uint8Array(count); + const byShape = new Map(); // (geometry * 2 + mirrored) * buckets + bucket -> instances + const b = this._boxes; + for (let i = 0; i < count; i++) { + const s = source(geometryOf[i]); + if (s.vertices === 0 || !finiteBox(b, i)) continue; + kind[i] = placeSlot(matrices, i, s, origins.subarray(i * 3, i * 3 + 3)); + mirrored[i] = isMirrored(matrices, i) ? 1 : 0; + weight[i] = s.vertices; + for (let a = 0; a < 3; a++) centres[i * 3 + a] = (b[i * 6 + a] + b[i * 6 + 3 + a]) / 2; + radius[i] = Math.hypot(b[i * 6 + 3] - b[i * 6], b[i * 6 + 4] - b[i * 6 + 1], b[i * 6 + 5] - b[i * 6 + 2]) / 2; + const key = (geometryOf[i] * 2 + mirrored[i]) * this._buckets.length + this._bucketOf[i]; + let list = byShape.get(key); + if (!list) byShape.set(key, (list = [])); + list.push(i); + } + // Shapes in (geometry, mirrored, bucket) order; above the merged-memory cap the most repeated ones are instanced. + const keys = [...byShape.keys()].sort((x, y) => x - y); + const shapes = keys.map((key) => { + const members = byShape.get(key); + const s = source(geometryOf[members[0]]); + const textured = this._buckets[this._bucketOf[members[0]]].texture !== null; + const quantizable = [0, 1, 2].every((a) => (s.max[a] - s.min[a]) / GRID <= 65535); + const bytes = s.vertices * ((quantizable ? 12 : 20) + (textured ? 8 : 0)) + s.indices.length * (s.vertices > 65535 ? 4 : 2); + return { k: members.length, bytes, members, textured }; + }); + const cap = options.mergedCap ?? MERGED_CAP; + const minRepeats = options.minRepeats ?? MIN_REPEATS; + if (shapes.reduce((sum, s) => sum + s.k * s.bytes, 0) > cap) { + // The cells of every candidate, so that the plan counts the draws they really take. + const box = this.visibleBox(); + const maxRadius = box.isEmpty() ? Infinity : box.min.distanceTo(box.max) * (options.cellExtent ?? CELL_EXTENT); + for (const shape of shapes) { + if (shape.k < minRepeats) continue; + shape.leaves = splitByCount(centres, shape.members, PER_CELL, maxRadius); + shape.cells = shape.leaves.length; + } + } + const instanced = planShapes(shapes, { cap, minRepeats, perCell: PER_CELL, maxDraws: MAX_CELLS }); + const groups = new Map(); // bucket * 2 + (f32 ? 1 : 0) -> merged instances + const uses = new Uint32Array(meta.geometries.length); // merged slots per geometry still to bake + const cells = []; + shapes.forEach((shape, k) => { + if (instanced[k]) { + for (const cell of shape.leaves) cells.push({ shape, members: sortByRadius(cell, radius) }); + return; + } + for (const i of shape.members) { + uses[geometryOf[i]]++; + const key = this._bucketOf[i] * 2 + (kind[i] === F32 ? 1 : 0); + let list = groups.get(key); + if (!list) groups.set(key, (list = [])); + list.push(i); + } + }); + const leaves = []; + for (const key of [...groups.keys()].sort((x, y) => x - y)) { + for (const leaf of splitCells(centres, weight, groups.get(key), options.chunkVertices ?? CHUNK_VERTICES)) leaves.push(sortByRadius(leaf, radius)); + } + const slots = [...leaves, ...cells.map((c) => c.members)].reduce((sum, members) => sum + members.length, 0); + // Slot numbers must stay exact in a float attribute (< 2^24) and the slot textures within the GPU's size. + if (slots > limit) throw new ModelTooLargeError(slots, limit); + const height = Math.max(1, Math.ceil(slots / ROW)); + const info = new Float32Array(ROW * height * 4); + this._state = new THREE.DataTexture(new Uint8Array(ROW * height * 4), ROW, height); + this._state.colorSpace = THREE.SRGBColorSpace; // SRGB8_ALPHA8: the GPU decodes the colour bytes exactly + this._state.onUpdate = () => { + this._fullPending = false; + this._pendingRanges = 0; + }; + this._fullPending = true; + this._pendingRanges = 0; + this._dirty = new Uint32Array(RANGE_LIMIT); + this._dirtyCount = 0; + const infoTexture = new THREE.DataTexture(info, ROW, height, THREE.RGBAFormat, THREE.FloatType); + infoTexture.needsUpdate = true; + infoTexture.onUpdate = () => (infoTexture.image.data = null); // static: no CPU copy once uploaded + this.slotUniforms.slotInfo.value = infoTexture; + this.slotUniforms.slotState.value = this._state; + this._stateBytes = ROW * height * 20; + + this._slotOf = new Uint32Array(count).fill(NO_SLOT); + this._slotInstance = new Uint32Array(slots); + this._slotUnit = new Uint32Array(slots); + this._slotRadius = new Float32Array(slots); + this._slotCost = new Float32Array(slots); + this._pixels = new Float32Array(slots); + this._planes = new Float64Array(24); + this._detailView = new Float64Array(8); + this._frameView = new Float64Array(8); + this._frameViewAt = NaN; + this._units = []; + this._cut = []; // units the detail cut hides for the current frame + this._unitScale = null; + this._cutInView = 0; + this._cutFrame = -1; + this._mergedBytes = 0; + this._instancedBytes = 0; + this._lineBytes = 0; + const ctx = { matrices, geometryOf, mirrored, kind, origins, source, textured: false }; + // Source indices for the wireframe lines, built on first use (after the BIN block has been released). + this._lineSources = new Map(); + let slotBase = 0; + for (const leaf of leaves) { + const bucket = this._buckets[this._bucketOf[leaf[0]]]; + ctx.textured = bucket.texture !== null; + const baked = bakeChunk(leaf, ctx); + for (let j = 0; j < leaf.length; j++) { + const i = leaf[j]; + const slot = slotBase + j; + this._slotOf[i] = slot; + this._slotInstance[slot] = i; + this._slotUnit[slot] = this._units.length; + this._slotRadius[slot] = radius[i]; + this._slotCost[slot] = (baked.indexStart[j + 1] - baked.indexStart[j]) / 3; + info.set(baked.origins.subarray(j * 3, j * 3 + 3), slot * 4); + info[slot * 4 + 3] = baked.steps[j]; + const g = geometryOf[i]; + if (!this._lineSources.has(g)) this._lineSources.set(g, source(g).indices.slice()); + if (--uses[g] === 0) sources.delete(g); // its decoded normals, if any, go with it + } + this._units.push(this._chunk(bucket, leaf, slotBase, baked, mirrored, geometryOf)); + slotBase += leaf.length; + } + this._lineMembers = { mirrored, geometryOf: geometryOf.slice() }; + this._chunkCount = this._units.length; + this._triangles = this._slotCost.reduce((sum, c) => sum + c, 0); // the chunks' so far + + // Cells: per instanced shape, its vertex data and index once, shared by its cells of up to PER_CELL instances. + const shared = new Map(); + for (const { shape, members } of cells) { + const i0 = members[0]; + const s = source(geometryOf[i0]); + let data = shared.get(shape); + if (!data) shared.set(shape, (data = this._shape(s, mirrored[i0] === 1, shape.textured))); + for (let j = 0; j < members.length; j++) { + const i = members[j]; + const slot = slotBase + j; + this._slotOf[i] = slot; + this._slotInstance[slot] = i; + this._slotUnit[slot] = this._units.length; + this._slotRadius[slot] = radius[i]; + this._slotCost[slot] = data.triangles * INSTANCED_COST; + // Cell positions are the geometry's own: decoded by its origin and step, then placed by instanceMatrix. + info.set(s.q ? s.q.o : [0, 0, 0], slot * 4); + info[slot * 4 + 3] = s.q ? s.q.s : 1; + } + this._triangles += data.triangles * members.length; + this._units.push(this._cell(this._buckets[this._bucketOf[i0]], members, slotBase, data, matrices)); + slotBase += members.length; + } + this._totalCost = this._slotCost.reduce((sum, c) => sum + c, 0); + } + + // Vertex data of an instanced shape in the chunks' format, in the geometry's own space: q (steps or f32, w = 0: the + // slot is slotBase alone), octahedral normals, uv (textured buckets) and the index, reversed for a mirrored shape. + // The index keeps its CPU copy: three builds the wireframe index from it. + _shape(s, mirror, textured) { + const V = s.vertices; + const q = s.q ? new Uint16Array(V * 4) : new Float32Array(V * 4); + for (let v = 0; v < V; v++) for (let a = 0; a < 3; a++) q[v * 4 + a] = s.positions[v * 3 + a]; + let o = s.oct ? s.oct.slice() : null; // a copy: the view would keep the whole BIN block alive + if (!o) { + const normals = unitNormals(s); + o = new Int16Array(V * 2); + for (let v = 0; v < V; v++) octEncodeInto(o, v * 2, normals[v * 3], normals[v * 3 + 1], normals[v * 3 + 2]); + } + const uv = textured ? new Float32Array(V * 2) : null; + if (uv && s.uvs) uv.set(s.uvs); + const src = s.indices; + const index = indexArray(V, src.length); + for (let t = 0; t < src.length; t += 3) { + index[t] = src[t]; + index[t + 1] = src[t + (mirror ? 2 : 1)]; + index[t + 2] = src[t + (mirror ? 1 : 2)]; + } + this._instancedBytes += q.byteLength + o.byteLength + (uv?.byteLength ?? 0) + index.byteLength; + return { + q: attr(q, 4), + normal: attr(o, 2, true), + uv: uv && attr(uv, 2), + index: new THREE.BufferAttribute(index, 1), + triangles: src.length / 3, + }; + } + + // A remainder cell: three's InstancedMesh over the shape's shared data, one slotBase (its slot) and one RTC matrix per + // instance, slots ordered by size, largest first; its sphere drives three's culling and transparent sort. + _cell(bucket, members, slotBase, data, matrices) { + const n = members.length; + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute('q', data.q); + geometry.setAttribute('position', data.q); // unread by the shaders; three builds a wireframe index only with it + geometry.setAttribute('normal', data.normal); + if (data.uv) geometry.setAttribute('uv', data.uv); + geometry.setAttribute('slotBase', attr(Float32Array.from({ length: n }, (_, j) => slotBase + j), 1, false, THREE.InstancedBufferAttribute)); + geometry.setIndex(data.index); + const s = chunkSphere(this._boxes, members); + const sphere = new THREE.Sphere(new THREE.Vector3(s[0], s[1], s[2]), s[3]); + geometry.boundingSphere = sphere; // set, so three never computes one from the positions + const mesh = new THREE.InstancedMesh(geometry, bucket.material, 0); + const m = new Float32Array(n * 16); // three's column-major 4 x 4 from the 3 x 4 columns + for (let j = 0; j < n; j++) { + const e = members[j] * 12; + for (let c = 0; c < 4; c++) { + m[j * 16 + c * 4] = matrices[e + c * 3]; + m[j * 16 + c * 4 + 1] = matrices[e + c * 3 + 1]; + m[j * 16 + c * 4 + 2] = matrices[e + c * 3 + 2]; + } + m[j * 16 + 15] = 1; + } + mesh.instanceMatrix = attr(m, 16, false, THREE.InstancedBufferAttribute); + mesh.count = n; + mesh.boundingSphere = sphere; + this._instancedBytes += n * (64 + 4); + const unit = { + bucket, mesh, lines: null, slotBase, n, sphere: s, shown: 0, first: 0, last: -1, end: 0, dirty: false, + cell: true, triangles: data.triangles, lastFrame: -Infinity, + }; + mesh.onBeforeRender = (renderer, scene, camera) => this._range(unit, renderer, camera, mesh); + mesh.visible = false; + this.root.add(mesh); + return unit; + } + + _chunk(bucket, members, slotBase, baked, mirrored, geometryOf) { + const geometry = new THREE.InstancedBufferGeometry(); + geometry.instanceCount = 1; + geometry.setAttribute('q', attr(baked.q, 4)); + geometry.setAttribute('normal', attr(baked.o, 2, true)); // octahedral, see slotShader + if (baked.uv) geometry.setAttribute('uv', attr(baked.uv, 2)); + geometry.setAttribute('slotBase', attr(new Float32Array([slotBase]), 1, false, THREE.InstancedBufferAttribute)); + geometry.setIndex(attr(baked.index, 1)); + // Drives three's frustum test and its back-to-front sort of transparent chunks. + const sphere = chunkSphere(this._boxes, members); + geometry.boundingSphere = new THREE.Sphere(new THREE.Vector3(sphere[0], sphere[1], sphere[2]), sphere[3]); + this._mergedBytes += baked.q.byteLength + baked.o.byteLength + (baked.uv?.byteLength ?? 0) + baked.index.byteLength; + const mesh = new THREE.Mesh(geometry, bucket.material); + const unit = { + bucket, mesh, lines: null, slotBase, n: members.length, sphere, shown: 0, first: 0, last: -1, end: 0, dirty: false, + cell: false, starts: baked.indexStart, vertices: baked.q.length / 4, f32: baked.q instanceof Float32Array, members: members.slice(), + lastFrame: -Infinity, + }; + mesh.onBeforeRender = (renderer, scene, camera) => this._range(unit, renderer, camera, mesh); + mesh.visible = false; + this.root.add(mesh); + return unit; + } + + // Wireframe: per chunk, LineSegments over the same attributes and a line index of 6 entries per triangle (a,b, + // b,c, c,a), as three's own wireframe draws; a slot's line range is twice its triangle range. Per cell, an + // InstancedMesh over the cell's geometry and matrices with the bucket's wireframe material: three's own wireframe. + _buildLines() { + if (!this._lineSources) return; + const { mirrored, geometryOf } = this._lineMembers; + for (const unit of this._units) { + if (unit.cell) { + const index = unit.mesh.geometry.index; + unit.lines = new THREE.InstancedMesh(unit.mesh.geometry, unit.bucket.wire, 0); + unit.lines.instanceMatrix = unit.mesh.instanceMatrix; // the same GPU buffer + unit.lines.boundingSphere = unit.mesh.boundingSphere; + unit.lines.onBeforeRender = (renderer, scene, camera) => this._range(unit, renderer, camera, unit.lines); + this._lineBytes += index.count * 2 * index.array.BYTES_PER_ELEMENT; // three's 6 entries per triangle + this.root.add(unit.lines); + continue; + } + const triangles = unit.starts[unit.n]; + const index = indexArray(unit.vertices, triangles * 2); + let at = 0; + let base = 0; + for (const i of unit.members) { + const src = this._lineSources.get(geometryOf[i]); + const flip = mirrored[i] === 1; + for (let t = 0; t < src.length; t += 3) { + const a = src[t] + base; + const b = src[t + (flip ? 2 : 1)] + base; + const c = src[t + (flip ? 1 : 2)] + base; + index[at++] = a; + index[at++] = b; + index[at++] = b; + index[at++] = c; + index[at++] = c; + index[at++] = a; + } + base += this._meta.geometries[geometryOf[i]].positions.n / 3; + } + const source = unit.mesh.geometry; + const geometry = new THREE.InstancedBufferGeometry(); + geometry.instanceCount = 1; + for (const name of ['q', 'normal', 'uv', 'slotBase']) if (source.attributes[name]) geometry.setAttribute(name, source.attributes[name]); + geometry.setIndex(attr(index, 1)); + geometry.boundingSphere = source.boundingSphere; + this._lineBytes += index.byteLength; + unit.lines = new THREE.LineSegments(geometry, unit.bucket.material); + unit.lines.onBeforeRender = (renderer, scene, camera) => this._range(unit, renderer, camera, unit.lines); + this.root.add(unit.lines); + unit.members = null; + } + this._lineSources = null; + this._lineMembers = null; + } + + // onBeforeRender of a unit's mesh or lines (also in the picking pass): the shown span, cut by the detail radius, as + // the chunk's index range or the cell's instance count (a cell always starts at its first instance: WebGL2 has no + // base instance; hidden instances inside the count leave the clip volume in the shader). + _range(unit, renderer, camera, object) { + if (unit.dirty) this._span(unit); + let end = unit.last + 1; + if (this._minPixels > 0 && end > unit.first) { + const frame = renderer.info.render.frame; + if (frame !== this._frameViewAt) { + viewMetrics(camera, this._viewHeight, this._frameView); + this._frameViewAt = frame; + } + const view = this._frameView; + const near = nearDepth(view, unit.sphere); + const rMin = view[7] ? (near > 0 ? (this._minPixels * near) / view[6] : 0) : this._minPixels / view[6]; + end = Math.min(end, prefixByRadius(this._slotRadius, unit.slotBase + unit.first, unit.slotBase + end, rMin) - unit.slotBase); + } + if (unit.cell) object.count = Math.max(end, 0); + else { + const wire = object.isLineSegments ? 2 : 1; + const [start, count] = drawSpan(unit.starts, unit.first, end); + object.geometry.setDrawRange(start * wire, count * wire); + } + unit.end = end; + unit.lastFrame = renderer.info.render.frame; + if (this._cutFrame === null && this._minPixels > 0) this._cutFrame = unit.lastFrame; + } + + // First and last shown slot of the unit (from the alpha bytes). + _span(unit) { + const alpha = this._state.image.data; + let first = 0; + while (first < unit.n && alpha[(unit.slotBase + first) * 4 + 3] === 0) first++; + let last = unit.n - 1; + while (last >= first && alpha[(unit.slotBase + last) * 4 + 3] === 0) last--; + unit.first = first < unit.n && !unit.cell ? first : 0; + unit.last = first < unit.n ? last : -1; + unit.dirty = false; + } + + // A unit with nothing shown is never submitted; in wireframe mode its lines are drawn instead of its triangles, + // except while picking (faces are picked, as before). + _showUnit(unit) { + const wire = this._mode === 'wireframe' && !this._picking; + unit.mesh.visible = unit.shown > 0 && !wire; + if (unit.lines) unit.lines.visible = unit.shown > 0 && wire; + } + + _createBuckets(meta, materialOf, noHideBranch) { + const byMaterial = new Map(); + const buckets = []; + for (let i = 0; i < materialOf.length; i++) { + const m = materialOf[i]; + let bucket = byMaterial.get(m); + if (!bucket) { + const source = meta.materials[m]; + bucket = { + index: buckets.length, + bytes: source.color.map((c) => Math.round(c * 255)), // META colours are bytes / 255: exact + opacity: source.opacity, + texture: source.texture, + // The material colour stays white: the slot colour multiplies it in the shader. + material: patchLambert(new THREE.MeshLambertMaterial({ side: THREE.DoubleSide }), this.slotUniforms, noHideBranch), + // The same for the instanced cells in wireframe mode (three's own wireframe of their triangles). + wire: patchLambert(new THREE.MeshLambertMaterial({ side: THREE.DoubleSide, wireframe: true }), this.slotUniforms, noHideBranch), + }; + this._applyOpacity(bucket); + byMaterial.set(m, bucket); + buckets.push(bucket); + } + this._bucketOf[i] = bucket.index; + } + return buckets; + } + + // Element -> instances as CSR arrays: instances of element e are _elementInstances[_elementStart[e].._elementStart[e+1]). + _indexElements() { + const start = new Uint32Array(this.elementCount + 1); + for (const e of this._elementOf) if (e >= 0) start[e + 1]++; + for (let e = 0; e < this.elementCount; e++) start[e + 1] += start[e]; + const list = new Uint32Array(start[this.elementCount]); + const fill = start.slice(0, this.elementCount); + this._elementOf.forEach((e, i) => { + if (e >= 0) list[fill[e]++] = i; + }); + this._elementStart = start; + this._elementInstances = list; + } + + _texture(index) { + let texture = this._textures.get(index); + if (!texture) { + const source = this._meta.textures[index]; + const image = new Image(); + texture = new THREE.Texture(image); + texture.colorSpace = THREE.SRGBColorSpace; + texture.wrapS = texture.wrapT = THREE.RepeatWrapping; + texture.repeat.set(1 / source.sx, 1 / source.sy); + image.onload = () => { + texture.needsUpdate = true; + this._changed('appearance'); + }; + image.src = `data:${source.mime};base64,${source.data}`; + this._textures.set(index, texture); + } + return texture; + } + + _applyOpacity(bucket) { + const opacity = bucket.opacity * this._opacity; + const transparent = opacity < 1; + for (const material of [bucket.material, bucket.wire]) { + if (material.transparent !== transparent) material.needsUpdate = true; + material.opacity = opacity; + material.transparent = transparent; + material.depthWrite = !transparent; // three sorts transparent units back to front + } + } + + // sRGB bytes instance i is drawn with: highlight, else tint, else white in white mode, else the material's. + _colorOf(i) { + return this._highlight[i] ? HIGHLIGHT : this._tint.get(i) ?? (this._mode === 'white' ? WHITE : this._buckets[this._bucketOf[i]].bytes); + } + + _applyColor(i) { + const slot = this._slotOf[i]; + if (slot === NO_SLOT) return; + this._state.image.data.set(this._colorOf(i), slot * 4); + this._markDirty(slot); + } + + _setInstanceVisible(i, visible) { + this._visible[i] = visible ? 1 : 0; + this._draw(i); + } + + _setLayerFlag(flags, i, hidden) { + if (flags[i] === (hidden ? 1 : 0)) return; + flags[i] = hidden ? 1 : 0; + this._layerHides[i] += hidden ? 1 : -1; + this._draw(i); + } + + _shown(i) { + return this._visible[i] === 1 && this._layerHides[i] === 0; + } + + // Writes the slot's shown flag (alpha 255/0); its chunk recounts its span before the next draw. + _draw(i) { + const slot = this._slotOf[i]; + if (slot === NO_SLOT) return; + const alpha = this._shown(i) ? 255 : 0; + const data = this._state.image.data; + if (data[slot * 4 + 3] === alpha) return; + data[slot * 4 + 3] = alpha; + this._markDirty(slot); + const unit = this._units[this._slotUnit[slot]]; + unit.shown += alpha ? 1 : -1; + unit.dirty = true; + this._showUnit(unit); + } + + _markDirty(slot) { + if (this._dirtyCount < RANGE_LIMIT) this._dirty[this._dirtyCount] = slot; + this._dirtyCount++; + } + + // Uploads the changed slots: a few one by one (three merges ranges in a row), many, or before the first upload, as + // the whole texture. + _flush() { + const n = this._dirtyCount; + if (n === 0) return; + this._dirtyCount = 0; + const texture = this._state; + if (this._fullPending || this._pendingRanges + n > RANGE_LIMIT) { + texture.clearUpdateRanges(); + this._fullPending = true; + } else { + for (let k = 0; k < n; k++) texture.addUpdateRange(this._dirty[k] * 4, 4); + this._pendingRanges += n; + } + texture.needsUpdate = true; + } + + _checkElement(index) { + if (!Number.isInteger(index) || index < 0 || index >= this.elementCount) { + throw new RangeError(`Element index ${index} is out of range 0..${this.elementCount - 1}.`); + } + } + + _expandBox(box, i) { + if (!finiteBox(this._boxes, i)) return; + const b = this._boxes; + const o = i * 6; + box.min.set(Math.min(box.min.x, b[o]), Math.min(box.min.y, b[o + 1]), Math.min(box.min.z, b[o + 2])); + box.max.set(Math.max(box.max.x, b[o + 3]), Math.max(box.max.y, b[o + 4]), Math.max(box.max.z, b[o + 5])); + } + + // kind: 'visibility' (the shown set changed), 'clipping' (the section planes changed) or 'appearance' (only how it + // looks). + _changed(kind) { + this._flush(); + this.root.dispatchEvent({ type: 'change', detail: { kind } }); + } +} + +// Triangles of a unit's slots a..b-1. +function unitTriangles(unit, a, b) { + return b <= a ? 0 : unit.cell ? (b - a) * unit.triangles : (unit.starts[b] - unit.starts[a]) / 3; +} + +// Depth of the nearest point of sphere s (x, y, z, r) along the view direction (see culling.viewMetrics). +function nearDepth(view, s) { + return (s[0] - view[0]) * view[3] + (s[1] - view[1]) * view[4] + (s[2] - view[2]) * view[5] - s[3]; +} + +// Sphere (x, y, z, r) around the boxes of the instances: centre of their joint box, radius to its corners. +function chunkSphere(boxes, members) { + const min = [Infinity, Infinity, Infinity]; + const max = [-Infinity, -Infinity, -Infinity]; + for (const i of members) { + for (let a = 0; a < 3; a++) { + min[a] = Math.min(min[a], boxes[i * 6 + a]); + max[a] = Math.max(max[a], boxes[i * 6 + 3 + a]); + } + } + return Float64Array.of((min[0] + max[0]) / 2, (min[1] + max[1]) / 2, (min[2] + max[2]) / 2, Math.hypot(max[0] - min[0], max[1] - min[1], max[2] - min[2]) / 2); +} diff --git a/src/IOViewer/Viewer/src/engine/motionDetail.js b/src/IOViewer/Viewer/src/engine/motionDetail.js new file mode 100644 index 0000000..4a6dc05 --- /dev/null +++ b/src/IOViewer/Viewer/src/engine/motionDetail.js @@ -0,0 +1,75 @@ +const TARGET_MS = 1000 / 30; // frame time to keep while the camera moves +const SETTLE_MS = 200; // after the last camera move, the full model is drawn again +const MIN_BUDGET = 1e6; // about 12 000 draws: the largest objects always stay in view + +/** + * Keeps camera motion smooth on weak GPUs: while the camera moves, a frame is drawn within a cost budget (see + * ModelScene.detailThreshold), leaving out what is smallest on screen; frames without camera motion (a click, hide, + * isolate, a 4D step) and the frame SETTLE_MS after the last move always draw everything. + * + * The budget follows the cost of the frames drawn while moving, measured as the time the browser made the frame wait + * after it was requested (a busy GPU delays the next animation frame) plus the frame's own script time; time spent + * waiting for input is never counted. Slow frames shrink the budget (never below MIN_BUDGET); fast frames grow it only + * while detail is being left out, and never past the cost of everything in view. + * Call requested(now) when a frame is requested, begin(camera, viewHeight, now) right before rendering and end(now) + * right after. + */ +export class MotionDetail { + constructor(scene, requestRender, budget = 10e6) { + this.scene = scene; + this.budget = budget; + this.minPixels = 0; // the threshold of the last frame (0 = drawn in full) + this._requestRender = requestRender; + this._last = new Float64Array(32); // camera world and projection matrices of the previous frame + this._seen = false; + this._requestedAt = null; + this._frame = null; // { start, waited } of a frame drawn while the camera moved + this._cost = 0; // smoothed frame cost (ms) while moving + this._settle = 0; + } + + requested(now) { + this._requestedAt ??= now; + } + + begin(camera, viewHeight, now) { + camera.updateMatrixWorld(); + const waited = this._requestedAt === null ? 0 : Math.max(now - this._requestedAt, 0); + this._requestedAt = null; + const moved = this._moved(camera); + this._frame = moved ? { start: now, waited } : null; + this.minPixels = moved ? this.scene.detailThreshold(camera, viewHeight, this.budget) : 0; + this.scene.setDetail(this.minPixels, viewHeight); + if (moved) { + clearTimeout(this._settle); + if (this.minPixels > 0) this._settle = setTimeout(this._requestRender, SETTLE_MS); + } + } + + end(now) { + this.scene.setDetail(0); + if (this._frame) this._adapt(this._frame.waited + (now - this._frame.start)); + this._frame = null; + } + + _adapt(frameMs) { + this._cost = this._cost ? this._cost * 0.8 + frameMs * 0.2 : frameMs; + if (this._cost > TARGET_MS * 1.1) this.budget = Math.max(this.budget * 0.9, MIN_BUDGET); + else if (this._cost < TARGET_MS * 0.7 && this.minPixels > 0) { + this.budget = Math.max(Math.min(this.budget * 1.05, this.scene.visibleCost), MIN_BUDGET); + } + } + + // True when the view changed since the previous frame. Near/far (projection elements 10 and 14) are left out: they + // follow what is shown (hide, isolate, show all), which is no camera motion; the other projection elements are + // recomputed from near too, so they are compared with a tolerance for rounding. + _moved(camera) { + const last = this._last; + const now = [...camera.matrixWorld.elements, ...camera.projectionMatrix.elements]; + const differs = (v, k) => k !== 26 && k !== 30 && Math.abs(v - last[k]) > 1e-9 * Math.max(1, Math.abs(v)); + const moved = this._seen && now.some(differs); + this._seen = true; + last.set(now); + return moved; + } +} diff --git a/src/IOViewer/Viewer/src/engine/picking.js b/src/IOViewer/Viewer/src/engine/picking.js new file mode 100644 index 0000000..3e27f61 --- /dev/null +++ b/src/IOViewer/Viewer/src/engine/picking.js @@ -0,0 +1,147 @@ +import * as THREE from 'three'; +import { SLOT_DECODE, SLOT_PARS } from './slotShader.js'; + +// Pick pass: the chunks' own slot decoding places every vertex, three's clipping chunks cut it like the visible +// material and hidden slots leave the clip volume; the output is the interpolated RTC surface point and slot + 1 +// (0 = nothing). vSlot carries the slot in two exact halves (a non-flat varying, constant over a triangle). +const VERTEX = /* glsl */ ` +#include +${SLOT_PARS} +#include +varying vec3 vPos; +varying vec2 vSlot; +void main() { + ${SLOT_DECODE} + vec3 transformed = slotPosition; + #include + #include + vec4 wp = vec4( transformed, 1.0 ); + #ifdef USE_INSTANCING + wp = instanceMatrix * wp; + #endif + vPos = ( modelMatrix * wp ).xyz; + vSlot = vec2( float( slot >> 12 ), float( slot & 4095 ) ); + if ( state.a < 0.5 ) gl_Position = vec4( 0.0, 0.0, 2.0, 1.0 ); +}`; + +// IO_PICK_RGBA8: slot + 1 as 4 bytes, little-endian; IO_PICK_DEPTH: the fragment depth packed into 4 bytes. +const FRAGMENT = /* glsl */ ` +#include +#include +#include +varying vec3 vPos; +varying vec2 vSlot; +void main() { + #include + float id = floor( vSlot.x + 0.5 ) * 4096.0 + floor( vSlot.y + 0.5 ) + 1.0; + #if defined( IO_PICK_DEPTH ) + gl_FragColor = packDepthToRGBA( gl_FragCoord.z ); + #elif defined( IO_PICK_RGBA8 ) + gl_FragColor = vec4( mod( id, 256.0 ), mod( floor( id / 256.0 ), 256.0 ), mod( floor( id / 65536.0 ), 256.0 ), floor( id / 16777216.0 ) ) / 255.0; + #else + gl_FragColor = vec4( vPos, id ); + #endif +}`; + +const copy = (hit) => hit && { element: hit.element, point: hit.point.clone() }; + +function pickMaterial(uniforms, define) { + const material = new THREE.ShaderMaterial({ + vertexShader: VERTEX, + fragmentShader: FRAGMENT, + uniforms, + defines: define ? { [define]: '' } : {}, + side: THREE.DoubleSide, + clipping: true, + }); + material.index0AttributeName = 'q'; + return material; +} + +/** + * GPU picking over ModelScene: the shown, unclipped model is drawn once into a 1x1 target through a view offset at the + * cursor, so the cost does not grow with the element count. With float targets (EXT_color_buffer_float) one pass gives + * the slot and the exact surface point; without them (or options.pickFloat === false) two RGBA8 passes give the slot + * and the depth, from which the point is unprojected. `camera` is public: set it when the active camera changes. + */ +export class Picker { + constructor(renderer, modelScene, camera, options = {}) { + this.renderer = renderer; + this.scene = modelScene; + this.camera = camera; + // The model's buffers are not restored with a restored context: after a loss nothing is picked any more. + this._lost = false; + renderer.domElement.addEventListener('webglcontextlost', () => (this._lost = true)); + // The last pick and what it depends on: the pixel, the canvas size, the camera and what is shown and cut away + // (picks come on every wheel notch and pointer press, and each draws the model; colours do not matter). + this._last = null; + this._key = new Float64Array(36); + modelScene.root.addEventListener('change', (e) => e.detail.kind !== 'appearance' && (this._last = null)); + const uniforms = modelScene.slotUniforms; + this._float = options.pickFloat !== false && renderer.extensions.has('EXT_color_buffer_float'); + if (this._float) { + this._material = pickMaterial(uniforms, null); + this._target = new THREE.WebGLRenderTarget(1, 1, { type: THREE.FloatType }); + this._pixel = new Float32Array(4); + } else { + this._material = pickMaterial(uniforms, 'IO_PICK_RGBA8'); + this._depthMaterial = pickMaterial(uniforms, 'IO_PICK_DEPTH'); + this._target = new THREE.WebGLRenderTarget(1, 1); + this._pixel = new Uint8Array(4); + } + } + + // { element, point } under the client pixel, or null for the background; element is -1 on a mesh without an + // element (navigation and measuring use its point, selection ignores it). + pick(clientX, clientY) { + const rect = this.renderer.domElement.getBoundingClientRect(); + const x = Math.floor(clientX - rect.left); + const y = Math.floor(clientY - rect.top); + if (this._lost || !(x >= 0 && y >= 0 && x < rect.width && y < rect.height)) return null; + const camera = this.camera; + camera.updateMatrixWorld(); + const key = this._key; + key.set([x, y, rect.width, rect.height]); + key.set(camera.matrixWorld.elements, 4); + key.set(camera.projectionMatrix.elements, 20); + const last = this._last; + if (last && last.camera === camera && last.key.every((v, k) => v === key[k])) return copy(last.hit); + const hit = this._pick(x, y, rect); + this._last = { camera, key: key.slice(), hit }; + return copy(hit); + } + + _pick(x, y, rect) { + const pixel = this._render(this._material, x, y, rect); + const slot = (this._float ? Math.round(pixel[3]) : new Uint32Array(pixel.buffer)[0]) - 1; + if (slot < 0) return null; + const element = this.scene.slotElement(slot); + if (this._float) return { element, point: new THREE.Vector3(pixel[0], pixel[1], pixel[2]) }; + const [r, g, b, a] = this._render(this._depthMaterial, x, y, rect); + const depth = r / 256 + g / 65536 + b / 16777216 + a / 255 / 16777216; // three's unpackRGBAToDepth on the bytes + const ndc = new THREE.Vector3(((x + 0.5) / rect.width) * 2 - 1, 1 - ((y + 0.5) / rect.height) * 2, depth * 2 - 1); + return { element, point: ndc.unproject(this.camera) }; + } + + // Draws the model with `material` into the 1x1 target at pixel (x, y) and reads it back. + _render(material, x, y, rect) { + const { renderer, camera } = this; + const previous = { target: renderer.getRenderTarget(), color: renderer.getClearColor(new THREE.Color()), alpha: renderer.getClearAlpha() }; + const source = this.scene._buckets[0]?.material; // setClipping gives every bucket the same planes + material.clippingPlanes = source?.clippingPlanes ?? null; + material.clipIntersection = source?.clipIntersection ?? false; + camera.updateMatrixWorld(); + camera.setViewOffset(rect.width, rect.height, x, y, 1, 1); + try { + renderer.setRenderTarget(this._target); + renderer.setClearColor(0x000000, 0); + this.scene.withMaterial(material, () => renderer.render(this.scene.root, camera)); + renderer.readRenderTargetPixels(this._target, 0, 0, 1, 1, this._pixel); + } finally { + renderer.setRenderTarget(previous.target); + renderer.setClearColor(previous.color, previous.alpha); + camera.clearViewOffset(); + } + return this._pixel; + } +} diff --git a/src/IOViewer/Viewer/src/engine/quantize.js b/src/IOViewer/Viewer/src/engine/quantize.js new file mode 100644 index 0000000..ea7db8f --- /dev/null +++ b/src/IOViewer/Viewer/src/engine/quantize.js @@ -0,0 +1,84 @@ +// Geometry encodings of export format 3 (the exporter's GeometryQuantizer writes them): positions as u16 steps of GRID +// metres from the origin `q.o` (p = o + q·s), normals as octahedral snorm16 pairs. Format 2's f32 arrays pass through. +// Callers branch on the view type and on `q`, never on the format number. + +export const GRID = 2 ** -10; // metres; exact in float32 and float64 +const SNORM = 32767; + +const sign = (v) => (v < 0 ? -1 : 1); + +// Rounds half away from zero, as the exporter does. +function snorm(v) { + const s = Math.min(Math.max(v, -1), 1) * SNORM; + return sign(s) * Math.round(Math.abs(s)); +} + +// Writes the octahedral snorm16 pair of the direction (x, y, z) at out[offset], out[offset + 1]. A zero vector has no +// direction and gets (0, 0), i.e. +Z, as in the exporter. +export function octEncodeInto(out, offset, x, y, z) { + const sum = Math.abs(x) + Math.abs(y) + Math.abs(z); + if (!(sum > 0)) { + out[offset] = out[offset + 1] = 0; + return; + } + let a = x / sum; + let b = y / sum; + if (z < 0) [a, b] = [(1 - Math.abs(b)) * sign(a), (1 - Math.abs(a)) * sign(b)]; + out[offset] = snorm(a); + out[offset + 1] = snorm(b); +} + +// Writes the unit vector of the octahedral snorm16 pair (a, b) at out[offset..offset + 2]. +export function octDecodeInto(out, offset, a, b) { + let x = Math.max(a / SNORM, -1); + let y = Math.max(b / SNORM, -1); + const z = 1 - Math.abs(x) - Math.abs(y); + if (z < 0) [x, y] = [(1 - Math.abs(y)) * sign(x), (1 - Math.abs(x)) * sign(y)]; + const length = Math.sqrt(x * x + y * y + z * z); + out[offset] = x / length; + out[offset + 1] = y / length; + out[offset + 2] = z / length; +} + +// Positions view of META geometry g as written: u16 steps (with `q`) or f32; any other combination throws. +export function positionsView(data, g) { + const view = data.view(g.positions); + if (!g.q) { + if (!(view instanceof Float32Array)) throw new Error(`Geometry positions of type '${g.positions.t}' need a "q" origin and step.`); + return view; + } + if (!(view instanceof Uint16Array)) throw new Error(`Quantized geometry positions must be u16, got '${g.positions.t}'.`); + return view; +} + +// Positions of META geometry g (3 per vertex, geometry-local): u16 steps with `q` decoded, f32 as written. +export function geometryPositions(data, g) { + const view = positionsView(data, g); + if (!g.q) return view; + const [ox, oy, oz] = g.q.o; + const s = g.q.s; + const positions = new Float32Array(view.length); + for (let i = 0; i < view.length; i += 3) { + positions[i] = ox + view[i] * s; + positions[i + 1] = oy + view[i + 1] * s; + positions[i + 2] = oz + view[i + 2] * s; + } + return positions; +} + +// Unit normals of META geometry g (3 per vertex): i16 octahedral pairs decoded, f32 as written; null without normals. +export function geometryNormals(data, g) { + const view = normalsView(data, g); + if (!(view instanceof Int16Array)) return view; + const normals = new Float32Array((view.length / 2) * 3); + for (let v = 0; v < view.length / 2; v++) octDecodeInto(normals, v * 3, view[v * 2], view[v * 2 + 1]); + return normals; +} + +// Normals view of META geometry g as written: i16 octahedral pairs or f32 x3; null without normals; others throw. +export function normalsView(data, g) { + if (!g.normals) return null; + const view = data.view(g.normals); + if (!(view instanceof Float32Array || view instanceof Int16Array)) throw new Error(`Geometry normals must be i16 or f32, got '${g.normals.t}'.`); + return view; +} diff --git a/src/IOViewer/Viewer/src/engine/slotShader.js b/src/IOViewer/Viewer/src/engine/slotShader.js new file mode 100644 index 0000000..fabb140 --- /dev/null +++ b/src/IOViewer/Viewer/src/engine/slotShader.js @@ -0,0 +1,102 @@ +// GLSL shared by the model material and the picking pass. Every vertex carries q (xyz: position steps, w: its slot +// within the chunk) and its octahedral normal (snorm16 pair) in three's own `normal` attribute, read as normal.xy: +// three declares `attribute vec3 normal` itself and shades a Lambert material flat, from screen derivatives, when the +// geometry has no attribute of that name (which would also turn wireframe lines into NaN). slotBase (one per chunk, an +// instanced attribute) + q.w is the slot, whose origin and step are in the slotInfo texture and whose sRGB colour and +// shown flag (alpha) are in slotState. + +export const SLOT_PARS = /* glsl */ ` +attribute vec4 q; +attribute float slotBase; +uniform highp sampler2D slotInfo; +uniform highp sampler2D slotState; +varying vec3 vTint; +// Octahedral decode without a branch (z < 0 folds x, y to (1 - |y|, 1 - |x|) with their signs, as quantize.js), not +// normalized: three's normal_vertex normalizes after the normal matrix. +vec3 ioOct( vec2 e ) { + vec3 n = vec3( e, 1.0 - abs( e.x ) - abs( e.y ) ); + float t = max( - n.z, 0.0 ); + n.xy += vec2( n.x >= 0.0 ? - t : t, n.y >= 0.0 ? - t : t ); + return n; +}`; + +// Defines slot, info, state and slotPosition (2048 slots per texture row). +export const SLOT_DECODE = /* glsl */ ` +int slot = int( slotBase ) + int( q.w ); +ivec2 st = ivec2( slot & 2047, slot >> 11 ); +vec4 info = texelFetch( slotInfo, st, 0 ); +vec4 state = texelFetch( slotState, st, 0 ); +vec3 slotPosition = info.xyz + q.xyz * info.w;`; + +// Hides the triangles of a hidden slot inside a drawn span: the slot is the same for a whole triangle, so all of it +// leaves the clip volume. Measured on an integrated AMD GPU (ANGLE/D3D11, 200k elements), the branch itself costs +// ~0.7 ms of a ~30 ms frame. The unexplained effect on that GPU sits on the data-dependent slotState fetch above, not +// on this branch: with the fetch replaced by a constant the frame takes ~50 ms instead of ~30. IO_NO_HIDE_BRANCH exists +// only for the performance harness (its nohidebranch variant). +export const HIDE_BRANCH = /* glsl */ ` +#ifndef IO_NO_HIDE_BRANCH +if ( state.a < 0.5 ) gl_Position = vec4( 0.0, 0.0, 2.0, 1.0 ); +#endif`; + +// three's defaultnormal_vertex divides an instance normal by the squared column lengths, which is wrong under shear. +// Instanced cells (USE_INSTANCING; merged chunks are plain meshes) use the cofactor matrix times sign(det) instead, the +// inverse transpose up to scale, as chunks.js bakes merged normals: right for scale, mirroring and shear. As there, +// det == 0 counts as positive (GLSL sign(0.0) is 0 and would zero a flattened instance's normal into NaN). three's +// USE_TANGENT (needs a normal map) and USE_BATCHING (BatchedMesh) branches never apply to these materials. +export const DEFAULT_NORMAL = /* glsl */ ` +vec3 transformedNormal = objectNormal; +#ifdef USE_INSTANCING +mat3 im = mat3( instanceMatrix ); +transformedNormal = mat3( cross( im[ 1 ], im[ 2 ] ), cross( im[ 2 ], im[ 0 ] ), cross( im[ 0 ], im[ 1 ] ) ) * transformedNormal + * ( dot( im[ 0 ], cross( im[ 1 ], im[ 2 ] ) ) < 0.0 ? -1.0 : 1.0 ); +#endif +transformedNormal = normalMatrix * transformedNormal; +#ifdef FLIP_SIDED +transformedNormal = - transformedNormal; +#endif`; + +// The MeshLambertMaterial includes replaced (each must occur exactly once; three is pinned, a change fails loudly). +export const REPLACED_INCLUDES = [ + '#include ', + '#include ', + '#include ', + '#include ', + '#include ', + '#include ', +]; + +function swap(source, include, code) { + const at = source.indexOf(include); + if (at < 0 || source.indexOf(include, at + 1) >= 0) throw new Error(`The shader must contain '${include}' exactly once.`); + return source.slice(0, at) + code + source.slice(at + include.length); +} + +// One shared function for every model material, so three keeps one program (its cache key is this source); the +// slot textures come from the material's userData. +function compileSlots(shader) { + Object.assign(shader.uniforms, this.userData.slotUniforms); + let vertex = swap(shader.vertexShader, '#include ', `#include \n${SLOT_PARS}`); + vertex = swap(vertex, '#include ', `${SLOT_DECODE}\nvTint = state.rgb;`); + // Merged chunks are in world space (identity model matrix), so the normal matrix is the view rotation. + vertex = swap(vertex, '#include ', 'vec3 objectNormal = ioOct( normal.xy );'); + vertex = swap(vertex, '#include ', DEFAULT_NORMAL); + vertex = swap(vertex, '#include ', 'vec3 transformed = slotPosition;'); + vertex = swap(vertex, '#include ', `#include \n${HIDE_BRANCH}`); + shader.vertexShader = vertex; + let fragment = swap(shader.fragmentShader, '#include ', '#include \nvarying vec3 vTint;'); + fragment = swap(fragment, '#include ', 'diffuseColor.rgb *= vTint;'); // white material x slot colour + shader.fragmentShader = fragment; +} + +/** + * Makes a MeshLambertMaterial draw slot geometry: position, normal and colour from q, o and the slot textures + * (`uniforms` = { slotInfo, slotState }, shared). Lights, map, opacity, clipping and face direction stay three's. + * noHideBranch (performance harness only) drops the hide branch. + */ +export function patchLambert(material, uniforms, noHideBranch = false) { + material.userData.slotUniforms = uniforms; + material.onBeforeCompile = compileSlots; + material.index0AttributeName = 'q'; // attribute 0 must not be the instanced slotBase (ANGLE) + if (noHideBranch) material.defines = { IO_NO_HIDE_BRANCH: '' }; + return material; +} diff --git a/src/IOViewer/Viewer/src/i18n.js b/src/IOViewer/Viewer/src/i18n.js new file mode 100644 index 0000000..53ede53 --- /dev/null +++ b/src/IOViewer/Viewer/src/i18n.js @@ -0,0 +1,225 @@ +const dictionaries = { + ru: { + elements: 'Элементов', + noGeometry: 'Нет геометрии', + contextLost: 'Видеокарта сброшена — перезагрузите страницу', + noWebGL: 'Нужен браузер с поддержкой WebGL 2', + badData: 'Не удалось прочитать данные модели', + tooLarge: 'Модель слишком большая для этого просмотрщика: объектов с геометрией — {count}, предел — {limit}', + selected: 'Выбрано', + close: 'Закрыть', + elementGroup: 'Элемент', + id: 'Id', + category: 'Категория', + type: 'Тип', + level: 'Уровень', + link: 'Связанный файл', + panels: 'Панели', + 'panel.model': 'Модель', + 'panel.views': 'Виды', + 'panel.sections': 'Сечения', + 'panel.clashes': 'Коллизии', + 'panel.timeliner': '4D-график', + 'panel.links': 'Связанные файлы', + 'group.navigation': 'Навигация', + 'group.sections': 'Сечения', + 'group.measure': 'Измерения', + 'group.display': 'Отображение', + 'group.view': 'Вид', + 'tool.orbit': 'Вращение', + 'tool.pan': 'Панорама', + 'tool.fit': 'Вписать всё', + 'tool.projection': 'Перспектива / ортография', + 'tool.plane': 'Плоскость сечения', + 'tool.box': 'Бокс сечения', + 'tool.resetSections': 'Сбросить сечения', + 'tool.distance': 'Расстояние', + 'tool.displayMode': 'Режим отображения', + 'tool.grids': 'Оси', + 'tool.opacity': 'Прозрачность', + 'tool.fullscreen': 'Полный экран', + 'menu.properties': 'Свойства', + 'menu.hide': 'Скрыть', + 'menu.isolate': 'Изолировать', + 'menu.showAll': 'Показать всё', + 'menu.zoomTo': 'Приблизить к объекту', + 'menu.sectionBox': 'Бокс сечения по объекту', + 'menu.copyId': 'Копировать Id', + 'menu.fitAll': 'Вписать всё', + 'menu.resetSections': 'Сбросить сечения', + searchPlaceholder: 'Поиск по имени, типу, Id…', + noCategory: 'Без категории', + noMatches: 'Ничего не найдено', + idCopied: 'Id скопирован', + copyFailed: 'Не удалось скопировать Id', + fullscreenUnavailable: 'Полноэкранный режим недоступен', + 'face.front': 'Спереди', + 'face.back': 'Сзади', + 'face.left': 'Слева', + 'face.right': 'Справа', + 'face.top': 'Сверху', + 'face.bottom': 'Снизу', + 'cube.title': 'Куб вида', + 'cube.up': 'Повернуть вверх', + 'cube.down': 'Повернуть вниз', + 'cube.left': 'Повернуть влево', + 'cube.right': 'Повернуть вправо', + 'cube.home': 'Исходный вид', + 'sections.mode': 'Режим сечения', + 'sections.planes': 'Плоскости', + 'sections.box': 'Бокс', + 'sections.off': 'Выкл', + 'sections.axis': 'Ось', + 'sections.addPlane': 'Добавить плоскость', + 'sections.noPlanes': 'Плоскостей нет', + 'sections.rotated': 'повёрнута', + 'sections.position': 'Положение плоскости', + 'sections.flip': 'Сменить сторону', + 'sections.enabled': 'Вкл', + 'sections.delete': 'Удалить плоскость', + 'sections.keepInside': 'Показать внутри', + 'sections.cutInside': 'Вырезать внутри', + 'sections.fitSelection': 'По выделенному', + 'sections.showGizmo': 'Показывать манипулятор', + 'sections.maxPlanes': 'Не больше 6 плоскостей', + 'unit.m': 'м', + 'display.materials': 'Материалы', + 'display.textures': 'Текстуры', + 'display.white': 'Белый', + 'display.wireframe': 'Каркас', + 'opacity.label': 'Непрозрачность', + 'opacity.value': '{value} %', + 'views.tour': 'Тур по видам', + 'views.stop': 'Остановить тур', + 'clash.New': 'Новая', + 'clash.Active': 'Активная', + 'clash.Reviewed': 'Проверена', + 'clash.Approved': 'Утверждена', + 'clash.Resolved': 'Устранена', + 'task.construct': 'Строительство', + 'task.demolish': 'Снос', + 'task.temporary': 'Временное', + 'player.title': '4D-проигрыватель', + 'player.play': 'Воспроизвести', + 'player.pause': 'Пауза', + 'player.date': 'Дата графика', + 'player.speed': 'Скорость', + truncated: 'Показано {meshes} из {total} мешей: модель обрезана настройкой MaxMeshVertices', + }, + en: { + elements: 'Elements', + noGeometry: 'No geometry', + contextLost: 'The graphics context was lost — reload the page', + noWebGL: 'This page needs a browser with WebGL 2', + badData: 'The model data could not be read', + tooLarge: 'The model is too large for this viewer: {count} objects with geometry, the limit is {limit}', + selected: 'Selected', + close: 'Close', + elementGroup: 'Element', + id: 'Id', + category: 'Category', + type: 'Type', + level: 'Level', + link: 'Link', + panels: 'Panels', + 'panel.model': 'Model', + 'panel.views': 'Views', + 'panel.sections': 'Sections', + 'panel.clashes': 'Clashes', + 'panel.timeliner': '4D schedule', + 'panel.links': 'Linked files', + 'group.navigation': 'Navigation', + 'group.sections': 'Sections', + 'group.measure': 'Measure', + 'group.display': 'Display', + 'group.view': 'View', + 'tool.orbit': 'Orbit', + 'tool.pan': 'Pan', + 'tool.fit': 'Fit all', + 'tool.projection': 'Perspective / orthographic', + 'tool.plane': 'Section plane', + 'tool.box': 'Section box', + 'tool.resetSections': 'Reset sections', + 'tool.distance': 'Distance', + 'tool.displayMode': 'Display mode', + 'tool.grids': 'Grids', + 'tool.opacity': 'Transparency', + 'tool.fullscreen': 'Full screen', + 'menu.properties': 'Properties', + 'menu.hide': 'Hide', + 'menu.isolate': 'Isolate', + 'menu.showAll': 'Show all', + 'menu.zoomTo': 'Zoom to object', + 'menu.sectionBox': 'Section box around object', + 'menu.copyId': 'Copy Id', + 'menu.fitAll': 'Fit all', + 'menu.resetSections': 'Reset sections', + searchPlaceholder: 'Search by name, type, Id…', + noCategory: 'No category', + noMatches: 'No matches', + idCopied: 'Id copied', + copyFailed: 'Could not copy Id', + fullscreenUnavailable: 'Fullscreen is not available', + 'face.front': 'Front', + 'face.back': 'Back', + 'face.left': 'Left', + 'face.right': 'Right', + 'face.top': 'Top', + 'face.bottom': 'Bottom', + 'cube.title': 'View cube', + 'cube.up': 'Rotate up', + 'cube.down': 'Rotate down', + 'cube.left': 'Rotate left', + 'cube.right': 'Rotate right', + 'cube.home': 'Home view', + 'sections.mode': 'Section mode', + 'sections.planes': 'Planes', + 'sections.box': 'Box', + 'sections.off': 'Off', + 'sections.axis': 'Axis', + 'sections.addPlane': 'Add plane', + 'sections.noPlanes': 'No planes', + 'sections.rotated': 'rotated', + 'sections.position': 'Plane position', + 'sections.flip': 'Flip side', + 'sections.enabled': 'On', + 'sections.delete': 'Delete plane', + 'sections.keepInside': 'Show inside', + 'sections.cutInside': 'Cut inside', + 'sections.fitSelection': 'Fit to selection', + 'sections.showGizmo': 'Show gizmo', + 'sections.maxPlanes': 'At most 6 planes', + 'unit.m': 'm', + 'display.materials': 'Materials', + 'display.textures': 'Textures', + 'display.white': 'White', + 'display.wireframe': 'Wireframe', + 'opacity.label': 'Opacity', + 'opacity.value': '{value}%', + 'views.tour': 'Play tour', + 'views.stop': 'Stop tour', + 'clash.New': 'New', + 'clash.Active': 'Active', + 'clash.Reviewed': 'Reviewed', + 'clash.Approved': 'Approved', + 'clash.Resolved': 'Resolved', + 'task.construct': 'Construct', + 'task.demolish': 'Demolish', + 'task.temporary': 'Temporary', + 'player.title': '4D player', + 'player.play': 'Play', + 'player.pause': 'Pause', + 'player.date': 'Schedule date', + 'player.speed': 'Speed', + truncated: 'Showing {meshes} of {total} meshes: the model was truncated by the MaxMeshVertices setting', + }, +}; + +export const lang = (globalThis.navigator?.language ?? '').toLowerCase().startsWith('ru') ? 'ru' : 'en'; + +// `language` is for tests; the UI always uses `lang`. +export function t(key, language = lang) { + const text = dictionaries[language][key]; + if (text === undefined) throw new Error(`Missing UI string '${key}'.`); + return text; +} diff --git a/src/IOViewer/Viewer/src/main.js b/src/IOViewer/Viewer/src/main.js new file mode 100644 index 0000000..46ca75c --- /dev/null +++ b/src/IOViewer/Viewer/src/main.js @@ -0,0 +1,221 @@ +import * as THREE from 'three'; +import { loadModelData, ModelDataError } from './data.js'; +import { ModelScene, ModelTooLargeError } from './engine/modelScene.js'; +import { lang, t } from './i18n.js'; +import { NavigationControls } from './nav/controls.js'; +import { ViewCube } from './nav/viewCube.js'; +import { Picker } from './engine/picking.js'; +import { MotionDetail } from './engine/motionDetail.js'; +import { Selection } from './select/selection.js'; +import { selectInRect, screenBoxes } from './select/boxSelect.js'; +import { mountUI } from './ui/app.js'; +import { Sections } from './tools/sections.js'; +import { SectionGizmo } from './tools/sectionGizmo.js'; +import { boxFullyClipped } from './tools/sectionMath.js'; +import { mountSections } from './ui/sectionsPanel.js'; +import { Measure } from './tools/measure.js'; +import { GridAxes } from './tools/grids.js'; +import { mountDisplay } from './tools/display.js'; +import { mountViews } from './ui/viewsPanel.js'; +import { mountClashes } from './ui/clashesPanel.js'; +import { mountTimeliner } from './ui/timeliner.js'; +import { mountLinks } from './ui/linksPanel.js'; + +document.documentElement.lang = lang; +const canvas = document.getElementById('ioviewer-canvas'); + +function showMessage(text) { + const message = document.getElementById('ioviewer-message'); + message.textContent = text; + message.hidden = false; +} + +// The context is asked for here, with the attributes three would ask for, so that without WebGL 2 the page says so +// instead of three logging an error; damaged model data is reported on the page too. +const context = canvas.getContext('webgl2', { antialias: true }); +if (!context) showMessage(t('noWebGL')); +else { + try { + start(loadModelData(), context); + } catch (error) { + if (!(error instanceof ModelDataError)) throw error; + showMessage(t('badData')); + } +} + +function start(data, context) { + if (data.meta.title) document.title = data.meta.title; + const renderer = new THREE.WebGLRenderer({ canvas, context, antialias: true }); + renderer.setPixelRatio(window.devicePixelRatio); + renderer.localClippingEnabled = true; + + const world = new THREE.Scene(); + world.background = new THREE.Color('#1f2228'); + const sky = new THREE.HemisphereLight('#ffffff', '#8a8f99', 1.6); + sky.position.set(0, 0, 1); + world.add(sky); + + const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 1000); + camera.up.set(0, 0, 1); + camera.position.set(1, -1, 0.8); // initial view direction: front-right, from above; fitBox keeps it + const headlight = new THREE.DirectionalLight('#ffffff', 1.4); + headlight.position.set(0.3, 0.6, 0); + camera.add(headlight, headlight.target); + headlight.target.position.set(0, 0, -1); + world.add(camera); + + // Test-only engine options (e2e and the performance harness): chunkVertices, noHideBranch, pickFloat, slotLimit, + // mergedCap, minRepeats, cellExtent. + const testOptions = window.IOVIEWER_TEST ?? {}; + const scene = createScene(); + data.releaseBinary(); // the scene has copied what it needs + // Starting at the whole model's cost, a GPU that draws it in time orbits at full detail; a slower one shrinks it. + // Larger models start at about what an integrated GPU draws at 30 fps (a 200k-element model, 19.2e6): from a 1M one's + // 134e6 the first gesture would take some 20 frames of 200 ms to shrink. A faster GPU grows the budget. + const motionDetail = new MotionDetail(scene, requestRender, Math.min(scene.totalCost, 20e6)); + world.add(scene.root); + // Sections never change which elements are shown, so they leave the bounds (orbit pivot, near/far) as they are. + const sections = new Sections(scene, scene.visibleBox()); + const sectionGizmo = new SectionGizmo(sections, canvas); + world.add(sectionGizmo.group); // outside scene.root: the picking id pass renders only the model + const uiRoot = document.getElementById('ioviewer-ui'); + const measure = new Measure(uiRoot); + const grids = new GridAxes(uiRoot, data.meta.grids ?? []); + world.add(measure.group, grids.group); // overlays too + + // A model over what the viewer can draw opens without geometry and says why; the tree and properties still work. + function createScene() { + try { + return new ModelScene(data, { ...testOptions, maxTextureSize: renderer.capabilities.maxTextureSize }); + } catch (error) { + if (!(error instanceof ModelTooLargeError)) throw error; + showMessage(t('tooLarge').replace('{count}', error.count).replace('{limit}', error.limit)); + const none = (type) => ({ o: 0, n: 0, t: type }); + const instances = { count: 0, geometry: none('u32'), material: none('u32'), element: none('i32'), matrix: none('f32') }; + return new ModelScene({ ...data, meta: { ...data.meta, instances } }); + } + } + if (data.meta.instances.count === 0) showMessage(t('noGeometry')); + // The model's GPU buffers keep no CPU copy, so a lost context cannot be rebuilt, not even when the browser restores + // it: say so instead of a blank page, and draw nothing more (the picker stops too). + let contextLost = false; + canvas.addEventListener('webglcontextlost', () => { + contextLost = true; + showMessage(t('contextLost')); + }); + + let resolveReady; + const ready = new Promise((resolve) => (resolveReady = resolve)); + let frameRequested = false; + function requestRender() { + if (frameRequested || contextLost) return; + frameRequested = true; + motionDetail.requested(performance.now()); + requestAnimationFrame(() => { + frameRequested = false; + if (contextLost) return; + const rect = canvas.getBoundingClientRect(); + controls.camera.updateMatrixWorld(); // the screen labels project with it before render() would update it + sectionGizmo.update(controls.camera); + measure.update(controls.camera, rect); + grids.update(controls.camera, rect); + motionDetail.begin(controls.camera, rect.height, performance.now()); + renderer.render(world, controls.camera); + motionDetail.end(performance.now()); + viewCube.update(); + resolveReady(); + }); + } + scene.root.addEventListener('change', requestRender); + sections.addEventListener('change', requestRender); + sectionGizmo.addEventListener('change', requestRender); + measure.addEventListener('change', requestRender); + grids.addEventListener('change', requestRender); + + const picker = new Picker(renderer, scene, camera, { pickFloat: testOptions.pickFloat }); + const controls = new NavigationControls(camera, canvas, { + pick: (x, y) => picker.pick(x, y)?.point ?? null, + visibleBox: () => scene.visibleBox(), + }); + controls.addEventListener('change', () => { + picker.camera = controls.camera; // swaps with the projection + requestRender(); + }); + scene.root.addEventListener('change', (e) => { + if (e.detail.kind !== 'visibility') return; // a highlight must not walk every instance for the bounds + controls.invalidateBounds(); + controls.resize(); + }); + + const selection = new Selection(); + selection.addEventListener('change', () => scene.setHighlighted([...selection.values()])); + const ui = mountUI({ root: uiRoot, data, scene, controls, picker, selection, measure }); + mountSections({ root: uiRoot, ...ui, sections, selection }); + const { meta } = data; + mountViews({ ...ui, controls, views: meta.viewpoints ?? [] }); + const clashes = mountClashes({ ...ui, tests: meta.clashTests ?? [], elements: meta.elements, scene, controls, selection }); + world.add(clashes.group); // overlay, like the measure + const timeliner = mountTimeliner({ root: uiRoot, ...ui, timeliner: meta.timeliner, elements: meta.elements, scene }); + mountLinks({ ...ui, files: meta.linkedFiles ?? [], elements: meta.elements, scene }); + for (const popover of mountDisplay({ root: uiRoot, toolbar: ui.toolbar, scene, meta: data.meta, grids })) ui.onEscape(popover.close); + ui.toolbar.setHandler('distance', () => { + measure.setActive(!measure.active); + ui.toolbar.setActive('distance', measure.active); + }); + ui.toolbar.setActive('distance', false); + ui.onEscape(() => measure.clear()); + // The overlays stay within the camera's near/far even when what is shown shrinks (isolate, hide). + const overlayBounds = () => { + controls.setExtraBounds('grids', grids.visible ? grids.box : null); + controls.setExtraBounds('measure', measure.box); + }; + grids.addEventListener('change', overlayBounds); + measure.addEventListener('change', overlayBounds); + overlayBounds(); + + // Click selects, Ctrl+click toggles; a click on nothing (or on a mesh without an element) clears unless a modifier is + // held. With the distance tool on, a click on the model places a measure point instead and the selection stays. + controls.addEventListener('click', (e) => { + const { x, y, ctrl, shift } = e.detail; + const hit = picker.pick(x, y); + const element = hit?.element ?? -1; + if (measure.active) { + if (hit) measure.add(hit.point); + } else if (ctrl) { + if (element >= 0) selection.toggle(element); + } else if (element >= 0) selection.set([element]); + else if (!shift) selection.clear(); + }); + // Box select replaces the selection, Ctrl+box adds to it; a sliver (< 4 px either way) counts as a click on nothing. + // Elements the section cuts away entirely are not selected. With the distance tool on, the selection stays. + controls.addEventListener('boxselect', (e) => { + if (measure.active) return; + const { rect, mode, ctrl, shift } = e.detail; + if (rect.width < 4 || rect.height < 4) { + if (!ctrl && !shift) selection.clear(); + return; + } + const box = new THREE.Box3(); + const found = selectInRect(rect, mode, screenBoxes(scene, controls.camera, canvas.getBoundingClientRect())); + const { planes, intersection } = sections.clipping(); // once, not per element + const shown = found.filter((i) => !boxFullyClipped(scene.elementBox(i, box), planes, intersection)); + selection.set(ctrl ? [...selection.values(), ...shown] : shown); + }); + + window.addEventListener('resize', () => { + renderer.setSize(window.innerWidth, window.innerHeight, false); + controls.resize(); + }); + renderer.setSize(window.innerWidth, window.innerHeight, false); + controls.resize(); // also schedules the first frame for an empty model + controls.fitBox(scene.visibleBox(), false); + const viewCube = new ViewCube(document.getElementById('ioviewer-ui'), camera, controls); // home = this load view + + // The canvas and the view cube take no focus, so a press on them takes it from a panel field or a toolbar button: + // the hotkeys work again and Space or Enter does not repeat the last tool. + window.addEventListener('pointerdown', (e) => { + if (e.target === canvas || viewCube.element.contains(e.target)) document.activeElement?.blur(); + }, true); + + window.__ioviewer = { scene, motionDetail, controls, picker, selection, ui, viewCube, sections, sectionGizmo, measure, grids, clashes, timeliner, ready, get camera() { return controls.camera; } }; +} diff --git a/src/IOViewer/Viewer/src/model/clashTargets.js b/src/IOViewer/Viewer/src/model/clashTargets.js new file mode 100644 index 0000000..030144e --- /dev/null +++ b/src/IOViewer/Viewer/src/model/clashTargets.js @@ -0,0 +1,19 @@ +/** + * Element indices (in META `elements`) of the two elements of a clash. Each side is found by its Id when the clash + * has one (an unknown Id finds nothing; a per-mesh stand-in element, `mesh: true`, has no element Id to match); + * without an Id, by its name, but only when exactly one element has that name. + */ +export function resolveClashElements(clash, elements) { + const found = []; + for (const [id, name] of [[clash.element1Id, clash.element1Name], [clash.element2Id, clash.element2Name]]) { + const index = id != null ? elements.findIndex((e) => e.id === id && !e.mesh) : uniqueName(elements, name); + if (index >= 0 && !found.includes(index)) found.push(index); + } + return found; +} + +function uniqueName(elements, name) { + if (name == null) return -1; + const first = elements.findIndex((e) => e.name === name); + return first >= 0 && elements.findIndex((e, i) => i > first && e.name === name) < 0 ? first : -1; +} diff --git a/src/IOViewer/Viewer/src/model/schedule.js b/src/IOViewer/Viewer/src/model/schedule.js new file mode 100644 index 0000000..a9136f0 --- /dev/null +++ b/src/IOViewer/Viewer/src/model/schedule.js @@ -0,0 +1,54 @@ +export const TASK_TYPES = ['construct', 'demolish', 'temporary']; + +// DateTimeOffset's default value (year 1) is how an exporter writes "no date". +const MIN_TIME = Date.parse('0002-01-01T00:00:00Z'); + +// Epoch ms of an ISO date, or NaN when it is missing, invalid or the default year 1. +function time(text) { + const ms = typeof text === 'string' ? Date.parse(text) : NaN; + return ms >= MIN_TIME ? ms : NaN; +} + +/** + * META `timeliner` with dates as epoch ms and attached element Ids as indices in META `elements`: + * { start, end, tasks: [{ name, type, start, end, elements }] }. A malformed task (missing or invalid dates, end before + * start, an unknown type) is left out, and Ids not in the model are dropped, so a bad schedule never stops the viewer. + * The range spans the tasks kept (start = Infinity, end = -Infinity without any). + */ +export function prepareSchedule(timeliner, elements) { + // A per-mesh stand-in element (`mesh: true`) carries a mesh id, which no task refers to. + const indexOf = new Map(elements.flatMap((e, i) => (e.mesh ? [] : [[e.id, i]]))); + const tasks = []; + for (const task of Array.isArray(timeliner?.tasks) ? timeliner.tasks : []) { + const start = time(task?.start); + const end = time(task?.end); + if (!TASK_TYPES.includes(task?.type) || !(start <= end)) continue; + const ids = Array.isArray(task.attachedElements) ? task.attachedElements : []; + tasks.push({ name: task.name, type: task.type, start, end, elements: ids.filter((id) => indexOf.has(id)).map((id) => indexOf.get(id)) }); + } + const start = tasks.reduce((min, task) => Math.min(min, task.start), Infinity); + const end = tasks.reduce((max, task) => Math.max(max, task.end), -Infinity); + return { start, end, tasks }; +} + +/** + * What the schedule shows at `time` (epoch ms), for elements attached to tasks; other elements are not in it. + * Construct hides its elements before its start, demolish after its end, temporary outside [start, end]; an element + * is hidden when any of its tasks hides it. `active` maps a shown element to the type of its active task (the later + * task in the list when several are active). + */ +export function scheduleState(tasks, time) { + const hidden = new Set(); + const active = new Map(); + for (const task of tasks) { + const before = time < task.start; + const after = time > task.end; + const hide = task.type === 'construct' ? before : task.type === 'demolish' ? after : before || after; + for (const i of task.elements) { + if (hide) hidden.add(i); + else if (!before && !after) active.set(i, task.type); + } + } + for (const i of hidden) active.delete(i); + return { hidden, active }; +} diff --git a/src/IOViewer/Viewer/src/nav/controls.js b/src/IOViewer/Viewer/src/nav/controls.js new file mode 100644 index 0000000..f349442 --- /dev/null +++ b/src/IOViewer/Viewer/src/nav/controls.js @@ -0,0 +1,269 @@ +import * as THREE from 'three'; +import { isClick, orbit, zoomTowards, wheelZoomFactor, boxSelectMode, interpolateView } from './math.js'; + +const ORBIT_SPEED = 0.005; // radians per pixel +const DEFAULT_DIRECTION = new THREE.Vector3(1, -1, 0.8).normalize(); + +const detail = (type, d) => new CustomEvent(type, { detail: d }); + +// Navisworks-style camera: left drag orbits (or pans), middle/right drag pans, wheel zooms to the cursor. +// Pointer coordinates in events and in `pick(x, y)` are client pixels; `pick` returns a Vector3 or null. +// `visibleBox()` gives the box of what is shown; it drives the orbit pivot fallback and near/far. +// setExtraBounds(key, box) adds overlay boxes (grids, a measurement) that near/far must also cover. +export class NavigationControls extends EventTarget { + constructor(camera, dom, { pick = () => null, visibleBox = () => new THREE.Box3() } = {}) { + super(); + this.dom = dom; + this.pick = pick; + this.visibleBox = visibleBox; + this.target = new THREE.Vector3(); + this.leftButtonMode = 'orbit'; + this._perspective = camera; + this._orthographic = new THREE.OrthographicCamera(-1, 1, 1, -1); + this.camera = camera; + this._drag = null; + this._wheelPick = null; // { point } picked for the wheel notches of the current animation frame + this._animation = 0; + this._overlay = null; + this._bounds = null; + this._extraBounds = new Map(); // key -> Box3 + dom.style.touchAction = 'none'; + dom.addEventListener('pointerdown', (e) => this._down(e)); + dom.addEventListener('pointermove', (e) => this._move(e)); + dom.addEventListener('pointerup', (e) => this._up(e)); + dom.addEventListener('pointercancel', () => this._end()); + dom.addEventListener('lostpointercapture', (e) => this._lostCapture(e)); + window.addEventListener('blur', () => this._end()); + dom.addEventListener('wheel', (e) => this._wheel(e), { passive: false }); + dom.addEventListener('contextmenu', (e) => e.preventDefault()); + } + + setLeftButtonMode(mode) { + if (mode !== 'orbit' && mode !== 'pan') throw new RangeError(`Unknown left button mode: ${mode}`); + this.leftButtonMode = mode; + } + + setProjection(kind) { + if (kind !== 'perspective' && kind !== 'orthographic') throw new RangeError(`Unknown projection: ${kind}`); + const next = kind === 'perspective' ? this._perspective : this._orthographic; + const previous = this.camera; + if (next === previous) return; + next.position.copy(previous.position); + next.quaternion.copy(previous.quaternion); + next.up.copy(previous.up); + previous.parent?.add(next); + previous.parent?.remove(previous); + for (const child of [...previous.children]) next.add(child); // headlight + this.camera = next; + this.resize(); + } + + // Re-derives aspect, the orthographic frustum and near/far from the current camera and canvas size. + resize() { + const cam = this.camera; + const aspect = this.dom.clientWidth / Math.max(this.dom.clientHeight, 1); + const distance = Math.max(cam.position.distanceTo(this.target), 1e-6); + if (cam.isPerspectiveCamera) { + cam.aspect = aspect; + } else { + // Same visible size at the target as the perspective view, so switching projection does not jump. + const half = distance * Math.tan(THREE.MathUtils.degToRad(this._perspective.fov / 2)); + Object.assign(cam, { top: half, bottom: -half, left: -half * aspect, right: half * aspect }); + } + const sphere = this._rangeSphere(); + if (sphere) { + const toCentre = cam.position.distanceTo(sphere.center); + cam.far = (toCentre + sphere.radius) * 1.05; + cam.near = Math.max((toCentre - sphere.radius) * 0.95, cam.far / 5000); + } + cam.updateProjectionMatrix(); + this.dispatchEvent(new Event('change')); + } + + // Box (or null to remove it) that near/far keep in range besides the model; it never moves the orbit pivot or a fit. + setExtraBounds(key, box) { + if (box && !box.isEmpty()) this._extraBounds.set(key, box.clone()); + else this._extraBounds.delete(key); + this.resize(); + } + + // Sphere around what is shown plus the extra bounds, or null when all are empty. + _rangeSphere() { + const sphere = this._visibleBounds()?.sphere ?? null; + if (this._extraBounds.size === 0) return sphere; + const box = sphere ? this._bounds.box.clone() : new THREE.Box3(); + for (const extra of this._extraBounds.values()) box.union(extra); + return box.getBoundingSphere(new THREE.Sphere()); + } + + // Call when the visible set changes: the box is cached because computing it walks every instance. + invalidateBounds() { + this._bounds = null; + } + + _visibleBounds() { + if (!this._bounds) { + const box = this.visibleBox(); + this._bounds = { box, sphere: box.isEmpty() ? null : box.getBoundingSphere(new THREE.Sphere()) }; + } + return this._bounds.sphere && this._bounds; + } + + fitBox(box, animate = true) { + if (box.isEmpty()) return; + const sphere = box.getBoundingSphere(new THREE.Sphere()); + const radius = Math.max(sphere.radius, 1e-3); + const halfFov = THREE.MathUtils.degToRad(this._perspective.fov / 2); + const aspect = this.dom.clientWidth / Math.max(this.dom.clientHeight, 1); + const distance = radius / Math.sin(Math.min(halfFov, Math.atan(Math.tan(halfFov) * aspect))); + const direction = this.camera.position.clone().sub(this.target); + if (direction.lengthSq() < 1e-12) direction.copy(DEFAULT_DIRECTION); + const position = direction.normalize().multiplyScalar(distance).add(sphere.center); + if (animate) this.animateTo({ position, target: sphere.center }); + else this._set(position, sphere.center); + } + + zoomToBox(box) { + this.fitBox(box, true); + } + + // Swings the camera round the target (see interpolateView); `up` stays +Z, so it is accepted and ignored. + animateTo({ position, target }, ms = 400) { + this._stopAnimation(); + const from = { position: this.camera.position.clone(), target: this.target.clone() }; + const to = { position: position ?? from.position, target: target ?? from.target }; + const start = performance.now(); + const step = (now) => { + const k = Math.min((now - start) / ms, 1); + const r = interpolateView(from, to, k * k * (3 - 2 * k)); + this._set(r.position, r.target); + this._animation = k < 1 ? requestAnimationFrame(step) : 0; + }; + this._animation = requestAnimationFrame(step); + } + + // True while an animateTo is running. + get animating() { + return this._animation !== 0; + } + + _stopAnimation() { + cancelAnimationFrame(this._animation); + this._animation = 0; + } + + _set(position, target) { + this.camera.position.copy(position); + this.target.copy(target); + this.camera.lookAt(target); + this.resize(); + } + + _down(e) { + this._end(); // a release can be lost (focus change, capture stolen); never stay stuck + this._stopAnimation(); + this.dom.setPointerCapture(e.pointerId); + e.preventDefault(); // middle button autoscroll + const mode = e.button === 0 && e.shiftKey ? 'box' : e.button === 0 && this.leftButtonMode === 'orbit' ? 'orbit' : 'pan'; + const point = { x: e.clientX, y: e.clientY }; + let pivot = null; + if (mode === 'orbit') { + pivot = this.pick(point.x, point.y); + if (!pivot) { + pivot = this._visibleBounds()?.sphere.center.clone() ?? this.target.clone(); + } + } + this._drag = { id: e.pointerId, button: e.button, mode, pivot, down: point, last: point, moved: false }; + } + + _move(e) { + const d = this._drag; + if (!d || e.pointerId !== d.id) return; + if (e.buttons === 0) return this._end(); + const point = { x: e.clientX, y: e.clientY }; + if (!d.moved && isClick(d.down, point)) return; + d.moved = true; + const dx = point.x - d.last.x; + const dy = point.y - d.last.y; + d.last = point; + if (d.mode === 'box') this._drawRect(d.down, point); + else if (d.mode === 'orbit') { + const r = orbit(this.camera.position, this.target, d.pivot, dx, dy, ORBIT_SPEED); + this._set(r.position, r.target); + } else this._pan(dx, dy); + } + + _pan(dx, dy) { + const cam = this.camera; + const height = Math.max(this.dom.clientHeight, 1); + const worldPerPixel = cam.isPerspectiveCamera + ? (2 * cam.position.distanceTo(this.target) * Math.tan(THREE.MathUtils.degToRad(cam.fov / 2))) / height + : (cam.top - cam.bottom) / height; + const right = new THREE.Vector3(1, 0, 0).applyQuaternion(cam.quaternion); + const up = new THREE.Vector3(0, 1, 0).applyQuaternion(cam.quaternion); + // The model follows the hand, so the camera moves the other way. + const shift = right.multiplyScalar(-dx * worldPerPixel).addScaledVector(up, dy * worldPerPixel); + this._set(cam.position.clone().add(shift), this.target.clone().add(shift)); + } + + _up(e) { + const d = this._drag; + if (!d || e.pointerId !== d.id || e.button !== d.button) return; + const point = { x: e.clientX, y: e.clientY }; + this._end(); + if (d.mode === 'box' && d.moved) { + const rect = { + x: Math.min(d.down.x, point.x), y: Math.min(d.down.y, point.y), + width: Math.abs(point.x - d.down.x), height: Math.abs(point.y - d.down.y), + }; + const mode = boxSelectMode(d.down.x, point.x); + this.dispatchEvent(detail('boxselect', { rect, mode, ctrl: e.ctrlKey, shift: e.shiftKey })); + } else if (!d.moved && d.button === 0) { + this.dispatchEvent(detail('click', { x: point.x, y: point.y, button: 0, ctrl: e.ctrlKey, shift: e.shiftKey })); + } else if (!d.moved && d.button === 2) { + this.dispatchEvent(detail('contextmenu', { x: point.x, y: point.y })); + } + } + + // Chromium drops the capture when a second button is released mid-drag while the first is still held: keep going. + _lostCapture(e) { + if (this._drag && e.buttons) this.dom.setPointerCapture(this._drag.id); + else this._end(); + } + + _end() { + this._drag = null; + this._overlay?.remove(); + this._overlay = null; + } + + _drawRect(a, b) { + if (!this._overlay) { + this._overlay = document.createElement('div'); + Object.assign(this._overlay.style, { + position: 'fixed', pointerEvents: 'none', border: '1px solid #2563eb', background: 'rgba(37,99,235,0.12)', + }); + (this.dom.parentElement ?? document.body).appendChild(this._overlay); + } + // Solid border for window (left to right), dashed for crossing. + Object.assign(this._overlay.style, { + borderStyle: boxSelectMode(a.x, b.x) === 'window' ? 'solid' : 'dashed', + left: `${Math.min(a.x, b.x)}px`, top: `${Math.min(a.y, b.y)}px`, + width: `${Math.abs(b.x - a.x)}px`, height: `${Math.abs(b.y - a.y)}px`, + }); + } + + _wheel(e) { + e.preventDefault(); + if (e.deltaY === 0) return; // a horizontal swipe or Shift+wheel does not zoom + this._stopAnimation(); + // One pick per animation frame: notches arriving within a frame zoom towards the same point. + if (!this._wheelPick) { + this._wheelPick = { point: this.pick(e.clientX, e.clientY) }; + requestAnimationFrame(() => (this._wheelPick = null)); + } + const point = this._wheelPick.point ?? this.target; + const r = zoomTowards(this.camera.position, this.target, point, wheelZoomFactor(e.deltaY, e.deltaMode, this.dom.clientHeight)); + this._set(r.position, r.target); + } +} diff --git a/src/IOViewer/Viewer/src/nav/math.js b/src/IOViewer/Viewer/src/nav/math.js new file mode 100644 index 0000000..d4063f4 --- /dev/null +++ b/src/IOViewer/Viewer/src/nav/math.js @@ -0,0 +1,73 @@ +import * as THREE from 'three'; + +const Z = new THREE.Vector3(0, 0, 1); +const MAX_PITCH = Math.PI / 2 - 0.01; + +export function isClick(down, up, threshold = 4) { + return Math.hypot(up.x - down.x, up.y - down.y) <= threshold; +} + +// Dragging right turns the camera clockwise seen from above (the model follows the hand); dragging down raises it. +export function orbit(position, target, pivot, dx, dy, speed) { + const offset = position.clone().sub(pivot); + const toTarget = target.clone().sub(pivot); + const rotate = (q) => (offset.applyQuaternion(q), toTarget.applyQuaternion(q)); + rotate(new THREE.Quaternion().setFromAxisAngle(Z, -dx * speed)); + const forward = toTarget.clone().sub(offset).normalize(); + const right = forward.clone().cross(Z); + if (right.lengthSq() > 1e-12) { + const pitch = Math.asin(THREE.MathUtils.clamp(forward.z, -1, 1)); // view elevation; moving the camera up lowers it + const dphi = THREE.MathUtils.clamp(dy * speed, pitch - MAX_PITCH, pitch + MAX_PITCH); + rotate(new THREE.Quaternion().setFromAxisAngle(right.normalize(), -dphi)); + } + return { position: offset.add(pivot), target: toTarget.add(pivot) }; +} + +// Homothety about the cursor point: distances to it (and camera-target) scale by `factor`. +export function zoomTowards(position, target, cursorPoint, factor) { + const scale = (p) => p.clone().sub(cursorPoint).multiplyScalar(factor).add(cursorPoint); + return { position: scale(position), target: scale(target) }; +} + +const WHEEL_STEP = 0.85; // zoom factor per 100 px of wheel delta, a mouse notch in Chromium +const LINE_PIXELS = 100 / 3; // Firefox scrolls 3 lines per notch + +// Zoom factor (distance multiplier) for a wheel event's deltaY in its deltaMode (0 pixels, 1 lines, 2 pages of +// `pageHeight` px): a notch zooms by WHEEL_STEP, a trackpad's small deltas proportionally less; at most 2x per event. +export function wheelZoomFactor(deltaY, deltaMode, pageHeight) { + const pixels = deltaY * (deltaMode === 1 ? LINE_PIXELS : deltaMode === 2 ? pageHeight : 1); + return THREE.MathUtils.clamp(WHEEL_STEP ** (-pixels / 100), 0.5, 2); +} + +export const boxSelectMode = (startX, endX) => (endX >= startX ? 'window' : 'crossing'); + +// Camera pose between two views, seen from the (lerped) target: distance, azimuth (shortest way round +Z) and +// elevation are interpolated, so opposite views swing round the target instead of passing through it. +// Elevation stays 0.01 rad off the poles, so a top/bottom view can still be orbited; a pole view takes its +// azimuth from the other end. The camera `up` is always +Z and plays no part. +export function interpolateView(a, b, k) { + const ofA = polar(a.position, a.target); + const ofB = polar(b.position, b.target); + const azA = ofA.azimuth ?? ofB.azimuth ?? 0; + const azB = ofB.azimuth ?? azA; + const turn = THREE.MathUtils.euclideanModulo(azB - azA + Math.PI, 2 * Math.PI) - Math.PI; + const lerp = (x, y) => x + (y - x) * k; + const azimuth = azA + turn * k; + const elevation = THREE.MathUtils.clamp(lerp(ofA.elevation, ofB.elevation), -MAX_PITCH, MAX_PITCH); + const distance = lerp(ofA.distance, ofB.distance); + const target = a.target.clone().lerp(b.target, k); + const flat = Math.cos(elevation) * distance; + const position = target.clone().add(new THREE.Vector3(flat * Math.cos(azimuth), flat * Math.sin(azimuth), Math.sin(elevation) * distance)); + return { position, target }; +} + +function polar(position, target) { + const o = position.clone().sub(target); + const distance = o.length(); + const flat = Math.hypot(o.x, o.y); + return { + distance, + elevation: distance > 1e-12 ? Math.asin(THREE.MathUtils.clamp(o.z / distance, -1, 1)) : 0, + azimuth: flat > 1e-9 * Math.max(distance, 1e-12) ? Math.atan2(o.y, o.x) : null, + }; +} diff --git a/src/IOViewer/Viewer/src/nav/viewCube.js b/src/IOViewer/Viewer/src/nav/viewCube.js new file mode 100644 index 0000000..07a377c --- /dev/null +++ b/src/IOViewer/Viewer/src/nav/viewCube.js @@ -0,0 +1,264 @@ +import * as THREE from 'three'; +import { t } from '../i18n.js'; +import { el, iconButton } from '../ui/dom.js'; +import { icons } from '../ui/icons.js'; + +const Z = new THREE.Vector3(0, 0, 1); +// Face normals of the cube = view directions from the target to the camera (Z up; "front" looks along +Y). +const FACES = { + right: new THREE.Vector3(1, 0, 0), + left: new THREE.Vector3(-1, 0, 0), + back: new THREE.Vector3(0, 1, 0), + front: new THREE.Vector3(0, -1, 0), + top: new THREE.Vector3(0, 0, 1), + bottom: new THREE.Vector3(0, 0, -1), +}; +// Upright direction of each label: side faces read with +Z up, top and bottom as seen from the front. +const LABEL_UP = { right: Z, left: Z, back: Z, front: Z, top: FACES.back, bottom: FACES.front }; +const INNER = 0.7; // a hit farther than this from the centre along an axis also takes that axis' face (edge/corner) +const ALIGNED = THREE.MathUtils.degToRad(1); +const POLE_TILT = 0.01; // radians off a pole, as NavigationControls.animateTo keeps it +const SIZE = 96; // CSS px of the cube canvas +const HALF = 1.75; // half size of the cube camera's view; the cube's half diagonal is sqrt(3) + +// Direction from the target to the camera for a region: a face ('front'), an edge ('front-right') or a corner +// ('top-front-right'); edges and corners are the normalized sum of their faces. +export function viewDirection(region) { + const names = region.split('-'); + const sum = new THREE.Vector3(); + for (const name of names) { + if (!Object.hasOwn(FACES, name)) throw new RangeError(`Unknown view cube region: ${region}`); + sum.add(FACES[name]); + } + if (new Set(names).size !== names.length || sum.lengthSq() !== names.length) { + throw new RangeError(`Unknown view cube region: ${region}`); + } + return sum.normalize(); +} + +// Region under a point of the cube surface (cube spans -1..1): faces in the order vertical, front/back, left/right. +export function regionAt(point) { + const parts = []; + if (Math.abs(point.z) >= INNER) parts.push(point.z > 0 ? 'top' : 'bottom'); + if (Math.abs(point.y) >= INNER) parts.push(point.y > 0 ? 'back' : 'front'); + if (Math.abs(point.x) >= INNER) parts.push(point.x > 0 ? 'right' : 'left'); + return parts.join('-'); +} + +// The face the view (target to camera) looks from, within about one degree; null when between faces. +export function alignedFace(offset, tolerance = ALIGNED) { + const direction = offset.clone().normalize(); + const limit = Math.cos(tolerance); + return Object.keys(FACES).find((name) => direction.dot(FACES[name]) >= limit) ?? null; +} + +// Side face a view is turned towards: the one nearest to the horizontal part of `offset` (target to camera). +// Ties (a 45 degree heading, or none at all) go to the first of front, right, back, left, so float noise +// cannot flip the choice. +export function headingSide(offset) { + const flat = new THREE.Vector3(offset.x, offset.y, 0); + if (flat.lengthSq() < 1e-24) return 'front'; + flat.normalize(); + const sides = ['front', 'right', 'back', 'left']; + const best = Math.max(...sides.map((name) => flat.dot(FACES[name]))); + return sides.find((name) => flat.dot(FACES[name]) >= best - 1e-9); +} + +// View direction for 'top' or 'bottom' from the current `offset`: tilted POLE_TILT towards the heading side, +// so the heading is a face centre (arrows then lead to whole faces) and the camera up (+Z) never degenerates. +export function poleView(pole, offset) { + const side = FACES[headingSide(offset)]; + return side.clone().multiplyScalar(Math.sin(POLE_TILT)).addScaledVector(FACES[pole], Math.cos(POLE_TILT)); +} + +// Next view direction for an arrow, from a view aligned with a face (null otherwise). +// - left/right turn the view 90 degrees about +Z; from a side face that is the face on that side of the screen. +// - up/down from a side face go to the top/bottom, keeping that face as the heading. +// - From the top, the heading side is at the bottom of the screen: down goes to it, up goes over the far edge to +// the opposite side face (the camera keeps +Z up, so it swings round rather than rolling upside down). From the +// bottom it is the other way round. +export function arrowView(offset, arrow) { + const face = alignedFace(offset); + if (!face) return null; + const pole = face === 'top' || face === 'bottom'; + if (arrow === 'left' || arrow === 'right') { + const base = pole ? poleView(face, offset) : viewDirection(face); + return base.applyAxisAngle(Z, arrow === 'right' ? Math.PI / 2 : -Math.PI / 2); + } + if (arrow !== 'up' && arrow !== 'down') throw new RangeError(`Unknown arrow: ${arrow}`); + if (!pole) return poleView(arrow === 'up' ? 'top' : 'bottom', offset); + const side = FACES[headingSide(offset)]; + return (arrow === 'up') === (face === 'top') ? side.clone().negate() : side.clone(); +} + +function labelTexture(text, fontFamily) { + const canvas = document.createElement('canvas'); + canvas.width = canvas.height = 128; + const g = canvas.getContext('2d'); + g.fillStyle = '#2a2f38'; + g.fillRect(0, 0, 128, 128); + g.fillStyle = '#cbd5e1'; + g.textAlign = 'center'; + g.textBaseline = 'middle'; + g.font = `600 26px ${fontFamily}`; + const width = g.measureText(text).width; + if (width > 112) g.font = `600 ${Math.floor((26 * 112) / width)}px ${fontFamily}`; + g.fillText(text, 64, 64); + const texture = new THREE.CanvasTexture(canvas); + texture.colorSpace = THREE.SRGBColorSpace; + return texture; +} + +/** + * View cube in the top-right corner: its own small renderer, scene and camera, turned like the main camera. + * Faces, edges and corners are clickable (hover highlights them); arrows show only while the view is aligned with + * a face; "home" returns to the view at construction. `controls.camera` is followed, so projection swaps work. + * Call `update()` after every main frame. The cube is an aid: when its WebGL context cannot be created or is lost, + * it hides itself, keeps the reason in `error` and never throws into the main render loop. + */ +export class ViewCube { + constructor(container, camera, controls) { + this.controls = controls; + this.home = { position: camera.position.clone(), target: controls.target.clone() }; + this.element = el('div', 'ioviewer-viewcube'); + this.canvas = el('canvas', 'ioviewer-viewcube-canvas'); + this.canvas.title = t('cube.title'); + this.canvas.setAttribute('aria-label', t('cube.title')); + this.element.append(this.canvas); + this.error = null; + this.renderer = this._createRenderer(); + this.scene = new THREE.Scene(); + this.camera = new THREE.OrthographicCamera(-HALF, HALF, HALF, -HALF, 0.1, 10); + this.faces = new THREE.Group(); + const fontFamily = getComputedStyle(container).fontFamily; + for (const [name, normal] of Object.entries(FACES)) { + const label = t(`face.${name}`); + const face = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), new THREE.MeshBasicMaterial({ map: labelTexture(label, fontFamily) })); + const up = LABEL_UP[name]; + face.quaternion.setFromRotationMatrix(new THREE.Matrix4().makeBasis(up.clone().cross(normal), up, normal)); + face.position.copy(normal); + face.name = name; + face.userData.label = label; + this.faces.add(face); + } + const edges = new THREE.LineSegments( + new THREE.EdgesGeometry(new THREE.BoxGeometry(2, 2, 2)), + new THREE.LineBasicMaterial({ color: '#5b6575' }), + ); + this.highlight = new THREE.Mesh( + new THREE.BoxGeometry(1, 1, 1), + new THREE.MeshBasicMaterial({ color: '#2563eb', transparent: true, opacity: 0.6 }), + ); + this.highlight.visible = false; + this.scene.add(this.faces, edges, this.highlight); + this._raycaster = new THREE.Raycaster(); + this.hovered = null; + + this.canvas.addEventListener('pointermove', (e) => this._hover(this.regionAtClient(e.clientX, e.clientY))); + this.canvas.addEventListener('pointerleave', () => this._hover(null)); + this.canvas.addEventListener('click', (e) => { + const region = this.regionAtClient(e.clientX, e.clientY); + if (!region) return; + const pole = region === 'top' || region === 'bottom'; + this._look(pole ? poleView(region, controls.camera.position.clone().sub(controls.target)) : viewDirection(region)); + }); + + this.arrows = ['up', 'down', 'left', 'right'].map((arrow) => { + const button = iconButton('ioviewer-viewcube-arrow', icons.chevron, t(`cube.${arrow}`)); + button.dataset.arrow = arrow; + button.addEventListener('click', () => this._arrow(arrow)); + return button; + }); + const home = iconButton('ioviewer-viewcube-home', icons.home, t('cube.home')); + home.addEventListener('click', () => + controls.animateTo({ position: this.home.position.clone(), target: this.home.target.clone() }), + ); + this.element.append(...this.arrows, home); + container.append(this.element); + this.update(); + } + + _createRenderer() { + // Runs before three's own listener, which would log; a lost cube is not restored, it stays hidden. + this.canvas.addEventListener('webglcontextlost', (e) => { + e.stopImmediatePropagation(); + this._fail(new Error('The view cube lost its WebGL context.')); + }); + // Asked for here, not by three, so a missing context hides the cube instead of logging an error. + const context = this.canvas.getContext('webgl2', { alpha: true, antialias: true }); + if (!context) return this._fail(new Error('WebGL 2 is not available for the view cube.')); + try { + const renderer = new THREE.WebGLRenderer({ canvas: this.canvas, context, alpha: true, antialias: true }); + renderer.setPixelRatio(window.devicePixelRatio); + renderer.setSize(SIZE, SIZE); + return renderer; + } catch (error) { + return this._fail(error); + } + } + + _fail(error) { + this.error = error; + this.renderer = null; + this.element.hidden = true; + return null; + } + + _render() { + if (!this.renderer) return; + try { + this.renderer.render(this.scene, this.camera); + } catch (error) { + this._fail(error); + } + } + + update() { + if (!this.renderer) return; + const main = this.controls.camera; + this.camera.quaternion.copy(main.quaternion); + this.camera.position.set(0, 0, 5).applyQuaternion(main.quaternion); + this.camera.updateMatrixWorld(); + const aligned = alignedFace(main.position.clone().sub(this.controls.target)) !== null; + for (const button of this.arrows) button.hidden = !aligned; + this._render(); + } + + // Region of the cube under a client point, or null. + regionAtClient(x, y) { + const r = this.canvas.getBoundingClientRect(); + const ndc = new THREE.Vector2(((x - r.left) / r.width) * 2 - 1, 1 - ((y - r.top) / r.height) * 2); + this._raycaster.setFromCamera(ndc, this.camera); + const hit = this._raycaster.intersectObjects(this.faces.children)[0]; + return hit ? regionAt(hit.point) : null; + } + + _hover(region) { + if (region === this.hovered) return; + this.hovered = region; + this.highlight.visible = !!region; + if (region) { + // A slab just over the region: across the face for a free axis, from INNER to the surface for a face's axis. + const signs = region.split('-').reduce((sum, name) => sum.add(FACES[name]), new THREE.Vector3()); + const range = (s) => (s === 0 ? [-INNER, INNER] : s > 0 ? [INNER, 1.02] : [-1.02, -INNER]); + const [x, y, z] = signs.toArray().map(range); + this.highlight.scale.set(x[1] - x[0], y[1] - y[0], z[1] - z[0]); + this.highlight.position.set((x[0] + x[1]) / 2, (y[0] + y[1]) / 2, (z[0] + z[1]) / 2); + } + this._render(); + } + + _arrow(arrow) { + const main = this.controls.camera; + const offset = main.position.clone().sub(this.controls.target); + const direction = arrowView(offset, arrow); + if (direction) this._look(direction); + } + + // Looks at the target from `direction`, at the current distance. + _look(direction) { + const { controls } = this; + const distance = controls.camera.position.distanceTo(controls.target); + controls.animateTo({ position: direction.clone().multiplyScalar(distance).add(controls.target), target: controls.target.clone() }); + } +} diff --git a/src/IOViewer/Viewer/src/select/boxSelect.js b/src/IOViewer/Viewer/src/select/boxSelect.js new file mode 100644 index 0000000..2bf141b --- /dev/null +++ b/src/IOViewer/Viewer/src/select/boxSelect.js @@ -0,0 +1,44 @@ +import * as THREE from 'three'; + +// Indices whose screen box is entirely inside the rect ('window') or touches it ('crossing'). +// rect: {x, y, width, height}; screenBoxes: [{index, minX, minY, maxX, maxY}], all in client pixels. +export function selectInRect(rect, mode, screenBoxes) { + if (mode !== 'window' && mode !== 'crossing') throw new RangeError(`Unknown box select mode: ${mode}`); + const right = rect.x + rect.width; + const bottom = rect.y + rect.height; + const hit = mode === 'window' + ? (b) => b.minX >= rect.x && b.maxX <= right && b.minY >= rect.y && b.maxY <= bottom + : (b) => b.maxX >= rect.x && b.minX <= right && b.maxY >= rect.y && b.minY <= bottom; + return screenBoxes.filter(hit).map((b) => b.index); +} + +// Client-pixel boxes of the 8 projected corners of every visible element's bounding box. +// shortcut: elements reaching behind the near plane (or past far) are skipped; clip them in 3D if walking inside a model matters. +export function screenBoxes(scene, camera, canvasRect) { + // The projection is written out (as Vector3.applyMatrix4 does it) so that a million elements take no allocations. + const m = new THREE.Matrix4().multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse).elements; + const { left, top, width, height } = canvasRect; + const box = new THREE.Box3(); + const boxes = []; + for (let index = 0; index < scene.elementCount; index++) { + if (!scene.isVisible(index)) continue; + const { min, max } = scene.elementBox(index, box); + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + let inDepth = true; + for (let k = 0; k < 8 && inDepth; k++) { + const x = k & 1 ? max.x : min.x; + const y = k & 2 ? max.y : min.y; + const z = k & 4 ? max.z : min.z; + const w = 1 / (m[3] * x + m[7] * y + m[11] * z + m[15]); + inDepth = Math.abs((m[2] * x + m[6] * y + m[10] * z + m[14]) * w) <= 1; + const sx = left + (((m[0] * x + m[4] * y + m[8] * z + m[12]) * w + 1) / 2) * width; + const sy = top + ((1 - (m[1] * x + m[5] * y + m[9] * z + m[13]) * w) / 2) * height; + minX = Math.min(minX, sx); + maxX = Math.max(maxX, sx); + minY = Math.min(minY, sy); + maxY = Math.max(maxY, sy); + } + if (inDepth) boxes.push({ index, minX, minY, maxX, maxY }); + } + return boxes; +} diff --git a/src/IOViewer/Viewer/src/select/selection.js b/src/IOViewer/Viewer/src/select/selection.js new file mode 100644 index 0000000..2b23056 --- /dev/null +++ b/src/IOViewer/Viewer/src/select/selection.js @@ -0,0 +1,36 @@ +// The set of selected element indices; fires `change` after every modification. +export class Selection extends EventTarget { + #items = new Set(); + + get size() { + return this.#items.size; + } + + has(index) { + return this.#items.has(index); + } + + values() { + return this.#items.values(); + } + + set(indices) { + this.#items = new Set(indices); + this.#changed(); + } + + toggle(index) { + if (!this.#items.delete(index)) this.#items.add(index); + this.#changed(); + } + + clear() { + if (this.#items.size === 0) return; + this.#items.clear(); + this.#changed(); + } + + #changed() { + this.dispatchEvent(new Event('change')); + } +} diff --git a/src/IOViewer/Viewer/src/tools/display.js b/src/IOViewer/Viewer/src/tools/display.js new file mode 100644 index 0000000..4bd3449 --- /dev/null +++ b/src/IOViewer/Viewer/src/tools/display.js @@ -0,0 +1,68 @@ +import { t } from '../i18n.js'; +import { el } from '../ui/dom.js'; +import { createPopover } from '../ui/popover.js'; +import { DISPLAY_MODES } from '../engine/modelScene.js'; + +// Display mode on open, from META options. +export function initialDisplayMode(options) { + return options?.initialWireframe ? 'wireframe' : options?.initialWhiteMode ? 'white' : 'materials'; +} + +/** + * Display tools: `displayMode` opens a menu of the modes (textures only when META has textures; the current one is + * checked), `grids` shows or hides the grid axes and their labels (off when there are none), `opacity` opens a + * 10–100 % slider for the whole model. Returns the popovers, for Esc. + */ +export function mountDisplay({ root, toolbar, scene, meta, grids }) { + const anchor = (id) => toolbar.element.querySelector(`[data-tool="${id}"]`); + + const modes = createPopover(root, anchor('displayMode'), t('tool.displayMode'), 'menu'); + modes.element.classList.add('ioviewer-display-modes'); + const hasTextures = (meta.textures ?? []).length > 0; + const items = DISPLAY_MODES.map((mode) => { + const item = el('button', 'ioviewer-menu-item', t(`display.${mode}`)); + item.type = 'button'; + item.dataset.mode = mode; + item.setAttribute('role', 'menuitemradio'); + item.disabled = mode === 'textures' && !hasTextures; + item.addEventListener('click', () => { + setMode(mode); + modes.close(); + }); + modes.element.append(item); + return item; + }); + const setMode = (mode) => { + scene.setDisplayMode(mode); + for (const item of items) item.setAttribute('aria-checked', String(item.dataset.mode === mode)); + }; + setMode(initialDisplayMode(meta.options)); + toolbar.setHandler('displayMode', modes.toggle); + + toolbar.setActive('grids', grids.visible); + if (grids.count > 0) { + toolbar.setHandler('grids', () => { + grids.setVisible(!grids.visible); + toolbar.setActive('grids', grids.visible); + }); + } + + const opacity = createPopover(root, anchor('opacity'), t('tool.opacity')); + opacity.element.classList.add('ioviewer-opacity'); + const slider = el('input', 'ioviewer-opacity-slider'); + Object.assign(slider, { type: 'range', min: '10', max: '100', step: '1', value: '100' }); + slider.setAttribute('aria-label', t('opacity.label')); + const value = el('output', 'ioviewer-opacity-value'); + const show = () => (value.textContent = t('opacity.value').replace('{value}', slider.value)); + slider.addEventListener('input', () => { + scene.setOpacity(Number(slider.value) / 100); + show(); + }); + show(); + const head = el('div', 'ioviewer-opacity-head'); + head.append(el('span', '', t('opacity.label')), value); + opacity.element.append(head, slider); + toolbar.setHandler('opacity', opacity.toggle); + + return [modes, opacity]; +} diff --git a/src/IOViewer/Viewer/src/tools/grids.js b/src/IOViewer/Viewer/src/tools/grids.js new file mode 100644 index 0000000..92e24c7 --- /dev/null +++ b/src/IOViewer/Viewer/src/tools/grids.js @@ -0,0 +1,46 @@ +import * as THREE from 'three'; +import { el, placeLabel } from '../ui/dom.js'; + +/** + * Grid axes from META grids ([{name, start, end}] in RTC): thin lines at their own elevation in `group` (an overlay + * outside ModelScene.root, so never picked), drawn over the model, and a round label with the axis name at both ends + * of each line, placed by update(camera, rect) before each render. Shown from the start when there are any. + * Dispatches 'change' when shown or hidden. + */ +export class GridAxes extends EventTarget { + constructor(root, grids) { + super(); + this.count = grids.length; + this.group = new THREE.LineSegments( + new THREE.BufferGeometry().setAttribute('position', new THREE.Float32BufferAttribute(grids.flatMap((g) => [...g.start, ...g.end]), 3)), + new THREE.LineBasicMaterial({ color: '#93c5fd', transparent: true, opacity: 0.55, depthTest: false, depthWrite: false }), + ); + this.group.name = 'grids'; + this.group.geometry.computeBoundingBox(); + this.box = this.group.geometry.boundingBox; // all the lines; empty without grids + this.group.renderOrder = 999; + this._labels = grids.flatMap((g) => + [g.start, g.end].map((p) => ({ element: el('div', 'ioviewer-grid-label', g.name), point: new THREE.Vector3(...p) })), + ); + const layer = el('div', 'ioviewer-grid-labels'); + layer.append(...this._labels.map((l) => l.element)); + root.prepend(layer); // under the panels and the toolbar + this._layer = layer; + this.setVisible(this.count > 0); + } + + get visible() { + return this.group.visible; + } + + setVisible(visible) { + this.group.visible = !!visible; + this._layer.hidden = !visible; + this.dispatchEvent(new Event('change')); + } + + update(camera, rect) { + if (!this.visible) return; + for (const { element, point } of this._labels) placeLabel(element, point, camera, rect); + } +} diff --git a/src/IOViewer/Viewer/src/tools/measure.js b/src/IOViewer/Viewer/src/tools/measure.js new file mode 100644 index 0000000..81faff4 --- /dev/null +++ b/src/IOViewer/Viewer/src/tools/measure.js @@ -0,0 +1,90 @@ +import * as THREE from 'three'; +import { lang, t } from '../i18n.js'; +import { el, placeLabel } from '../ui/dom.js'; + +const COLOR = '#facc15'; +const MARKER_PX = 9; + +// «12,35 м» / "12.35 m": metres with 2 decimals, no thousands separator. +export function formatDistance(meters, language = lang) { + const number = new Intl.NumberFormat(language, { minimumFractionDigits: 2, maximumFractionDigits: 2, useGrouping: false }); + return `${number.format(meters)} ${t('unit.m', language)}`; +} + +/** + * Distance tool. While `active`, add(point) places points on the model: the first starts a measurement, the second + * ends it (a line with end markers and a label in metres at its midpoint), the next one starts a new measurement. + * The visuals live in `group`, an overlay outside ModelScene.root drawn on top of the model; the label is a DOM + * element in `root`, placed by update(camera, rect) before each render. Dispatches 'change' after every change. + */ +export class Measure extends EventTarget { + constructor(root) { + super(); + this.active = false; + this.points = []; + this.group = new THREE.Group(); + this.group.name = 'measure'; + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(6), 3)); + const overlay = { color: COLOR, depthTest: false, depthWrite: false, transparent: true }; + this._line = new THREE.Line(geometry, new THREE.LineBasicMaterial(overlay)); + this._markers = new THREE.Points(geometry, new THREE.PointsMaterial({ ...overlay, size: MARKER_PX, sizeAttenuation: false })); + for (const object of [this._line, this._markers]) { + object.frustumCulled = false; // the points move; the bounds are never recomputed + object.renderOrder = 1001; + this.group.add(object); + } + this.label = el('div', 'ioviewer-measure-label'); + this.label.hidden = true; + root.prepend(this.label); // under the panels and the toolbar + this._sync(); + } + + // Distance between the two points of a finished measurement, else null. + get distance() { + return this.points.length === 2 ? this.points[0].distanceTo(this.points[1]) : null; + } + + // Box of the points (empty without any), for the camera's near/far. + get box() { + return new THREE.Box3().setFromPoints(this.points); + } + + // Turning the tool off clears the measurement. + setActive(active) { + this.active = !!active; + if (!this.active) this.points = []; + this._sync(); + } + + add(point) { + if (!this.active) return; + this.points = this.points.length === 1 ? [this.points[0], point.clone()] : [point.clone()]; + this._sync(); + } + + // True when there was something to clear. + clear() { + if (this.points.length === 0) return false; + this.points = []; + this._sync(); + return true; + } + + update(camera, rect) { + if (this.distance === null) return; + placeLabel(this.label, this.points[0].clone().lerp(this.points[1], 0.5), camera, rect); + } + + _sync() { + const position = this._line.geometry.attributes.position; + this.points.forEach((p, i) => position.setXYZ(i, p.x, p.y, p.z)); + position.needsUpdate = true; + this._line.geometry.setDrawRange(0, this.points.length); + this._line.visible = this.points.length === 2; + this._markers.visible = this.points.length > 0; + this.label.hidden = this.distance === null; + if (this.distance !== null) this.label.textContent = formatDistance(this.distance); + this.dispatchEvent(new Event('change')); + } +} diff --git a/src/IOViewer/Viewer/src/tools/sectionGizmo.js b/src/IOViewer/Viewer/src/tools/sectionGizmo.js new file mode 100644 index 0000000..6d16d7c --- /dev/null +++ b/src/IOViewer/Viewer/src/tools/sectionGizmo.js @@ -0,0 +1,313 @@ +import * as THREE from 'three'; +import { MAX_PLANES } from './sections.js'; +import { closestOnLine, planeRect } from './sectionMath.js'; + +const GIZMO_PX = 80; // plane gizmo: arrow length on screen +const HANDLE_PX = 8; // box handle radius on screen +const COLOR = { x: '#ef4444', y: '#22c55e', z: '#3b82f6', centre: '#e2e8f0', hover: '#facc15', frame: '#93c5fd', fill: '#60a5fa' }; +const AXIS_KEYS = ['x', 'y', 'z']; +// An arrow or face handle whose axis is within 12 degrees of the line of sight is hidden: it would sit on top of the +// box centre handle, and dragging along it would be ill-conditioned (sectionMath.closestOnLine refuses under ~10 deg). +const SIGHT_COS = Math.cos(THREE.MathUtils.degToRad(12)); +const Z = new THREE.Vector3(0, 0, 1); + +const solid = (color) => new THREE.MeshBasicMaterial({ color, depthTest: false, depthWrite: false, transparent: true, opacity: 0.9 }); +const HIT = new THREE.MeshBasicMaterial({ visible: false }); // invisible, still hit by the raycaster + +// A handle: visible meshes plus a fatter invisible mesh that takes the pointer; drawn on top of the model. +function handle(kind, color, visuals, hit) { + const group = new THREE.Group(); + const material = solid(color); + for (const geometry of visuals) group.add(new THREE.Mesh(geometry, material)); + const proxy = new THREE.Mesh(hit, HIT); + proxy.userData.handle = group; + group.add(proxy); + group.userData = { kind, color, material, proxy }; + group.traverse((o) => (o.renderOrder = 1000)); + return group; +} + +/** + * The sections in the scene, kept in `group` (an overlay outside ModelScene.root, so the picking id pass never sees + * it): a translucent framed rectangle per enabled plane, a gizmo on the active plane (arrow: move along the normal; + * red/green rings: turn about the plane's own axes), the section box wireframe with a handle on each face and one + * in the centre (moves the whole box). A press on a handle is taken before NavigationControls sees it; anything else + * (the rectangles included) goes on to orbit and pick. Call update(camera) before each render: handles keep their + * size on screen. Dispatches 'change' when only the hover highlight changed. + */ +export class SectionGizmo extends EventTarget { + constructor(sections, dom) { + super(); + this.sections = sections; + this.dom = dom; + this.camera = null; + this.group = new THREE.Group(); + this.group.name = 'sections'; + this._raycaster = new THREE.Raycaster(); + this._drag = null; + this._hovered = null; + this._buildPlanes(); + this._buildPlaneGizmo(); + this._buildBox(); + sections.addEventListener('change', () => this._sync()); + // Capture on the parent: runs before the canvas listeners of NavigationControls, so a handle press never orbits. + (dom.parentElement ?? dom).addEventListener('pointerdown', (e) => this._down(e), true); + dom.addEventListener('pointermove', (e) => this._move(e)); + dom.addEventListener('pointerup', (e) => this._up(e)); + dom.addEventListener('pointercancel', () => this._end()); + dom.addEventListener('lostpointercapture', () => this._end()); + this._sync(); + } + + get dragging() { + return this._drag !== null; + } + + // Keeps handles a constant size on screen for `camera` (the one about to render) and hides the ones that point + // along the line of sight. + update(camera) { + this.camera = camera; + const gizmo = this.planeGizmo; + if (gizmo.visible) { + gizmo.scale.setScalar(this._worldPerPixel(gizmo.position) * GIZMO_PX); + this.arrow.visible = this._across(Z.clone().applyQuaternion(gizmo.quaternion), gizmo.position); + } + for (const h of this.boxHandles) { + const { shown, axis } = h.userData; + h.visible = shown && (!axis || this._across(new THREE.Vector3().setComponent(AXIS_KEYS.indexOf(axis), 1), h.position)); + if (h.visible) h.scale.setScalar(this._worldPerPixel(h.position) * HANDLE_PX); + } + } + + // True when `axis` at `point` is not within 12 degrees of the line of sight. + _across(axis, point) { + const cam = this.camera; + const sight = cam.isOrthographicCamera ? cam.getWorldDirection(new THREE.Vector3()) : point.clone().sub(cam.position).normalize(); + return Math.abs(axis.dot(sight)) < SIGHT_COS; + } + + _buildPlanes() { + const square = new THREE.PlaneGeometry(1, 1); + const outline = new THREE.BufferGeometry().setFromPoints([[-1, -1], [1, -1], [1, 1], [-1, 1]].map(([x, y]) => new THREE.Vector3(x / 2, y / 2, 0))); + const fill = new THREE.MeshBasicMaterial({ color: COLOR.fill, transparent: true, opacity: 0.12, side: THREE.DoubleSide, depthWrite: false }); + const frame = new THREE.LineBasicMaterial({ color: COLOR.frame }); + this.planeVisuals = Array.from({ length: MAX_PLANES }, () => { + const visual = new THREE.Group(); + visual.add(new THREE.Mesh(square, fill), new THREE.LineLoop(outline, frame)); + this.group.add(visual); + return visual; + }); + } + + // Local frame: Z is the plane normal; unit size, scaled to GIZMO_PX in update(). + _buildPlaneGizmo() { + const along = (geometry, z) => geometry.rotateX(Math.PI / 2).translate(0, 0, z); + const arrow = handle('move', COLOR.z, + [along(new THREE.CylinderGeometry(0.025, 0.025, 1), 0.5), along(new THREE.ConeGeometry(0.08, 0.25), 1.125)], + along(new THREE.CylinderGeometry(0.12, 0.12, 1.25), 0.625)); + arrow.name = 'arrow'; + this.arrow = arrow; + const ring = (name, axis, turn) => { + const r = handle('rotate', COLOR[axis], [turn(new THREE.TorusGeometry(0.8, 0.018, 8, 64))], turn(new THREE.TorusGeometry(0.8, 0.09, 8, 48))); + r.name = name; + r.userData.axis = axis; + return r; + }; + this.planeGizmo = new THREE.Group(); + this.planeGizmo.add( + arrow, + ring('ringX', 'x', (g) => g.rotateY(Math.PI / 2)), + ring('ringY', 'y', (g) => g.rotateX(Math.PI / 2)), + ); + this.group.add(this.planeGizmo); + } + + _buildBox() { + this.boxWire = new THREE.LineSegments(new THREE.EdgesGeometry(new THREE.BoxGeometry(1, 1, 1)), new THREE.LineBasicMaterial({ color: COLOR.frame })); + this.group.add(this.boxWire); + const sphere = new THREE.SphereGeometry(1, 16, 12); + const hit = new THREE.SphereGeometry(1.6, 8, 6); + this.boxHandles = []; + for (const axis of AXIS_KEYS) { + for (const sign of [-1, 1]) { + const h = handle('face', COLOR[axis], [sphere], hit); + h.name = `face${sign > 0 ? '+' : '-'}${axis}`; + Object.assign(h.userData, { axis, sign }); + this.boxHandles.push(h); + } + } + const centre = handle('centre', COLOR.centre, [new THREE.BoxGeometry(1.4, 1.4, 1.4)], hit); + centre.name = 'centre'; + this.boxHandles.push(centre); + this.group.add(...this.boxHandles); + } + + // Places the visuals for the current section state. + _sync() { + const s = this.sections; + const planes = s.mode === 'planes'; + this.planeVisuals.forEach((visual, i) => { + const plane = s.planes[i]; + visual.visible = planes && !!plane?.enabled; + if (!visual.visible) return; + const rect = planeRect(s.modelBox, plane.quaternion, s.origin(i)); + visual.position.copy(rect.center); + visual.quaternion.copy(plane.quaternion); + visual.scale.set(rect.width, rect.height, 1); + }); + const active = s.planes[s.active]; + this.planeGizmo.visible = planes && s.gizmoVisible && !!active?.enabled; + if (this.planeGizmo.visible) { + this.planeGizmo.position.copy(s.origin(s.active)); + this.planeGizmo.quaternion.copy(active.quaternion); + } + const box = s.mode === 'box' ? s.box : null; + this.boxWire.visible = !!box; + for (const h of this.boxHandles) h.visible = h.userData.shown = !!box && s.gizmoVisible; // update() refines + if (box) { + const centre = box.getCenter(new THREE.Vector3()); + const size = box.getSize(new THREE.Vector3()); + this.boxWire.position.copy(centre); + this.boxWire.scale.copy(size).max(new THREE.Vector3(1e-6, 1e-6, 1e-6)); + for (const h of this.boxHandles) { + h.position.copy(centre); + const { axis, sign } = h.userData; + if (axis) h.position[axis] = sign > 0 ? box.max[axis] : box.min[axis]; + } + } + } + + _worldPerPixel(point) { + const cam = this.camera; + const height = Math.max(this.dom.clientHeight, 1); + if (cam.isOrthographicCamera) return (cam.top - cam.bottom) / cam.zoom / height; + const depth = point.clone().sub(cam.position).dot(cam.getWorldDirection(new THREE.Vector3())); + return (2 * Math.max(depth, cam.near) * Math.tan(THREE.MathUtils.degToRad(cam.fov / 2))) / height; + } + + _ray(e) { + const rect = this.dom.getBoundingClientRect(); + const ndc = new THREE.Vector2(((e.clientX - rect.left) / rect.width) * 2 - 1, 1 - ((e.clientY - rect.top) / rect.height) * 2); + this.camera.updateMatrixWorld(); + this._raycaster.setFromCamera(ndc, this.camera); + return this._raycaster.ray.clone(); + } + + // The shown handle under the pointer, or null. + _hit(e) { + if (!this.camera) return null; + this.update(this.camera); + this.group.updateMatrixWorld(true); + const shown = (o) => { + for (; o && o !== this.group; o = o.parent) if (!o.visible) return false; + return true; + }; + const proxies = []; + this.group.traverse((o) => o.userData.handle && shown(o) && proxies.push(o)); + this._ray(e); + const hit = this._raycaster.intersectObjects(proxies, false)[0]; + return hit ? hit.object.userData.handle : null; + } + + _down(e) { + if (e.target !== this.dom || e.button !== 0) return; + const h = this._hit(e); + const start = h && this._start(h, this._ray(e)); + if (!start) return; // nothing to drag: the press goes on to the navigation + e.stopPropagation(); + e.preventDefault(); + this.dom.setPointerCapture(e.pointerId); + this._drag = { id: e.pointerId, handle: h, ...start }; + this._highlight(h); + } + + // What a drag of handle h needs from its start, or null when the ray gives no usable start. + _start(h, ray) { + const s = this.sections; + const { kind } = h.userData; + if (kind === 'move' || kind === 'rotate') { + const index = s.active; + const origin = s.origin(index); + const quaternion = s.planes[index].quaternion.clone(); + if (kind === 'move') { + const normal = s.normal(index); + const s0 = closestOnLine(ray, origin, normal); + return s0 === null ? null : { index, origin, normal, offset: s.planes[index].offset, s0 }; + } + const axis = new THREE.Vector3().setComponent(AXIS_KEYS.indexOf(h.userData.axis), 1).applyQuaternion(quaternion); + const plane = new THREE.Plane().setFromNormalAndCoplanarPoint(axis, origin); + const from = ray.intersectPlane(plane, new THREE.Vector3())?.sub(origin); + return from ? { index, origin, quaternion, axis, plane, from } : null; + } + const box = s.box.clone(); + if (kind === 'face') { + const { axis, sign } = h.userData; + const normal = new THREE.Vector3().setComponent(AXIS_KEYS.indexOf(axis), sign); + const at = h.position.clone(); + const s0 = closestOnLine(ray, at, normal); + return s0 === null ? null : { box, normal, at, s0 }; + } + const facing = this.camera.getWorldDirection(new THREE.Vector3()); + const plane = new THREE.Plane().setFromNormalAndCoplanarPoint(facing, box.getCenter(new THREE.Vector3())); + const from = ray.intersectPlane(plane, new THREE.Vector3()); + return from ? { box, plane, from } : null; + } + + _move(e) { + const d = this._drag; + if (!d) return this._hover(e); + if (e.pointerId !== d.id) return; + if (e.buttons === 0) return this._end(); + const ray = this._ray(e); + const s = this.sections; + const { kind } = d.handle.userData; + if (kind === 'move') { + const along = closestOnLine(ray, d.origin, d.normal); + if (along !== null) s.updatePlane(d.index, { offset: d.offset + along - d.s0 }); + } else if (kind === 'rotate') { + const to = ray.intersectPlane(d.plane, new THREE.Vector3())?.sub(d.origin); + if (!to) return; + const angle = Math.atan2(d.axis.dot(new THREE.Vector3().crossVectors(d.from, to)), d.from.dot(to)); + const quaternion = new THREE.Quaternion().setFromAxisAngle(d.axis, angle).multiply(d.quaternion); + // Turn about the gizmo point: the plane keeps passing through it. + const normal = Z.clone().applyQuaternion(quaternion); + s.updatePlane(d.index, { quaternion, offset: normal.dot(d.origin.clone().sub(s.center)) }); + } else if (kind === 'face') { + const along = closestOnLine(ray, d.at, d.normal); + if (along === null) return; + const { axis, sign } = d.handle.userData; + const box = d.box.clone(); + const least = 1e-3 * Math.max(...s.modelBox.getSize(new THREE.Vector3()).toArray(), 1e-3); + const moved = (sign > 0 ? d.box.max[axis] : d.box.min[axis]) + sign * (along - d.s0); + if (sign > 0) box.max[axis] = Math.max(moved, box.min[axis] + least); + else box.min[axis] = Math.min(moved, box.max[axis] - least); + s.setBox(box); + } else { + const to = ray.intersectPlane(d.plane, new THREE.Vector3()); + if (to) s.setBox(d.box.clone().translate(to.sub(d.from))); + } + } + + _up(e) { + if (this._drag && e.pointerId === this._drag.id) this._end(); + } + + _end() { + if (!this._drag) return; + this._drag = null; + this._highlight(null); + } + + _hover(e) { + if (e.buttons !== 0) return; + this._highlight(this._hit(e)); + } + + _highlight(h) { + if (this._hovered === h) return; + for (const g of [this._hovered, h]) if (g) g.userData.material.color.set(g === h ? COLOR.hover : g.userData.color); + this._hovered = h; + this.dom.style.cursor = h ? 'grab' : ''; + this.dispatchEvent(new Event('change')); + } +} diff --git a/src/IOViewer/Viewer/src/tools/sectionMath.js b/src/IOViewer/Viewer/src/tools/sectionMath.js new file mode 100644 index 0000000..1b56398 --- /dev/null +++ b/src/IOViewer/Viewer/src/tools/sectionMath.js @@ -0,0 +1,96 @@ +import * as THREE from 'three'; + +export const AXES = { x: new THREE.Vector3(1, 0, 0), y: new THREE.Vector3(0, 1, 0), z: new THREE.Vector3(0, 0, 1) }; + +export function axisVector(axis) { + const vector = AXES[axis]; + if (!vector) throw new RangeError(`Unknown axis: ${axis}`); + return vector.clone(); +} + +// three's rule (clipping_planes_fragment): a point is cut when its distance to a plane is negative ("behind") for +// any plane (union, intersection = false) or for every plane (intersection = true). The positive side is kept. +export function isClipped(point, planes, intersection) { + if (planes.length === 0) return false; + const behind = (plane) => plane.distanceToPoint(point) < 0; + return intersection ? planes.every(behind) : planes.some(behind); +} + +// Plane through `center + normal * offset`; it keeps the side behind the normal and cuts the side it points to, +// or the other way round when flipped. +export function sectionPlane(normal, center, offset, flipped) { + const n = normal.clone().normalize(); + const constant = n.dot(center) + offset; + return flipped ? new THREE.Plane(n, -constant) : new THREE.Plane(n.negate(), constant); +} + +// Plane perpendicular to axis 'x' | 'y' | 'z' through the box centre moved by `offset` along the axis. +export function planeFromAxis(axis, box, offset, flipped) { + return sectionPlane(axisVector(axis), box.getCenter(new THREE.Vector3()), offset, flipped); +} + +/** + * Clipping planes of a section box. keepInside: six planes facing into the box, union (anything outside one of + * them is cut). cutInside: the same planes turned to face out, intersection (cut only what is inside all six): + * with the inward planes an intersection would cut only points outside every face, which a box has none of. + */ +export function boxPlanes(box, mode) { + if (mode !== 'keepInside' && mode !== 'cutInside') throw new RangeError(`Unknown box mode: ${mode}`); + const { min, max } = box; + const inward = [ + new THREE.Plane(new THREE.Vector3(1, 0, 0), -min.x), + new THREE.Plane(new THREE.Vector3(-1, 0, 0), max.x), + new THREE.Plane(new THREE.Vector3(0, 1, 0), -min.y), + new THREE.Plane(new THREE.Vector3(0, -1, 0), max.y), + new THREE.Plane(new THREE.Vector3(0, 0, 1), -min.z), + new THREE.Plane(new THREE.Vector3(0, 0, -1), max.z), + ]; + if (mode === 'keepInside') return { planes: inward, intersection: false }; + return { planes: inward.map((plane) => plane.negate()), intersection: true }; +} + +function corners(box) { + const { min, max } = box; + return Array.from({ length: 8 }, (_, k) => new THREE.Vector3(k & 1 ? max.x : min.x, k & 2 ? max.y : min.y, k & 4 ? max.z : min.z)); +} + +// True when every point of the box is cut: all corners behind one plane (union), or behind every plane +// (intersection; exact, since the box and the half-spaces are convex). +export function boxFullyClipped(box, planes, intersection) { + if (planes.length === 0 || box.isEmpty()) return false; + const points = corners(box); + const allBehind = (plane) => points.every((p) => plane.distanceToPoint(p) < 0); + return intersection ? planes.every(allBehind) : planes.some(allBehind); +} + +// Half the size of the box measured along the unit vector `direction`. +export function extentAlong(box, direction) { + const half = box.getSize(new THREE.Vector3()).multiplyScalar(0.5); + return Math.abs(direction.x) * half.x + Math.abs(direction.y) * half.y + Math.abs(direction.z) * half.z; +} + +// Parameter s of the point `point + direction * s` (unit direction) nearest to the ray; null when they are within +// about 10 degrees of parallel, where a pixel would move the point by more than ~6 pixels' worth along the line. +export function closestOnLine(ray, point, direction) { + const w = point.clone().sub(ray.origin); + const b = direction.dot(ray.direction); + const denominator = 1 - b * b; // sin² of the angle between them + if (denominator < 0.03) return null; + return (b * ray.direction.dot(w) - direction.dot(w)) / denominator; +} + +// Rectangle on the plane through `origin` with orientation `quaternion` (local Z = normal) that covers the box +// projected onto it, 10 % larger: { center, width, height } along the plane's local X and Y. +export function planeRect(box, quaternion, origin) { + const u = new THREE.Vector3(1, 0, 0).applyQuaternion(quaternion); + const v = new THREE.Vector3(0, 1, 0).applyQuaternion(quaternion); + let [minU, maxU, minV, maxV] = [Infinity, -Infinity, Infinity, -Infinity]; + for (const corner of corners(box)) { + corner.sub(origin); + const a = corner.dot(u); + const b = corner.dot(v); + [minU, maxU, minV, maxV] = [Math.min(minU, a), Math.max(maxU, a), Math.min(minV, b), Math.max(maxV, b)]; + } + const center = origin.clone().addScaledVector(u, (minU + maxU) / 2).addScaledVector(v, (minV + maxV) / 2); + return { center, width: (maxU - minU) * 1.1, height: (maxV - minV) * 1.1 }; +} diff --git a/src/IOViewer/Viewer/src/tools/sections.js b/src/IOViewer/Viewer/src/tools/sections.js new file mode 100644 index 0000000..74af92a --- /dev/null +++ b/src/IOViewer/Viewer/src/tools/sections.js @@ -0,0 +1,176 @@ +import * as THREE from 'three'; +import { axisVector, boxPlanes, boxFullyClipped, extentAlong, sectionPlane } from './sectionMath.js'; + +export const MAX_PLANES = 6; +const MODES = ['off', 'planes', 'box']; +const BOX_MARGIN = 0.05; // of the largest dimension of the elements' box +const Z = new THREE.Vector3(0, 0, 1); + +/** + * Section state, applied to ModelScene.setClipping. Modes are exclusive: 'off', 'planes' (up to 6 planes, an element + * part is shown when no enabled plane cuts it) or 'box' ('keepInside' shows what is in the box, 'cutInside' hides it). + * A plane is { axis, quaternion (local Z = normal), offset, flipped, enabled, rotated }: it passes through the model + * box centre + normal * offset and cuts the side its normal points to (the other side when flipped). + * Dispatches 'change' after every change. + */ +export class Sections extends EventTarget { + constructor(scene, modelBox) { + super(); + this.scene = scene; + this.modelBox = modelBox.clone(); + this.center = modelBox.getCenter(new THREE.Vector3()); + this.planes = []; + this.active = -1; // plane that carries the gizmo + this.box = null; + this.boxMode = 'keepInside'; + this.gizmoVisible = true; + this._mode = 'off'; + } + + get mode() { + return this._mode; + } + + // 'box' without a box yet takes the box of what is shown. + setMode(mode) { + if (!MODES.includes(mode)) throw new RangeError(`Unknown section mode: ${mode}`); + if (mode === 'box' && !this.box && this.fitBoxToShown()) return; + this._mode = mode; + this._changed(true); + } + + // Index of the new plane through the model centre, or null when there are already 6 or the model is empty. + addPlane(axis) { + const normal = axisVector(axis); + if (this.planes.length >= MAX_PLANES || this.modelBox.isEmpty()) return null; + const quaternion = new THREE.Quaternion().setFromUnitVectors(Z, normal); + this.planes.push({ axis, quaternion, offset: 0, flipped: false, enabled: true, rotated: false }); + this.active = this.planes.length - 1; + this._mode = 'planes'; + this._changed(true); + return this.active; + } + + removePlane(i) { + this._plane(i); + this.planes.splice(i, 1); + if (this.active >= this.planes.length || this.active > i) this.active--; + this._changed(true); + } + + // { offset, flipped, enabled, quaternion }; the quaternion turns the plane freely (local Z = new normal). + updatePlane(i, { offset, flipped, enabled, quaternion }) { + const plane = this._plane(i); + if (offset !== undefined) { + if (!Number.isFinite(offset)) throw new RangeError(`Offset must be a finite number, got ${offset}.`); + plane.offset = offset; + } + if (flipped !== undefined) plane.flipped = !!flipped; + if (enabled !== undefined) plane.enabled = !!enabled; + if (quaternion) { + plane.quaternion.copy(quaternion).normalize(); + plane.rotated = this.normal(i).angleTo(axisVector(plane.axis)) > 1e-6; + } + this._changed(true); + } + + setActivePlane(i) { + this._plane(i); + this.active = i; + this._changed(false); + } + + normal(i) { + return Z.clone().applyQuaternion(this._plane(i).quaternion); + } + + // Half the model's size along the plane normal: the range of its offset that still crosses the model. + extent(i) { + return extentAlong(this.modelBox, this.normal(i)); + } + + // Point of the plane nearest to the model centre (where the gizmo sits). + origin(i) { + return this.center.clone().addScaledVector(this.normal(i), this._plane(i).offset); + } + + clipPlane(i) { + const plane = this._plane(i); + return sectionPlane(this.normal(i), this.center, plane.offset, plane.flipped); + } + + setBox(box) { + if (box.isEmpty()) throw new RangeError('The section box is empty.'); + this.box = box.clone(); + this._mode = 'box'; + this._changed(true); + } + + setBoxMode(mode) { + if (mode !== 'keepInside' && mode !== 'cutInside') throw new RangeError(`Unknown box mode: ${mode}`); + this.boxMode = mode; + this._changed(true); + } + + // Box around the elements plus 5 % of its largest dimension on every side; false when there is nothing to fit. + fitBoxToElements(indices) { + const box = new THREE.Box3(); + for (const i of indices) box.union(this.scene.elementBox(i)); + if (box.isEmpty()) return false; + const size = box.getSize(new THREE.Vector3()); + this.setBox(box.expandByScalar(BOX_MARGIN * Math.max(size.x, size.y, size.z))); + return true; + } + + // Box of what is shown, a hair larger so faces lying on it are not cut by rounding; false when nothing is shown. + fitBoxToShown() { + const box = this.scene.visibleBox(); + if (box.isEmpty()) return false; + const size = box.getSize(new THREE.Vector3()); + this.setBox(box.expandByScalar(1e-3 * Math.max(size.x, size.y, size.z))); + return true; + } + + setGizmoVisible(visible) { + this.gizmoVisible = !!visible; + this._changed(false); + } + + reset() { + this.planes = []; + this.active = -1; + this.box = null; + this._mode = 'off'; + this._changed(true); + } + + // The planes and rule handed to ModelScene.setClipping for the current mode. + clipping() { + if (this._mode === 'planes') { + const planes = this.planes.flatMap((plane, i) => (plane.enabled ? [this.clipPlane(i)] : [])); + return { planes, intersection: false }; + } + if (this._mode === 'box' && this.box) return boxPlanes(this.box, this.boxMode); + return { planes: [], intersection: false }; + } + + // True when the current section cuts the whole of `box` (an element's box) away. + isCut(box) { + const { planes, intersection } = this.clipping(); + return boxFullyClipped(box, planes, intersection); + } + + _plane(i) { + const plane = this.planes[i]; + if (!Number.isInteger(i) || !plane) throw new RangeError(`Plane index ${i} is out of range 0..${this.planes.length - 1}.`); + return plane; + } + + _changed(clipping) { + if (clipping) { + const { planes, intersection } = this.clipping(); + this.scene.setClipping(planes, intersection); + } + this.dispatchEvent(new Event('change')); + } +} diff --git a/src/IOViewer/Viewer/src/ui/app.js b/src/IOViewer/Viewer/src/ui/app.js new file mode 100644 index 0000000..36c8f7f --- /dev/null +++ b/src/IOViewer/Viewer/src/ui/app.js @@ -0,0 +1,136 @@ +import { t } from '../i18n.js'; +import { icons } from './icons.js'; +import { createShell } from './shell.js'; +import { createToolbar, toolbarGroups } from './toolbar.js'; +import { mountTree } from './tree.js'; +import { createContextMenu } from './contextMenu.js'; +import { installHotkeys } from './hotkeys.js'; +import { mountStatusBar, mountTruncationBanner, showToast } from './statusBar.js'; +import { mountProperties } from './properties.js'; + +// Copies text; the async clipboard API is missing or refused on some file:// pages, so it falls back to execCommand. +async function copyText(text) { + if (await navigator.clipboard?.writeText(text).then(() => true, () => false)) return true; + const area = document.createElement('textarea'); + area.value = text; + area.setAttribute('readonly', ''); + area.className = 'ioviewer-offscreen'; + document.body.append(area); + area.select(); + try { + return document.execCommand('copy'); + } finally { + area.remove(); + } +} + +/** + * Builds the viewer UI inside `root` (#ioviewer-ui) and wires it to the model. + * Returns { shell, toolbar, contextMenu, onEscape } so features can register panels, enable tools and menu items; + * onEscape(handler) adds a handler that Esc tries, in order, after closing the context menu and before clearing the + * selection: it returns true when it used the key. + */ +export function mountUI({ root, data, scene, controls, picker, selection, measure }) { + const { meta } = data; + const fitAll = () => controls.fitBox(scene.visibleBox()); + const actions = { + hide: () => selection.size && scene.setVisible([...selection.values()], false), + isolate: () => selection.size && scene.isolate([...selection.values()]), + showAll: () => scene.showAll(), + // Selection's combined box; nothing selected fits everything shown. + zoomTo: () => { + if (!selection.size) return fitAll(); + const [first, ...rest] = selection.values(); + const box = scene.elementBox(first); + for (const i of rest) box.union(scene.elementBox(i)); + controls.zoomToBox(box); + }, + fitAll, + }; + + const shell = createShell(root); + shell.registerPanel('model', { + icon: icons.model, + title: t('panel.model'), + render: (body) => + mountTree(body, { + elements: meta.elements, + selection, + scene, + activate: (i) => { + selection.set([i]); + controls.zoomToBox(scene.elementBox(i)); + }, + }), + }); + const properties = document.createElement('aside'); + properties.id = 'ioviewer-properties'; + properties.className = 'ioviewer-floating'; + root.append(properties); + mountProperties(properties, selection, data); + + const toolbar = createToolbar(root, toolbarGroups); + const setLeftMode = (mode) => { + controls.setLeftButtonMode(mode); + toolbar.setActive('orbit', mode === 'orbit'); + toolbar.setActive('pan', mode === 'pan'); + }; + toolbar.setHandler('orbit', () => setLeftMode('orbit')); + toolbar.setHandler('pan', () => setLeftMode('pan')); + setLeftMode(controls.leftButtonMode); + toolbar.setHandler('fit', fitAll); + toolbar.setHandler('projection', () => { + const orthographic = !controls.camera.isOrthographicCamera; + controls.setProjection(orthographic ? 'orthographic' : 'perspective'); + toolbar.setActive('projection', orthographic); + }); + toolbar.setActive('projection', false); + if (document.fullscreenEnabled) { + toolbar.setHandler('fullscreen', () => { + const request = document.fullscreenElement ? document.exitFullscreen() : document.documentElement.requestFullscreen(); + request.catch(() => { + toolbar.setActive('fullscreen', !!document.fullscreenElement); + showToast(root, t('fullscreenUnavailable')); + }); + }); + document.addEventListener('fullscreenchange', () => toolbar.setActive('fullscreen', !!document.fullscreenElement)); + } + + const contextMenu = createContextMenu(root); + contextMenu.setItem('properties', { handler: (i) => selection.set([i]) }); + for (const id of ['hide', 'isolate', 'showAll', 'zoomTo', 'fitAll']) contextMenu.setItem(id, { handler: actions[id] }); + contextMenu.setItem('copyId', { + handler: (i) => + copyText(String(meta.elements[i].id)).then( + (copied) => showToast(root, t(copied ? 'idCopied' : 'copyFailed')), + () => showToast(root, t('copyFailed')), + ), + }); + // Right click on an element that is not selected selects it first, as in Navisworks. With the distance tool on the + // selection stays: the menu is for the current selection, wherever the click was. + controls.addEventListener('contextmenu', (e) => { + const { x, y } = e.detail; + if (measure.active) { + const [first] = selection.values(); + return first === undefined ? contextMenu.open('empty', x, y, null) : contextMenu.open('element', x, y, first); + } + const hit = picker.pick(x, y); + if (!hit || hit.element < 0) return contextMenu.open('empty', x, y, null); + if (!selection.has(hit.element)) selection.set([hit.element]); + contextMenu.open('element', x, y, hit.element); + }); + + const escapeHandlers = []; + installHotkeys(window, { + ...actions, + // Esc closes an open menu and keeps the selection; otherwise a feature may use it; otherwise it clears the selection. + escape: () => { + if (contextMenu.isOpen) return contextMenu.close(); + if (!escapeHandlers.some((handle) => handle())) selection.clear(); + }, + }); + mountStatusBar(document.getElementById('ioviewer-status'), selection, scene.elementCount); + mountTruncationBanner(root, meta.truncated); + + return { shell, toolbar, contextMenu, onEscape: (handler) => escapeHandlers.push(handler) }; +} diff --git a/src/IOViewer/Viewer/src/ui/clashesPanel.js b/src/IOViewer/Viewer/src/ui/clashesPanel.js new file mode 100644 index 0000000..6e7e162 --- /dev/null +++ b/src/IOViewer/Viewer/src/ui/clashesPanel.js @@ -0,0 +1,104 @@ +import * as THREE from 'three'; +import { t } from '../i18n.js'; +import { resolveClashElements } from '../model/clashTargets.js'; +import { formatDistance } from '../tools/measure.js'; +import { icons } from './icons.js'; +import { el } from './dom.js'; +import { rowButton } from './viewsPanel.js'; + +const STATUSES = ['New', 'Active', 'Reviewed', 'Approved', 'Resolved']; +const MARKER_PX = 14; +const MIN_DISTANCE = 2; // m from the clash point +const FALLBACK_DIRECTION = new THREE.Vector3(1, -1, 0.8).normalize(); + +/** + * «Коллизии»: clashes grouped by test (collapsible), each with its status and distance. A click selects the two + * elements (resolveClashElements; none found clears the selection), flies the camera to the clash point from the + * current direction at 3× the larger element's size (at least 2 m), and marks the point until another clash is + * chosen or the panel closes. The marker is in `group`, an overlay outside ModelScene.root. + */ +export function mountClashes({ shell, tests, elements, scene, controls, selection }) { + const group = new THREE.Group(); + group.name = 'clash-marker'; + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(3), 3)); + const overlay = { color: '#ef4444', depthTest: false, depthWrite: false, transparent: true }; + const marker = new THREE.Points(geometry, new THREE.PointsMaterial({ ...overlay, size: MARKER_PX, sizeAttenuation: false })); + marker.frustumCulled = false; // the point moves; the bounds are never recomputed + marker.renderOrder = 1002; + group.add(marker); + group.visible = false; + let activeRow = null; + + function hideMarker() { + group.visible = false; + activeRow?.removeAttribute('aria-current'); + activeRow = null; + controls.setExtraBounds('clash', null); // also renders + } + + function show(clash, row) { + const found = resolveClashElements(clash, elements); + if (found.length) selection.set(found); + else selection.clear(); + + const point = new THREE.Vector3(...clash.point); + let size = 0; + for (const i of found) { + const box = scene.elementBox(i); + if (!box.isEmpty()) size = Math.max(size, ...box.getSize(new THREE.Vector3()).toArray()); + } + const direction = controls.camera.position.clone().sub(controls.target); + if (direction.lengthSq() < 1e-12) direction.copy(FALLBACK_DIRECTION); + const distance = Math.max(3 * size, MIN_DISTANCE); + controls.animateTo({ position: direction.normalize().multiplyScalar(distance).add(point), target: point }); + + geometry.attributes.position.setXYZ(0, point.x, point.y, point.z); + geometry.attributes.position.needsUpdate = true; + group.visible = true; + activeRow?.removeAttribute('aria-current'); + activeRow = row; + row.setAttribute('aria-current', 'true'); + controls.setExtraBounds('clash', new THREE.Box3(point.clone(), point.clone())); + } + + function clashRow(clash) { + const row = rowButton('ioviewer-row ioviewer-clash', String(clash.name ?? '')); + row.setAttribute('title', `${clash.name ?? ''}: ${clash.element1Name ?? ''} — ${clash.element2Name ?? ''}`); + const known = STATUSES.includes(clash.status); + const status = el('span', 'ioviewer-clash-status', known ? t(`clash.${clash.status}`) : String(clash.status ?? '')); + if (known) status.dataset.status = clash.status; + row.append(status, el('span', 'ioviewer-clash-distance', formatDistance(clash.distance))); + row.addEventListener('click', () => show(clash, row)); + return row; + } + + function testGroup(test) { + const box = el('div', 'ioviewer-clash-test'); + const header = el('button', 'ioviewer-row ioviewer-clash-header'); + header.type = 'button'; + header.setAttribute('aria-expanded', 'true'); + const title = `${test.name ?? ''} (${test.clashes.length})`; + header.setAttribute('title', title); + header.insertAdjacentHTML('afterbegin', icons.chevron); // static icon markup + header.append(el('span', 'ioviewer-row-name', title)); + const list = el('div', 'ioviewer-row-list'); + list.append(...test.clashes.map(clashRow)); + header.addEventListener('click', () => { + list.hidden = !list.hidden; + header.setAttribute('aria-expanded', String(!list.hidden)); + }); + box.append(header, list); + return box; + } + + shell.registerPanel('clashes', { + icon: icons.clashes, + title: t('panel.clashes'), + visible: tests.length > 0, + render: (body) => body.append(...tests.map(testGroup)), + onClose: () => group.visible && hideMarker(), + }); + + return { group }; +} diff --git a/src/IOViewer/Viewer/src/ui/contextMenu.js b/src/IOViewer/Viewer/src/ui/contextMenu.js new file mode 100644 index 0000000..8967c06 --- /dev/null +++ b/src/IOViewer/Viewer/src/ui/contextMenu.js @@ -0,0 +1,87 @@ +import { t } from '../i18n.js'; +import { el, swallowPress } from './dom.js'; + +// Menu layouts: item ids in order, '-' is a separator. Labels come from t('menu.'). +export const menus = { + element: ['properties', 'hide', 'isolate', 'showAll', '-', 'zoomTo', 'sectionBox', '-', 'copyId'], + empty: ['showAll', 'fitAll', 'resetSections'], +}; +const shortcuts = { hide: 'H', isolate: 'I', showAll: 'Shift+H', zoomTo: 'F' }; +const MARGIN = 4; // px kept free between the menu and the window edge + +/** + * Right-click menu. open(kind, x, y, target) shows menus[kind] at client (x, y), kept inside the window; + * an item calls its handler with `target` and closes the menu. setItem(id, { enabled, handler }) configures + * an item; items without a handler are shown disabled. Closes on wheel, resize, page scroll (not on scrolling inside + * the UI panels, which cannot move the menu) and on a press outside it; that press only dismisses the menu, + * so it never reaches the model or the panels (no pick, no selection change). + */ +export function createContextMenu(root) { + const menu = el('div', 'ioviewer-context-menu'); + menu.setAttribute('role', 'menu'); + menu.hidden = true; + root.append(menu); + const items = new Map(Object.values(menus).flat().filter((id) => id !== '-').map((id) => [id, { enabled: false, handler: null }])); + + function close() { + if (menu.hidden) return; + menu.hidden = true; + menu.replaceChildren(); + window.removeEventListener('pointerdown', outside, true); + window.removeEventListener('wheel', close, true); + window.removeEventListener('scroll', pageScroll, true); + window.removeEventListener('resize', close); + } + + function pageScroll(e) { + if (!root.contains(e.target)) close(); + } + + // The dismissing press only closes the menu. + function outside(e) { + if (menu.contains(e.target)) return; + close(); + swallowPress(e); + } + + function open(kind, x, y, target) { + const layout = menus[kind]; + if (!layout) throw new RangeError(`Unknown menu: ${kind}`); + close(); + for (const id of layout) { + if (id === '-') { + menu.append(el('div', 'ioviewer-menu-separator')); + continue; + } + const item = items.get(id); + const button = el('button', 'ioviewer-menu-item'); + button.type = 'button'; + button.dataset.item = id; + button.setAttribute('role', 'menuitem'); + button.disabled = !item.enabled; + button.append(el('span', '', t(`menu.${id}`)), el('kbd', '', shortcuts[id] ?? '')); + button.addEventListener('click', () => { + close(); + item.handler(target); + }); + menu.append(button); + } + menu.hidden = false; + const { width, height } = menu.getBoundingClientRect(); + menu.style.left = `${Math.max(MARGIN, Math.min(x, innerWidth - width - MARGIN))}px`; + menu.style.top = `${Math.max(MARGIN, Math.min(y, innerHeight - height - MARGIN))}px`; + window.addEventListener('pointerdown', outside, true); + window.addEventListener('wheel', close, true); + window.addEventListener('scroll', pageScroll, true); + window.addEventListener('resize', close); + } + + function setItem(id, { enabled = true, handler }) { + const item = items.get(id); + if (!item) throw new RangeError(`Unknown menu item: ${id}`); + if (handler) item.handler = handler; + item.enabled = enabled && !!item.handler; + } + + return { element: menu, open, close, setItem, get isOpen() { return !menu.hidden; } }; +} diff --git a/src/IOViewer/Viewer/src/ui/dom.js b/src/IOViewer/Viewer/src/ui/dom.js new file mode 100644 index 0000000..0d7b6e8 --- /dev/null +++ b/src/IOViewer/Viewer/src/ui/dom.js @@ -0,0 +1,49 @@ +// Element with an optional class and text; text always goes in via textContent. +export function el(tag, className, text) { + const node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined) node.textContent = text; + return node; +} + +// Icon-only button; `icon` is a static SVG string from icons.js, `label` is both tooltip and accessible name. +export function iconButton(className, icon, label) { + const button = el('button', className); + button.type = 'button'; + button.innerHTML = icon; + button.title = label; + button.setAttribute('aria-label', label); + return button; +} + +function swallow(e) { + e.preventDefault(); + e.stopPropagation(); +} + +// Swallows a whole press from its capturing pointerdown `e`: that pointerdown, its pointerup and the click that +// follows, so a press that dismisses a menu or popover never reaches the model or the panels. +export function swallowPress(e) { + swallow(e); + const release = () => { + window.removeEventListener('pointerup', swallowUp, true); + setTimeout(() => window.removeEventListener('click', swallow, true)); // the click, if any, comes in this task + }; + const swallowUp = (up) => { + swallow(up); + release(); + }; + window.addEventListener('pointerup', swallowUp, true); + window.addEventListener('click', swallow, true); +} + +// Puts a screen-space label (position: absolute in the full-window #ioviewer-ui) over the scene point `point`; +// hides it when the point is behind the camera or off the canvas (not for depth: near/far do not matter here). +export function placeLabel(label, point, camera, rect) { + const inFront = point.clone().applyMatrix4(camera.matrixWorldInverse).z < 0; + const p = point.clone().project(camera); + label.hidden = !(inFront && Math.abs(p.x) <= 1 && Math.abs(p.y) <= 1); + if (label.hidden) return; + label.style.left = `${rect.left + ((p.x + 1) / 2) * rect.width}px`; + label.style.top = `${rect.top + ((1 - p.y) / 2) * rect.height}px`; +} diff --git a/src/IOViewer/Viewer/src/ui/hotkeys.js b/src/IOViewer/Viewer/src/ui/hotkeys.js new file mode 100644 index 0000000..5c3ebe0 --- /dev/null +++ b/src/IOViewer/Viewer/src/ui/hotkeys.js @@ -0,0 +1,29 @@ +const FIELDS = 'input, textarea, select, [contenteditable]'; + +// Action for a keydown, or null. Keys are matched by physical position (KeyboardEvent.code), so H/I/F work in any +// layout, e.g. Russian. Ignored with Ctrl/Alt/Meta (browser shortcuts), on auto-repeat and while typing in a field. +export function hotkeyAction(e) { + if (e.ctrlKey || e.altKey || e.metaKey || e.repeat || e.target?.closest?.(FIELDS)) return null; + switch (e.code) { + case 'KeyH': + return e.shiftKey ? 'showAll' : 'hide'; + case 'KeyI': + return e.shiftKey ? null : 'isolate'; + case 'KeyF': + return e.shiftKey ? null : 'zoomTo'; + case 'Escape': + return 'escape'; + default: + return null; + } +} + +// Calls actions[hotkeyAction(e)] for keydowns on `target` (window). +export function installHotkeys(target, actions) { + target.addEventListener('keydown', (e) => { + const action = hotkeyAction(e); + if (!action) return; + e.preventDefault(); + actions[action](); + }); +} diff --git a/src/IOViewer/Viewer/src/ui/icons.js b/src/IOViewer/Viewer/src/ui/icons.js new file mode 100644 index 0000000..f02b14c --- /dev/null +++ b/src/IOViewer/Viewer/src/ui/icons.js @@ -0,0 +1,39 @@ +// Inline SVG icons (static markup, stroked with currentColor); keys match panel and tool ids. +const svg = (body) => + ``; + +const cube = ''; + +export const icons = { + // Panels + model: svg(''), + views: svg(''), + sections: svg(''), + clashes: svg(''), + timeliner: svg(''), + links: svg( + '', + ), + // Tools + orbit: svg(''), + pan: svg(''), + fit: svg(''), + projection: svg(cube), + plane: svg(''), + box: svg(`${cube}`), + resetSections: svg(''), + distance: svg(''), + displayMode: svg(''), + grids: svg(''), + opacity: svg(''), + fullscreen: svg(''), + // Controls + close: svg(''), + chevron: svg(''), + flip: svg(''), + remove: svg(''), + play: svg(''), + pause: svg(''), + home: svg(''), +}; diff --git a/src/IOViewer/Viewer/src/ui/linksPanel.js b/src/IOViewer/Viewer/src/ui/linksPanel.js new file mode 100644 index 0000000..0512f61 --- /dev/null +++ b/src/IOViewer/Viewer/src/ui/linksPanel.js @@ -0,0 +1,43 @@ +import { t } from '../i18n.js'; +import { icons } from './icons.js'; +import { el } from './dom.js'; + +/** + * «Связанные файлы»: a checkbox per linked file. A file's elements are those whose `link` equals the file's name or + * its path (exporters write either). Unchecking hides them in the file's own visibility layer ('link:') and checking + * clears that layer, so the user's Hide/Isolate/Show all and the 4D schedule (their own layers) are untouched and + * combine with it; the box shows only the file's own state. «Показать всё» does not re-check a file. A file listed as + * not visible starts unchecked. + */ +export function mountLinks({ shell, files, elements, scene }) { + const links = files.map((file, n) => ({ + file, + layer: `link:${n}`, + members: elements.flatMap((e, i) => (e.link != null && (e.link === file.name || e.link === file.path) ? [i] : [])), + })); + const setShown = (link, shown) => + shown ? scene.clearLayer(link.layer) : scene.setLayerHidden(link.layer, link.members, true); + + shell.registerPanel('links', { + icon: icons.links, + title: t('panel.links'), + visible: links.length > 0, + render: (body) => { + const list = el('div', 'ioviewer-row-list'); + for (const link of links) { + const name = String(link.file.name ?? ''); + const row = el('label', 'ioviewer-row ioviewer-check ioviewer-link'); + row.setAttribute('title', `${name} (${link.file.path ?? ''})`); + const input = el('input'); + input.type = 'checkbox'; + input.checked = link.members.length === 0 || link.file.visible !== false; + input.disabled = link.members.length === 0; + input.addEventListener('change', () => setShown(link, input.checked)); + if (!input.checked) setShown(link, false); + row.append(input, el('span', 'ioviewer-row-name', name), el('span', 'ioviewer-row-count', String(link.members.length))); + list.append(row); + } + body.append(list); + }, + }); +} diff --git a/src/IOViewer/Viewer/src/ui/popover.js b/src/IOViewer/Viewer/src/ui/popover.js new file mode 100644 index 0000000..055a751 --- /dev/null +++ b/src/IOViewer/Viewer/src/ui/popover.js @@ -0,0 +1,65 @@ +import { el, swallowPress } from './dom.js'; + +const GAP = 8; // px between the popover and its button +const MARGIN = 4; // px kept free between the popover and the window edge + +/** + * Small panel that opens above a toolbar button. Closes on Esc (close() from the hotkeys, or a keydown inside it) + * and on a press outside it; that press only dismisses the popover, like the context menu, so it never reaches the + * model, the panels or another tool (a press on its own button just closes it). + */ +export function createPopover(root, anchor, label, role = 'dialog') { + const element = el('div', 'ioviewer-popover ioviewer-floating'); + element.setAttribute('role', role); + element.setAttribute('aria-label', label); + element.hidden = true; + anchor.setAttribute('aria-haspopup', role); + anchor.setAttribute('aria-expanded', 'false'); + root.append(element); + + function outside(e) { + if (element.contains(e.target)) return; + close(); + swallowPress(e); + } + + // Esc with focus inside (the slider, a menu item) closes only the popover: it never reaches the global hotkeys, + // which would also clear the measurement or the selection. + element.addEventListener('keydown', (e) => { + if (e.key !== 'Escape') return; + e.preventDefault(); + e.stopPropagation(); + close(); + anchor.focus(); + }); + + function open() { + element.hidden = false; + anchor.setAttribute('aria-expanded', 'true'); + const a = anchor.getBoundingClientRect(); + const { width } = element.getBoundingClientRect(); + element.style.left = `${Math.max(MARGIN, Math.min(a.left + a.width / 2 - width / 2, innerWidth - width - MARGIN))}px`; + element.style.bottom = `${innerHeight - a.top + GAP}px`; + window.addEventListener('pointerdown', outside, true); + window.addEventListener('resize', close); + } + + // True when it was open. + function close() { + if (element.hidden) return false; + element.hidden = true; + anchor.setAttribute('aria-expanded', 'false'); + window.removeEventListener('pointerdown', outside, true); + window.removeEventListener('resize', close); + return true; + } + + return { + element, + close, + toggle: () => (element.hidden ? open() : close()), + get isOpen() { + return !element.hidden; + }, + }; +} diff --git a/src/IOViewer/Viewer/src/ui/properties.js b/src/IOViewer/Viewer/src/ui/properties.js new file mode 100644 index 0000000..1ad2465 --- /dev/null +++ b/src/IOViewer/Viewer/src/ui/properties.js @@ -0,0 +1,48 @@ +import { t } from '../i18n.js'; +import { el } from './dom.js'; + +function group(title, rows) { + const table = el('table'); + for (const [name, value] of rows) { + const row = table.insertRow(); + row.append(el('th', '', name), el('td', '', String(value ?? ''))); + } + const section = el('section'); + section.append(el('h3', '', title), table); + return section; +} + +/** + * Properties of the selection inside `container`: one element shows its fields and PROPS groups, several show + * only their count, none hides the panel. The close button clears the selection. Text goes in via textContent only. + * data: { meta: { elements }, props(elementId) } from loadModelData. + */ +export function mountProperties(container, selection, data) { + const title = el('h2', 'ioviewer-properties-title'); + const close = el('button', 'ioviewer-properties-close', '×'); + close.type = 'button'; + close.title = t('close'); + close.setAttribute('aria-label', t('close')); + close.addEventListener('click', () => selection.clear()); + const header = el('header'); + header.append(title, close); + const body = el('div', 'ioviewer-properties-body'); + container.replaceChildren(header, body); + container.hidden = true; + + selection.addEventListener('change', () => { + container.hidden = selection.size === 0; + body.replaceChildren(); + if (selection.size !== 1) { + title.textContent = `${t('selected')}: ${selection.size}`; + return; + } + const [index] = selection.values(); + const element = data.meta.elements[index]; + title.textContent = element.name || `#${element.id}`; + const fields = [[t('id'), element.id], [t('category'), element.category], [t('type'), element.type], [t('level'), element.level]]; + if (element.link) fields.push([t('link'), element.link]); + body.append(group(t('elementGroup'), fields)); + for (const [name, values] of Object.entries(data.props(element.id) ?? {})) body.append(group(name, Object.entries(values))); + }); +} diff --git a/src/IOViewer/Viewer/src/ui/sectionsPanel.js b/src/IOViewer/Viewer/src/ui/sectionsPanel.js new file mode 100644 index 0000000..d8e5a81 --- /dev/null +++ b/src/IOViewer/Viewer/src/ui/sectionsPanel.js @@ -0,0 +1,153 @@ +import { t } from '../i18n.js'; +import { MAX_PLANES } from '../tools/sections.js'; +import { icons } from './icons.js'; +import { el, iconButton } from './dom.js'; +import { showToast } from './statusBar.js'; + +// Row of toggle buttons; select(value) is called on click, update(value) marks the pressed one. +export function segmented(label, options, select) { + const group = el('div', 'ioviewer-segmented'); + group.setAttribute('role', 'group'); + group.setAttribute('aria-label', label); + const buttons = options.map(([value, text]) => { + const button = el('button', '', text); + button.type = 'button'; + button.dataset.value = value; + button.addEventListener('click', () => select(value)); + group.append(button); + return button; + }); + const update = (value) => buttons.forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.value === value))); + return { element: group, update }; +} + +function checkbox(text, change) { + const label = el('label', 'ioviewer-check'); + const input = el('input'); + input.type = 'checkbox'; + input.addEventListener('change', () => change(input.checked)); + label.append(input, el('span', '', text)); + return { element: label, input }; +} + +// One plane: name (axis, "rotated"), flip, on/off, delete, and a position slider across the model along its normal. +function planeRow(sections, i) { + const row = el('li', 'ioviewer-section-plane'); + row.addEventListener('pointerdown', () => sections.active !== i && sections.setActivePlane(i)); + const name = el('span', 'ioviewer-section-name'); + const flip = iconButton('ioviewer-icon-button', icons.flip, t('sections.flip')); + flip.addEventListener('click', () => sections.updatePlane(i, { flipped: !sections.planes[i].flipped })); + const enabled = checkbox(t('sections.enabled'), (on) => sections.updatePlane(i, { enabled: on })); + const remove = iconButton('ioviewer-icon-button', icons.remove, t('sections.delete')); + remove.addEventListener('click', () => sections.removePlane(i)); + const slider = el('input', 'ioviewer-section-slider'); + slider.type = 'range'; + slider.step = 'any'; + slider.setAttribute('aria-label', t('sections.position')); + slider.addEventListener('input', () => sections.updatePlane(i, { offset: Number(slider.value) })); + const head = el('div', 'ioviewer-section-head'); + head.append(name, flip, enabled.element, remove); + row.append(head, slider); + const update = () => { + const plane = sections.planes[i]; + const rotated = plane.rotated ? ` (${t('sections.rotated')})` : ''; + name.textContent = `${i + 1}. ${plane.axis.toUpperCase()}${rotated}`; + row.classList.toggle('active', sections.active === i); + flip.setAttribute('aria-pressed', String(plane.flipped)); + enabled.input.checked = plane.enabled; + const extent = sections.extent(i); + slider.min = String(-extent); + slider.max = String(extent); + slider.value = String(plane.offset); + }; + return { element: row, update }; +} + +/** + * The «Сечения» panel and the section tools: toolbar `plane` / `box` / `resetSections`, context menu + * «Бокс сечения по объекту» / «Сбросить сечения». Tools and panel stay off for a model with no geometry. + */ +export function mountSections({ root, shell, toolbar, contextMenu, sections, selection }) { + let axis = 'z'; // axis for new planes, chosen in the panel + const addPlane = () => { + if (sections.addPlane(axis) !== null) return; + sections.setMode('planes'); + showToast(root, t('sections.maxPlanes')); + }; + const selected = () => [...selection.values()]; + const fitSelection = () => sections.fitBoxToElements(selected()); + + const mode = segmented(t('sections.mode'), [['planes', t('sections.planes')], ['box', t('sections.box')], ['off', t('sections.off')]], + (value) => sections.setMode(value)); + const axes = segmented(t('sections.axis'), ['x', 'y', 'z'].map((a) => [a, a.toUpperCase()]), (value) => axes.update((axis = value))); + axes.update(axis); + const add = el('button', 'ioviewer-text-button', t('sections.addPlane')); + add.type = 'button'; + add.addEventListener('click', addPlane); + const list = el('ul', 'ioviewer-section-list'); + const empty = el('div', 'ioviewer-section-empty', t('sections.noPlanes')); + const planesBlock = el('div', 'ioviewer-section-block'); + const axisRow = el('div', 'ioviewer-section-row'); + axisRow.append(el('span', '', t('sections.axis')), axes.element, add); + planesBlock.append(axisRow, list, empty); + + const boxMode = segmented(t('sections.box'), [['keepInside', t('sections.keepInside')], ['cutInside', t('sections.cutInside')]], + (value) => sections.setBoxMode(value)); + const fit = el('button', 'ioviewer-text-button', t('sections.fitSelection')); + fit.type = 'button'; + fit.addEventListener('click', fitSelection); + const boxBlock = el('div', 'ioviewer-section-block'); + boxBlock.append(boxMode.element, fit); + const gizmo = checkbox(t('sections.showGizmo'), (on) => sections.setGizmoVisible(on)); + + let rows = []; + const update = () => { + mode.update(sections.mode); + planesBlock.hidden = sections.mode !== 'planes'; + boxBlock.hidden = sections.mode !== 'box'; + add.disabled = sections.planes.length >= MAX_PLANES; + if (rows.length !== sections.planes.length) { + rows = sections.planes.map((_, i) => planeRow(sections, i)); + list.replaceChildren(...rows.map((r) => r.element)); + } + rows.forEach((r) => r.update()); + empty.hidden = rows.length > 0; + boxMode.update(sections.boxMode); + gizmo.input.checked = sections.gizmoVisible; + toolbar.setActive('plane', sections.mode === 'planes'); + toolbar.setActive('box', sections.mode === 'box'); + }; + const updateFit = () => (fit.disabled = selection.size === 0); + + shell.registerPanel('sections', { + icon: icons.sections, + title: t('panel.sections'), + visible: !sections.modelBox.isEmpty(), + render: (body) => body.append(mode.element, planesBlock, boxBlock, gizmo.element), + }); + sections.addEventListener('change', update); + selection.addEventListener('change', updateFit); + update(); + updateFit(); + + toolbar.setActive('plane', false); + toolbar.setActive('box', false); + if (sections.modelBox.isEmpty()) return; + const open = () => shell.openPanel('sections'); + toolbar.setHandler('plane', () => { + addPlane(); + open(); + }); + toolbar.setHandler('box', () => { + if (!(selection.size && fitSelection()) && !sections.fitBoxToShown()) return; + open(); + }); + toolbar.setHandler('resetSections', () => sections.reset()); + contextMenu.setItem('sectionBox', { + handler: (i) => { + sections.fitBoxToElements(selection.has(i) ? selected() : [i]); + open(); + }, + }); + contextMenu.setItem('resetSections', { handler: () => sections.reset() }); +} diff --git a/src/IOViewer/Viewer/src/ui/shell.js b/src/IOViewer/Viewer/src/ui/shell.js new file mode 100644 index 0000000..d6c1de3 --- /dev/null +++ b/src/IOViewer/Viewer/src/ui/shell.js @@ -0,0 +1,76 @@ +import { t } from '../i18n.js'; +import { icons } from './icons.js'; +import { el, iconButton } from './dom.js'; + +// Rail order is fixed, so a panel registered later (sections, by its own task) still lands in its place. +const ORDER = ['model', 'views', 'sections', 'clashes', 'timeliner', 'links']; + +/** + * Left icon rail with floating panels; one panel is open at a time and its active rail icon closes it. + * registerPanel(name, { icon, title, render(body), visible = true }): `icon` is an SVG string from icons.js, + * `render` fills the panel body once at registration, `visible: false` leaves the rail icon out (no data), + * `onClose()` runs whenever the panel closes (its icon, its ×, or another panel opening). + */ +export function createShell(root) { + const rail = el('nav', 'ioviewer-rail ioviewer-floating'); + rail.setAttribute('aria-label', t('panels')); + root.append(rail); + const panels = new Map(); // name -> { button, panel, onClose } + let open = null; + + function get(name) { + const entry = panels.get(name); + if (!entry) throw new RangeError(`Unknown panel: ${name}`); + return entry; + } + + function closePanel(name) { + const { button, panel, onClose } = get(name); + if (open !== name) return; + panel.hidden = true; + button.classList.remove('active'); + button.setAttribute('aria-expanded', 'false'); + open = null; + onClose?.(); + } + + function openPanel(name) { + const { button, panel } = get(name); + if (button.hidden || open === name) return; + if (open) closePanel(open); + panel.hidden = false; + button.classList.add('active'); + button.setAttribute('aria-expanded', 'true'); + open = name; + } + + function registerPanel(name, { icon, title, render, visible = true, onClose }) { + const rank = ORDER.indexOf(name); + if (rank < 0) throw new RangeError(`Unknown panel: ${name}`); + if (panels.has(name)) throw new Error(`Panel ${name} is already registered.`); + + const button = iconButton('ioviewer-rail-button', icon, title); + button.dataset.panel = name; + button.hidden = !visible; + button.setAttribute('aria-expanded', 'false'); + button.addEventListener('click', () => (open === name ? closePanel(name) : openPanel(name))); + const next = [...rail.children].find((b) => ORDER.indexOf(b.dataset.panel) > rank); + rail.insertBefore(button, next ?? null); + + const panel = el('section', 'ioviewer-side-panel ioviewer-floating'); + panel.dataset.panel = name; + panel.hidden = true; + panel.setAttribute('aria-label', title); + const close = iconButton('ioviewer-panel-close', icons.close, t('close')); + close.addEventListener('click', () => closePanel(name)); + const header = el('header', 'ioviewer-panel-header'); + header.append(el('h2', '', title), close); + const body = el('div', 'ioviewer-panel-body'); + panel.append(header, body); + root.append(panel); + panels.set(name, { button, panel, onClose }); + render(body); + } + + return { rail, openPanel, closePanel, registerPanel }; +} diff --git a/src/IOViewer/Viewer/src/ui/statusBar.js b/src/IOViewer/Viewer/src/ui/statusBar.js new file mode 100644 index 0000000..f88d035 --- /dev/null +++ b/src/IOViewer/Viewer/src/ui/statusBar.js @@ -0,0 +1,32 @@ +import { t } from '../i18n.js'; +import { icons } from './icons.js'; +import { el, iconButton } from './dom.js'; + +const TOAST_MS = 1600; + +// «Выбрано: N · Элементов: M» in `status`, kept in step with the selection. +export function mountStatusBar(status, selection, elementCount) { + const update = () => (status.textContent = `${t('selected')}: ${selection.size} · ${t('elements')}: ${elementCount}`); + selection.addEventListener('change', update); + update(); +} + +// Dismissible banner for meta.truncated = { meshes, totalMeshes }; nothing when the model is complete. +export function mountTruncationBanner(root, truncated) { + if (!truncated) return; + const banner = el('div', 'ioviewer-banner ioviewer-floating'); + banner.setAttribute('role', 'status'); + const text = t('truncated').replace('{meshes}', truncated.meshes).replace('{total}', truncated.totalMeshes); + const close = iconButton('ioviewer-panel-close', icons.close, t('close')); + close.addEventListener('click', () => banner.remove()); + banner.append(el('span', '', text), close); + root.append(banner); +} + +// Short message above the toolbar that fades by itself. +export function showToast(root, text) { + const toast = el('div', 'ioviewer-toast', text); + toast.setAttribute('role', 'status'); + root.append(toast); + setTimeout(() => toast.remove(), TOAST_MS); +} diff --git a/src/IOViewer/Viewer/src/ui/timeliner.js b/src/IOViewer/Viewer/src/ui/timeliner.js new file mode 100644 index 0000000..b06ba68 --- /dev/null +++ b/src/IOViewer/Viewer/src/ui/timeliner.js @@ -0,0 +1,171 @@ +import { t } from '../i18n.js'; +import { prepareSchedule, scheduleState } from '../model/schedule.js'; +import { icons } from './icons.js'; +import { el, iconButton } from './dom.js'; +import { segmented } from './sectionsPanel.js'; + +const DAY_MS = 86400000; +const SPEEDS = ['1', '5', '15']; // schedule days per second +const COLORS = { construct: '#22c55e', demolish: '#ef4444', temporary: '#a855f7' }; +const PLAYER_SPACE = '156px'; // --toolbar-space while the player bar is open: panels end above it + +/** + * «4D-график»: the task list and the 4D player, a bar above the toolbar with play/pause, a date slider across the + * schedule, speed 1×/5×/15× (1× = one schedule day per second) and the current date. At a date the schedule + * (scheduleState) hides and tints the elements attached to tasks; other elements are left alone. Malformed tasks are + * left out (prepareSchedule). + * + * The schedule hides elements only in the scene's 'schedule' visibility layer, so the user's Hide/Isolate/Show all and + * the links (their own layers) stay as they are and combine with it; closing the player clears that layer and its + * tints. Dates are shown in UTC: exporters write the schedule's wall-clock times with a 'Z'. Returns the player, or + * null without a usable 4D schedule. + */ +export function mountTimeliner({ root, shell, timeliner, elements, scene }) { + const schedule = prepareSchedule(timeliner, elements); + if (schedule.tasks.length === 0) { + shell.registerPanel('timeliner', { icon: icons.timeliner, title: t('panel.timeliner'), visible: false, render: () => {} }); + return null; + } + const dayFormat = new Intl.DateTimeFormat(undefined, { dateStyle: 'short', timeZone: 'UTC' }); + const dateFormat = new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeZone: 'UTC' }); + let date = schedule.start; + let speed = 1; + let frame = 0; // requestAnimationFrame id while playing + let last = 0; + let isOpen = false; + let hidden = new Set(); // elements in the 'schedule' layer + let tinted = new Map(); // element -> task type + + const bar = el('div', 'ioviewer-player ioviewer-floating'); + bar.setAttribute('role', 'group'); + bar.setAttribute('aria-label', t('player.title')); + bar.hidden = true; + const playButton = iconButton('ioviewer-icon-button ioviewer-player-play', icons.play, t('player.play')); + playButton.addEventListener('click', () => (frame ? pause() : play())); + const label = el('output', 'ioviewer-player-date'); + const slider = el('input', 'ioviewer-player-slider'); + Object.assign(slider, { type: 'range', min: String(schedule.start), max: String(schedule.end), step: 'any' }); + slider.setAttribute('aria-label', t('player.date')); + slider.addEventListener('input', () => setDate(Number(slider.value))); + const speeds = segmented(t('player.speed'), SPEEDS.map((s) => [s, `${s}×`]), (s) => { + speed = Number(s); + speeds.update(s); + }); + speeds.update(String(speed)); + const close = iconButton('ioviewer-panel-close', icons.close, t('close')); + close.addEventListener('click', closePlayer); + bar.append(playButton, label, slider, speeds.element, close); + root.append(bar); + + function apply() { + const state = scheduleState(schedule.tasks, date); + const show = [...hidden].filter((i) => !state.hidden.has(i)); + const hide = [...state.hidden].filter((i) => !hidden.has(i)); + hidden = state.hidden; + const clear = [...tinted.keys()].filter((i) => state.active.get(i) !== tinted.get(i)); + const paint = new Map(); // type -> elements to tint with it + for (const [i, type] of state.active) { + if (tinted.get(i) === type) continue; + if (!paint.has(type)) paint.set(type, []); + paint.get(type).push(i); + } + tinted = state.active; + if (show.length) scene.setLayerHidden('schedule', show, false); + if (hide.length) scene.setLayerHidden('schedule', hide, true); + if (clear.length) scene.setTint(clear, null); + for (const [type, list] of paint) scene.setTint(list, COLORS[type]); + } + + function setDate(value) { + date = Math.min(Math.max(value, schedule.start), schedule.end); + slider.value = String(date); + label.textContent = dateFormat.format(date); + if (isOpen) apply(); + } + + function open() { + if (isOpen) return; + isOpen = true; + bar.hidden = false; + root.style.setProperty('--toolbar-space', PLAYER_SPACE); + setDate(schedule.start); + } + + function closePlayer() { + if (!isOpen) return; + pause(); + isOpen = false; + scene.setTint([...tinted.keys()], null); + tinted = new Map(); + hidden = new Set(); + scene.clearLayer('schedule'); + bar.hidden = true; + root.style.removeProperty('--toolbar-space'); + } + + function tick(now) { + setDate(date + (Math.max(now - last, 0) / 1000) * speed * DAY_MS); + last = now; + if (date >= schedule.end) pause(); + else frame = requestAnimationFrame(tick); + } + + function play() { + open(); + if (frame) return; + if (date >= schedule.end) setDate(schedule.start); + last = performance.now(); + frame = requestAnimationFrame(tick); + showPlaying(); + } + + function pause() { + cancelAnimationFrame(frame); + frame = 0; + showPlaying(); + } + + function showPlaying() { + const text = t(frame ? 'player.pause' : 'player.play'); + playButton.innerHTML = frame ? icons.pause : icons.play; // static icon markup + playButton.title = text; + playButton.setAttribute('aria-label', text); + } + + shell.registerPanel('timeliner', { + icon: icons.timeliner, + title: t('panel.timeliner'), + render: (body) => { + const start = el('button', 'ioviewer-text-button ioviewer-player-open'); + start.type = 'button'; + start.insertAdjacentHTML('afterbegin', icons.play); // static icon markup + start.append(el('span', '', t('player.play'))); + start.addEventListener('click', play); + const list = el('div', 'ioviewer-row-list'); + for (const task of schedule.tasks) { + const name = String(task.name ?? ''); + const row = el('div', 'ioviewer-row ioviewer-task'); + row.dataset.type = task.type; + row.setAttribute('title', `${name} · ${t(`task.${task.type}`)}`); + const dates = `${dayFormat.format(task.start)} – ${dayFormat.format(task.end)}`; + row.append(el('span', 'ioviewer-row-name', name), el('span', 'ioviewer-task-dates', dates)); + list.append(row); + } + body.append(start, list); + }, + }); + + return { + open, + close: closePlayer, + play, + pause, + setDate, + get date() { + return date; + }, + get isOpen() { + return isOpen; + }, + }; +} diff --git a/src/IOViewer/Viewer/src/ui/toolbar.js b/src/IOViewer/Viewer/src/ui/toolbar.js new file mode 100644 index 0000000..430ffda --- /dev/null +++ b/src/IOViewer/Viewer/src/ui/toolbar.js @@ -0,0 +1,68 @@ +import { t } from '../i18n.js'; +import { icons } from './icons.js'; +import { el, iconButton } from './dom.js'; + +// Groups and their order; labels come from t('group.'), tooltips from t('tool.'), icons from icons[]. +export const toolbarGroups = [ + { id: 'navigation', items: ['orbit', 'pan', 'fit', 'projection'] }, + { id: 'sections', items: ['plane', 'box', 'resetSections'] }, + { id: 'measure', items: ['distance'] }, + { id: 'display', items: ['displayMode', 'grids', 'opacity'] }, + { id: 'view', items: ['fullscreen'] }, +]; + +/** + * Bottom toolbar of labelled groups. Every button starts disabled; setHandler(id, fn) enables it, + * setEnabled(id, bool) toggles it, setActive(id, bool) paints it as the active tool. + * Disabled buttons use aria-disabled so their tooltip still shows. + */ +export function createToolbar(root, groups) { + const bar = el('div', 'ioviewer-toolbar ioviewer-floating'); + bar.setAttribute('role', 'toolbar'); + const buttons = new Map(); // id -> { button, handler } + + for (const group of groups) { + const box = el('div', 'ioviewer-toolbar-group'); + box.setAttribute('role', 'group'); + box.setAttribute('aria-label', t(`group.${group.id}`)); + const row = el('div', 'ioviewer-toolbar-row'); + for (const id of group.items) { + const button = iconButton('ioviewer-tool', icons[id], t(`tool.${id}`)); + button.dataset.tool = id; + button.setAttribute('aria-disabled', 'true'); + const entry = { button, handler: null }; + button.addEventListener('click', () => { + if (button.getAttribute('aria-disabled') !== 'true') entry.handler?.(); + }); + buttons.set(id, entry); + row.append(button); + } + box.append(row, el('div', 'ioviewer-toolbar-label', t(`group.${group.id}`))); + bar.append(box); + } + root.append(bar); + + function get(id) { + const entry = buttons.get(id); + if (!entry) throw new RangeError(`Unknown tool: ${id}`); + return entry; + } + + function setEnabled(id, enabled) { + get(id).button.setAttribute('aria-disabled', String(!enabled)); + } + + return { + element: bar, + setHandler(id, handler) { + get(id).handler = handler; + setEnabled(id, true); + }, + setEnabled, + setActive(id, active) { + const { button } = get(id); + button.classList.toggle('active', active); + button.setAttribute('aria-pressed', String(active)); + }, + }; +} diff --git a/src/IOViewer/Viewer/src/ui/tree.js b/src/IOViewer/Viewer/src/ui/tree.js new file mode 100644 index 0000000..c5f98a9 --- /dev/null +++ b/src/IOViewer/Viewer/src/ui/tree.js @@ -0,0 +1,173 @@ +import { t } from '../i18n.js'; +import { icons } from './icons.js'; +import { el } from './dom.js'; + +export const ROW_HEIGHT = 24; // px, every row; the list is virtualized on this fixed height +const OVERSCAN = 8; + +// Rows [start, end) to draw for a list of `total` rows scrolled to `scrollTop`, with `overscan` extra rows each way. +export function visibleRange(scrollTop, rowHeight, viewportHeight, total, overscan) { + const start = Math.min(total, Math.max(0, Math.floor(scrollTop / rowHeight) - overscan)); + const end = Math.min(total, Math.ceil((scrollTop + viewportHeight) / rowHeight) + overscan); + return { start, end: Math.max(start, end) }; +} + +// search(query) -> indices of elements whose name, type or Id contains the query, case-insensitively. +export function createSearch(elements) { + let texts = null; // lower-cased once, on the first search + return (query) => { + const q = query.trim().toLowerCase(); + texts ??= elements.map((e) => `${e.name ?? ''}\n${e.type ?? ''}\n${e.id}`.toLowerCase()); + const found = []; + for (let i = 0; i < texts.length; i++) if (texts[i].includes(q)) found.push(i); + return found; + }; +} + +// [{ name, indices }] by category name (sorted); elements keep their order inside a category. +export function groupByCategory(elements) { + const byName = new Map(); + elements.forEach((element, i) => { + const name = element.category ?? ''; + if (!byName.has(name)) byName.set(name, []); + byName.get(name).push(i); + }); + return [...byName].map(([name, indices]) => ({ name, indices })).sort((a, b) => a.name.localeCompare(b.name)); +} + +/** + * Model tree in `container`: collapsed categories with counts, then elements (name, muted type); a search flattens + * matches. Only rows in view exist in the DOM. A click on an element calls activate(index); selecting elsewhere + * scrolls to and highlights the last selected row, expanding its category. Hidden elements are dimmed. + */ +export function mountTree(container, { elements, selection, scene, activate }) { + const groups = groupByCategory(elements); + const categoryOf = new Int32Array(elements.length); + groups.forEach((group, c) => group.indices.forEach((i) => (categoryOf[i] = c))); + const expanded = new Uint8Array(groups.length); + const search = createSearch(elements); + let query = ''; + let rows = new Int32Array(0); // element index, or -(category + 1) for a category header + let pendingRow = -1; // row to scroll to once the tree has a size (it is laid out only while its panel is open) + + const input = el('input', 'ioviewer-tree-search'); + input.type = 'search'; + input.placeholder = t('searchPlaceholder'); + input.setAttribute('aria-label', t('searchPlaceholder')); + const viewport = el('div', 'ioviewer-tree'); + viewport.setAttribute('role', 'tree'); + viewport.setAttribute('aria-label', t('panel.model')); + const spacer = el('div', 'ioviewer-tree-spacer'); + viewport.append(spacer); + const empty = el('div', 'ioviewer-tree-empty', t('noMatches')); + container.append(input, viewport, empty); + + function rebuild() { + if (query) { + rows = Int32Array.from(search(query)); + } else { + let count = groups.length; + groups.forEach((group, c) => expanded[c] && (count += group.indices.length)); + rows = new Int32Array(count); + let r = 0; + groups.forEach((group, c) => { + rows[r++] = -(c + 1); + if (expanded[c]) for (const i of group.indices) rows[r++] = i; + }); + } + spacer.style.height = `${rows.length * ROW_HEIGHT}px`; + empty.hidden = rows.length > 0 || !query; + render(); + } + + function render() { + const { start, end } = visibleRange(viewport.scrollTop, ROW_HEIGHT, viewport.clientHeight, rows.length, OVERSCAN); + const nodes = []; + for (let r = start; r < end; r++) nodes.push(row(r)); + spacer.replaceChildren(...nodes); + } + + function row(r) { + const value = rows[r]; + const node = el('div', 'tree-row'); + node.style.top = `${r * ROW_HEIGHT}px`; + node.style.height = `${ROW_HEIGHT}px`; + node.dataset.row = r; + node.setAttribute('role', 'treeitem'); + if (value < 0) { + const c = -value - 1; + node.classList.add('tree-category'); + node.setAttribute('aria-expanded', String(!!expanded[c])); + const chevron = el('span', 'tree-chevron'); + chevron.innerHTML = icons.chevron; + const { name, indices } = groups[c]; + node.append(chevron, el('span', 'tree-name', name || t('noCategory')), el('span', 'tree-count', String(indices.length))); + } else { + const element = elements[value]; + const selected = selection.has(value); + node.classList.add('tree-element'); + node.classList.toggle('tree-child', !query); + node.classList.toggle('selected', selected); + node.classList.toggle('tree-hidden', !scene.isVisible(value)); + node.setAttribute('aria-selected', String(selected)); + node.append(el('span', 'tree-name', element.name || `#${element.id}`), el('span', 'tree-type', element.type ?? '')); + } + return node; + } + + function scrollToRow(r) { + const height = viewport.clientHeight; + if (height === 0) { + pendingRow = r; + return; + } + const top = r * ROW_HEIGHT; + if (top < viewport.scrollTop) viewport.scrollTop = top; + else if (top + ROW_HEIGHT > viewport.scrollTop + height) viewport.scrollTop = top + ROW_HEIGHT - height; + } + + function reveal(index) { + const c = categoryOf[index]; + if (!query && !expanded[c]) { + expanded[c] = 1; + rebuild(); + } + const r = rows.indexOf(index); + if (r >= 0) scrollToRow(r); + render(); + } + + input.addEventListener('input', () => { + query = input.value.trim(); + viewport.scrollTop = 0; + rebuild(); + }); + viewport.addEventListener('scroll', render); + viewport.addEventListener('click', (e) => { + const node = e.target.closest('.tree-row'); + if (!node) return; + const value = rows[Number(node.dataset.row)]; + if (value >= 0) { + activate(value); + return; + } + expanded[-value - 1] ^= 1; + rebuild(); + }); + new ResizeObserver(() => { + if (pendingRow >= 0 && viewport.clientHeight > 0) { + scrollToRow(pendingRow); + pendingRow = -1; + } + render(); + }).observe(viewport); + selection.addEventListener('change', () => { + let last = -1; + for (const i of selection.values()) last = i; + if (last >= 0) reveal(last); + else render(); + }); + scene.root.addEventListener('change', (e) => e.detail.kind === 'visibility' && render()); + + rebuild(); +} diff --git a/src/IOViewer/Viewer/src/ui/viewsPanel.js b/src/IOViewer/Viewer/src/ui/viewsPanel.js new file mode 100644 index 0000000..a5ad7bd --- /dev/null +++ b/src/IOViewer/Viewer/src/ui/viewsPanel.js @@ -0,0 +1,97 @@ +import * as THREE from 'three'; +import { t } from '../i18n.js'; +import { icons } from './icons.js'; +import { el } from './dom.js'; + +const TOUR_PAUSE_MS = 3000; + +// Row button showing `text` on one line (ellipsis); the full text is its tooltip. +export function rowButton(className, text) { + const button = el('button', className); + button.type = 'button'; + button.setAttribute('title', text); + button.append(el('span', 'ioviewer-row-name', text)); + return button; +} + +/** + * «Виды»: the saved viewpoints. A click flies the camera there (its stored `up` is ignored: up stays +Z; the + * projection becomes orthographic only for a viewpoint marked so). «Тур по видам» flies through all of them in turn, + * pausing 3 s at each, round and round, until stopped by its button or by any press, wheel or key. + */ +export function mountViews({ shell, toolbar, controls, views }) { + let tour = null; // { next, frame, timer } + const tourButton = el('button', 'ioviewer-text-button ioviewer-tour'); + tourButton.type = 'button'; + + function go(view) { + const orthographic = view.camera.projection === 'orthographic'; + controls.setProjection(orthographic ? 'orthographic' : 'perspective'); + toolbar.setActive('projection', orthographic); + controls.animateTo({ + position: new THREE.Vector3(...view.camera.position), + target: new THREE.Vector3(...view.camera.target), + }); + } + + // Next stop once the flight there has ended and the pause is over. + function step() { + go(views[tour.next]); + tour.next = (tour.next + 1) % views.length; + const wait = () => { + if (controls.animating) tour.frame = requestAnimationFrame(wait); + else tour.timer = setTimeout(step, TOUR_PAUSE_MS); + }; + tour.frame = requestAnimationFrame(wait); + } + + // A press on the tour button, or Enter/Space on it, is left to its click, which toggles the tour. + const onButton = (e) => tourButton.contains(e.target); + const stopOnPress = (e) => !onButton(e) && stopTour(); + const stopOnKey = (e) => !(onButton(e) && (e.key === 'Enter' || e.key === ' ')) && stopTour(); + + function stopTour() { + if (!tour) return; + cancelAnimationFrame(tour.frame); + clearTimeout(tour.timer); + tour = null; + window.removeEventListener('pointerdown', stopOnPress, true); + window.removeEventListener('wheel', stopTour, true); + window.removeEventListener('keydown', stopOnKey, true); + showTourState(); + } + + function startTour() { + tour = { next: 0, frame: 0, timer: 0 }; + window.addEventListener('pointerdown', stopOnPress, true); + window.addEventListener('wheel', stopTour, true); + window.addEventListener('keydown', stopOnKey, true); + showTourState(); + step(); + } + + function showTourState() { + const label = t(tour ? 'views.stop' : 'views.tour'); + tourButton.replaceChildren(); + tourButton.insertAdjacentHTML('afterbegin', tour ? icons.pause : icons.play); // static icon markup + tourButton.append(el('span', '', label)); + tourButton.setAttribute('aria-pressed', String(!!tour)); + } + tourButton.addEventListener('click', () => (tour ? stopTour() : startTour())); + showTourState(); + + shell.registerPanel('views', { + icon: icons.views, + title: t('panel.views'), + visible: views.length > 0, + render: (body) => { + const list = el('div', 'ioviewer-row-list'); + for (const view of views) { + const row = rowButton('ioviewer-row ioviewer-view', String(view.name ?? '')); + row.addEventListener('click', () => go(view)); + list.append(row); + } + body.append(tourButton, list); + }, + }); +} diff --git a/src/IOViewer/Viewer/styles/viewer.css b/src/IOViewer/Viewer/styles/viewer.css new file mode 100644 index 0000000..d606e00 --- /dev/null +++ b/src/IOViewer/Viewer/styles/viewer.css @@ -0,0 +1,901 @@ +:root { + color-scheme: dark; + font: 13px/1.4 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + --panel: rgba(28, 31, 37, 0.95); + --border: #323843; + --button: #2a2f38; + --button-hover: #353b46; + --accent: #2563eb; + --accent-text: #93c5fd; + --text: #cbd5e1; + --text-dim: #a8b3c3; + --muted: #6b7686; + /* Space kept clear above the bottom edge for the toolbar, so panels never cover it. */ + --toolbar-space: 104px; +} + +html, +body { + margin: 0; + height: 100%; + overflow: hidden; + background: #1f2228; + color: var(--text); +} + +#ioviewer-canvas { + display: block; + width: 100%; + height: 100%; + touch-action: none; +} + +/* Overlay for UI; children opt back into pointer events. */ +#ioviewer-ui { + position: fixed; + inset: 0; + pointer-events: none; +} + +[hidden] { + display: none !important; +} + +button { + font: inherit; + color: inherit; +} + +button:focus-visible, +input:focus-visible { + outline: 2px solid var(--accent-text); + outline-offset: 1px; +} + +.ioviewer-floating { + position: absolute; + box-sizing: border-box; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 8px; + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.45); + pointer-events: auto; +} + +#ioviewer-message { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + color: #94a3b8; +} + +#ioviewer-status { + position: absolute; + right: 14px; + bottom: 10px; + font-size: 11px; + color: #94a3b8; +} + +/* Rail of panel icons and the floating panels it opens */ +.ioviewer-rail { + left: 12px; + top: 12px; + display: flex; + flex-direction: column; + gap: 4px; + padding: 5px; +} + +.ioviewer-rail-button, +.ioviewer-tool { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 0; + border: 0; + border-radius: 6px; + background: var(--button); + cursor: pointer; +} + +.ioviewer-rail-button:hover, +.ioviewer-tool:hover { + background: var(--button-hover); +} + +.ioviewer-rail-button.active, +.ioviewer-tool.active { + background: var(--accent); + color: #fff; +} + +.ioviewer-tool[aria-disabled="true"] { + opacity: 0.35; + cursor: default; +} + +.ioviewer-tool[aria-disabled="true"]:hover { + background: var(--button); +} + +.ioviewer-side-panel { + left: 62px; + top: 12px; + display: flex; + flex-direction: column; + width: 300px; + max-width: calc(100% - 74px); + max-height: calc(100% - 12px - var(--toolbar-space)); + padding: 8px; +} + +.ioviewer-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 6px; +} + +.ioviewer-panel-header h2 { + margin: 0; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.5px; + text-transform: uppercase; + color: var(--accent-text); +} + +.ioviewer-panel-close { + display: flex; + padding: 2px; + border: 0; + border-radius: 4px; + background: none; + color: #94a3b8; + cursor: pointer; +} + +.ioviewer-panel-close svg { + width: 14px; + height: 14px; +} + +.ioviewer-panel-close:hover { + background: var(--button-hover); + color: #fff; +} + +.ioviewer-panel-body { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; + overflow: auto; +} + +/* Model tree */ +.ioviewer-tree-search { + box-sizing: border-box; + width: 100%; + margin-bottom: 6px; + padding: 5px 7px; + border: 1px solid var(--border); + border-radius: 4px; + background: #1b1e24; + color: var(--text); + font: inherit; +} + +.ioviewer-tree { + position: relative; + flex: 1 1 auto; + min-height: 0; + overflow: auto; +} + +.ioviewer-tree-spacer { + position: relative; +} + +.ioviewer-tree-empty { + padding: 6px 4px; + color: var(--muted); +} + +.tree-row { + position: absolute; + left: 0; + right: 0; + box-sizing: border-box; + display: flex; + align-items: center; + gap: 6px; + padding: 0 6px; + border-radius: 3px; + color: var(--text-dim); + white-space: nowrap; + cursor: pointer; +} + +.tree-row:hover { + background: #262b33; +} + +.tree-child { + padding-left: 26px; +} + +.tree-chevron { + display: flex; + color: var(--muted); +} + +.tree-chevron svg { + width: 14px; + height: 14px; +} + +.tree-category[aria-expanded="true"] .tree-chevron { + transform: rotate(90deg); +} + +.tree-name { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.tree-type, +.tree-count { + flex: 0 1 auto; + max-width: 45%; + overflow: hidden; + text-overflow: ellipsis; + font-size: 11px; + color: #64748b; +} + +.tree-element.selected { + background: #1e3a8a; + color: #bfdbfe; +} + +.tree-hidden { + opacity: 0.45; +} + +/* View cube, top right: a 96 px cube with an arrow on each side and "home" in the corner. Only the cube canvas + and the buttons take pointer events. */ +.ioviewer-viewcube { + position: absolute; + top: 12px; + right: 12px; + width: 132px; + height: 132px; + pointer-events: none; +} + +.ioviewer-viewcube-canvas { + position: absolute; + left: 18px; + top: 18px; + pointer-events: auto; + cursor: pointer; +} + +.ioviewer-viewcube-arrow, +.ioviewer-viewcube-home { + position: absolute; + display: flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + padding: 0; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--panel); + color: var(--text-dim); + pointer-events: auto; + cursor: pointer; +} + +.ioviewer-viewcube-arrow:hover, +.ioviewer-viewcube-home:hover { + background: var(--button-hover); + color: #fff; +} + +.ioviewer-viewcube-arrow svg, +.ioviewer-viewcube-home svg { + width: 14px; + height: 14px; +} + +.ioviewer-viewcube-arrow[data-arrow="up"] { + left: 57px; + top: 0; +} + +.ioviewer-viewcube-arrow[data-arrow="up"] svg { + transform: rotate(-90deg); +} + +.ioviewer-viewcube-arrow[data-arrow="down"] { + left: 57px; + bottom: 0; +} + +.ioviewer-viewcube-arrow[data-arrow="down"] svg { + transform: rotate(90deg); +} + +.ioviewer-viewcube-arrow[data-arrow="left"] { + left: 0; + top: 57px; +} + +.ioviewer-viewcube-arrow[data-arrow="left"] svg { + transform: rotate(180deg); +} + +.ioviewer-viewcube-arrow[data-arrow="right"] { + right: 0; + top: 57px; +} + +.ioviewer-viewcube-home { + left: 0; + top: 0; +} + +/* Properties of the selection, right, below the view cube. */ +#ioviewer-properties { + right: 12px; + top: 152px; + width: 300px; + max-width: calc(100% - 24px); + max-height: calc(100% - 152px - var(--toolbar-space)); + overflow: auto; + padding: 8px 12px 12px; +} + +#ioviewer-properties header { + display: flex; + align-items: flex-start; + gap: 8px; +} + +.ioviewer-properties-title { + flex: 1; + margin: 2px 0; + font-size: 14px; + color: #e2e8f0; + overflow-wrap: anywhere; +} + +.ioviewer-properties-close { + padding: 2px 4px; + border: 0; + border-radius: 4px; + background: none; + font-size: 18px; + line-height: 1; + color: #94a3b8; + cursor: pointer; +} + +.ioviewer-properties-close:hover { + background: var(--button-hover); + color: #fff; +} + +#ioviewer-properties h3 { + margin: 10px 0 4px; + font-size: 11px; + letter-spacing: 0.5px; + text-transform: uppercase; + color: var(--accent-text); +} + +#ioviewer-properties table { + width: 100%; + border-collapse: collapse; +} + +#ioviewer-properties th, +#ioviewer-properties td { + padding: 2px 0; + border-bottom: 1px solid #262b33; + text-align: left; + vertical-align: top; + overflow-wrap: anywhere; +} + +#ioviewer-properties th { + width: 40%; + padding-right: 8px; + font-weight: normal; + color: #7c8797; +} + +/* Bottom toolbar: groups of tools with a label under each */ +.ioviewer-toolbar { + left: 50%; + bottom: 16px; + transform: translateX(-50%); + display: flex; + align-items: flex-end; + padding: 6px 0; +} + +.ioviewer-toolbar-group { + display: flex; + flex-direction: column; + align-items: center; + padding: 0 8px; + border-right: 1px solid #39404c; +} + +.ioviewer-toolbar-group:last-child { + border-right: 0; +} + +.ioviewer-toolbar-row { + display: flex; + gap: 3px; +} + +.ioviewer-toolbar-label { + margin-top: 4px; + font-size: 9px; + letter-spacing: 0.4px; + text-transform: uppercase; + color: var(--muted); + white-space: nowrap; +} + +/* Context menu */ +.ioviewer-context-menu { + position: fixed; + z-index: 10; + min-width: 220px; + padding: 4px 0; + background: #262a31; + border: 1px solid #3a404b; + border-radius: 6px; + box-shadow: 0 8px 22px rgba(0, 0, 0, 0.55); + pointer-events: auto; +} + +.ioviewer-menu-item { + display: flex; + justify-content: space-between; + gap: 16px; + width: 100%; + padding: 5px 12px; + border: 0; + background: none; + text-align: left; + cursor: pointer; +} + +.ioviewer-menu-item:hover:not(:disabled) { + background: var(--accent); + color: #fff; +} + +.ioviewer-menu-item:disabled { + color: #5b6575; + cursor: default; +} + +.ioviewer-menu-item kbd { + font: inherit; + font-size: 11px; + color: #64748b; +} + +.ioviewer-menu-item:hover:not(:disabled) kbd { + color: #dbeafe; +} + +.ioviewer-menu-separator { + margin: 4px 0; + border-top: 1px solid #3a404b; +} + +/* Truncation banner, toast, off-screen copy buffer */ +.ioviewer-banner { + left: 50%; + top: 12px; + transform: translateX(-50%); + display: flex; + align-items: center; + gap: 8px; + max-width: calc(100% - 260px); + padding: 6px 8px 6px 12px; + border-color: #a16207; + color: #fde68a; +} + +.ioviewer-toast { + position: absolute; + left: 50%; + bottom: var(--toolbar-space); + transform: translateX(-50%); + padding: 6px 12px; + border-radius: 6px; + background: #262a31; + border: 1px solid #3a404b; + color: #e2e8f0; +} + +.ioviewer-offscreen { + position: fixed; + top: 0; + left: -9999px; + opacity: 0; +} + +/* Sections panel */ +.ioviewer-segmented { + display: flex; + gap: 2px; +} + +.ioviewer-segmented button, +.ioviewer-text-button { + flex: 1 1 auto; + padding: 4px 8px; + border: 0; + border-radius: 4px; + background: var(--button); + cursor: pointer; +} + +.ioviewer-segmented button:hover, +.ioviewer-text-button:hover:not(:disabled) { + background: var(--button-hover); +} + +.ioviewer-segmented button[aria-pressed="true"] { + background: var(--accent); + color: #fff; +} + +.ioviewer-text-button:disabled { + opacity: 0.4; + cursor: default; +} + +.ioviewer-section-block { + display: flex; + flex-direction: column; + gap: 6px; + margin-top: 8px; +} + +.ioviewer-section-row { + display: flex; + align-items: center; + gap: 6px; +} + +.ioviewer-section-list { + margin: 0; + padding: 0; + list-style: none; +} + +.ioviewer-section-plane { + padding: 4px 6px; + border: 1px solid transparent; + border-radius: 4px; +} + +.ioviewer-section-plane.active { + border-color: var(--accent); +} + +.ioviewer-section-head { + display: flex; + align-items: center; + gap: 6px; +} + +.ioviewer-section-name { + flex: 1 1 auto; + color: var(--text-dim); +} + +.ioviewer-section-slider { + width: 100%; + accent-color: var(--accent-text); +} + +.ioviewer-section-empty { + color: var(--muted); +} + +.ioviewer-icon-button { + display: flex; + padding: 3px; + border: 0; + border-radius: 4px; + background: none; + color: var(--text-dim); + cursor: pointer; +} + +.ioviewer-icon-button svg { + width: 15px; + height: 15px; +} + +.ioviewer-icon-button:hover, +.ioviewer-icon-button[aria-pressed="true"] { + background: var(--button-hover); + color: #fff; +} + +.ioviewer-check { + display: flex; + align-items: center; + gap: 4px; + color: var(--text-dim); + cursor: pointer; +} + +.ioviewer-panel-body > .ioviewer-check { + margin-top: 10px; +} + +/* Display popovers above their toolbar buttons */ +.ioviewer-popover { + position: fixed; + z-index: 10; + padding: 4px 0; +} + +.ioviewer-display-modes { + min-width: 160px; +} + +.ioviewer-display-modes .ioviewer-menu-item[aria-checked="true"] { + color: var(--accent-text); +} + +.ioviewer-display-modes .ioviewer-menu-item[aria-checked="true"]::after { + content: "\2713"; +} + +.ioviewer-opacity { + width: 200px; + padding: 8px 12px; +} + +.ioviewer-opacity-head { + display: flex; + justify-content: space-between; + color: var(--text-dim); +} + +.ioviewer-opacity-slider { + width: 100%; + accent-color: var(--accent-text); +} + +/* Screen-space labels over the scene: measured distance, grid axis names */ +.ioviewer-measure-label, +.ioviewer-grid-label { + position: absolute; + transform: translate(-50%, -50%); + pointer-events: none; + white-space: nowrap; +} + +.ioviewer-measure-label { + padding: 2px 6px; + border-radius: 4px; + background: rgba(17, 19, 23, 0.85); + border: 1px solid #facc15; + color: #fde68a; + font-variant-numeric: tabular-nums; +} + +.ioviewer-grid-label { + min-width: 22px; + height: 22px; + padding: 0 3px; + box-sizing: border-box; + border: 1px solid #93c5fd; + border-radius: 11px; + background: rgba(17, 19, 23, 0.8); + color: #dbeafe; + font-size: 11px; + line-height: 20px; + text-align: center; +} + +/* Rows of the views, clashes, 4D and links panels: one line each, a long name ends in an ellipsis */ +.ioviewer-row-list { + display: flex; + flex-direction: column; + gap: 1px; +} + +.ioviewer-row { + display: flex; + align-items: center; + gap: 6px; + box-sizing: border-box; + width: 100%; + min-height: 26px; + padding: 3px 6px; + border: 0; + border-radius: 4px; + background: none; + color: var(--text-dim); + text-align: left; +} + +button.ioviewer-row, +label.ioviewer-row { + cursor: pointer; +} + +button.ioviewer-row:hover, +label.ioviewer-row:hover { + background: #262b33; +} + +.ioviewer-row[aria-current="true"], +.ioviewer-row[aria-current="true"]:hover { + background: #1e3a8a; + color: #bfdbfe; +} + +.ioviewer-row-name { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ioviewer-row-count, +.ioviewer-task-dates, +.ioviewer-clash-distance { + flex: none; + font-size: 11px; + color: #7c8797; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.ioviewer-tour, +.ioviewer-player-open { + display: flex; + flex: none; + align-items: center; + justify-content: center; + gap: 6px; + margin-bottom: 6px; +} + +.ioviewer-tour svg, +.ioviewer-player-open svg { + width: 14px; + height: 14px; +} + +.ioviewer-tour[aria-pressed="true"] { + background: var(--accent); + color: #fff; +} + +/* Clashes: test headers fold their clashes; the status is a coloured chip */ +.ioviewer-clash-header { + color: var(--text); + font-weight: 600; +} + +.ioviewer-clash-header svg { + flex: none; + width: 14px; + height: 14px; + color: var(--muted); +} + +.ioviewer-clash-header[aria-expanded="true"] svg { + transform: rotate(90deg); +} + +.ioviewer-clash { + padding-left: 26px; +} + +.ioviewer-clash-status { + flex: none; + max-width: 35%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + padding: 0 6px; + border-radius: 8px; + background: #475569; + color: #fff; + font-size: 10px; + line-height: 16px; +} + +.ioviewer-clash-status[data-status="New"] { + background: #dc2626; +} + +.ioviewer-clash-status[data-status="Active"] { + background: #c2410c; +} + +.ioviewer-clash-status[data-status="Reviewed"] { + background: #2563eb; +} + +.ioviewer-clash-status[data-status="Approved"] { + background: #15803d; +} + +/* 4D: task rows with a dot in the colour their elements get while active, and the player above the toolbar */ +.ioviewer-task::before { + content: ""; + flex: none; + width: 8px; + height: 8px; + border-radius: 50%; + background: #22c55e; +} + +.ioviewer-task[data-type="demolish"]::before { + background: #ef4444; +} + +.ioviewer-task[data-type="temporary"]::before { + background: #a855f7; +} + +.ioviewer-player { + left: 50%; + bottom: 102px; + transform: translateX(-50%); + display: flex; + align-items: center; + gap: 8px; + width: min(720px, calc(100% - 24px)); + padding: 6px 10px; +} + +.ioviewer-player-date { + flex: none; + min-width: 110px; + color: var(--text); + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.ioviewer-player-slider { + flex: 1 1 auto; + min-width: 60px; + accent-color: var(--accent-text); +} + +.ioviewer-player .ioviewer-segmented { + flex: none; +} diff --git a/src/IOViewer/Viewer/test/e2e/global-setup.js b/src/IOViewer/Viewer/test/e2e/global-setup.js new file mode 100644 index 0000000..ee41be9 --- /dev/null +++ b/src/IOViewer/Viewer/test/e2e/global-setup.js @@ -0,0 +1,72 @@ +import { execFileSync } from 'node:child_process'; +import { mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; + +export const viewerDir = path.resolve(import.meta.dirname, '../..'); +export const fixturesDir = path.resolve(viewerDir, '../../../tests/fixtures/viewer'); +export const LARGE_ELEMENTS = 20000; + +// Exports a JSON document to test-results/.html with the CLI, as a user would; `options` are extra CLI flags. +function exportHtml(jsonPath, name, options = []) { + // The CLI targets net8.0 and net10.0, so `dotnet run` needs an explicit framework. + const args = ['run', '--project', '../../IOViewer.Cli', '-c', 'Release', '-f', 'net10.0', '--', + jsonPath, '-o', `test-results/${name}.html`, ...options]; + try { + execFileSync('dotnet', args, { cwd: viewerDir, stdio: 'pipe' }); + } catch (error) { + throw new Error(`Exporting ${jsonPath} failed:\n${error.stdout}\n${error.stderr}`, { cause: error }); + } +} + +// ViewerDocument JSON with `count` small boxes on one shared geometry, in 4 categories, all of type "Segment 50". +function largeModel(count) { + const v = (X, Y, Z) => ({ X, Y, Z }); + const axes = { XAxis: v(1, 0, 0), YAxis: v(0, 1, 0), ZAxis: v(0, 0, 1) }; + const corners = [v(0, 0, 0), v(0.3, 0, 0), v(0.3, 0.3, 0), v(0, 0.3, 0), v(0, 0, 0.3), v(0.3, 0, 0.3), v(0.3, 0.3, 0.3), v(0, 0.3, 0.3)]; + const faces = [0, 2, 1, 0, 3, 2, 4, 5, 6, 4, 6, 7, 0, 1, 5, 0, 5, 4, 1, 2, 6, 1, 6, 5, 2, 3, 7, 2, 7, 6, 3, 0, 4, 3, 4, 7]; + const elements = []; + const meshes = []; + for (let i = 0; i < count; i++) { + const id = 900000 + i; + elements.push({ Id: id, Name: `Pipe ${i}`, Category: `System ${i % 4}`, TypeName: 'Segment 50', Level: 'Level 1', MeshIndices: [i] }); + meshes.push({ + Id: i, Name: `Pipe ${i}`, ElementId: id, Vertices: [], Indices: [], GeometryId: 1, + Transform: { ...axes, Translation: v((i % 200) * 0.5, Math.floor(i / 200) * 0.5, 0) }, + Color: { R: 120, G: 140, B: 160, A: 255 }, Opacity: 1, + }); + } + return { Title: 'Large Model', Elements: elements, Meshes: meshes, Geometries: [{ Id: 1, Vertices: corners, Indices: faces }] }; +} + +// Two boxes on one shared geometry, the first sheared (YAxis (1, 1, 0), as a non-uniform glTF parent scale over a +// rotated child gives), for the instanced-normal check. +function shearModel() { + const model = largeModel(2); + model.Title = 'Shear'; + model.Meshes[0].Transform.YAxis = { X: 1, Y: 1, Z: 0 }; + model.Meshes[1].Transform.Translation = { X: 1, Y: 0, Z: 0 }; + return model; +} + +// Exports every committed fixture, the building again opening in white mode, a generated large model for the +// tree virtualization test, the sheared model and a copy of the building whose binary block is cut in half, as in a +// truncated file. +export default function globalSetup() { + for (const file of readdirSync(fixturesDir).filter((f) => f.endsWith('.json'))) { + exportHtml(path.join(fixturesDir, file), path.basename(file, '.json')); + } + exportHtml(path.join(fixturesDir, 'building.json'), 'building-white', ['--white-mode']); + mkdirSync(path.join(viewerDir, 'test-results'), { recursive: true }); + const large = path.join(viewerDir, 'test-results', 'large.json'); + writeFileSync(large, JSON.stringify(largeModel(LARGE_ELEMENTS))); + exportHtml(large, 'large'); + const shear = path.join(viewerDir, 'test-results', 'shear.json'); + writeFileSync(shear, JSON.stringify(shearModel())); + exportHtml(shear, 'shear'); + + const results = path.join(viewerDir, 'test-results'); + const html = readFileSync(path.join(results, 'building.html'), 'utf8'); + const bin = /(', start); + const data = JSON.parse(html.slice(start, end)); + const bad = (id, name, dates) => ({ id, name, type: 'construct', progress: 0, attachedElements: [300002], ...dates }); + data.timeliner.start = '0001-01-01T00:00:00Z'; + data.timeliner.tasks.unshift( + bad('B1', 'No dates', {}), + bad('B2', 'Default year', { start: '0001-01-01T00:00:00Z', end: '2026-03-03T00:00:00Z' }), + bad('B3', 'Reversed', { start: '2026-03-10T00:00:00Z', end: '2026-03-05T00:00:00Z' }), + bad('B4', 'Invalid', { start: 'soon', end: '2026-03-05T00:00:00Z' }), + { ...bad('B5', 'Unknown ids', { start: '2026-03-02T08:00:00Z', end: '2026-03-05T00:00:00Z' }), attachedElements: [999999] }, + ); + writeFileSync(path.join(viewerDir, 'test-results', 'bad-4d.html'), html.slice(0, start) + JSON.stringify(data) + html.slice(end)); + + const errors = await open(page, 'bad-4d'); + await rail(page, 'timeliner').click(); + const names = sidePanel(page, 'timeliner').locator('.ioviewer-task .ioviewer-row-name'); + await expect(names).toHaveCount(8); + await expect(names.first()).toHaveText('Unknown ids'); + await page.evaluate(() => window.__ioviewer.timeliner.open()); + expect(await page.locator('.ioviewer-player-slider').getAttribute('min')).toBe(String(Date.parse('2026-03-02T08:00:00Z'))); + expect(await visible(page, await indexOf(page, 'Ground Slab'))).toBe(true); + expect(await visible(page, await indexOf(page, 'Column A1 Level 1'))).toBe(false); + expect(errors).toEqual([]); + }); +}); + +test.describe('links', () => { + test('unchecking a link hides its elements; checking shows them again but not those the user hid', async ({ page }) => { + const errors = await open(page); + const duct = await indexOf(page, 'Supply Duct SA-1'); + const column = await indexOf(page, 'Column A1 Level 1'); + const twin = await indexOf(page, 'Column Twin'); + const slab = await indexOf(page, 'Ground Slab'); + await rail(page, 'links').click(); + const rows = sidePanel(page, 'links').locator('.ioviewer-link'); + await expect(rows.locator('.ioviewer-row-name')).toHaveText(['Structure', 'MEP']); + await expect(rows.locator('.ioviewer-row-count')).toHaveText(['60', '1']); + await expect(rows.nth(1)).toHaveAttribute('title', 'MEP (MEP.rvt)'); + const mep = rows.nth(1).getByRole('checkbox'); + const structure = rows.nth(0).getByRole('checkbox'); + + await mep.uncheck(); + expect(await visible(page, duct)).toBe(false); + expect(await visible(page, column)).toBe(true); + await mep.check(); + expect(await visible(page, duct)).toBe(true); + + await page.evaluate((i) => window.__ioviewer.scene.setVisible([i], false), column); + await structure.uncheck(); + for (const i of [column, twin]) expect(await visible(page, i)).toBe(false); + expect(await visible(page, slab)).toBe(true); + await structure.check(); + expect(await visible(page, twin)).toBe(true); + expect(await visible(page, column)).toBe(false); + + // Show all is the user's: it does not bring back an unchecked file. + await mep.uncheck(); + await page.evaluate(() => window.__ioviewer.scene.showAll()); // Shift+H: the focused checkbox keeps the hotkeys off + await expect(mep).not.toBeChecked(); + expect(await visible(page, duct)).toBe(false); + expect(await visible(page, column)).toBe(true); + expect(errors).toEqual([]); + }); +}); diff --git a/src/IOViewer/Viewer/test/e2e/sections.spec.js b/src/IOViewer/Viewer/test/e2e/sections.spec.js new file mode 100644 index 0000000..64d1a43 --- /dev/null +++ b/src/IOViewer/Viewer/test/e2e/sections.spec.js @@ -0,0 +1,448 @@ +import { test, expect } from '@playwright/test'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { viewerDir } from './global-setup.js'; + +async function open(page, name = 'building') { + const errors = []; + page.on('console', (message) => message.type() === 'error' && errors.push(message.text())); + page.on('pageerror', (error) => errors.push(error.message)); + await page.goto(pathToFileURL(path.join(viewerDir, 'test-results', `${name}.html`)).href); + await page.evaluate(() => window.__ioviewer.ready); + return errors; +} + +const frame = (page) => page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))); +const indexOf = (page, name) => + page.evaluate((n) => JSON.parse(document.getElementById('ioviewer-meta').textContent).elements.findIndex((e) => e.name === n), name); +const selected = (page) => page.evaluate(() => [...window.__ioviewer.selection.values()]); +const tool = (page, id) => page.locator(`.ioviewer-tool[data-tool="${id}"]`); +const panel = (page) => page.locator('.ioviewer-side-panel[data-panel="sections"]'); +const BACKGROUND = [31, 34, 40]; // #1f2228 + +// Client position of a point given as fractions of the element's box (0.5 = centre). +const screenAt = (page, index, f = [0.5, 0.5, 0.5]) => + page.evaluate(([i, f]) => { + const { scene, camera } = window.__ioviewer; + camera.updateMatrixWorld(); + const { min, max } = scene.elementBox(i); + const p = min.clone().set(...[0, 1, 2].map((k) => min.getComponent(k) + (max.getComponent(k) - min.getComponent(k)) * f[k])); + p.project(camera); + const rect = document.getElementById('ioviewer-canvas').getBoundingClientRect(); + return [rect.left + ((p.x + 1) / 2) * rect.width, rect.top + ((1 - p.y) / 2) * rect.height]; + }, [index, f]); + +// Mean [r, g, b] of the 3x3 screen pixels around a point; the PNG is decoded by the page itself. +async function rgb(page, [x, y]) { + const png = await page.screenshot({ clip: { x: Math.round(x) - 1, y: Math.round(y) - 1, width: 3, height: 3 } }); + return page.evaluate(async (base64) => { + const image = new Image(); + image.src = `data:image/png;base64,${base64}`; + await image.decode(); + const context = document.createElement('canvas').getContext('2d'); + context.drawImage(image, 0, 0); + const { data } = context.getImageData(0, 0, image.width, image.height); + const sum = [0, 0, 0]; + for (let i = 0; i < data.length; i += 4) for (let c = 0; c < 3; c++) sum[c] += data[i + c]; + return sum.map((s) => s / (data.length / 4)); + }, png.toString('base64')); +} +const distance = (a, b) => a.reduce((sum, c, i) => sum + Math.abs(c - b[i]), 0); +// The pixel now shows (nearly) the background where it showed the model: at most a quarter of the old difference +// is left, which allows for the translucent plane rectangle lying in front of it. +const cleared = (now, before) => distance(now, BACKGROUND) < 0.25 * distance(before, BACKGROUND); +const pickAt = (page, [x, y]) => page.evaluate(([x, y]) => window.__ioviewer.picker.pick(x, y)?.element ?? null, [x, y]); +const pose = (page) => + page.evaluate(() => { + const { camera, controls } = window.__ioviewer; + return [...camera.position.toArray(), ...controls.target.toArray()]; + }); + +// Client position of a point in the plane gizmo's own frame (Z = normal, unit = arrow length). +const gizmoAt = (page, local) => + page.evaluate((local) => { + const { sectionGizmo, camera } = window.__ioviewer; + camera.updateMatrixWorld(); + const g = sectionGizmo.planeGizmo; + g.updateMatrixWorld(true); + const p = g.localToWorld(g.position.clone().set(...local)).project(camera); + const rect = document.getElementById('ioviewer-canvas').getBoundingClientRect(); + return [rect.left + ((p.x + 1) / 2) * rect.width, rect.top + ((1 - p.y) / 2) * rect.height]; + }, local); + +async function drag(page, from, to, steps = 8) { + await page.mouse.move(...from); + await page.mouse.down(); + for (let k = 1; k <= steps; k++) await page.mouse.move(from[0] + ((to[0] - from[0]) * k) / steps, from[1] + ((to[1] - from[1]) * k) / steps); + await page.mouse.up(); +} + +test('a Z plane through the middle cuts the roof away; flipping it brings the roof back and cuts the ground slab; reset restores', async ({ page }) => { + const errors = await open(page); + await page.evaluate(() => window.__ioviewer.grids.setVisible(false)); // axis 2 runs across the sampled slab rim + const roof = await indexOf(page, 'Roof Slab'); + const ground = await indexOf(page, 'Ground Slab'); + // Roof top near its back edge (seen past the roof there is only background) and the ground slab rim right of the + // floors above it (nothing below it). + const roofAt = await screenAt(page, roof, [0.5, 0.95, 1]); + const groundAt = await screenAt(page, ground, [0.985, 0.5, 1]); + const roofBefore = await rgb(page, roofAt); + const groundBefore = await rgb(page, groundAt); + expect(await pickAt(page, roofAt)).toBe(roof); + expect(await pickAt(page, groundAt)).toBe(ground); + expect(distance(roofBefore, BACKGROUND)).toBeGreaterThan(60); + expect(distance(groundBefore, BACKGROUND)).toBeGreaterThan(60); + + await tool(page, 'plane').click(); + await expect(panel(page)).toBeVisible(); + await expect(tool(page, 'plane')).toHaveClass(/active/); + const state = await page.evaluate(() => { + const { sections, scene } = window.__ioviewer; + const box = scene.visibleBox(); + return { mode: sections.mode, axis: sections.planes[0].axis, z: sections.origin(0).z, mid: (box.min.z + box.max.z) / 2 }; + }); + expect(state).toMatchObject({ mode: 'planes', axis: 'z' }); + expect(state.z).toBeCloseTo(state.mid, 9); + await page.evaluate(() => window.__ioviewer.sections.setGizmoVisible(false)); // keep the handles off the sampled pixels + await frame(page); + expect(cleared(await rgb(page, roofAt), roofBefore)).toBe(true); + expect(await pickAt(page, roofAt)).not.toBe(roof); + expect(cleared(await rgb(page, groundAt), groundBefore)).toBe(false); + expect(await pickAt(page, groundAt)).toBe(ground); + + await panel(page).getByRole('button', { name: 'Сменить сторону' }).click(); + await frame(page); + expect(await rgb(page, roofAt)).toEqual(roofBefore); // the roof is in front of the plane rectangle + expect(await pickAt(page, roofAt)).toBe(roof); + expect(cleared(await rgb(page, groundAt), groundBefore)).toBe(true); + expect(await pickAt(page, groundAt)).not.toBe(ground); + + await tool(page, 'resetSections').click(); + await frame(page); + expect(await rgb(page, roofAt)).toEqual(roofBefore); + expect(await rgb(page, groundAt)).toEqual(groundBefore); + expect(await page.evaluate(() => [window.__ioviewer.sections.mode, window.__ioviewer.sections.planes.length])).toEqual(['off', 0]); + await expect(tool(page, 'plane')).not.toHaveClass(/active/); + expect(errors).toEqual([]); +}); + +test('box around the selected column: keepInside shows only the column, cutInside hides only it, reset brings all back', async ({ page }) => { + const errors = await open(page); + const d2 = await indexOf(page, 'Column D2 Level 1'); + const d1 = await indexOf(page, 'Column D1 Level 1'); + const roof = await indexOf(page, 'Roof Slab'); + const d2At = await screenAt(page, d2); + const d1At = await screenAt(page, d1); + const roofAt = await screenAt(page, roof, [0.5, 0.95, 1]); + const before = { d2: await rgb(page, d2At), d1: await rgb(page, d1At), roof: await rgb(page, roofAt) }; + + await page.mouse.click(...d2At); + expect(await selected(page)).toEqual([d2]); + await tool(page, 'box').click(); + await expect(panel(page)).toBeVisible(); + await expect(tool(page, 'box')).toHaveClass(/active/); + // The column's box plus 5 % of its largest dimension (its height) on every side. + const fit = await page.evaluate((i) => { + const { sections, scene } = window.__ioviewer; + const column = scene.elementBox(i); + const margin = 0.05 * (column.max.z - column.min.z); + return { mode: sections.mode, boxMode: sections.boxMode, expected: [...column.min.toArray().map((c) => c - margin), ...column.max.toArray().map((c) => c + margin)], box: [...sections.box.min.toArray(), ...sections.box.max.toArray()] }; + }, d2); + expect(fit.mode).toBe('box'); + expect(fit.boxMode).toBe('keepInside'); + fit.box.forEach((c, k) => expect(c).toBeCloseTo(fit.expected[k], 5)); + await page.evaluate(() => window.__ioviewer.sections.setGizmoVisible(false)); + await frame(page); + expect(await pickAt(page, d2At)).toBe(d2); + expect(cleared(await rgb(page, d1At), before.d1)).toBe(true); + expect(cleared(await rgb(page, roofAt), before.roof)).toBe(true); + expect(await pickAt(page, d1At)).toBe(null); + + await panel(page).getByRole('button', { name: 'Вырезать внутри' }).click(); + await frame(page); + expect(await page.evaluate(() => window.__ioviewer.sections.boxMode)).toBe('cutInside'); + expect(await pickAt(page, d2At)).not.toBe(d2); + expect(await rgb(page, d2At)).not.toEqual(before.d2); + expect(await rgb(page, d1At)).toEqual(before.d1); + expect(await rgb(page, roofAt)).toEqual(before.roof); + expect(await pickAt(page, d1At)).toBe(d1); + + await tool(page, 'resetSections').click(); + await page.keyboard.press('Escape'); // clear the selection, so the highlight does not tint the column + await frame(page); + expect(await rgb(page, d2At)).toEqual(before.d2); + expect(await rgb(page, d1At)).toEqual(before.d1); + expect(await rgb(page, roofAt)).toEqual(before.roof); + await expect(tool(page, 'box')).not.toHaveClass(/active/); + expect(errors).toEqual([]); +}); + +test('a click through the plane rectangle selects the element behind it; the rectangle is never picked', async ({ page }) => { + const errors = await open(page); + const d1 = await indexOf(page, 'Column D1 Level 1'); + const at = await screenAt(page, d1); + await tool(page, 'plane').click(); + await frame(page); + // The rectangle at mid height lies between the camera and the lower column here. + const covered = await page.evaluate(([x, y]) => { + const { sectionGizmo, camera } = window.__ioviewer; + const rect = document.getElementById('ioviewer-canvas').getBoundingClientRect(); + const ndc = { x: ((x - rect.left) / rect.width) * 2 - 1, y: 1 - ((y - rect.top) / rect.height) * 2 }; + const ray = new sectionGizmo._raycaster.constructor(); + ray.setFromCamera(ndc, camera); + return ray.intersectObject(sectionGizmo.planeVisuals[0], true).length > 0; + }, at); + expect(covered).toBe(true); + await page.mouse.click(...at); + expect(await selected(page)).toEqual([d1]); + expect(errors).toEqual([]); +}); + +test('dragging the gizmo arrow moves the plane along its normal and the rings turn it, without moving the camera', async ({ page }) => { + const errors = await open(page); + await tool(page, 'plane').click(); + await frame(page); + await page.evaluate(() => { + const seen = []; + for (const type of ['click', 'boxselect', 'contextmenu']) window.__ioviewer.controls.addEventListener(type, () => seen.push(type)); + window.__seen = seen; + }); + const start = await pose(page); + const offset = () => page.evaluate(() => window.__ioviewer.sections.planes[0].offset); + + // Press on the arrow shaft and pull it up the screen along its own direction. + const shaft = await gizmoAt(page, [0, 0, 0.6]); + const tip = await gizmoAt(page, [0, 0, 1]); + await page.mouse.move(...shaft); + await frame(page); + expect(await page.evaluate(() => window.__ioviewer.sectionGizmo._hovered?.name)).toBe('arrow'); + await drag(page, shaft, [shaft[0] + (tip[0] - shaft[0]) * 2, shaft[1] + (tip[1] - shaft[1]) * 2]); + const moved = await offset(); + expect(moved).toBeGreaterThan(0.5); + expect(await pose(page)).toEqual(start); + expect(await page.evaluate(() => window.__ioviewer.sections.planes[0].rotated)).toBe(false); + + // Red ring (about the plane's X, lying in its YZ plane): press on a part of it that the green ring does not cover + // on screen and pull it 40 degrees round. + const ringPoint = (angle) => gizmoAt(page, [0, 0.8 * Math.cos(angle), 0.8 * Math.sin(angle)]); + const hovered = () => page.evaluate(() => window.__ioviewer.sectionGizmo._hovered?.name); + let from = null; + for (let k = 0; k < 12 && from === null; k++) { + await page.mouse.move(...(await ringPoint((k * Math.PI) / 6))); + if ((await hovered()) === 'ringX') from = (k * Math.PI) / 6; + } + expect(from).not.toBe(null); + const a = (40 * Math.PI) / 180; + await drag(page, await ringPoint(from), await ringPoint(from + a), 12); + const turned = await page.evaluate(() => { + const { sections } = window.__ioviewer; + return { rotated: sections.planes[0].rotated, nz: sections.normal(0).z, nx: sections.normal(0).x }; + }); + expect(turned.rotated).toBe(true); + expect(turned.nz).toBeCloseTo(Math.cos(a), 1); + expect(Math.abs(turned.nx)).toBeLessThan(1e-9); + await expect(panel(page).locator('.ioviewer-section-name')).toHaveText('1. Z (повёрнута)'); + expect(await pose(page)).toEqual(start); + expect(await selected(page)).toEqual([]); + expect(await page.evaluate(() => window.__seen)).toEqual([]); + + // Hidden gizmo: the same press orbits the camera instead. + await page.getByLabel('Показывать манипулятор').uncheck(); + expect(await page.evaluate(() => window.__ioviewer.sectionGizmo.planeGizmo.visible)).toBe(false); + await drag(page, [640, 300], [700, 330]); + expect(await pose(page)).not.toEqual(start); + expect(errors).toEqual([]); +}); + +test('box select skips elements the section cuts away entirely', async ({ page }) => { + const errors = await open(page); + const names = ['Roof Slab', 'Column D2 Level 2', 'Column D2 Level 1', 'Floor Slab Level 2']; + const [roof, upper, lower, slab2] = await Promise.all(names.map((n) => indexOf(page, n))); + await tool(page, 'plane').click(); + await page.evaluate(() => window.__ioviewer.sections.setGizmoVisible(false)); + // Crossing box (right to left) over the whole canvas between the UI parts. + await page.keyboard.down('Shift'); + await page.mouse.move(1180, 690); + await page.mouse.down(); + await page.mouse.move(600, 400); + await page.mouse.move(380, 160); + await page.mouse.up(); + await page.keyboard.up('Shift'); + const found = await selected(page); + expect(found).toContain(lower); + expect(found).toContain(slab2); // crosses the plane: partly shown + expect(found).not.toContain(roof); + expect(found).not.toContain(upper); + expect(errors).toEqual([]); +}); + +test('panel: axis choice, add up to six planes, flip, on/off, position, delete; modes are exclusive', async ({ page }) => { + const errors = await open(page); + await page.locator('.ioviewer-rail-button[data-panel="sections"]').click(); + const p = panel(page); + await expect(p.locator('h2')).toHaveText('Сечения'); + await expect(p.getByRole('group', { name: 'Режим сечения' }).locator('button')).toHaveText(['Плоскости', 'Бокс', 'Выкл']); + await p.getByRole('button', { name: 'Плоскости' }).click(); + await expect(p.locator('.ioviewer-section-empty')).toHaveText('Плоскостей нет'); + + await p.getByRole('button', { name: 'X', exact: true }).click(); + await p.getByRole('button', { name: 'Добавить плоскость' }).click(); + await expect(p.locator('.ioviewer-section-name')).toHaveText(['1. X']); + const clip = () => page.evaluate(() => window.__ioviewer.scene._buckets[0].material.clippingPlanes?.map((pl) => [...pl.normal.toArray(), pl.constant]) ?? []); + const normals = async () => (await clip()).map((pl) => pl.slice(0, 3).map((c) => Math.round(c * 1e9) / 1e9 + 0)); + expect(await normals()).toEqual([[-1, 0, 0]]); + + const row = p.locator('.ioviewer-section-plane').first(); + await row.getByRole('button', { name: 'Сменить сторону' }).click(); + expect(await normals()).toEqual([[1, 0, 0]]); + await row.getByLabel('Положение плоскости').fill('2'); + expect(await page.evaluate(() => window.__ioviewer.sections.origin(0).x - window.__ioviewer.sections.center.x)).toBeCloseTo(2, 9); + await row.getByLabel('Вкл').uncheck(); + expect(await clip()).toEqual([]); + await row.getByLabel('Вкл').check(); + expect((await clip()).length).toBe(1); + + for (let k = 0; k < 5; k++) await p.getByRole('button', { name: 'Добавить плоскость' }).click(); + await expect(p.locator('.ioviewer-section-plane')).toHaveCount(6); + await expect(p.getByRole('button', { name: 'Добавить плоскость' })).toBeDisabled(); + await tool(page, 'plane').click(); + await expect(page.locator('.ioviewer-toast')).toHaveText('Не больше 6 плоскостей'); + expect((await clip()).length).toBe(6); + await p.locator('.ioviewer-section-plane').nth(2).getByRole('button', { name: 'Удалить плоскость' }).click(); + await expect(p.locator('.ioviewer-section-plane')).toHaveCount(5); + await expect(p.getByRole('button', { name: 'Добавить плоскость' })).toBeEnabled(); + + // Box and planes exclude each other; off keeps the planes for later. + await p.getByRole('button', { name: 'Бокс', exact: true }).click(); + expect((await clip()).length).toBe(6); + await expect(tool(page, 'box')).toHaveClass(/active/); + await expect(tool(page, 'plane')).not.toHaveClass(/active/); + await expect(p.getByRole('button', { name: 'Показать внутри' })).toHaveAttribute('aria-pressed', 'true'); + await expect(p.getByRole('button', { name: 'По выделенному' })).toBeDisabled(); + await p.getByRole('button', { name: 'Выкл' }).click(); + expect(await clip()).toEqual([]); + await p.getByRole('button', { name: 'Плоскости' }).click(); + expect((await clip()).length).toBe(5); + expect(errors).toEqual([]); +}); + +test('context menu: section box around the right-clicked column, reset from the empty menu', async ({ page }) => { + const errors = await open(page); + const d2 = await indexOf(page, 'Column D2 Level 1'); + await page.mouse.click(...(await screenAt(page, d2)), { button: 'right' }); + await page.locator('.ioviewer-menu-item', { hasText: 'Бокс сечения по объекту' }).click(); + const box = await page.evaluate((i) => { + const { sections, scene } = window.__ioviewer; + return { mode: sections.mode, inside: sections.box.containsBox(scene.elementBox(i)) }; + }, d2); + expect(box).toEqual({ mode: 'box', inside: true }); + await expect(panel(page)).toBeVisible(); + + await page.mouse.click(900, 60, { button: 'right' }); // background + await page.locator('.ioviewer-menu-item', { hasText: 'Сбросить сечения' }).click(); + expect(await page.evaluate(() => window.__ioviewer.sections.mode)).toBe('off'); + expect(errors).toEqual([]); +}); + +test('a document without geometry offers no sections', async ({ page }) => { + const errors = await open(page, 'empty'); + await expect(page.locator('.ioviewer-rail-button[data-panel="sections"]')).toBeHidden(); + for (const id of ['plane', 'box', 'resetSections']) await expect(tool(page, id)).toHaveAttribute('aria-disabled', 'true'); + expect(errors).toEqual([]); +}); + +// Client position of a world point, and of a box handle (by name: face+z, face-x, centre, ...). +const worldAt = (page, p) => + page.evaluate((p) => { + const { camera, sections } = window.__ioviewer; + camera.updateMatrixWorld(); + const q = sections.center.clone().set(...p).project(camera); + const rect = document.getElementById('ioviewer-canvas').getBoundingClientRect(); + return [rect.left + ((q.x + 1) / 2) * rect.width, rect.top + ((1 - q.y) / 2) * rect.height]; + }, p); +const handle = (page, name) => page.evaluate((n) => window.__ioviewer.sectionGizmo.group.getObjectByName(n).position.toArray(), name); +const boxNow = (page) => page.evaluate(() => [...window.__ioviewer.sections.box.min.toArray(), ...window.__ioviewer.sections.box.max.toArray()]); +// Drags a handle `px` pixels along the screen image of the world direction `dir` from it. +async function dragAlong(page, name, dir, px = 40) { + const at = await handle(page, name); + const from = await worldAt(page, at); + const ahead = await worldAt(page, at.map((c, k) => c + dir[k])); + const len = Math.hypot(ahead[0] - from[0], ahead[1] - from[1]); + await drag(page, from, [from[0] + ((ahead[0] - from[0]) / len) * px, from[1] + ((ahead[1] - from[1]) / len) * px]); +} +const size = (b) => [b[3] - b[0], b[4] - b[1], b[5] - b[2]]; +const centre = (b) => [(b[0] + b[3]) / 2, (b[1] + b[4]) / 2, (b[2] + b[5]) / 2]; +const cubePoint = (page, p) => + page.evaluate((p) => { + const { viewCube } = window.__ioviewer; + const v = viewCube.camera.position.clone().set(...p).project(viewCube.camera); + const r = viewCube.canvas.getBoundingClientRect(); + return [r.left + ((v.x + 1) / 2) * r.width, r.top + ((1 - v.y) / 2) * r.height]; + }, p); +async function settle(page) { + await expect.poll(() => page.evaluate(() => window.__ioviewer.controls.animating), { timeout: 3000 }).toBe(false); + await frame(page); +} + +test('box handles: the +Z and -X faces move only their side, the centre moves the whole box; the camera stays', async ({ page }) => { + const errors = await open(page); + await tool(page, 'box').click(); // nothing selected: the box of the shown model + await frame(page); + const start = await pose(page); + + const b0 = await boxNow(page); + await dragAlong(page, 'face+z', [0, 0, 1]); + const b1 = await boxNow(page); + expect(b1[5]).toBeGreaterThan(b0[5] + 0.5); + expect(b1.slice(0, 5)).toEqual(b0.slice(0, 5)); + + await dragAlong(page, 'face-x', [-1, 0, 0]); + await frame(page); + const b2 = await boxNow(page); + expect(b2[0]).toBeLessThan(b1[0] - 0.5); + expect(b2.slice(1)).toEqual(b1.slice(1)); + + const from = await worldAt(page, await handle(page, 'centre')); + await drag(page, from, [from[0] + 60, from[1] + 30]); + const b3 = await boxNow(page); + size(b3).forEach((c, k) => expect(c).toBeCloseTo(size(b2)[k], 9)); + expect(Math.hypot(...centre(b3).map((c, k) => c - centre(b2)[k]))).toBeGreaterThan(0.5); + expect(await pose(page)).toEqual(start); + expect(await selected(page)).toEqual([]); + expect(errors).toEqual([]); +}); + +test('Top view: the faces along the line of sight hide, so a press on the box centre moves the box', async ({ page }) => { + const errors = await open(page); + await tool(page, 'box').click(); + await page.mouse.click(...(await cubePoint(page, [0, 0, 1]))); // «Сверху» + await settle(page); + const shown = (name) => page.evaluate((n) => window.__ioviewer.sectionGizmo.group.getObjectByName(n).visible, name); + expect([await shown('face+z'), await shown('face-z'), await shown('centre'), await shown('face+x')]).toEqual([false, false, true, true]); + const start = await pose(page); + const b0 = await boxNow(page); + const from = await worldAt(page, await handle(page, 'centre')); + await drag(page, from, [from[0] + 50, from[1] + 40]); + const b1 = await boxNow(page); + size(b1).forEach((c, k) => expect(c).toBeCloseTo(size(b0)[k], 9)); + const moved = centre(b1).map((c, k) => c - centre(b0)[k]); + expect(Math.hypot(moved[0], moved[1])).toBeGreaterThan(0.5); + expect(Math.abs(moved[2])).toBeLessThan(0.05); // the drag plane faces the camera: almost horizontal + expect(await pose(page)).toEqual(start); + expect(errors).toEqual([]); +}); + +test('orthographic: dragging the gizmo arrow moves the plane and not the camera', async ({ page }) => { + const errors = await open(page); + await tool(page, 'projection').click(); + expect(await page.evaluate(() => window.__ioviewer.camera.isOrthographicCamera)).toBe(true); + await tool(page, 'plane').click(); + await frame(page); + const start = await pose(page); + const shaft = await gizmoAt(page, [0, 0, 0.6]); + const tip = await gizmoAt(page, [0, 0, 1]); + await drag(page, shaft, [shaft[0] + (tip[0] - shaft[0]) * 2, shaft[1] + (tip[1] - shaft[1]) * 2]); + expect(await page.evaluate(() => window.__ioviewer.sections.planes[0].offset)).toBeGreaterThan(0.5); + expect(await pose(page)).toEqual(start); + expect(errors).toEqual([]); +}); diff --git a/src/IOViewer/Viewer/test/e2e/selection.spec.js b/src/IOViewer/Viewer/test/e2e/selection.spec.js new file mode 100644 index 0000000..6630f07 --- /dev/null +++ b/src/IOViewer/Viewer/test/e2e/selection.spec.js @@ -0,0 +1,279 @@ +import { test, expect } from '@playwright/test'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { viewerDir } from './global-setup.js'; + +async function open(page) { + const errors = []; + page.on('console', (message) => message.type() === 'error' && errors.push(message.text())); + page.on('pageerror', (error) => errors.push(error.message)); + await page.goto(pathToFileURL(path.join(viewerDir, 'test-results', 'building.html')).href); + await page.evaluate(() => window.__ioviewer.ready); + return errors; +} + +const frame = (page) => page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))); +const indexOf = (page, name) => + page.evaluate((n) => JSON.parse(document.getElementById('ioviewer-meta').textContent).elements.findIndex((e) => e.name === n), name); +const selected = (page) => page.evaluate(() => [...window.__ioviewer.selection.values()]); + +// Client position of a world point given as fractions of the element's box (0.5 = centre). +const screenAt = (page, index, f = [0.5, 0.5, 0.5]) => + page.evaluate(([i, f]) => { + const { scene, camera } = window.__ioviewer; + camera.updateMatrixWorld(); // a camera just swapped in by setProjection has not been rendered yet + const { min, max } = scene.elementBox(i); + const p = min.clone().set(...[0, 1, 2].map((k) => min.getComponent(k) + (max.getComponent(k) - min.getComponent(k)) * f[k])); + p.project(camera); + const rect = document.getElementById('ioviewer-canvas').getBoundingClientRect(); + return [rect.left + ((p.x + 1) / 2) * rect.width, rect.top + ((1 - p.y) / 2) * rect.height]; + }, [index, f]); + +// Client rect of the element's 8 projected box corners. +const screenRect = (page, index) => + page.evaluate((i) => { + const { scene, camera } = window.__ioviewer; + const { min, max } = scene.elementBox(i); + const rect = document.getElementById('ioviewer-canvas').getBoundingClientRect(); + const xs = [], ys = []; + for (let k = 0; k < 8; k++) { + const p = min.clone().set(k & 1 ? max.x : min.x, k & 2 ? max.y : min.y, k & 4 ? max.z : min.z).project(camera); + xs.push(rect.left + ((p.x + 1) / 2) * rect.width); + ys.push(rect.top + ((1 - p.y) / 2) * rect.height); + } + return { minX: Math.min(...xs), minY: Math.min(...ys), maxX: Math.max(...xs), maxY: Math.max(...ys) }; + }, index); + +async function shiftDrag(page, from, to, extra) { + await page.keyboard.down('Shift'); + if (extra) await page.keyboard.down(extra); + await page.mouse.move(...from); + await page.mouse.down(); + await page.mouse.move((from[0] + to[0]) / 2, (from[1] + to[1]) / 2); + await page.mouse.move(...to); + await page.mouse.up(); + if (extra) await page.keyboard.up(extra); + await page.keyboard.up('Shift'); +} + +const panel = (page) => page.locator('#ioviewer-properties'); +const blank = [100, 15]; // background: right of the panel rail, far from the fitted model + +test('click selects a column and shows its properties; Ctrl+click adds a second; click on nothing clears', async ({ page }) => { + const errors = await open(page); + const d2 = await indexOf(page, 'Column D2 Level 1'); + const d3 = await indexOf(page, 'Column D3 Level 1'); + await expect(panel(page)).toBeHidden(); + + await page.mouse.click(...(await screenAt(page, d2))); + expect(await selected(page)).toEqual([d2]); + await expect(panel(page)).toBeVisible(); + await expect(panel(page).locator('h2')).toHaveText('Column D2 Level 1'); + // First group: element fields (ru UI), then the PROPS groups with the mark. + await expect(panel(page).locator('h3').first()).toHaveText('Элемент'); + await expect(panel(page)).toContainText('Structural Columns'); + await expect(panel(page)).toContainText('Structure.rvt'); + await expect(panel(page).locator('tr', { hasText: 'Mark' }).locator('td')).toHaveText('Column D2 Level 1'); + await expect(panel(page)).toContainText('400 x 400 mm'); + + await page.keyboard.down('Control'); + await page.mouse.click(...(await screenAt(page, d3))); + await page.keyboard.up('Control'); + expect(await page.evaluate(() => window.__ioviewer.selection.size)).toBe(2); + await expect(panel(page).locator('h2')).toHaveText('Выбрано: 2'); + await expect(panel(page).locator('table')).toHaveCount(0); + + // Ctrl+click on nothing keeps the selection; a plain click on nothing clears it. + await page.keyboard.down('Control'); + await page.mouse.click(...blank); + await page.keyboard.up('Control'); + expect(await selected(page)).toEqual([d2, d3]); + await page.mouse.click(...blank); + expect(await selected(page)).toEqual([]); + await expect(panel(page)).toBeHidden(); + expect(errors).toEqual([]); +}); + +test('Ctrl+click toggles off, Esc and the close button clear', async ({ page }) => { + await open(page); + const d2 = await indexOf(page, 'Column D2 Level 1'); + const point = await screenAt(page, d2); + await page.mouse.click(...point); + await page.keyboard.down('Control'); + await page.mouse.click(...point); + await page.keyboard.up('Control'); + expect(await selected(page)).toEqual([]); + + await page.mouse.click(...point); + await page.keyboard.press('Escape'); + expect(await selected(page)).toEqual([]); + + await page.mouse.click(...point); + await panel(page).locator('button').click(); + expect(await selected(page)).toEqual([]); + await expect(panel(page)).toBeHidden(); +}); + +test('pick returns the element and a point on its surface, and the controls use it', async ({ page }) => { + await open(page); + const d2 = await indexOf(page, 'Column D2 Level 1'); + const [x, y] = await screenAt(page, d2); + const result = await page.evaluate(([x, y, i]) => { + const { picker, scene } = window.__ioviewer; + const hit = picker.pick(x, y); + const box = scene.elementBox(i); + // On the box surface: inside the box and on one of its faces. + const p = hit.point; + const onFace = ['x', 'y', 'z'].some((k) => Math.abs(p[k] - box.min[k]) < 1e-3 || Math.abs(p[k] - box.max[k]) < 1e-3); + return { element: hit.element, inside: box.expandByScalar(1e-3).containsPoint(p), onFace, pivot: window.__ioviewer.controls.pick(x, y)?.toArray() }; + }, [x, y, d2]); + expect(result).toMatchObject({ element: d2, inside: true, onFace: true }); + expect(result.pivot).toHaveLength(3); + + // The picker follows the controls to the orthographic camera. + await page.evaluate(() => window.__ioviewer.controls.setProjection('orthographic')); + const [ox, oy] = await screenAt(page, d2); + expect(await page.evaluate(([x, y]) => window.__ioviewer.picker.pick(x, y)?.element, [ox, oy])).toBe(d2); +}); + +test('pick skips hidden and clipped elements', async ({ page }) => { + await open(page); + const d2 = await indexOf(page, 'Column D2 Level 1'); + const [x, y] = await screenAt(page, d2); + const picked = () => page.evaluate(([x, y]) => window.__ioviewer.picker.pick(x, y)?.element ?? null, [x, y]); + + await page.evaluate((i) => window.__ioviewer.scene.setVisible([i], false), d2); + expect(await picked()).not.toBe(d2); + await page.evaluate((i) => window.__ioviewer.scene.setVisible([i], true), d2); + expect(await picked()).toBe(d2); + + // Keep only x <= c, just short of the column (THREE.Plane shape: n·p + constant >= 0 is kept), so it is cut away. + await page.evaluate((i) => { + const { scene } = window.__ioviewer; + const box = scene.elementBox(i); + const normal = box.min.clone().set(-1, 0, 0); + const constant = box.min.x - 0.1; + scene.setClipping([{ normal, constant, distanceToPoint: (p) => normal.dot(p) + constant }], false); + }, d2); + expect(await picked()).not.toBe(d2); + await page.evaluate(() => window.__ioviewer.scene.setClipping([], false)); + expect(await picked()).toBe(d2); +}); + +test('Shift box: window around one column selects it, crossing through a row selects more, Ctrl adds', async ({ page }) => { + const errors = await open(page); + const d2 = await indexOf(page, 'Column D2 Level 1'); + const r = await screenRect(page, d2); + await shiftDrag(page, [r.minX - 3, r.minY - 3], [r.maxX + 3, r.maxY + 3]); // left to right: window + expect(await selected(page)).toEqual([d2]); + + const a3 = await screenAt(page, await indexOf(page, 'Column A3 Level 1')); + const d3 = await screenAt(page, await indexOf(page, 'Column D3 Level 1')); + const y = (a3[1] + d3[1]) / 2; + await shiftDrag(page, [Math.max(a3[0], d3[0]), y - 10], [Math.min(a3[0], d3[0]), y + 10]); // right to left: crossing + const crossing = await selected(page); + expect(crossing.length).toBeGreaterThan(1); + expect(crossing).not.toContain(d2); // replaced, not added + + await shiftDrag(page, [r.minX - 3, r.minY - 3], [r.maxX + 3, r.maxY + 3], 'Control'); + expect((await selected(page)).sort()).toEqual([...crossing, d2].sort()); + + // A sliver under 4 px tall changes nothing while Shift is held. + await shiftDrag(page, [100, 100], [300, 102]); + expect((await selected(page)).length).toBe(crossing.length + 1); + expect(errors).toEqual([]); +}); + +test('an element of an opaque and a transparent mesh is picked through its glazing and highlighted whole', async ({ page }) => { + const errors = await open(page); + const wall = await indexOf(page, 'Curtain Wall South'); + // Glazing: the transparent mesh, x 0..18, z 0.1..7.2; a point in its lower middle. + await page.mouse.click(...(await screenAt(page, wall, [0.5, 0.5, 0.2]))); + expect(await selected(page)).toEqual([wall]); + await expect(panel(page).locator('h2')).toHaveText('Curtain Wall South'); + const colours = await page.evaluate((i) => { + const { scene } = window.__ioviewer; + return [...scene.instancesOf(i)].map((n) => { + return { transparent: scene.instance(n).mesh.material.transparent, hex: scene.instanceColor(n).getHexString() }; + }); + }, wall); + expect(colours).toHaveLength(2); + expect(colours.map((c) => c.transparent).sort()).toEqual([false, true]); + // Instance colours are linear; #f59e0b in linear sRGB. + const highlight = await page.evaluate(() => window.__ioviewer.scene.instance(0).mesh.material.color.clone().set('#f59e0b').getHexString()); + expect(colours.map((c) => c.hex)).toEqual([highlight, highlight]); + expect(errors).toEqual([]); +}); + +// Client position of a world point. +const screenOfWorld = (page, world) => + page.evaluate((w) => { + const { scene, camera } = window.__ioviewer; + const [ox, oy, oz] = JSON.parse(document.getElementById('ioviewer-meta').textContent).rtcOffset; + const p = scene.elementBox(0).min.clone().set(w[0] - ox, w[1] - oy, w[2] - oz).project(camera); + const rect = document.getElementById('ioviewer-canvas').getBoundingClientRect(); + return [rect.left + ((p.x + 1) / 2) * rect.width, rect.top + ((1 - p.y) / 2) * rect.height]; + }, world); + +test('a mesh without an element is picked for navigation and measuring, but clicking it selects nothing', async ({ page }) => { + const errors = await open(page); + const marker = await page.evaluate(() => { + const { scene } = window.__ioviewer; + for (let i = 0; i < scene.instanceCount; i++) if (scene.instance(i).element === -1) return i; + return -1; + }); + expect(marker).toBeGreaterThanOrEqual(0); + // The Survey Marker pyramid: base -3.3..-2.7 in X and Y, apex at z 0.8 (world); aim a little above the base centre. + const point = await screenOfWorld(page, [-3, -3, 0.2]); + const background = await page.evaluate(() => window.__ioviewer.picker.pick(100, 15)); + expect(background).toBeNull(); + // The marker is picked with element -1 and its surface point, in world coordinates on the pyramid. + const hit = await page.evaluate(([x, y]) => { + const h = window.__ioviewer.picker.pick(x, y); + const [ox, oy, oz] = JSON.parse(document.getElementById('ioviewer-meta').textContent).rtcOffset; + return h && { element: h.element, point: h.point.toArray(), world: [h.point.x + ox, h.point.y + oy, h.point.z + oz] }; + }, point); + expect(hit.element).toBe(-1); + const [wx, wy, wz] = hit.world; + for (const c of [wx, wy]) expect(c > -3.3 - 1e-3 && c < -2.7 + 1e-3).toBe(true); + expect(wz > -1e-3 && wz < 0.8 + 1e-3).toBe(true); + // The orbit pivot and the wheel zoom target are that point. + expect(await page.evaluate(([x, y]) => window.__ioviewer.controls.pick(x, y)?.toArray(), point)).toEqual(hit.point); + + // Clicking it selects nothing and clears the selection, without errors. + await page.mouse.click(...(await screenAt(page, await indexOf(page, 'Column D2 Level 1')))); + expect(await selected(page)).toHaveLength(1); + await page.mouse.click(...point); + expect(await selected(page)).toEqual([]); + await page.mouse.click(...point, { modifiers: ['Control'] }); + expect(await selected(page)).toEqual([]); + await page.mouse.click(...point, { button: 'right' }); + await expect(page.locator('.ioviewer-context-menu')).toBeVisible(); + expect(await selected(page)).toEqual([]); + await page.keyboard.press('Escape'); + + // The distance tool measures from the marker to a point on the ground slab. + const slabPoint = [2, -0.9, 0]; + await page.locator('.ioviewer-tool[data-tool="distance"]').click(); + await page.mouse.click(...point); + await page.mouse.click(...(await screenOfWorld(page, slabPoint))); + const measured = await page.evaluate(() => ({ count: window.__ioviewer.measure.points.length, distance: window.__ioviewer.measure.distance })); + expect(measured.count).toBe(2); + expect(Math.abs(measured.distance - Math.hypot(wx - slabPoint[0], wy - slabPoint[1], wz - slabPoint[2]))).toBeLessThan(0.05); + expect(await selected(page)).toEqual([]); + expect(errors).toEqual([]); +}); + +test('highlighting a selection does not recompute the visible bounds', async ({ page }) => { + await open(page); + await page.evaluate(() => { + const { scene } = window.__ioviewer; + window.__boxCalls = 0; + const original = scene.visibleBox.bind(scene); + scene.visibleBox = () => (window.__boxCalls++, original()); + }); + await page.mouse.click(...(await screenAt(page, await indexOf(page, 'Column D2 Level 1')))); + await page.keyboard.press('Escape'); + await frame(page); + expect(await page.evaluate(() => window.__boxCalls)).toBe(0); +}); diff --git a/src/IOViewer/Viewer/test/e2e/smoke.spec.js b/src/IOViewer/Viewer/test/e2e/smoke.spec.js new file mode 100644 index 0000000..f3b8707 --- /dev/null +++ b/src/IOViewer/Viewer/test/e2e/smoke.spec.js @@ -0,0 +1,298 @@ +import { test, expect } from '@playwright/test'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { fixturesDir, viewerDir } from './global-setup.js'; + +const building = JSON.parse(readFileSync(path.join(fixturesDir, 'building.json'), 'utf8')); + +async function open(page, name) { + const errors = []; + const requests = []; + page.on('console', (message) => message.type() === 'error' && errors.push(message.text())); + page.on('pageerror', (error) => errors.push(error.message)); + page.on('request', (request) => !request.url().startsWith('file:') && requests.push(request.url())); + await page.goto(pathToFileURL(path.join(viewerDir, 'test-results', `${name}.html`)).href); + await page.evaluate(() => window.__ioviewer.ready); + return { errors, requests }; +} + +const nextFrame = (page) => + page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))); + +const region = (page, x, y) => page.screenshot({ clip: { x, y, width: 40, height: 40 } }); + +test('building renders the model and counts elements', async ({ page }) => { + const { errors, requests } = await open(page, 'building'); + + const { width, height } = page.viewportSize(); + const center = await region(page, width / 2 - 20, height / 2 - 20); + const background = await region(page, 80, 0); // right of the panel rail + expect(center.equals(background)).toBe(false); + await expect(page.locator('#ioviewer-status')).toHaveText(`Выбрано: 0 · Элементов: ${building.Elements.length}`); + expect(errors).toEqual([]); + expect(requests).toEqual([]); +}); + +test('empty document shows "no geometry"', async ({ page }) => { + const { errors, requests } = await open(page, 'empty'); + + await expect(page.getByText('Нет геометрии')).toBeVisible(); + await expect(page.locator('#ioviewer-status')).toHaveText('Выбрано: 0 · Элементов: 0'); + expect(errors).toEqual([]); + expect(requests).toEqual([]); +}); + +test('ModelScene addresses elements by index across meshes and materials', async ({ page }) => { + const { errors } = await open(page, 'building'); + const duct = building.Elements.findIndex((e) => e.Name === 'Supply Duct SA-1'); + const curtain = building.Elements.findIndex((e) => e.Name === 'Curtain Wall South'); + + const result = await page.evaluate(({ duct, curtain }) => { + const { scene } = window.__ioviewer; + const size = (box) => box.getSize(box.min.clone()).toArray(); + const all = scene.visibleBox(); + scene.isolate([duct]); + const isolated = scene.visibleBox(); + scene.showAll(); + return { + ids: [scene.elementId(duct), scene.elementId(curtain)], + count: scene.elementCount, + isolatedEqualsDuct: isolated.equals(scene.elementBox(duct)), + restored: scene.visibleBox().equals(all), + curtainSize: size(scene.elementBox(curtain)), + }; + }, { duct, curtain }); + + expect(result.ids).toEqual([building.Elements[duct].Id, building.Elements[curtain].Id]); + expect(result.count).toBe(building.Elements.length); + expect(result.isolatedEqualsDuct).toBe(true); + expect(result.restored).toBe(true); + // The curtain wall element = opaque mullion (z 0..0.1) + transparent glazing (z 0.1..7.2). + expect(result.curtainSize[0]).toBeCloseTo(18, 4); + expect(result.curtainSize[1]).toBeCloseTo(0.1, 4); + expect(result.curtainSize[2]).toBeCloseTo(7.2, 4); + expect(errors).toEqual([]); +}); + +// Mean grey level (0..255) of the 4x4 screen pixels around (x, y); the PNG is decoded by the page itself. +async function brightness(page, x, y) { + const png = await page.screenshot({ clip: { x: x - 2, y: y - 2, width: 4, height: 4 } }); + return page.evaluate(async (base64) => { + const image = new Image(); + image.src = `data:image/png;base64,${base64}`; + await image.decode(); + const context = document.createElement('canvas').getContext('2d'); + context.drawImage(image, 0, 0); + const { data } = context.getImageData(0, 0, image.width, image.height); + let sum = 0; + for (let i = 0; i < data.length; i += 4) sum += (data[i] + data[i + 1] + data[i + 2]) / 3; + return sum / (data.length / 4); + }, png.toString('base64')); +} + +test('a mirrored instance is shaded like its unmirrored twin', async ({ page }) => { + const { errors } = await open(page, 'building'); + const twins = ['Column Twin', 'Column Twin Mirrored'].map((n) => building.Elements.findIndex((e) => e.Name === n)); + + // Centre of each twin's -Y face, which faces the camera, in CSS pixels. + const points = await page.evaluate((indices) => { + const { scene, camera } = window.__ioviewer; + return indices.map((index) => { + const box = scene.elementBox(index); + const point = box.getCenter(box.min.clone()); + point.y = box.min.y; + point.project(camera); + return [((point.x + 1) / 2) * innerWidth, ((1 - point.y) / 2) * innerHeight]; + }); + }, twins); + const plain = await brightness(page, ...points[0]); + const mirrored = await brightness(page, ...points[1]); + const background = await brightness(page, 100, 20); + + expect(Math.abs(plain - background)).toBeGreaterThan(10); // the sample hits the column, not the background + expect(Math.abs(mirrored - plain)).toBeLessThanOrEqual(3); + expect(errors).toEqual([]); +}); + +test('highlight, visibility and display modes change the picture without errors', async ({ page }) => { + const { errors } = await open(page, 'building'); + const { width, height } = page.viewportSize(); + const shot = () => region(page, width / 2 - 20, height / 2 - 20); + const before = await shot(); + + await page.evaluate((n) => window.__ioviewer.scene.setHighlighted([...Array(n).keys()]), building.Elements.length); + await nextFrame(page); + expect((await shot()).equals(before)).toBe(false); + + await page.evaluate(() => window.__ioviewer.scene.setHighlighted([])); + await nextFrame(page); + expect((await shot()).equals(before)).toBe(true); + + for (const mode of ['white', 'wireframe', 'textures', 'materials']) { + await page.evaluate((m) => window.__ioviewer.scene.setDisplayMode(m), mode); + await nextFrame(page); + } + await page.evaluate(() => window.__ioviewer.scene.setOpacity(0.5)); + await nextFrame(page); + await page.evaluate(() => window.__ioviewer.scene.setOpacity(1)); + await nextFrame(page); + expect((await shot()).equals(before)).toBe(true); + expect(errors).toEqual([]); +}); + +// Grey levels (0..255) of the screen pixels in `clip`, row by row; the PNG is decoded by the page itself. +async function greys(page, clip) { + const png = await page.screenshot({ clip }); + return page.evaluate(async (base64) => { + const image = new Image(); + image.src = `data:image/png;base64,${base64}`; + await image.decode(); + const canvas = Object.assign(document.createElement('canvas'), { width: image.width, height: image.height }); + const context = canvas.getContext('2d'); + context.drawImage(image, 0, 0); + const { data } = context.getImageData(0, 0, image.width, image.height); + return Array.from({ length: data.length / 4 }, (_, i) => (data[i * 4] + data[i * 4 + 1] + data[i * 4 + 2]) / 3); + }, png.toString('base64')); +} + +// How many neighbouring pixels differ by more than 1.5 grey levels across the middle 80 % of the textured fixture's +// cylinder, along the screen row through its centre: smooth shading changes by at most one level per pixel there, flat +// shading stays constant over a facet and jumps at its edge. +async function cylinderJumps(page) { + const [x0, x1, y] = await page.evaluate(() => { + const { camera } = window.__ioviewer; + camera.updateMatrixWorld(); + const right = camera.position.clone().setFromMatrixColumn(camera.matrixWorld, 0).normalize(); + const screen = (p) => { + const v = p.project(camera); + return [((v.x + 1) / 2) * innerWidth, ((1 - v.y) / 2) * innerHeight]; + }; + const centre = window.__ioviewer.scene.elementBox(0).getCenter(camera.position.clone()); // the tank, RTC + const [a] = screen(centre.clone().addScaledVector(right, -1.6)); + const [b, y] = screen(centre.clone().addScaledVector(right, 1.6)); + return [Math.round(a), Math.round(b), Math.round(y)]; + }); + const row = await greys(page, { x: x0, y, width: x1 - x0, height: 1 }); + return row.slice(1).filter((g, i) => Math.abs(g - row[i]) > 1.5).length; +} + +test('curved surfaces are shaded smoothly from the baked normals, not flat per facet', async ({ page }) => { + const { errors } = await open(page, 'textured'); + const geometries = await page.evaluate(() => + window.__ioviewer.scene.root.children.map((m) => ({ itemSize: m.geometry.getAttribute('normal')?.itemSize, normalized: m.geometry.getAttribute('normal')?.normalized })), + ); + expect(geometries.length).toBeGreaterThan(0); + expect(geometries.every((g) => g.itemSize === 2 && g.normalized)).toBe(true); + const smooth = await cylinderJumps(page); + // The defect this guards against: without a `normal` attribute three shades Lambert flat, per 7.5° facet, as here. + await page.evaluate(() => { + for (const { material } of window.__ioviewer.scene._buckets) { + material.flatShading = true; + material.needsUpdate = true; + } + window.__ioviewer.scene.setOpacity(1); + }); + await nextFrame(page); + const flat = await cylinderJumps(page); + expect(smooth).toBe(0); + expect(flat).toBeGreaterThanOrEqual(2); + expect(errors).toEqual([]); +}); + +// Opens a page that is expected to fail at startup: no __ioviewer.ready to wait for. +async function openFailing(page, name) { + const errors = []; + page.on('console', (message) => message.type() === 'error' && errors.push(message.text())); + page.on('pageerror', (error) => errors.push(error.message)); + await page.goto(pathToFileURL(path.join(viewerDir, 'test-results', `${name}.html`)).href); + return errors; +} + +test('without WebGL 2 the page says so instead of staying blank', async ({ page }) => { + await page.addInitScript(() => { + const getContext = HTMLCanvasElement.prototype.getContext; + HTMLCanvasElement.prototype.getContext = function (type, ...rest) { + return type === 'webgl2' ? null : getContext.call(this, type, ...rest); + }; + }); + const errors = await openFailing(page, 'building'); + await expect(page.locator('#ioviewer-message')).toHaveText('Нужен браузер с поддержкой WebGL 2'); + await expect(page.locator('#ioviewer-message')).toBeVisible(); + expect(errors).toEqual([]); +}); + +test('a page with damaged model data says so instead of staying blank', async ({ page }) => { + const errors = await openFailing(page, 'damaged'); + await expect(page.locator('#ioviewer-message')).toHaveText('Не удалось прочитать данные модели'); + await expect(page.locator('#ioviewer-message')).toBeVisible(); + expect(errors).toEqual([]); +}); + +test('context lost shows the reload message', async ({ page }) => { + await open(page, 'building'); + await expect(page.locator('#ioviewer-message')).toBeHidden(); + await page.evaluate(() => document.getElementById('ioviewer-canvas').getContext('webgl2').getExtension('WEBGL_lose_context').loseContext()); + await expect(page.locator('#ioviewer-message')).toHaveText('Видеокарта сброшена — перезагрузите страницу'); + await expect(page.locator('#ioviewer-message')).toBeVisible(); +}); + +test('pick element with index > 4096 (large fixture)', async ({ page }) => { + const { errors } = await open(page, 'large'); + for (const element of [4097, 15123, 19999]) { + // Zoomed onto the element (it is 0.3 m among 20000), the centre of its top face. + const hit = await page.evaluate((element) => { + const { scene, controls, picker, camera } = window.__ioviewer; + const box = scene.elementBox(element); + controls.fitBox(box, false); + camera.updateMatrixWorld(); + const top = box.getCenter(box.min.clone()); + top.z = box.max.z; + const p = top.clone().project(camera); + const rect = document.getElementById('ioviewer-canvas').getBoundingClientRect(); + const found = picker.pick(rect.left + ((p.x + 1) / 2) * rect.width, rect.top + ((1 - p.y) / 2) * rect.height); + return found && { element: found.element, distance: found.point.distanceTo(top), slot: scene.instance(scene.instancesOf(element)[0]).slot }; + }, element); + expect(hit.element).toBe(element); + expect(hit.distance).toBeLessThan(0.01); + } + expect(errors).toEqual([]); +}); + +test('a restored context keeps the reload message and nothing throws: no render, no pick', async ({ page }) => { + const { errors } = await open(page, 'building'); + await page.evaluate(() => { + const lose = document.getElementById('ioviewer-canvas').getContext('webgl2').getExtension('WEBGL_lose_context'); + window.__lose = lose; + lose.loseContext(); + }); + await expect(page.locator('#ioviewer-message')).toHaveText('Видеокарта сброшена — перезагрузите страницу'); + await page.evaluate(() => window.__lose.restoreContext()); + await page.waitForTimeout(300); + // Interaction after the restore: an orbit drag, a click (pick) and a visibility change, each of which used to render. + const { width, height } = page.viewportSize(); + await page.mouse.move(width / 2, height / 2); + await page.mouse.down(); + await page.mouse.move(width / 2 + 60, height / 2 + 20, { steps: 4 }); + await page.mouse.up(); + await page.mouse.click(width / 2, height / 2); + expect(await page.evaluate(() => window.__ioviewer.picker.pick(innerWidth / 2, innerHeight / 2))).toBeNull(); + await page.evaluate(() => window.__ioviewer.scene.setHighlighted([0])); + await nextFrame(page); + await expect(page.locator('#ioviewer-message')).toBeVisible(); + await expect(page.locator('#ioviewer-message')).toHaveText('Видеокарта сброшена — перезагрузите страницу'); + expect(errors).toEqual([]); +}); + +test.describe('a model over the slot limit', () => { + test.beforeEach(({ page }) => page.addInitScript(() => (window.IOVIEWER_TEST = { slotLimit: 10 }))); + + test('opens without geometry and says it is too large; the element tree still counts', async ({ page }) => { + const { errors } = await open(page, 'building'); + const drawable = building.Meshes.filter((m) => m.Vertices.length > 0 || m.GeometryId != null).length; + await expect(page.locator('#ioviewer-message')).toHaveText(`Модель слишком большая для этого просмотрщика: объектов с геометрией — ${drawable}, предел — 10`); + await expect(page.locator('#ioviewer-status')).toHaveText(`Выбрано: 0 · Элементов: ${building.Elements.length}`); + expect(await page.evaluate(() => window.__ioviewer.scene.memory().mergedInstances)).toBe(0); + expect(errors).toEqual([]); + }); +}); diff --git a/src/IOViewer/Viewer/test/e2e/tools.spec.js b/src/IOViewer/Viewer/test/e2e/tools.spec.js new file mode 100644 index 0000000..5386eb1 --- /dev/null +++ b/src/IOViewer/Viewer/test/e2e/tools.spec.js @@ -0,0 +1,388 @@ +import { test, expect } from '@playwright/test'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { viewerDir } from './global-setup.js'; + +const SCREENSHOT = path.resolve(viewerDir, '../../../.superpowers/sdd/2026-11-11-viewer-redesign-plan/task-10-screenshot.png'); + +async function open(page, name = 'building') { + const errors = []; + page.on('console', (message) => message.type() === 'error' && errors.push(message.text())); + page.on('pageerror', (error) => errors.push(error.message)); + await page.goto(pathToFileURL(path.join(viewerDir, 'test-results', `${name}.html`)).href); + await page.evaluate(() => window.__ioviewer.ready); + return errors; +} + +const frame = (page) => page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))); +const tool = (page, id) => page.locator(`.ioviewer-tool[data-tool="${id}"]`); +const indexOf = (page, name) => + page.evaluate((n) => JSON.parse(document.getElementById('ioviewer-meta').textContent).elements.findIndex((e) => e.name === n), name); +const selected = (page) => page.evaluate(() => [...window.__ioviewer.selection.values()]); +const modeItem = (page, mode) => page.locator(`.ioviewer-display-modes [data-mode="${mode}"]`); +// The middle of the canvas, clear of the panels and the toolbar. +const VIEW = { x: 340, y: 160, width: 600, height: 420 }; +const shot = async (page) => { + await frame(page); + return page.screenshot({ clip: VIEW }); +}; + +// Client position of a world point (META coordinates are RTC: world minus rtcOffset). +const screenOfWorld = (page, world) => + page.evaluate((world) => { + const { camera } = window.__ioviewer; + const rtc = JSON.parse(document.getElementById('ioviewer-meta').textContent).rtcOffset; + camera.updateMatrixWorld(); + const p = camera.position.clone().set(...world.map((c, i) => c - rtc[i])).project(camera); + const rect = document.getElementById('ioviewer-canvas').getBoundingClientRect(); + return [rect.left + ((p.x + 1) / 2) * rect.width, rect.top + ((1 - p.y) / 2) * rect.height]; + }, world); + +// Two points on the ground slab top (z = 0) in front of the curtain wall, 6 m apart along X. +const A = [2, -0.9, 0]; +const B = [8, -0.9, 0]; + +test('distance: two clicks on the slab top 6 m apart measure 6.00 m; the selection stays; Esc and turning off clear', async ({ page }) => { + const errors = await open(page); + const column = await indexOf(page, 'Column D2 Level 1'); + await page.evaluate((i) => window.__ioviewer.selection.set([i]), column); + const slab = await indexOf(page, 'Ground Slab'); + const [a, b] = [await screenOfWorld(page, A), await screenOfWorld(page, B)]; + expect(await page.evaluate(([x, y]) => window.__ioviewer.picker.pick(x, y)?.element, a)).toBe(slab); + expect(await page.evaluate(([x, y]) => window.__ioviewer.picker.pick(x, y)?.element, b)).toBe(slab); + + await tool(page, 'distance').click(); + await expect(tool(page, 'distance')).toHaveClass(/active/); + await page.mouse.click(640, 40); // empty space: no point, selection kept + expect(await page.evaluate(() => window.__ioviewer.measure.points.length)).toBe(0); + await page.mouse.click(...a); + await expect(page.locator('.ioviewer-measure-label')).toBeHidden(); // one point: no distance yet + await page.mouse.click(...b); + const distance = await page.evaluate(() => window.__ioviewer.measure.distance); + expect(Math.abs(distance - 6)).toBeLessThanOrEqual(0.05); + await expect(page.locator('.ioviewer-measure-label')).toBeVisible(); + await expect(page.locator('.ioviewer-measure-label')).toHaveText(/^\d+,\d\d м$/); + expect(Number((await page.locator('.ioviewer-measure-label').textContent()).replace(',', '.').split(' ')[0])).toBeCloseTo(distance, 2); + expect(await selected(page)).toEqual([column]); + + // The label sits at the projected midpoint. + await frame(page); + const label = await page.locator('.ioviewer-measure-label').boundingBox(); + expect(Math.abs(label.x + label.width / 2 - (a[0] + b[0]) / 2)).toBeLessThan(2); + expect(Math.abs(label.y + label.height / 2 - (a[1] + b[1]) / 2)).toBeLessThan(2); + + // A drag still orbits and does not place a point. + const before = await page.evaluate(() => window.__ioviewer.camera.position.toArray()); + await page.mouse.move(700, 300); + await page.mouse.down(); + await page.mouse.move(760, 320, { steps: 4 }); + await page.mouse.up(); + expect(await page.evaluate(() => window.__ioviewer.camera.position.toArray())).not.toEqual(before); + expect(await page.evaluate(() => window.__ioviewer.measure.points.length)).toBe(2); + + await page.keyboard.press('Escape'); // clears the measurement, keeps the selection + await expect(page.locator('.ioviewer-measure-label')).toBeHidden(); + expect(await page.evaluate(() => window.__ioviewer.measure.points.length)).toBe(0); + expect(await selected(page)).toEqual([column]); + + await page.evaluate(() => window.__ioviewer.controls.fitBox(window.__ioviewer.scene.visibleBox(), false)); + const [a2, b2] = [await screenOfWorld(page, A), await screenOfWorld(page, B)]; + await page.mouse.click(...a2); + await page.mouse.click(...b2); + await expect(page.locator('.ioviewer-measure-label')).toBeVisible(); + await tool(page, 'distance').click(); // off: the measurement goes, clicks select again + await expect(tool(page, 'distance')).not.toHaveClass(/active/); + await expect(page.locator('.ioviewer-measure-label')).toBeHidden(); + await page.mouse.click(...a2); + expect(await selected(page)).toEqual([slab]); + expect(errors).toEqual([]); +}); + +test('display mode menu: materials checked, textures disabled without textures, each mode changes the picture', async ({ page }) => { + const errors = await open(page); + await tool(page, 'displayMode').click(); + await expect(page.locator('.ioviewer-display-modes')).toBeVisible(); + await expect(page.locator('.ioviewer-display-modes .ioviewer-menu-item')).toHaveText(['Материалы', 'Текстуры', 'Белый', 'Каркас']); + await expect(modeItem(page, 'materials')).toHaveAttribute('aria-checked', 'true'); + await expect(modeItem(page, 'textures')).toBeDisabled(); + + const pictures = { materials: await shot(page) }; + for (const mode of ['white', 'wireframe']) { + if (!(await page.locator('.ioviewer-display-modes').isVisible())) await tool(page, 'displayMode').click(); + await modeItem(page, mode).click(); + await expect(page.locator('.ioviewer-display-modes')).toBeHidden(); // a choice closes the menu + pictures[mode] = await shot(page); + } + expect(pictures.white.equals(pictures.materials)).toBe(false); + expect(pictures.wireframe.equals(pictures.materials)).toBe(false); + expect(pictures.wireframe.equals(pictures.white)).toBe(false); + await tool(page, 'displayMode').click(); + await expect(modeItem(page, 'wireframe')).toHaveAttribute('aria-checked', 'true'); + await expect(modeItem(page, 'materials')).toHaveAttribute('aria-checked', 'false'); + await modeItem(page, 'materials').click(); + expect((await shot(page)).equals(pictures.materials)).toBe(true); + expect(errors).toEqual([]); +}); + +test('initialWhiteMode from the export options opens in white', async ({ page }) => { + const errors = await open(page, 'building-white'); + const white = await shot(page); + await tool(page, 'displayMode').click(); + await expect(modeItem(page, 'white')).toHaveAttribute('aria-checked', 'true'); + await modeItem(page, 'materials').click(); + const materials = await shot(page); + expect(materials.equals(white)).toBe(false); + await tool(page, 'displayMode').click(); + await modeItem(page, 'white').click(); + expect((await shot(page)).equals(white)).toBe(true); + expect(errors).toEqual([]); +}); + +test('grid axes: lines and labels shown from the start, the toggle hides and shows the labels; grids are not pickable', async ({ page }) => { + const errors = await open(page); + const labels = page.locator('.ioviewer-grid-label'); + await expect(tool(page, 'grids')).toHaveClass(/active/); + await expect(labels).toHaveCount(14); // 7 axes, a label at both ends + expect(new Set(await labels.allTextContents())).toEqual(new Set(['A', 'B', 'C', 'D', '1', '2', '3'])); + await expect(labels.filter({ visible: true })).not.toHaveCount(0); + // The label of axis A at its south end sits on the projected end of the line. + await frame(page); + const end = await screenOfWorld(page, [0, -2, 0]); + const box = await labels.first().boundingBox(); + expect(Math.abs(box.x + box.width / 2 - end[0])).toBeLessThan(2); + expect(Math.abs(box.y + box.height / 2 - end[1])).toBeLessThan(2); + expect(await page.evaluate(([x, y]) => window.__ioviewer.picker.pick(x, y), end)).toBeNull(); + + // Labels hidden in the shots: they are compared elsewhere and their text antialiasing may vary by a level. + const lines = async () => { + const style = await page.addStyleTag({ content: '.ioviewer-grid-labels { visibility: hidden; }' }); + const picture = await shot(page); + await style.evaluate((node) => node.remove()); + return picture; + }; + const withGrids = await lines(); + await tool(page, 'grids').click(); + await expect(tool(page, 'grids')).not.toHaveClass(/active/); + await expect(labels.filter({ visible: true })).toHaveCount(0); + expect((await lines()).equals(withGrids)).toBe(false); // the lines went too + await tool(page, 'grids').click(); + await expect(tool(page, 'grids')).toHaveClass(/active/); + await expect(labels.filter({ visible: true })).not.toHaveCount(0); + expect((await lines()).equals(withGrids)).toBe(true); + expect(errors).toEqual([]); +}); + +test('grids button is off for a model without grids', async ({ page }) => { + await open(page, 'empty'); + await expect(tool(page, 'grids')).toHaveAttribute('aria-disabled', 'true'); + await expect(page.locator('.ioviewer-grid-label')).toHaveCount(0); +}); + +test('opacity slider 10-100 % changes the picture; the popover closes on Esc and on an outside press that changes nothing', async ({ page }) => { + const errors = await open(page); + const column = await indexOf(page, 'Column D2 Level 1'); + await page.evaluate((i) => window.__ioviewer.selection.set([i]), column); + const opaque = await shot(page); + await tool(page, 'opacity').click(); + const slider = page.locator('.ioviewer-opacity-slider'); + await expect(slider).toHaveAttribute('min', '10'); + await expect(slider).toHaveAttribute('max', '100'); + await expect(page.locator('.ioviewer-opacity-value')).toHaveText('100 %'); + await slider.fill('40'); + await expect(page.locator('.ioviewer-opacity-value')).toHaveText('40 %'); + expect((await shot(page)).equals(opaque)).toBe(false); + await page.keyboard.press('Escape'); // focus is on the slider + await expect(page.locator('.ioviewer-opacity')).toBeHidden(); + expect(await selected(page)).toEqual([column]); + + await tool(page, 'opacity').click(); + await slider.fill('100'); + expect((await shot(page)).equals(opaque)).toBe(true); + await page.mouse.click(640, 40); // outside, over empty space: closes, selection kept + await expect(page.locator('.ioviewer-opacity')).toBeHidden(); + expect(await selected(page)).toEqual([column]); + + await tool(page, 'displayMode').click(); + await tool(page, 'displayMode').click(); // its own button closes it + await expect(page.locator('.ioviewer-display-modes')).toBeHidden(); + await tool(page, 'displayMode').click(); + await page.keyboard.press('Escape'); // Esc closes the menu and keeps the selection + await expect(page.locator('.ioviewer-display-modes')).toBeHidden(); + expect(await selected(page)).toEqual([column]); + expect(errors).toEqual([]); +}); + +// Largest per-pixel difference from the background in a 5x5 patch around a point (catches a 1 px line). +async function maxFromBackground(page, [x, y]) { + const png = await page.screenshot({ clip: { x: Math.round(x) - 2, y: Math.round(y) - 2, width: 5, height: 5 } }); + return page.evaluate(async (base64) => { + const image = new Image(); + image.src = `data:image/png;base64,${base64}`; + await image.decode(); + const context = document.createElement('canvas').getContext('2d'); + context.drawImage(image, 0, 0); + const { data } = context.getImageData(0, 0, image.width, image.height); + let max = 0; + for (let i = 0; i < data.length; i += 4) max = Math.max(max, Math.abs(data[i] - 31) + Math.abs(data[i + 1] - 34) + Math.abs(data[i + 2] - 40)); + return max; + }, png.toString('base64')); +} + +test('grid axes and a measurement stay drawn after Isolate and Hide shrink what is shown', async ({ page }) => { + const errors = await open(page); + const labels = page.locator('.ioviewer-grid-labels .ioviewer-grid-label'); + await tool(page, 'distance').click(); + await page.mouse.click(...(await screenOfWorld(page, A))); + await page.mouse.click(...(await screenOfWorld(page, B))); + const onAxisA = [0, 13, 0]; // axis A, far behind and left of the column, away from its labels + expect(await maxFromBackground(page, await screenOfWorld(page, onAxisA))).toBeGreaterThan(60); + await expect(labels.filter({ visible: true })).toHaveCount(14); + + const column = await indexOf(page, 'Column D2 Level 1'); + await page.evaluate((i) => window.__ioviewer.selection.set([i]), column); + await page.keyboard.press('i'); + await frame(page); + expect(await page.evaluate(() => window.__ioviewer.scene.visibleBox().max.x - window.__ioviewer.scene.visibleBox().min.x)).toBeLessThan(1); + await expect(labels.filter({ visible: true })).toHaveCount(14); + expect(await maxFromBackground(page, await screenOfWorld(page, onAxisA))).toBeGreaterThan(60); + await expect(page.locator('.ioviewer-measure-label')).toBeVisible(); + const onLine = await screenOfWorld(page, [2.8, -0.9, 0]); // the measure line, clear of its label, over nothing now + expect(await maxFromBackground(page, onLine)).toBeGreaterThan(60); + + // Show all, then hide everything but the two twin columns in front of the building. + await page.keyboard.press('Shift+H'); + const twins = await Promise.all(['Column Twin', 'Column Twin Mirrored'].map((n) => indexOf(page, n))); + await page.evaluate((keep) => { + const { selection, scene } = window.__ioviewer; + selection.set([...Array(scene.elementCount).keys()].filter((i) => !keep.includes(i))); + }, twins); + await page.keyboard.press('h'); + await page.evaluate(() => window.__ioviewer.selection.clear()); + await frame(page); + // Shown now: the twins and the survey marker (no element, so H cannot hide it), all in front of the building. + const shown = await page.evaluate(() => { + const { scene } = window.__ioviewer; + return [...Array(scene.elementCount).keys()].filter((i) => scene.isVisible(i)); + }); + expect(shown).toEqual(twins); + await expect(labels.filter({ visible: true })).toHaveCount(14); + expect(await maxFromBackground(page, await screenOfWorld(page, onAxisA))).toBeGreaterThan(60); + expect(errors).toEqual([]); +}); + +test('Esc on a focused display-mode item closes only the menu: selection and measurement stay', async ({ page }) => { + const errors = await open(page); + const column = await indexOf(page, 'Column D2 Level 1'); + await page.evaluate((i) => window.__ioviewer.selection.set([i]), column); + await tool(page, 'distance').click(); + await page.mouse.click(...(await screenOfWorld(page, A))); + await page.mouse.click(...(await screenOfWorld(page, B))); + await tool(page, 'displayMode').click(); + await modeItem(page, 'white').focus(); + await page.keyboard.press('Escape'); + await expect(page.locator('.ioviewer-display-modes')).toBeHidden(); + expect(await selected(page)).toEqual([column]); + expect(await page.evaluate(() => window.__ioviewer.measure.points.length)).toBe(2); + expect(errors).toEqual([]); +}); + +test('with the distance tool on, box select and right click keep the selection', async ({ page }) => { + const errors = await open(page); + const column = await indexOf(page, 'Column D2 Level 1'); + await page.evaluate((i) => window.__ioviewer.selection.set([i]), column); + await tool(page, 'distance').click(); + const drag = async (from, to, modifiers) => { + for (const key of modifiers) await page.keyboard.down(key); + await page.mouse.move(...from); + await page.mouse.down(); + await page.mouse.move(...to, { steps: 5 }); + await page.mouse.up(); + for (const key of modifiers) await page.keyboard.up(key); + }; + await drag([300, 200], [1000, 650], ['Shift']); // window over the whole building + expect(await selected(page)).toEqual([column]); + await drag([1000, 650], [300, 200], ['Control', 'Shift']); // crossing, adding + expect(await selected(page)).toEqual([column]); + expect(await page.evaluate(() => window.__ioviewer.measure.points.length)).toBe(0); + + const slab = await screenOfWorld(page, A); + await page.mouse.click(...slab, { button: 'right' }); + await expect(page.locator('.ioviewer-context-menu')).toBeVisible(); + await expect(page.locator('.ioviewer-context-menu [data-item="isolate"]')).toBeVisible(); // the element menu + expect(await selected(page)).toEqual([column]); + await page.locator('.ioviewer-context-menu [data-item="isolate"]').click(); // acts on the selection + expect(await page.evaluate((i) => window.__ioviewer.scene.isVisible(i), column)).toBe(true); + const ground = await indexOf(page, 'Ground Slab'); + expect(await page.evaluate((i) => window.__ioviewer.scene.isVisible(i), ground)).toBe(false); + expect(errors).toEqual([]); +}); + +test('screenshot: a measurement and the grid labels', async ({ page }) => { + await open(page); + await tool(page, 'distance').click(); + await page.mouse.click(...(await screenOfWorld(page, A))); + await page.mouse.click(...(await screenOfWorld(page, B))); + await expect(page.locator('.ioviewer-measure-label')).toBeVisible(); + await frame(page); + await page.screenshot({ path: SCREENSHOT }); +}); + +test.describe('without float render targets', () => { + test.beforeEach(({ page }) => page.addInitScript(() => (window.IOVIEWER_TEST = { pickFloat: false }))); + + test('pick + measure with pickFloat:false: the slab is picked and two clicks 6 m apart measure 6.00 m', async ({ page }) => { + const errors = await open(page); + expect(await page.evaluate(() => window.__ioviewer.picker._float)).toBe(false); + const slab = await indexOf(page, 'Ground Slab'); + const [a, b] = [await screenOfWorld(page, A), await screenOfWorld(page, B)]; + expect(await page.evaluate(([x, y]) => window.__ioviewer.picker.pick(x, y)?.element, a)).toBe(slab); + await tool(page, 'distance').click(); + await page.mouse.click(...a); + await page.mouse.click(...b); + expect(Math.abs((await page.evaluate(() => window.__ioviewer.measure.distance)) - 6)).toBeLessThanOrEqual(0.05); + expect(errors).toEqual([]); + }); +}); + +test('textures mode changes pixels and returns pixel-identical', async ({ page }) => { + const errors = await open(page, 'textured'); + const materials = await shot(page); + await tool(page, 'displayMode').click(); + await expect(modeItem(page, 'textures')).toBeEnabled(); + await modeItem(page, 'textures').click(); + // The texture image decodes asynchronously; the scene redraws when it is ready. + await expect.poll(async () => (await shot(page)).equals(materials)).toBe(false); + await tool(page, 'displayMode').click(); + await modeItem(page, 'materials').click(); + expect((await shot(page)).equals(materials)).toBe(true); + expect(errors).toEqual([]); +}); + +test('wireframe click still selects the face\'s element', async ({ page }) => { + const errors = await open(page); + await page.evaluate(() => window.__ioviewer.scene.setDisplayMode('wireframe')); + const slab = await indexOf(page, 'Ground Slab'); + await page.mouse.click(...(await screenOfWorld(page, A))); // between the slab's edges: only its face is there + expect(await selected(page)).toEqual([slab]); + expect(errors).toEqual([]); +}); + +test('wireframe lines have no black (NaN-shaded) pixels', async ({ page }) => { + const errors = await open(page); + await page.evaluate(() => window.__ioviewer.scene.setDisplayMode('wireframe')); + const png = await shot(page); + const black = await page.evaluate(async (base64) => { + const image = new Image(); + image.src = `data:image/png;base64,${base64}`; + await image.decode(); + const canvas = Object.assign(document.createElement('canvas'), { width: image.width, height: image.height }); + const context = canvas.getContext('2d'); + context.drawImage(image, 0, 0); + const { data } = context.getImageData(0, 0, image.width, image.height); + let count = 0; + for (let i = 0; i < data.length; i += 4) if (data[i] + data[i + 1] + data[i + 2] === 0) count++; + return count; + }, png.toString('base64')); + expect(black).toBe(0); // today's engine draws none; flat shading of lines drew thousands + expect(errors).toEqual([]); +}); diff --git a/src/IOViewer/Viewer/test/e2e/ui.spec.js b/src/IOViewer/Viewer/test/e2e/ui.spec.js new file mode 100644 index 0000000..b53f340 --- /dev/null +++ b/src/IOViewer/Viewer/test/e2e/ui.spec.js @@ -0,0 +1,432 @@ +import { test, expect } from '@playwright/test'; +import { readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { viewerDir, LARGE_ELEMENTS } from './global-setup.js'; + +const page$ = (name) => pathToFileURL(path.join(viewerDir, 'test-results', `${name}.html`)).href; + +async function open(page, name = 'building') { + const errors = []; + page.on('console', (message) => message.type() === 'error' && errors.push(message.text())); + page.on('pageerror', (error) => errors.push(error.message)); + await page.goto(page$(name)); + await page.evaluate(() => window.__ioviewer.ready); + return errors; +} + +const frame = (page) => page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))); +const indexOf = (page, name) => + page.evaluate((n) => JSON.parse(document.getElementById('ioviewer-meta').textContent).elements.findIndex((e) => e.name === n), name); +const idOf = (page, i) => page.evaluate((i) => window.__ioviewer.scene.elementId(i), i); +const selected = (page) => page.evaluate(() => [...window.__ioviewer.selection.values()]); +const visible = (page, i) => page.evaluate((i) => window.__ioviewer.scene.isVisible(i), i); +const screenAt = (page, index) => + page.evaluate((i) => { + const { scene, camera } = window.__ioviewer; + const box = scene.elementBox(i); + const p = box.getCenter(box.min.clone()).project(camera); + return [((p.x + 1) / 2) * innerWidth, ((1 - p.y) / 2) * innerHeight]; + }, index); +const pixel = (page, [x, y]) => page.screenshot({ clip: { x: Math.round(x) - 1, y: Math.round(y) - 1, width: 3, height: 3 } }); +const blank = [100, 15]; // background: right of the panel rail, far from the fitted model + +const menu = (page) => page.locator('.ioviewer-context-menu'); +const menuItem = (page, text) => menu(page).locator('.ioviewer-menu-item', { hasText: text }); +const rail = (page, name) => page.locator(`.ioviewer-rail-button[data-panel="${name}"]`); +const sidePanel = (page, name) => page.locator(`.ioviewer-side-panel[data-panel="${name}"]`); +const rows = (page) => page.locator('.tree-row'); + +test('right click on a column selects it and opens the element menu; Hide removes it, Shift+H brings it back', async ({ page }) => { + const errors = await open(page); + const d2 = await indexOf(page, 'Column D2 Level 1'); + const at = await screenAt(page, d2); + const before = await pixel(page, at); + + await page.mouse.click(...at, { button: 'right' }); + await expect(menu(page)).toBeVisible(); + expect(await selected(page)).toEqual([d2]); + await expect(menu(page).locator('.ioviewer-menu-item span')).toHaveText([ + 'Свойства', 'Скрыть', 'Изолировать', 'Показать всё', 'Приблизить к объекту', 'Бокс сечения по объекту', 'Копировать Id', + ]); + await expect(menuItem(page, 'Скрыть').locator('kbd')).toHaveText('H'); + await expect(menuItem(page, 'Бокс сечения по объекту')).toBeEnabled(); + + await menuItem(page, 'Скрыть').click(); + await expect(menu(page)).toBeHidden(); + expect(await visible(page, d2)).toBe(false); + await frame(page); + expect((await pixel(page, at)).equals(before)).toBe(false); + + await page.keyboard.press('Shift+KeyH'); + expect(await visible(page, d2)).toBe(true); + await page.keyboard.press('Escape'); // drop the highlight to compare with the first picture + await frame(page); + expect((await pixel(page, at)).equals(before)).toBe(true); + expect(errors).toEqual([]); +}); + +test('right click on nothing opens the short menu; Esc, an outside click and the wheel close it', async ({ page }) => { + await open(page); + const d2 = await indexOf(page, 'Column D2 Level 1'); + const d3 = await indexOf(page, 'Column D3 Level 1'); + await page.mouse.click(...(await screenAt(page, d2))); + + await page.mouse.click(...blank, { button: 'right' }); + await expect(menu(page).locator('.ioviewer-menu-item span')).toHaveText(['Показать всё', 'Вписать всё', 'Сбросить сечения']); + expect(await selected(page)).toEqual([d2]); // the short menu keeps the selection + // Esc with the menu open closes only the menu; the next Esc clears the selection. + await page.keyboard.press('Escape'); + await expect(menu(page)).toBeHidden(); + expect(await selected(page)).toEqual([d2]); + await page.keyboard.press('Escape'); + expect(await selected(page)).toEqual([]); + + // A click outside the menu only dismisses it: clicking another column neither selects it nor clears. + await page.mouse.click(...(await screenAt(page, d2))); + await page.mouse.click(...blank, { button: 'right' }); + await page.mouse.click(...(await screenAt(page, d3))); + await expect(menu(page)).toBeHidden(); + expect(await selected(page)).toEqual([d2]); + await page.mouse.click(...(await screenAt(page, d3))); // the next click works as usual + expect(await selected(page)).toEqual([d3]); + // The same for a tree row under the press. + await rail(page, 'model').click(); + await sidePanel(page, 'model').locator('.ioviewer-tree').evaluate((t) => (t.scrollTop = 0)); // it scrolled to D3 + const ducts = await rows(page).filter({ hasText: 'Ducts' }).boundingBox(); + await page.mouse.click(1000, 40, { button: 'right' }); // background clear of the open panel and the view cube + await expect(menu(page)).toBeVisible(); + await page.mouse.click(ducts.x + 20, ducts.y + ducts.height / 2); // press on the Ducts category header + await expect(menu(page)).toBeHidden(); + await expect(rows(page).filter({ hasText: 'Supply Duct SA-1' })).toHaveCount(0); // the category did not expand + expect(await selected(page)).toEqual([d3]); + + await page.mouse.click(1000, 40, { button: 'right' }); + await page.mouse.wheel(0, 100); + await expect(menu(page)).toBeHidden(); +}); + +test('the menu stays open when the tree scrolls to reveal the right-clicked element', async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 720 }); + await open(page); + await rail(page, 'model').click(); + const tree = sidePanel(page, 'model').locator('.ioviewer-tree'); + expect(await tree.evaluate((t) => t.scrollTop)).toBe(0); + const twin = await indexOf(page, 'Column Twin'); + await page.mouse.click(...(await screenAt(page, twin)), { button: 'right' }); + expect(await selected(page)).toEqual([twin]); + await expect(rows(page).filter({ hasText: /^Column Twin(?! Mirrored)/ })).toHaveClass(/selected/); + expect(await tree.evaluate((t) => t.scrollTop)).toBeGreaterThan(0); // the row was below the fold + await page.waitForTimeout(300); + await expect(menu(page)).toBeVisible(); +}); + +test('the menu stays inside the window near its corner', async ({ page }) => { + await open(page); + const { width, height } = page.viewportSize(); + await page.mouse.click(width - 5, height - 120, { button: 'right' }); + const box = await menu(page).boundingBox(); + expect(box.x).toBeGreaterThanOrEqual(0); + expect(box.y).toBeGreaterThanOrEqual(0); + expect(box.x + box.width).toBeLessThanOrEqual(width); + expect(box.y + box.height).toBeLessThanOrEqual(height); +}); + +test('hotkeys: I isolates, H hides, Shift+H shows all, F zooms to the selection; ignored while typing', async ({ page }) => { + const errors = await open(page); + const d2 = await indexOf(page, 'Column D2 Level 1'); + const d3 = await indexOf(page, 'Column D3 Level 1'); + await page.mouse.click(...(await screenAt(page, d2))); + + await page.keyboard.press('KeyI'); + expect([await visible(page, d2), await visible(page, d3)]).toEqual([true, false]); + await page.keyboard.press('Shift+KeyH'); + expect(await visible(page, d3)).toBe(true); + + const distance = () => page.evaluate(() => window.__ioviewer.camera.position.distanceTo(window.__ioviewer.controls.target)); + const far = await distance(); + await page.keyboard.press('KeyF'); + const offCentre = () => + page.evaluate((i) => { + const { scene, controls } = window.__ioviewer; + const box = scene.elementBox(i); + return box.getCenter(box.min.clone()).distanceTo(controls.target); + }, d2); + await expect.poll(offCentre).toBeLessThan(1e-6); + expect(await distance()).toBeLessThan(far / 2); + + // Typing "h" in the tree search must not hide anything. + await rail(page, 'model').click(); + await sidePanel(page, 'model').locator('input').fill(''); + await sidePanel(page, 'model').locator('input').press('KeyH'); + expect(await visible(page, d2)).toBe(true); + await page.mouse.click(...blank); // leaves the field; H now hides + await page.mouse.click(...(await screenAt(page, d2))); + await page.keyboard.press('KeyH'); + expect(await visible(page, d2)).toBe(false); + expect(errors).toEqual([]); +}); + +test('a press in the 3D view or on the view cube takes focus from a panel field or a toolbar button: hotkeys work', async ({ page }) => { + const errors = await open(page); + const d2 = await indexOf(page, 'Column D2 Level 1'); + await rail(page, 'model').click(); + const search = sidePanel(page, 'model').locator('input'); + await search.click(); + await page.keyboard.type('col'); + await search.fill(''); + await expect(search).toBeFocused(); + await page.mouse.click(...(await screenAt(page, d2))); + expect(await selected(page)).toEqual([d2]); + await page.keyboard.press('KeyH'); + expect(await visible(page, d2)).toBe(false); + await expect(search).toHaveValue(''); + await page.keyboard.press('Escape'); + expect(await selected(page)).toEqual([]); + + // A toolbar button does not keep the focus after a press in the view: Enter does not fire it again. + await page.locator('.ioviewer-tool[data-tool="fit"]').click(); + await page.mouse.click(...blank); + expect(await page.evaluate(() => document.activeElement === document.body)).toBe(true); + + // Nor does the view cube leave it in a field. + await search.click(); + await page.locator('.ioviewer-viewcube-canvas').click({ position: { x: 3, y: 3 } }); + await expect(search).not.toBeFocused(); + expect(errors).toEqual([]); +}); + +test('F with nothing selected fits everything', async ({ page }) => { + await open(page); + const fitted = () => page.evaluate(() => window.__ioviewer.controls.target.toArray()); + const start = await fitted(); + await page.evaluate(() => window.__ioviewer.controls.zoomToBox(window.__ioviewer.scene.elementBox(1))); + await expect.poll(async () => (await fitted())[0]).not.toBeCloseTo(start[0], 3); + await page.keyboard.press('KeyF'); + await expect.poll(async () => Math.max(...(await fitted()).map((v, k) => Math.abs(v - start[k])))).toBeLessThan(1e-6); +}); + +test('rail: data panels show only with data; one panel at a time; the active icon closes it', async ({ page }) => { + await open(page); + for (const name of ['model', 'views', 'clashes', 'timeliner', 'links']) await expect(rail(page, name)).toBeVisible(); + await expect(rail(page, 'clashes')).toHaveAttribute('title', 'Коллизии'); + + await rail(page, 'model').click(); + await expect(sidePanel(page, 'model')).toBeVisible(); + await rail(page, 'clashes').click(); + await expect(sidePanel(page, 'model')).toBeHidden(); + await expect(sidePanel(page, 'clashes')).toContainText('Duct vs Structure (3)'); + await expect(rail(page, 'clashes')).toHaveClass(/active/); + await rail(page, 'clashes').click(); + await expect(sidePanel(page, 'clashes')).toBeHidden(); + await rail(page, 'timeliner').click(); + await page.keyboard.press('Escape'); // Esc leaves panels alone + await expect(sidePanel(page, 'timeliner')).toBeVisible(); + await sidePanel(page, 'timeliner').getByRole('button', { name: 'Закрыть' }).click(); + await expect(sidePanel(page, 'timeliner')).toBeHidden(); +}); + +test('rail: a model without clashes, 4D, links and views shows only the model panel', async ({ page }) => { + await open(page, 'escaping'); + await expect(rail(page, 'model')).toBeVisible(); + for (const name of ['views', 'clashes', 'timeliner', 'links']) await expect(rail(page, name)).toBeHidden(); +}); + +test('tree: categories collapsed with counts; a row click selects and zooms; selecting in 3D reveals the row', async ({ page }) => { + const errors = await open(page); + await rail(page, 'model').click(); + const columns = rows(page).filter({ hasText: 'Structural Columns' }); + await expect(columns.locator('.tree-count')).toHaveText('26'); + await expect(rows(page)).toHaveCount(6); // only category headers + + await columns.click(); + const d2 = await indexOf(page, 'Column D2 Level 1'); + const d2Row = rows(page).filter({ hasText: 'Column D2 Level 1' }); + await expect(d2Row.locator('.tree-type')).toHaveText('Concrete 400x400'); + const target = () => page.evaluate(() => window.__ioviewer.controls.target.toArray()); + const before = await target(); + await d2Row.click(); + expect(await selected(page)).toEqual([d2]); + await expect(d2Row).toHaveClass(/selected/); + await expect.poll(target).not.toEqual(before); + + // Selecting the duct in 3D expands Ducts and highlights its row; the panel stays above the toolbar. + const duct = await indexOf(page, 'Supply Duct SA-1'); + await page.evaluate((i) => window.__ioviewer.selection.set([i]), duct); + await expect(rows(page).filter({ hasText: 'Supply Duct SA-1' })).toHaveClass(/selected/); + await expect(d2Row).not.toHaveClass(/selected/); + + // Hidden elements are dimmed. + await page.keyboard.press('KeyH'); + await expect(rows(page).filter({ hasText: 'Supply Duct SA-1' })).toHaveClass(/tree-hidden/); + expect(errors).toEqual([]); +}); + +test('tree: search by Id, type and name flattens the matches', async ({ page }) => { + await open(page); + await rail(page, 'model').click(); + const search = sidePanel(page, 'model').locator('input'); + await expect(search).toHaveAttribute('placeholder', 'Поиск по имени, типу, Id…'); + await search.fill('300063'); + await expect(rows(page)).toHaveText(['Supply Duct SA-1Rectangular 600x400']); + await search.fill('steel i'); + await expect(rows(page)).toHaveCount(34); + await search.fill('column d2'); + await expect(rows(page)).toHaveCount(2); + await search.fill('nothing like this'); + await expect(rows(page)).toHaveCount(0); + await expect(sidePanel(page, 'model')).toContainText('Ничего не найдено'); +}); + +test(`tree: ${LARGE_ELEMENTS} rows scroll with fewer than 200 row nodes`, async ({ page }) => { + const errors = await open(page, 'large'); + await rail(page, 'model').click(); + await sidePanel(page, 'model').locator('input').fill('segment'); + const tree = sidePanel(page, 'model').locator('.ioviewer-tree'); + expect(await tree.evaluate((t) => t.scrollHeight)).toBe(LARGE_ELEMENTS * 24); + for (const fraction of [0, 0.25, 0.5, 1]) { + await tree.evaluate((t, f) => (t.scrollTop = (t.scrollHeight - t.clientHeight) * f), fraction); + await frame(page); + const count = await rows(page).count(); + expect(count).toBeGreaterThan(0); + expect(count).toBeLessThan(200); + } + await expect(rows(page).last()).toContainText(`Pipe ${LARGE_ELEMENTS - 1}`); + // The open panel ends above the toolbar. + const panel = await sidePanel(page, 'model').boundingBox(); + const toolbar = await page.locator('.ioviewer-toolbar').boundingBox(); + expect(panel.y + panel.height).toBeLessThan(toolbar.y); + expect(errors).toEqual([]); +}); + +test('toolbar: grouped tools, orbit active, pan and projection switch, tools carry a tooltip', async ({ page }) => { + await open(page); + await expect(page.locator('.ioviewer-toolbar-label')).toHaveText(['Навигация', 'Сечения', 'Измерения', 'Отображение', 'Вид']); + const tool = (id) => page.locator(`.ioviewer-tool[data-tool="${id}"]`); + await expect(tool('orbit')).toHaveClass(/active/); + + await tool('pan').click(); + expect(await page.evaluate(() => window.__ioviewer.controls.leftButtonMode)).toBe('pan'); + await expect(tool('pan')).toHaveClass(/active/); + await expect(tool('orbit')).not.toHaveClass(/active/); + + await tool('projection').click(); + expect(await page.evaluate(() => window.__ioviewer.camera.isOrthographicCamera)).toBe(true); + await expect(tool('projection')).toHaveClass(/active/); + await tool('projection').click(); + expect(await page.evaluate(() => window.__ioviewer.camera.isPerspectiveCamera)).toBe(true); + + await expect(tool('plane')).toHaveAttribute('aria-disabled', 'false'); + await expect(tool('distance')).toHaveAttribute('aria-disabled', 'false'); + await expect(tool('distance')).toHaveAttribute('title', 'Расстояние'); + await expect(tool('fit')).toHaveAttribute('aria-disabled', 'false'); + await expect(page.locator('.ioviewer-toolbar-group').last().locator('.ioviewer-tool')).toHaveCount(1); // view: fullscreen only + await expect(tool('settings')).toHaveCount(0); +}); + +test('a refused fullscreen request shows a message and leaves the button off', async ({ page }) => { + await page.addInitScript(() => (Element.prototype.requestFullscreen = () => Promise.reject(new TypeError('denied')))); + const errors = await open(page); + const fullscreen = page.locator('.ioviewer-tool[data-tool="fullscreen"]'); + await fullscreen.click(); + await expect(page.locator('.ioviewer-toast')).toHaveText('Полноэкранный режим недоступен'); + await expect(fullscreen).not.toHaveClass(/active/); + expect(errors).toEqual([]); +}); + +test('status bar counts the selection; the truncation banner shows and closes', async ({ page }) => { + await open(page); + const d2 = await indexOf(page, 'Column D2 Level 1'); + await expect(page.locator('.ioviewer-banner')).toHaveCount(0); + await page.mouse.click(...(await screenAt(page, d2))); + await expect(page.locator('#ioviewer-status')).toHaveText('Выбрано: 1 · Элементов: 65'); + + const html = readFileSync(path.join(viewerDir, 'test-results', 'building.html'), 'utf8'); + const truncated = html.replace('"truncated":null', '"truncated":{"meshes":40,"totalMeshes":67}'); + expect(truncated).not.toBe(html); + writeFileSync(path.join(viewerDir, 'test-results', 'truncated.html'), truncated); + await open(page, 'truncated'); + const banner = page.locator('.ioviewer-banner'); + await expect(banner).toHaveText('Показано 40 из 67 мешей: модель обрезана настройкой MaxMeshVertices'); + await banner.getByRole('button').click(); + await expect(banner).toHaveCount(0); +}); + +test('Copy Id writes the Id to the clipboard and confirms', async ({ page }) => { + await page.addInitScript(() => { + window.__copied = []; + Object.defineProperty(navigator, 'clipboard', { value: { writeText: async (text) => window.__copied.push(text) } }); + }); + const errors = await open(page); + const d2 = await indexOf(page, 'Column D2 Level 1'); + await page.mouse.click(...(await screenAt(page, d2)), { button: 'right' }); + await menuItem(page, 'Копировать Id').click(); + await expect(page.locator('.ioviewer-toast')).toHaveText('Id скопирован'); + expect(await page.evaluate(() => window.__copied)).toEqual([String(await idOf(page, d2))]); + expect(errors).toEqual([]); +}); + +test('Copy Id falls back to execCommand when the clipboard API is refused', async ({ page }) => { + await page.addInitScript(() => { + window.__copied = []; + Object.defineProperty(navigator, 'clipboard', { value: { writeText: () => Promise.reject(new DOMException('denied')) } }); + document.execCommand = (command) => command === 'copy' && window.__copied.push(document.activeElement.value) > 0; + }); + const errors = await open(page); + const d2 = await indexOf(page, 'Column D2 Level 1'); + await page.mouse.click(...(await screenAt(page, d2)), { button: 'right' }); + await menuItem(page, 'Копировать Id').click(); + await expect(page.locator('.ioviewer-toast')).toHaveText('Id скопирован'); + expect(await page.evaluate(() => window.__copied)).toEqual([String(await idOf(page, d2))]); + expect(await page.locator('textarea').count()).toBe(0); + expect(errors).toEqual([]); +}); + +test('Copy Id reports a failure when no way to copy works', async ({ page }) => { + await page.addInitScript(() => { + Object.defineProperty(navigator, 'clipboard', { value: { writeText: () => Promise.reject(new DOMException('denied')) } }); + document.execCommand = () => { + throw new Error('blocked'); + }; + }); + const errors = await open(page); + const d2 = await indexOf(page, 'Column D2 Level 1'); + await page.mouse.click(...(await screenAt(page, d2)), { button: 'right' }); + await menuItem(page, 'Копировать Id').click(); + await expect(page.locator('.ioviewer-toast')).toHaveText('Не удалось скопировать Id'); + expect(await page.locator('textarea').count()).toBe(0); + expect(errors).toEqual([]); +}); + +test('hostile names and properties are shown as text and never run (Review Focus 2)', async ({ page }) => { + const errors = await open(page, 'escaping'); + const name = ''; + const script = ''; + await rail(page, 'model').click(); + await rows(page).filter({ hasText: 'Walls' }).click(); + const row = rows(page).filter({ hasText: name }); + await expect(row.locator('.tree-name')).toHaveText(name); + + await row.click(); + const panel = page.locator('#ioviewer-properties'); + await expect(panel.locator('h2')).toHaveText(name); + await expect(panel.locator('h3').nth(1)).toHaveText(script); + await expect(panel.locator('th', { hasText: 'window.__xss=2' })).toHaveText(script); + await expect(panel.locator('td', { hasText: 'window.__xss=2' })).toHaveText(script); + expect(await page.evaluate(() => window.__xss)).toBeUndefined(); + expect(await page.locator('#ioviewer-ui img').count()).toBe(0); + expect(errors).toEqual([]); +}); + +test.describe('in English', () => { + test.use({ locale: 'en-US' }); + + test('labels, menu and status are English', async ({ page }) => { + await open(page); + await expect(page.locator('.ioviewer-toolbar-label')).toHaveText(['Navigation', 'Sections', 'Measure', 'Display', 'View']); + await expect(page.locator('#ioviewer-status')).toHaveText('Selected: 0 · Elements: 65'); + await expect(rail(page, 'links')).toHaveAttribute('title', 'Linked files'); + await page.mouse.click(...blank, { button: 'right' }); + await expect(menu(page).locator('.ioviewer-menu-item span')).toHaveText(['Show all', 'Fit all', 'Reset sections']); + }); +}); diff --git a/src/IOViewer/Viewer/test/perf/orbit.perf.js b/src/IOViewer/Viewer/test/perf/orbit.perf.js new file mode 100644 index 0000000..5857097 --- /dev/null +++ b/src/IOViewer/Viewer/test/perf/orbit.perf.js @@ -0,0 +1,437 @@ +// Performance harness for large exported pages (`npm run perf`, headed; not part of `npm run e2e`). +// PERF_MODELS: comma-separated exported .html files (default /synthetic-200k.html unless PERF_LADDER is set); +// PERF_LADDER: comma-separated element counts, e.g. 25000,50000,100000,200000,400000,800000; the synthetic page of each +// (/synthetic-.html) is generated by tools/SyntheticModel when missing or built from other sources; +// PERF_VARIANTS: comma-separated extra runs of each model, rendering only: sections (a section box around the model), +// textures (textures mode; ladder pages are generated with --textured), nohidebranch (IOVIEWER_TEST.noHideBranch); +// PERF_OUT: where JSON results go (default /ioviewer-perf); PERF_LABEL names the run (e.g. baseline); +// PERF_PROFILE=1 also writes a CPU profile of each model's whole run; PERF_GPU: see playwright.perf.config.js. +// After all runs, one table across them is printed and written with every result to /ladder-