Repository: gkjohnson/three-edge-projection Branch: main Commit: d74a7e7d4539 Files: 75 Total size: 246.7 KB Directory structure: gitextract__n5tqol9/ ├── .editorconfig ├── .github/ │ ├── FUNDING.yml │ └── workflows/ │ ├── examples-build.yml │ └── node.js.yml ├── .gitignore ├── API.md ├── CHANGELOG.md ├── LICENSE ├── README.md ├── eslint.config.js ├── example/ │ ├── bimProjection.html │ ├── bimProjection.js │ ├── edgeProjection.html │ ├── edgeProjection.js │ ├── edgeProjectionWebGPU.html │ ├── edgeProjectionWebGPU.js │ ├── floorProjection.html │ ├── floorProjection.js │ ├── perspectiveProjection.html │ ├── perspectiveProjection.js │ ├── planarIntersection.html │ ├── planarIntersection.js │ ├── silhouetteProjection.html │ └── silhouetteProjection.js ├── package.json ├── src/ │ ├── EdgeGenerator.js │ ├── MeshVisibilityCuller.js │ ├── PlanarIntersectionGenerator.js │ ├── ProjectionGenerator.js │ ├── SilhouetteGenerator.js │ ├── index.js │ ├── utils/ │ │ ├── LineObjectsBVH.js │ │ ├── ProjectionEdge.js │ │ ├── bvhcastEdges.js │ │ ├── compressPoints.js │ │ ├── generateEdges.js │ │ ├── generateIntersectionEdges.js │ │ ├── geometryUtils.js │ │ ├── getAllMeshes.js │ │ ├── getProjectedLineOverlap.js │ │ ├── getProjectedOverlaps.js │ │ ├── getSizeSortedTriList.js │ │ ├── nextFrame.js │ │ ├── overlapUtils.js │ │ ├── planeUtils.js │ │ ├── triangleIsInsidePaths.js │ │ ├── triangleLineUtils.js │ │ └── trimToBeneathTriPlane.js │ ├── webgpu/ │ │ ├── MeshVisibilityCuller.js │ │ ├── ProjectionGenerator.js │ │ ├── ProjectionGeneratorBVHComputeData.js │ │ ├── index.js │ │ ├── kernels/ │ │ │ ├── EdgeOverlapsKernel.js │ │ │ └── ZeroOutBufferKernel.js │ │ ├── lib/ │ │ │ ├── BVHComputeData.js │ │ │ ├── nodes/ │ │ │ │ ├── NodeProxy.js │ │ │ │ └── WGSLTagFnNode.js │ │ │ └── wgsl/ │ │ │ ├── common.wgsl.js │ │ │ └── structs.wgsl.js │ │ ├── nodes/ │ │ │ ├── common.wgsl.js │ │ │ ├── overlapFunctions.wgsl.js │ │ │ ├── primitives.js │ │ │ ├── structs.wgsl.js │ │ │ └── utils.wgsl.js │ │ └── utils/ │ │ └── ComputeKernel.js │ └── worker/ │ ├── SilhouetteGeneratorWorker.js │ └── silhouetteAsync.worker.js ├── test/ │ ├── Utils.getProjectedLineOverlap.test.js │ ├── Utils.triangleLineUtils.test.js │ └── Utils.trimToBeneathTriPlane.test.js ├── utils/ │ ├── CommandUtils.js │ └── docs/ │ ├── RenderDocsUtils.js │ └── build.js ├── vite.config.js └── vitest.config.js ================================================ FILE CONTENTS ================================================ ================================================ FILE: .editorconfig ================================================ root = true [*] end_of_line = lf charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true indent_style = tab insert_final_newline = true ================================================ FILE: .github/FUNDING.yml ================================================ # These are supported funding model platforms github: gkjohnson patreon: # Replace with a single Patreon username open_collective: # Replace with a single Open Collective username ko_fi: # Replace with a single Ko-fi username tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry liberapay: # Replace with a single Liberapay username issuehunt: # Replace with a single IssueHunt username otechie: # Replace with a single Otechie username custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] ================================================ FILE: .github/workflows/examples-build.yml ================================================ name: Deploy Examples to GitHub Pages on: push: branches: [ main ] permissions: contents: read pages: write id-token: write concurrency: group: "pages" cancel-in-progress: true jobs: deploy: environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 22.x cache: 'npm' - run: npm ci - run: npm run build-examples - name: Upload artifact uses: actions/upload-pages-artifact@v3 with: path: ./example/dist - name: Deploy to GitHub Pages id: deployment uses: actions/deploy-pages@v4 ================================================ FILE: .github/workflows/node.js.yml ================================================ # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions name: Node.js CI on: push: branches: [ "main" ] pull_request: branches: [ "*" ] jobs: build: runs-on: ubuntu-latest strategy: matrix: node-version: [24.x] steps: - uses: actions/checkout@v2 - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} cache: 'npm' - run: npm ci - run: npm run lint - run: npm test ================================================ FILE: .gitignore ================================================ # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* lerna-debug.log* .pnpm-debug.log* # Diagnostic reports (https://nodejs.org/api/report.html) report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json # Runtime data pids *.pid *.seed *.pid.lock # Directory for instrumented libs generated by jscoverage/JSCover lib-cov # Coverage directory used by tools like istanbul coverage *.lcov # nyc test coverage .nyc_output # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) .grunt # Bower dependency directory (https://bower.io/) bower_components # node-waf configuration .lock-wscript # Compiled binary addons (https://nodejs.org/api/addons.html) build/Release # Dependency directories node_modules/ jspm_packages/ # Snowpack dependency directory (https://snowpack.dev/) web_modules/ # TypeScript cache *.tsbuildinfo # Optional npm cache directory .npm # Optional eslint cache .eslintcache # Optional stylelint cache .stylelintcache # Microbundle cache .rpt2_cache/ .rts2_cache_cjs/ .rts2_cache_es/ .rts2_cache_umd/ # Optional REPL history .node_repl_history # Output of 'npm pack' *.tgz # Yarn Integrity file .yarn-integrity # dotenv environment variable files .env .env.development.local .env.test.local .env.production.local .env.local # parcel-bundler cache (https://parceljs.org/) .cache .parcel-cache # Next.js build output .next out # Nuxt.js build / generate output .nuxt /dist # Gatsby files .cache/ # Comment in the public line in if your project uses Gatsby and not Next.js # https://nextjs.org/blog/next-9-1#public-directory-support # public # vuepress build output .vuepress/dist # vuepress v2.x temp and cache directory .temp .cache # Docusaurus cache and generated files .docusaurus # Serverless directories .serverless/ # FuseBox cache .fusebox/ # DynamoDB Local files .dynamodb/ # TernJS port file .tern-port # Stores VSCode versions used for testing VSCode extensions .vscode-test # yarn v2 .yarn/cache .yarn/unplugged .yarn/build-state.yml .yarn/install-state.gz .pnp.* ================================================ FILE: API.md ================================================ # three-edge-projection ## Constants ### OUTPUT_MESH ```js OUTPUT_MESH: number ``` ### OUTPUT_LINE_SEGMENTS ```js OUTPUT_LINE_SEGMENTS: number ``` ### OUTPUT_BOTH ```js OUTPUT_BOTH: number ``` ## EdgeSet Set of projected edges produced by ProjectionGenerator. ### .getLineGeometry ```js getLineGeometry( meshes = null: Array | null ): BufferGeometry ``` Returns a new BufferGeometry representing the edges. Pass a list of meshes in to extract edges from a specific subset of meshes in the given order. Returns all edges if null. ### .getRangeForMesh ```js getRangeForMesh( mesh: Mesh ): Object | null ``` Returns the range of vertices associated with the given mesh in the geometry returned from getLineGeometry. The `start` value is only relevant if lines are generated with the default order and set of meshes. Can be used to add extra vertex attributes in a geometry associated with a specific subrange of the geometry. ## MeshVisibilityCuller Utility for determining visible geometry from a top down orthographic perspective. This can be run before performing projection generation to reduce the complexity of the operation at the cost of potentially missing small details. Constructor for the visibility culler that takes the renderer to use for culling. ### .pixelsPerMeter ```js pixelsPerMeter: number ``` The size of a pixel on a single dimension. If this results in a texture larger than what the graphics context can provide then the rendering is tiled. ### .constructor ```js constructor( renderer: WebGLRenderer, { pixelsPerMeter = 0.1: number, } ) ``` ### .cull ```js async cull( object: Object3D | Array ): Promise> ``` Returns the set of meshes that are visible within the given object. ## PlanarIntersectionGenerator Utility for generating the line segments produced by a planar intersection with geometry. ### .plane ```js plane: Plane ``` Plane that defaults to y up plane at the origin. ### .generate ```js generate( geometry: MeshBVH | BufferGeometry ): BufferGeometry ``` Generates a geometry of the resulting line segments from the planar intersection. ## ProjectionGenerator Utility for generating 2D projections of 3D geometry. ### .iterationTime ```js iterationTime: number ``` How long to spend trimming edges before yielding. ### .angleThreshold ```js angleThreshold: number ``` The threshold angle in degrees at which edges are generated. ### .includeIntersectionEdges ```js includeIntersectionEdges: boolean ``` Whether to generate edges representing the intersections between triangles. ### .generateAsync ```js async generateAsync( geometry: Object3D | BufferGeometry | Array, { onProgress?: ( percent: number, message: string ) => void, signal?: AbortSignal, } ): ProjectionResult ``` Generate the geometry with a promise-style API. ### .generate ```js generate( scene: Object3D | BufferGeometry | Array, { onProgress?: ( percent: number, message: string ) => void, } ): ProjectionResult ``` Generate the edge geometry result using a generator function. ## ProjectionResult Result object returned by ProjectionGenerator containing visible and hidden edge sets. ### .visibleEdges ```js visibleEdges: EdgeSet ``` ### .hiddenEdges ```js hiddenEdges: EdgeSet ``` ## SilhouetteGenerator Used for generating a projected silhouette of a geometry using the clipper2-js project. Performing these operations can be extremely slow with more complex geometry and not always yield a stable result. ### .iterationTime ```js iterationTime: number ``` How long to spend trimming edges before yielding. ### .doubleSided ```js doubleSided: boolean ``` If `false` then only the triangles facing upwards are included in the silhouette. ### .sortTriangles ```js sortTriangles: boolean ``` Whether to sort triangles and project them large-to-small. In some cases this can cause the performance to drop since the union operation is best performed with smooth, simple edge shapes. ### .output ```js output: number ``` Whether to output mesh geometry, line segments geometry, or both in an array ( `[ mesh, line segments ]` ). ### .generateAsync ```js async generateAsync( geometry: BufferGeometry, { onProgress?: ( percent: number ) => void, signal?: AbortSignal, } ): BufferGeometry | Array ``` Generate the silhouette geometry with a promise-style API. ### .generate ```js generate( geometry: BufferGeometry, { onProgress?: ( percent: number ) => void, } ): BufferGeometry | Array ``` Generate the geometry using a generator function. # WebGPU API ## MeshVisibilityCuller Utility for determining visible geometry from a top down orthographic perspective. This can be run before performing projection generation to reduce the complexity of the operation at the cost of potentially missing small details. Takes the WebGPURenderer instance used to render. ### .pixelsPerMeter ```js pixelsPerMeter: number ``` The size of a pixel on a single dimension. If this results in a texture larger than what the graphics context can provide then the rendering is tiled. ### .constructor ```js constructor( renderer: WebGPURenderer, { pixelsPerMeter = 0.1: number, } ) ``` ### .cull ```js async cull( object: Object3D | Array ): Promise> ``` Returns the set of meshes that are visible within the given object. ## ProjectionGenerator Takes the WebGPURenderer instance used to run compute kernels. ### .angleThreshold ```js angleThreshold: number ``` The threshold angle in degrees at which edges are generated. ### .batchSize ```js batchSize: number ``` The number of edges to process in one compute kernel pass. Larger values can process faster but may cause internal buffers to overflow, resulting in extra kernel executions, taking more time. ### .includeIntersectionEdges ```js includeIntersectionEdges: boolean ``` Whether to generate edges representing the intersections between triangles. ### .iterationTime ```js iterationTime: number ``` How long to spend generating edges. ### .parallelJobs ```js parallelJobs: number ``` How many compute jobs to perform in parallel. ### .constructor ```js constructor( renderer: WebGPURenderer ) ``` ### .generate ```js async generate( scene: Object3D | BufferGeometry | Array, { onProgress?: ( percent: number, message: string ) => void, signal?: AbortSignal, } ): Promise ``` Asynchronously generate the edge geometry result. ================================================ FILE: CHANGELOG.md ================================================ # Changelog All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). ## [0.0.9] - 2026.04.18 ### Added - Use of "ReadbackBuffer" from three.js r184 to improve performance, memory management. ## [0.0.8] - 2026.04.03 ### Added - Support for projection matrix transformations. ### Changed - Increased time spent per frame on edge generation. ## [0.0.7] - 2026.03.31 ### Fixed - ProjectionGenerator: Fixed intersection edges not being generated correctly. ## [0.0.6] - 2026.03.31 ### Fixed - MeshVisibilityCuller: fix case where the id buffer could be corrupted with separate renders. - MeshVisibilityCuller: fix incorrect tiling resulting in incorrect results. ### Changed - ProjectionGenerator: Adjust the "onProgress" option callback to always take the "progress" number as the first argument. ### Added - Add a "three-edge-projection/webgpu" export including a WebGPURenderer-compatible MeshVsibilityCuller, ProjectionGenerator. ## [0.0.5] - 2025.01.29 ### Fixed - Accidental variable conflict. - Add support for passing arrays of objects to MeshVisibilityCuller & ProjectionGenerator. ## [0.0.4] - 2025.01.29 ### Changed - ProjectionGenerator now returns an object with functions for extracting edges. ### Added - Ability to extract hidden edges in addition to visible edges. - Optimizations to increase generation speed. - Remove requirement to merge geometry ahead of time. - A "MeshVisibilityCuller" class that can be run to help reduce the number of meshes that need to be processed. ### Removed - ProjectionGeneratorWorker ## [0.0.3] - 2025.04.04 ### Added - PlanarIntersectionGenerator for generating model cross sections. ## [0.0.2] - 2023.09.30 ### Added - SilhouetteGenerator: performance improvements by skipping unnecessary triangles that are determined to already be in the shape. - SilhouetteGenerator: Perform simplification of edges. - SilhouetteGenerator: Add ability to see outline and mesh edges. - ProjectionGenerator: `includeIntersectionEdges` option defaults to true. ## [0.0.1] - 2023.09.18 ### Fixed - Some missing edges in projection ### Changed - Largely simplified code - Migrated logic from three-mesh-bvh ### Added - ProjectionGenerator class for generating flattened, projected edges - SilhouetteGenerator class for generating flattened, projected silhouette geometry (slow and sometimes unstable) - Ability to generate intersection edges for projection with `ProjectionGenerator.includeIntersectionEdges` ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2023 Garrett Johnson 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. ================================================ FILE: README.md ================================================ # three-edge-projection [![build](https://img.shields.io/github/actions/workflow/status/gkjohnson/three-edge-projection/node.js.yml?style=flat-square&label=build&branch=main)](https://github.com/gkjohnson/three-edge-projection/actions) [![github](https://flat.badgen.net/badge/icon/github?icon=github&label)](https://github.com/gkjohnson/three-edge-projection/) [![twitter](https://flat.badgen.net/badge/twitter/@garrettkjohnson/?icon&label)](https://twitter.com/garrettkjohnson) [![sponsors](https://img.shields.io/github/sponsors/gkjohnson?style=flat-square&color=1da1f2)](https://github.com/sponsors/gkjohnson/) ![](./docs/banner.png) Edge projection based on [three-mesh-bvh](https://github.com/gkjohnson/three-mesh-bvh/) to extract visible projected lines along the y-axis into flattened line segments for scalable 2d rendering. Additonally includes a silhouette mesh generator based on [clipper2-js](https://www.npmjs.com/package/clipper2-js) to merge flattened triangles. # Examples [Rover edge projection](https://gkjohnson.github.io/three-edge-projection/edgeProjection.html) [Lego edge projection](https://gkjohnson.github.io/three-edge-projection/edgeProjection.html#lego) [Silhouette projection](https://gkjohnson.github.io/three-edge-projection/silhouetteProjection.html) [Floor plan projection](https://gkjohnson.github.io/three-edge-projection/floorProjection.html) [Planar intersection](https://gkjohnson.github.io/three-edge-projection/planarIntersection.html) ### WebGPU [Rover edge projection](https://gkjohnson.github.io/three-edge-projection/edgeProjectionWebGPU.html) # Installation ``` npm install github:@gkjohnson/three-edge-projection ``` # API See [API.md](./API.md) for full API documentation. # Use **Generator** More granular API with control over when edge trimming work happens. ```js const generator = new ProjectionGenerator(); generator.generate( scene ); let result = task.next(); while ( ! result.done ) { result = task.next(); } const lines = new LineSegments( result.value.getVisibleLineGeometry(), material ); scene.add( lines ); ``` **Promise** Simpler API with less control over when the work happens. ```js const generator = new ProjectionGenerator(); const result = await generator.generateAsync( scene ); const mesh = new Mesh( result.getVisibleLineGeometry(), material ); scene.add( mesh ); ``` **Visibility Culling** To visibility cull a scene before generation you can use MeshVisibilityCuller before running the projection step. ```js const input = new MeshVisibilityCuller( renderer ).cull( scene ); const result = await generator.generateAsync( scene ); const mesh = new Mesh( result.getVisibleLineGeometry(), material ); scene.add( mesh ); ``` ================================================ FILE: eslint.config.js ================================================ import js from '@eslint/js'; import globals from 'globals'; import mdcs from 'eslint-config-mdcs'; import tseslint from 'typescript-eslint'; import vitest from '@vitest/eslint-plugin'; import jsdoc from 'eslint-plugin-jsdoc'; export default [ // files to ignore { name: 'files to ignore', ignores: [ '**/node_modules/**', '**/build/**', '**/dist/**', ], }, // recommended js.configs.recommended, ...tseslint.configs.recommended.map( config => ( { ...config, files: [ '**/*.ts' ], } ) ), // base rules { name: 'base rules', files: [ '**/*.js' ], languageOptions: { ecmaVersion: 2022, sourceType: 'module', globals: { ...globals.browser, ...globals.node, }, }, rules: { ...mdcs.rules, 'no-unused-vars': [ 'warn', { vars: 'all', args: 'none', } ], }, }, // ts rule overrides { name: 'ts rule overrides', files: [ '**/*.ts' ], rules: { 'no-undef': 'off', 'no-unused-vars': 'off', '@typescript-eslint/no-unused-vars': [ 'error', { args: 'none' } ], indent: [ 'error', 2 ], }, }, // jsdoc { name: 'jsdoc rules', files: [ '**/*.js' ], plugins: { jsdoc, }, settings: { jsdoc: { preferredTypes: { Any: 'any', Boolean: 'boolean', Number: 'number', object: 'Object', String: 'string', }, tagNamePreference: { return: 'returns', augments: 'extends', classdesc: false, }, }, }, rules: { 'jsdoc/check-tag-names': [ 'error', { definedTags: [ 'warn', 'note', 'section' ] } ], 'jsdoc/check-types': 'error', 'jsdoc/no-undefined-types': 'error', 'jsdoc/require-param-type': 'error', 'jsdoc/require-returns-type': 'error', 'jsdoc/require-returns': 'off', 'jsdoc/require-param-description': 'off', 'jsdoc/require-returns-description': 'off', }, }, // vitest { name: 'vitest rules', files: [ '**/*.test.js', '**/*.test.ts', '**/*.spec.js', '**/*.spec.ts' ], plugins: { vitest, }, languageOptions: { globals: { ...vitest.environments.env.globals, }, }, rules: { ...vitest.configs.recommended.rules, }, }, ]; ================================================ FILE: example/bimProjection.html ================================================ Document
================================================ FILE: example/bimProjection.js ================================================ import { Quaternion, AxesHelper, Group, MeshLambertMaterial, BufferGeometry, Float32BufferAttribute, Mesh, Box3, Vector3, PlaneGeometry, MeshBasicMaterial, LineBasicMaterial, LineSegments, LineDashedMaterial, Matrix4, BoxGeometry, } from 'three'; import { GUI } from 'three/examples/jsm/libs/lil-gui.module.min.js'; import { MeshBVH, SAH } from 'three-mesh-bvh'; import * as OBC from '@thatopen/components'; import * as WEBIFC from 'web-ifc'; import { GeometryEngine } from '@thatopen/fragments'; import { PlanarIntersectionGenerator } from 'three-edge-projection'; import { WebGPURenderer } from 'three/webgpu'; import { ProjectionGenerator, MeshVisibilityCuller } from 'three-edge-projection/webgpu'; const params = { displayModel: true, displayDrawThroughProjection: false, includeIntersectionEdges: false, enableClipping: false, displayClippingEdges: true, rotate: () => { const randomQuaternion = new Quaternion(); randomQuaternion.random(); allMeshes.quaternion.copy( randomQuaternion ); allMeshes.position.set( 0, 0, 0 ); allMeshes.updateMatrixWorld( true ); }, regenerate: () => { updateEdges(); }, }; const ANGLE_THRESHOLD = 50; let gui; let projection, drawThroughProjection; let outputContainer; // Initialize a WebGPU renderer for compute (separate from OBC's WebGL display renderer) const gpuRenderer = new WebGPURenderer(); await gpuRenderer.init(); const components = new OBC.Components(); components.init(); const container = document.getElementById( 'container' ); const world = components.get( OBC.Worlds ).create(); world.scene = new OBC.SimpleScene( components ); world.renderer = new OBC.SimpleRenderer( components, container ); world.camera = new OBC.OrthoPerspectiveCamera( components ); world.scene.setup(); world.scene.three.add( new AxesHelper() ); outputContainer = document.getElementById( 'output' ); // Initialize GeometryEngine for boolean operations const ifcApi = new WEBIFC.IfcAPI(); ifcApi.SetWasmPath( 'https://unpkg.com/web-ifc@0.0.75/', false ); await ifcApi.Init(); const geometryEngine = new GeometryEngine( ifcApi ); // init fragments worker const githubUrl = 'https://thatopen.github.io/engine_fragment/resources/worker.mjs'; const req = await fetch( githubUrl ); const blob = await req.blob(); const workerFile = new File( [ blob ], 'worker.mjs', { type: 'text/javascript' } ); const workerUrl = URL.createObjectURL( workerFile ); const fragments = components.get( OBC.FragmentsManager ); fragments.init( workerUrl ); world.camera.controls.addEventListener( 'control', () => { fragments.core.update( true ); } ); // Remove z fighting fragments.core.models.materials.list.onItemSet.add( ( { value: material } ) => { if ( ! ( 'isLodMaterial' in material && material.isLodMaterial ) ) { material.polygonOffset = true; material.polygonOffsetUnits = 1; material.polygonOffsetFactor = Math.random(); } } ); const model = await loadModel( '/frags/m3d.frag' ); const allMeshes = new Group(); // world.scene.three.add(allMeshes); // Separate group for clipped results const clippedMeshes = new Group(); // world.scene.three.add(clippedMeshes); const material = new MeshLambertMaterial(); // Add picking meshes (deduplicating geometries to save memory) const idsWithGeometry = await model.getItemsIdsWithGeometry(); const allMeshesData = await model.getItemsGeometry( idsWithGeometry ); const geometries = new Map(); for ( const itemId in allMeshesData ) { const meshData = allMeshesData[ itemId ]; for ( const geomData of meshData ) { if ( ! geomData.positions || ! geomData.indices || ! geomData.transform || ! geomData.representationId ) { continue; } const representationId = geomData.representationId; if ( ! geometries.has( representationId ) ) { const geometry = new BufferGeometry(); geometry.setAttribute( 'position', new Float32BufferAttribute( geomData.positions, 3 ) ); geometry.setAttribute( 'normal', new Float32BufferAttribute( geomData.normals, 3 ) ); geometry.setIndex( Array.from( geomData.indices ) ); geometries.set( representationId, geometry ); } const geometry = geometries.get( representationId ); const mesh = new Mesh( geometry, material ); mesh.applyMatrix4( geomData.transform ); mesh.applyMatrix4( model.object.matrixWorld ); mesh.updateWorldMatrix( true, true ); allMeshes.add( mesh ); } } // initialize BVHs allMeshes.traverse( c => { if ( c.geometry && ! c.geometry.boundsTree ) { const elCount = c.geometry.index ? c.geometry.index.count : c.geometry.attributes.position.count; c.geometry.groups.forEach( group => { if ( group.count === Infinity ) { group.count = elCount - group.start; } } ); c.geometry.boundsTree = new MeshBVH( c.geometry ); } } ); // Compute bounding box of allMeshes allMeshes.updateWorldMatrix( true, true ); const box = new Box3(); allMeshes.traverse( ( child ) => { if ( child.isMesh && child.geometry ) { child.updateWorldMatrix( false, false ); box.expandByObject( child, true ); } } ); const size = box.getSize( new Vector3() ); const center = box.getCenter( new Vector3() ); console.log( 'Model bounds:', box.min.toArray(), box.max.toArray() ); console.log( 'Model size:', size.toArray(), 'center:', center.toArray() ); // Create white ground plane on top of the bounding box (plus 3m offset) const planeHeight = box.max.y + 3; const planeSize = Math.max( size.x, size.z ) * 1.5; const planeGeometry = new PlaneGeometry( planeSize, planeSize ); const planeMaterial = new MeshBasicMaterial( { color: 0xffffff, transparent: true, opacity: 0.95, } ); const groundPlane = new Mesh( planeGeometry, planeMaterial ); groundPlane.rotation.x = - Math.PI / 2; // Rotate to be horizontal groundPlane.position.set( center.x, planeHeight, center.z ); world.scene.three.add( groundPlane ); const clipper = components.get( OBC.Clipper ); // const clipNormal = new Vector3(0, 1, 0).applyEuler(new Euler(Math.PI / 2, 0, 0)).applyEuler(new Euler(Math.PI / 4, Math.PI / 4, 0)); // const planeId = clipper.createFromNormalAndCoplanarPoint(world, new Vector3(0, 1, 0), new Vector3(-50, 50, 0)) // const plane = clipper.list.get(planeId); // --- Clipping edge projection --- const clippingEdgeMaterial = new LineBasicMaterial( { color: 0xff0000 } ); const clippingEdgesGroup = new Group(); clippingEdgesGroup.position.y = planeHeight + 0.02; world.scene.three.add( clippingEdgesGroup ); const intersectingMeshes = new Set(); // create projection display mesh const projectionMaterial = new LineBasicMaterial( { color: 0x888888 } ); projection = new LineSegments( new BufferGeometry(), projectionMaterial ); projection.position.y = planeHeight + 0.01; drawThroughProjection = new LineSegments( new BufferGeometry(), new LineDashedMaterial( { color: 0x444444, dashSize: 0.03, gapSize: 0.03, transparent: true } ) ); drawThroughProjection.position.y = planeHeight + 0.01; drawThroughProjection.renderOrder = - 1; world.scene.three.add( projection, drawThroughProjection ); gui = new GUI(); gui.add( params, 'includeIntersectionEdges' ); gui.add( params, 'displayDrawThroughProjection' ); gui.add( params, 'enableClipping' ); gui.add( params, 'displayClippingEdges' ); gui.add( params, 'rotate' ); gui.add( params, 'regenerate' ); world.renderer.onBeforeUpdate.add( () => { drawThroughProjection.visible = params.displayDrawThroughProjection; clippingEdgesGroup.visible = params.displayClippingEdges; } ); updateEdges(); async function loadModel( url ) { const fetched = await fetch( url ); const buffer = await fetched.arrayBuffer(); const model = await fragments.core.load( buffer, { modelId: url, camera: world.camera.three, raw: false, } ); world.scene.three.add( model.object ); // model.object.rotation.x = Math.PI / 4; // model.object.rotation.y = Math.PI / 4; const now = performance.now(); await fragments.core.update( true ); const then = performance.now(); console.log( `Time taken: ${ then - now }ms` ); return model; } function generateClippingEdges() { // Clear previous clipping edges for ( const child of [ ...clippingEdgesGroup.children ] ) { clippingEdgesGroup.remove( child ); if ( child.geometry ) child.geometry.dispose(); } const clipPlane = plane.three; const generator = new PlanarIntersectionGenerator(); const invMatrix = new Matrix4(); const v = new Vector3(); // Ensure world matrices are up to date allMeshes.updateWorldMatrix( true, true ); let totalSegments = 0; intersectingMeshes.clear(); for ( const child of allMeshes.children ) { if ( ! child.isMesh || ! child.geometry ) continue; // Transform clip plane to mesh's local space invMatrix.copy( child.matrixWorld ).invert(); const localPlane = clipPlane.clone().applyMatrix4( invMatrix ); generator.plane.copy( localPlane ); const bvh = child.geometry.boundsTree || child.geometry; const edgeGeom = generator.generate( bvh ); const posAttr = edgeGeom.getAttribute( 'position' ); if ( ! posAttr || posAttr.count === 0 ) continue; // This mesh actually has triangles crossing the plane intersectingMeshes.add( child ); // Transform positions back to world space and project (flatten Y) const positions = posAttr.array; for ( let i = 0; i < positions.length; i += 3 ) { v.set( positions[ i ], positions[ i + 1 ], positions[ i + 2 ] ); v.applyMatrix4( child.matrixWorld ); positions[ i ] = v.x; positions[ i + 1 ] = 0; positions[ i + 2 ] = v.z; } posAttr.needsUpdate = true; const line = new LineSegments( edgeGeom, clippingEdgeMaterial ); clippingEdgesGroup.add( line ); totalSegments += posAttr.count / 2; } console.log( `Clipping edges: ${totalSegments} line segments from ${clippingEdgesGroup.children.length} meshes (${intersectingMeshes.size} intersecting)` ); } // --- Boolean clipping --- function applyClipping() { // Clear previous clipped meshes const previous = [ ...clippedMeshes.children ]; for ( const child of previous ) { clippedMeshes.remove( child ); if ( child.geometry ) child.geometry.dispose(); } const clipPlane = plane.three; const n = clipPlane.normal; // Create clipping box: a large box on the clipped side (above the plane) const boxSize = Math.max( size.x, size.y, size.z ) * 4; const clipBoxGeom = new BoxGeometry( boxSize, boxSize, boxSize ); const clipBoxMesh = new Mesh( clipBoxGeom, material ); // Orient and position the box on the negative side of the clip plane clipBoxMesh.quaternion.setFromUnitVectors( new Vector3( 0, 1, 0 ), n ); const coplanarPoint = new Vector3(); clipPlane.coplanarPoint( coplanarPoint ); clipBoxMesh.position.copy( coplanarPoint ).addScaledVector( n, - boxSize / 2 ); clipBoxMesh.updateMatrixWorld( true ); let clipped = 0, skipped = 0, errors = 0, kept = 0; for ( const child of allMeshes.children ) { if ( ! child.isMesh || ! child.geometry ) continue; // Use the precise intersection test from generateClippingEdges() if ( ! intersectingMeshes.has( child ) ) { // No triangles cross the plane — check which side mesh center is on const meshCenter = new Box3().setFromObject( child, true ).getCenter( new Vector3() ); const dist = clipPlane.distanceToPoint( meshCenter ); if ( dist >= 0 ) { // Positive side — keep as-is const keepMesh = new Mesh( child.geometry.clone(), material ); keepMesh.applyMatrix4( child.matrixWorld ); keepMesh.updateMatrixWorld( true ); clippedMeshes.add( keepMesh ); kept ++; } else { skipped ++; } continue; } // Actually straddles the clip plane — boolean DIFFERENCE try { child.updateMatrixWorld( true ); const booleanData = { type: 'DIFFERENCE', target: child, operands: [ clipBoxMesh ], }; const resultGeom = new BufferGeometry(); geometryEngine.getBooleanOperation( resultGeom, booleanData ); // Check if result has vertices const posAttr = resultGeom.getAttribute( 'position' ); if ( ! posAttr || posAttr.count === 0 ) { skipped ++; continue; } // Result is in world space, so create mesh with identity transform const resultMesh = new Mesh( resultGeom, material ); resultMesh.updateMatrixWorld( true ); clippedMeshes.add( resultMesh ); clipped ++; } catch ( e ) { console.warn( 'Boolean op error:', e ); // On error, keep the original mesh const fallbackMesh = new Mesh( child.geometry.clone(), material ); fallbackMesh.applyMatrix4( child.matrixWorld ); fallbackMesh.updateMatrixWorld( true ); clippedMeshes.add( fallbackMesh ); errors ++; } } console.log( `Boolean clipping: clipped=${clipped}, kept=${kept}, skipped=${skipped}, errors=${errors}, total=${allMeshes.children.length}` ); // Build BVHs for clipped meshes clippedMeshes.traverse( c => { if ( c.geometry && ! c.geometry.boundsTree ) { const elCount = c.geometry.index ? c.geometry.index.count : c.geometry.attributes.position.count; c.geometry.groups.forEach( group => { if ( group.count === Infinity ) { group.count = elCount - group.start; } } ); c.geometry.boundsTree = new MeshBVH( c.geometry, { maxLeafSize: 1, strategy: SAH } ); } } ); // Hide original meshes, show clipped // allMeshes.visible = false; // model.object.visible = false; // clippedMeshes.visible = true; } async function updateEdges() { outputContainer.innerText = 'Generating...'; // dispose the geometry projection.geometry.dispose(); drawThroughProjection.geometry.dispose(); // initialize an empty geometry projection.geometry = new BufferGeometry(); drawThroughProjection.geometry = new BufferGeometry(); const timeStart = window.performance.now(); if ( params.enableClipping ) { generateClippingEdges(); applyClipping(); } const generator = new ProjectionGenerator( gpuRenderer ); generator.angleThreshold = ANGLE_THRESHOLD; generator.includeIntersectionEdges = params.includeIntersectionEdges; // Use clippedMeshes if clipping is enabled, otherwise allMeshes const meshSource = params.enableClipping ? clippedMeshes : allMeshes; let input = await new MeshVisibilityCuller( gpuRenderer, { pixelsPerMeter: 0.01 } ).cull( meshSource ); const result = await generator.generate( input, { onProgress: p => { outputContainer.innerText = `Generating... ${ ( p * 100 ).toFixed( 1 ) }%`; }, } ); drawThroughProjection.geometry.dispose(); drawThroughProjection.geometry = result.hiddenEdges.getLineGeometry(); drawThroughProjection.computeLineDistances(); projection.geometry.dispose(); projection.geometry = result.visibleEdges.getLineGeometry(); const trimTime = window.performance.now() - timeStart; outputContainer.innerText = `Generation time: ${trimTime.toFixed( 2 )}ms`; } ================================================ FILE: example/edgeProjection.html ================================================ three-edge-projection - Projected Edge Generation
Accelerated geometry edge projection and clipping onto
the XZ plane for orthographic vector views.
================================================ FILE: example/edgeProjection.js ================================================ import { Box3, WebGLRenderer, Scene, DirectionalLight, AmbientLight, Group, BufferGeometry, LineSegments, LineBasicMaterial, PerspectiveCamera, } from 'three'; import { GUI } from 'three/examples/jsm/libs/lil-gui.module.min.js'; import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; import { LDrawLoader } from 'three/examples/jsm/loaders/LDrawLoader.js'; import { LDrawConditionalLineMaterial } from 'three/examples/jsm/materials/LDrawConditionalLineMaterial.js'; import { MeshoptDecoder } from 'three/examples/jsm/libs/meshopt_decoder.module.js'; import { ProjectionGenerator, MeshVisibilityCuller } from '..'; import { MeshBVH, SAH } from 'three-mesh-bvh'; const params = { displayModel: true, displayDrawThroughProjection: false, includeIntersectionEdges: true, visibilityCullMeshes: false, rotate: () => { group.quaternion.random(); group.position.set( 0, 0, 0 ); group.updateMatrixWorld( true ); const box = new Box3(); box.setFromObject( model, true ); box.getCenter( group.position ).multiplyScalar( - 1 ); group.position.y = Math.max( 0, - box.min.y ) + 1; group.updateMatrixWorld( true ); needsRender = true; task = updateEdges(); }, regenerate: () => { task = updateEdges(); }, }; const ANGLE_THRESHOLD = 50; let needsRender = false; let renderer, camera, scene, gui, controls; let model, projection, drawThroughProjection, group; let outputContainer; let task = null; init(); async function init() { outputContainer = document.getElementById( 'output' ); const bgColor = 0xeeeeee; // renderer setup renderer = new WebGLRenderer( { antialias: true } ); renderer.setPixelRatio( window.devicePixelRatio ); renderer.setSize( window.innerWidth, window.innerHeight ); renderer.setClearColor( bgColor, 1 ); document.body.appendChild( renderer.domElement ); // scene setup scene = new Scene(); // lights const light = new DirectionalLight( 0xffffff, 3.5 ); light.position.set( 1, 2, 3 ); scene.add( light ); const ambientLight = new AmbientLight( 0xb0bec5, 0.5 ); scene.add( ambientLight ); // load model group = new Group(); scene.add( group ); window.ROOT = group; if ( window.location.hash === '#lego' ) { // init loader const loader = new LDrawLoader(); loader.setConditionalLineMaterial( LDrawConditionalLineMaterial ); await loader.preloadMaterials( 'https://raw.githubusercontent.com/gkjohnson/ldraw-parts-library/master/colors/ldcfgalt.ldr' ); // load model model = await loader .setPartsLibraryPath( 'https://raw.githubusercontent.com/gkjohnson/ldraw-parts-library/master/complete/ldraw/' ) .loadAsync( 'https://raw.githubusercontent.com/mrdoob/three.js/dev/examples/models/ldraw/officialLibrary/models/1621-1-LunarMPVVehicle.mpd_Packed.mpd' ); // adjust model transforms model.scale.setScalar( 0.01 ); model.rotation.x = Math.PI; // remove lines const toRemove = []; model.traverse( c => { if ( c.isLine ) { toRemove.push( c ); } } ); toRemove.forEach( c => { c.removeFromParent(); } ); } else { const gltf = await new GLTFLoader() .setMeshoptDecoder( MeshoptDecoder ) .loadAsync( 'https://raw.githubusercontent.com/gkjohnson/3d-demo-data/main/models/nasa-m2020/Perseverance.glb' ); model = gltf.scene; } // initialize BVHs model.traverse( c => { if ( c.geometry && ! c.geometry.boundsTree ) { const elCount = c.geometry.index ? c.geometry.index.count : c.geometry.attributes.position.count; c.geometry.groups.forEach( group => { if ( group.count === Infinity ) { group.count = elCount - group.start; } } ); c.geometry.boundsTree = new MeshBVH( c.geometry, { maxLeafSize: 1, strategy: SAH } ); } } ); // center model const box = new Box3(); box.setFromObject( model, true ); box.getCenter( group.position ).multiplyScalar( - 1 ); group.position.y = Math.max( 0, - box.min.y ) + 1; group.add( model ); group.updateMatrixWorld( true ); // create projection display mesh projection = new LineSegments( new BufferGeometry(), new LineBasicMaterial( { color: 0x030303, depthWrite: false } ) ); drawThroughProjection = new LineSegments( new BufferGeometry(), new LineBasicMaterial( { color: 0xcacaca, depthWrite: false } ) ); drawThroughProjection.renderOrder = - 1; scene.add( projection, drawThroughProjection ); // camera setup camera = new PerspectiveCamera( 75, window.innerWidth / window.innerHeight, 0.01, 1e3 ); camera.position.setScalar( 3.5 ); camera.updateProjectionMatrix(); needsRender = true; // controls controls = new OrbitControls( camera, renderer.domElement ); controls.addEventListener( 'change', () => { needsRender = true; } ); gui = new GUI(); gui.add( params, 'displayModel' ).onChange( () => needsRender = true ); gui.add( params, 'displayDrawThroughProjection' ).onChange( () => needsRender = true ); gui.add( params, 'includeIntersectionEdges' ); gui.add( params, 'visibilityCullMeshes' ); gui.add( params, 'rotate' ); gui.add( params, 'regenerate' ).onChange( () => needsRender = true ); render(); task = updateEdges(); window.addEventListener( 'resize', function () { camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize( window.innerWidth, window.innerHeight ); needsRender = true; }, false ); } async function* updateEdges( runTime = 30 ) { outputContainer.innerText = 'Generating...'; // dispose the geometry projection.geometry.dispose(); drawThroughProjection.geometry.dispose(); // initialize an empty geometry projection.geometry = new BufferGeometry(); drawThroughProjection.geometry = new BufferGeometry(); const timeStart = window.performance.now(); const generator = new ProjectionGenerator(); generator.iterationTime = runTime; generator.angleThreshold = ANGLE_THRESHOLD; generator.includeIntersectionEdges = params.includeIntersectionEdges; let input = [ model ]; if ( params.visibilityCullMeshes ) { input = await new MeshVisibilityCuller( renderer, { pixelsPerMeter: 0.01 } ).cull( input ); } const collection = yield* generator.generate( input, { onProgress: ( tot, msg, edges ) => { outputContainer.innerText = msg; if ( tot ) outputContainer.innerText += ' ' + ( 100 * tot ).toFixed( 1 ) + '%'; if ( edges ) { projection.geometry.dispose(); projection.geometry = edges.visibleEdges.getLineGeometry(); needsRender = true; } }, } ); drawThroughProjection.geometry.dispose(); drawThroughProjection.geometry = collection.hiddenEdges.getLineGeometry(); projection.geometry.dispose(); projection.geometry = collection.visibleEdges.getLineGeometry(); const geometry = projection.geometry; const trimTime = window.performance.now() - timeStart; projection.geometry.dispose(); projection.geometry = geometry; outputContainer.innerText = `Generation time: ${ trimTime.toFixed( 2 ) }ms`; needsRender = true; } function render() { requestAnimationFrame( render ); if ( task ) { const res = task.next(); if ( res.done ) { task = null; } } model.visible = params.displayModel; drawThroughProjection.visible = params.displayDrawThroughProjection; if ( needsRender ) { renderer.render( scene, camera ); needsRender = false; } } ================================================ FILE: example/edgeProjectionWebGPU.html ================================================ three-edge-projection - WebGPU Projected Edge Generation
WebGPU accelerated geometry edge projection and clipping onto
the XZ plane for orthographic vector views.
================================================ FILE: example/edgeProjectionWebGPU.js ================================================ import { Box3, Scene, DirectionalLight, AmbientLight, Group, BufferGeometry, BufferAttribute, LineSegments, LineBasicMaterial, PerspectiveCamera, WebGPURenderer, } from 'three/webgpu'; import { GUI } from 'three/examples/jsm/libs/lil-gui.module.min.js'; import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; import { MeshoptDecoder } from 'three/examples/jsm/libs/meshopt_decoder.module.js'; import { ProjectionGenerator, MeshVisibilityCuller } from 'three-edge-projection/webgpu'; import { Color } from 'three'; const params = { displayModel: true, displayDrawThroughProjection: false, includeIntersectionEdges: false, visibilityCullMeshes: false, perObjectColors: false, regenerate: () => { updateEdges(); }, rotate: () => { group.quaternion.random(); group.position.set( 0, 0, 0 ); group.updateMatrixWorld( true ); const box = new Box3(); box.setFromObject( model, true ); box.getCenter( group.position ).multiplyScalar( - 1 ); group.position.y = Math.max( 0, - box.min.y ) + 1; group.updateMatrixWorld( true ); needsRender = true; }, }; let needsRender = false; let renderer, camera, scene, gui, controls; let model, projection, drawThroughProjection, group; let outputContainer; let abortController; init(); async function init() { outputContainer = document.getElementById( 'output' ); const bgColor = 0xeeeeee; // renderer setup renderer = new WebGPURenderer( { antialias: true } ); renderer.setPixelRatio( window.devicePixelRatio ); renderer.setSize( window.innerWidth, window.innerHeight ); renderer.setClearColor( bgColor, 1 ); await renderer.init(); document.body.appendChild( renderer.domElement ); // scene setup scene = new Scene(); // lights const light = new DirectionalLight( 0xffffff, 3.5 ); light.position.set( 1, 2, 3 ); scene.add( light ); const ambientLight = new AmbientLight( 0xb0bec5, 0.5 ); scene.add( ambientLight ); // load model group = new Group(); scene.add( group ); const gltf = await new GLTFLoader() .setMeshoptDecoder( MeshoptDecoder ) .loadAsync( 'https://raw.githubusercontent.com/gkjohnson/3d-demo-data/main/models/nasa-m2020/Perseverance.glb' ); model = gltf.scene; const box = new Box3(); box.setFromObject( model, true ); box.getCenter( group.position ).multiplyScalar( - 1 ); group.position.y = Math.max( 0, - box.min.y ) + 1; group.add( model ); group.updateMatrixWorld( true ); // create projection display meshes projection = new LineSegments( new BufferGeometry(), new LineBasicMaterial( { depthWrite: false } ) ); drawThroughProjection = new LineSegments( new BufferGeometry(), new LineBasicMaterial( { depthWrite: false } ) ); drawThroughProjection.renderOrder = - 1; scene.add( projection, drawThroughProjection ); // camera setup camera = new PerspectiveCamera( 75, window.innerWidth / window.innerHeight, 0.01, 1e6 ); camera.position.setScalar( 3.5 ); camera.updateProjectionMatrix(); needsRender = true; // controls controls = new OrbitControls( camera, renderer.domElement ); controls.addEventListener( 'change', () => { needsRender = true; } ); gui = new GUI(); const displayFolder = gui.addFolder( 'Display' ); displayFolder.add( params, 'displayModel' ).onChange( () => needsRender = true ); displayFolder.add( params, 'displayDrawThroughProjection' ).onChange( () => needsRender = true ); const projectionFolder = gui.addFolder( 'Projection' ); projectionFolder.add( params, 'includeIntersectionEdges' ); projectionFolder.add( params, 'visibilityCullMeshes' ); projectionFolder.add( params, 'perObjectColors' ); projectionFolder.add( params, 'rotate' ); projectionFolder.add( params, 'regenerate' ); render(); updateEdges(); window.addEventListener( 'resize', function () { camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize( window.innerWidth, window.innerHeight ); needsRender = true; }, false ); } async function updateEdges() { if ( abortController ) { abortController.abort(); } abortController = new AbortController(); projection.geometry.dispose(); projection.material.dispose(); projection.geometry = new BufferGeometry(); drawThroughProjection.geometry.dispose(); drawThroughProjection.material.dispose(); drawThroughProjection.geometry = new BufferGeometry(); needsRender = true; const timeStart = window.performance.now(); const generator = new ProjectionGenerator( renderer ); generator.includeIntersectionEdges = params.includeIntersectionEdges; model.visible = true; let input = [ model ]; if ( params.visibilityCullMeshes ) { input = await new MeshVisibilityCuller( renderer, { pixelsPerMeter: 0.1 } ).cull( input ); } let result; try { result = await generator.generate( input, { signal: abortController.signal, onProgress: ( p, msg ) => { outputContainer.innerText = `${ msg }... ${ ( p * 100 ).toFixed( 2 ) }%`; }, } ); } catch { // cancelled return; } const visGeom = result.visibleEdges.getLineGeometry(); const hidGeom = result.hiddenEdges.getLineGeometry(); if ( params.perObjectColors ) { applyPerObjectColors( result.visibleEdges, visGeom ); applyPerObjectColors( result.hiddenEdges, hidGeom, 0.8 ); } projection.geometry.dispose(); projection.material.dispose(); projection.geometry = visGeom; projection.material.vertexColors = params.perObjectColors; projection.material.color.set( params.perObjectColors ? 0xffffff : 0x030303 ); drawThroughProjection.geometry.dispose(); drawThroughProjection.material.dispose(); drawThroughProjection.geometry = hidGeom; drawThroughProjection.material.vertexColors = params.perObjectColors; drawThroughProjection.material.color.set( params.perObjectColors ? 0xffffff : 0xcacaca ); const elapsed = window.performance.now() - timeStart; outputContainer.innerText = `Generation time: ${ elapsed.toFixed( 2 ) }ms`; needsRender = true; } function applyPerObjectColors( edgeSet, geometry, lightness = 0.5 ) { const totalVertices = geometry.attributes.position.count; const colorArray = new Float32Array( totalVertices * 3 ); const color = new Color(); for ( const mesh of edgeSet.meshToSegments.keys() ) { const range = edgeSet.getRangeForMesh( mesh ); if ( ! range ) continue; color.setHSL( Math.random(), 0.75, lightness ); for ( let i = range.start; i < range.start + range.count; i ++ ) { colorArray[ i * 3 + 0 ] = color.r; colorArray[ i * 3 + 1 ] = color.g; colorArray[ i * 3 + 2 ] = color.b; } } geometry.setAttribute( 'color', new BufferAttribute( colorArray, 3 ) ); } function render() { requestAnimationFrame( render ); model.visible = params.displayModel; drawThroughProjection.visible = params.displayDrawThroughProjection; if ( needsRender ) { renderer.render( scene, camera ); needsRender = false; } } ================================================ FILE: example/floorProjection.html ================================================ three-edge-projection - Projected Edge Generation
Floor plan silhouette and edge projection from Sketchfab model.
loading...
================================================ FILE: example/floorProjection.js ================================================ import { Box3, WebGLRenderer, Scene, DirectionalLight, AmbientLight, Group, BufferGeometry, LineSegments, LineBasicMaterial, PerspectiveCamera, MeshBasicMaterial, Mesh, DoubleSide, } from 'three'; import { MapControls } from 'three/examples/jsm/controls/MapControls.js'; import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'; import { MeshoptDecoder } from 'three/examples/jsm/libs/meshopt_decoder.module.js'; import { ProjectionGenerator, SilhouetteGenerator } from '../src'; import { GUI } from 'three/examples/jsm/libs/lil-gui.module.min.js'; const ANGLE_THRESHOLD = 50; let renderer, camera, scene, gui, controls; let model, outlines, group, silhouette; let outputContainer; let task = null; const params = { displayModel: false, regenerate: () => { task = updateProjection(); }, }; init(); async function init() { outputContainer = document.getElementById( 'output' ); const bgColor = 0x111111; // renderer setup renderer = new WebGLRenderer( { antialias: true } ); renderer.setPixelRatio( window.devicePixelRatio ); renderer.setSize( window.innerWidth, window.innerHeight ); renderer.setClearColor( bgColor, 1 ); document.body.appendChild( renderer.domElement ); // scene setup scene = new Scene(); // lights const light = new DirectionalLight( 0xffffff, 3.5 ); light.position.set( 1, 2, 3 ); scene.add( light ); const ambientLight = new AmbientLight( 0xb0bec5, 0.5 ); scene.add( ambientLight ); // load model group = new Group(); scene.add( group ); const gltf = await new GLTFLoader() .setMeshoptDecoder( MeshoptDecoder ) .loadAsync( 'https://raw.githubusercontent.com/gkjohnson/3d-demo-data/main/models/3d-home-layout/scene.glb' ); model = gltf.scene; group.updateMatrixWorld( true ); // center model const box = new Box3(); box.setFromObject( model, true ); box.getCenter( group.position ).multiplyScalar( - 1 ); group.position.y = Math.max( 0, - box.min.y ) + 1; group.add( model ); model.visible = false; // create projection display mesh silhouette = new Mesh( new BufferGeometry(), new MeshBasicMaterial( { color: '#eee', polygonOffset: true, polygonOffsetFactor: 3, polygonOffsetUnits: 3, side: DoubleSide, } ) ); outlines = new LineSegments( new BufferGeometry(), new LineBasicMaterial( { color: 0x030303 } ) ); scene.add( outlines, silhouette ); // camera setup camera = new PerspectiveCamera( 75, window.innerWidth / window.innerHeight, 0.01, 50 ); camera.position.setScalar( 5.5 ); camera.updateProjectionMatrix(); // controls controls = new MapControls( camera, renderer.domElement ); controls.zoomToCursor = true; controls.maxPolarAngle = Math.PI / 3; task = updateProjection(); gui = new GUI(); gui.add( params, 'displayModel' ); gui.add( params, 'regenerate' ); render(); window.addEventListener( 'resize', function () { camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize( window.innerWidth, window.innerHeight ); }, false ); } function* updateProjection() { outputContainer.innerText = 'processing: --'; silhouette.visible = false; outlines.visible = false; // transform and merge geometries to project into a single model const geometries = []; model.updateWorldMatrix( true, true ); model.traverse( c => { if ( c.geometry ) { const clone = c.geometry.clone(); clone.applyMatrix4( c.matrixWorld ); for ( const key in clone.attributes ) { if ( key !== 'position' ) { clone.deleteAttribute( key ); } } geometries.push( clone ); } } ); const mergedGeometry = mergeGeometries( geometries, false ); yield; // generate the silhouette let task, result, generator; generator = new SilhouetteGenerator(); generator.sortTriangles = true; task = generator.generate( mergedGeometry, { onProgress: ( p, data ) => { outputContainer.innerText = `processing: ${ parseFloat( ( p * 100 ).toFixed( 2 ) ) }%`; silhouette.geometry.dispose(); silhouette.geometry = data.getGeometry(); silhouette.visible = true; }, } ); result = task.next(); while ( ! result.done ) { result = task.next(); yield; } silhouette.geometry.dispose(); silhouette.geometry = result.value; silhouette.visible = true; outputContainer.innerText = 'generating intersection edges...'; // generate the edges generator = new ProjectionGenerator(); generator.angleThreshold = ANGLE_THRESHOLD; task = generator.generate( mergedGeometry, { onProgress: ( p, data ) => { outputContainer.innerText = `processing: ${ parseFloat( ( p * 100 ).toFixed( 2 ) ) }%`; outlines.geometry.dispose(); outlines.geometry = data.visibleEdges.getLineGeometry(); outlines.visible = true; }, } ); result = task.next(); while ( ! result.done ) { result = task.next(); yield; } outlines.geometry.dispose(); outlines.geometry = result.value; outlines.visible = true; outputContainer.innerText = ''; } function render() { requestAnimationFrame( render ); if ( task ) { const res = task.next(); if ( res.done ) { task = null; } } model.visible = params.displayModel; renderer.render( scene, camera ); } ================================================ FILE: example/perspectiveProjection.html ================================================ three-edge-projection - Perspective Camera Projection
Perspective camera edge projection - position the camera then click Generate.
Note that compute shader-based generation can result in artifacts due to floating point precision.
================================================ FILE: example/perspectiveProjection.js ================================================ import { Box3, Scene, DirectionalLight, AmbientLight, Group, BufferGeometry, LineSegments, LineBasicMaterial, PerspectiveCamera, WebGPURenderer, Vector3, } from 'three/webgpu'; import { GUI } from 'three/examples/jsm/libs/lil-gui.module.min.js'; import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; import { MeshoptDecoder } from 'three/examples/jsm/libs/meshopt_decoder.module.js'; import { ProjectionGenerator as ProjectionGeneratorCompute } from 'three-edge-projection/webgpu'; import { ProjectionGenerator } from 'three-edge-projection'; const params = { displayModel: true, webGPU: false, displayDrawThroughProjection: false, includeIntersectionEdges: false, regenerate: () => { updateEdges(); }, }; let needsRender = false; let renderer, camera, scene, gui, controls; let model, projection, drawThroughProjection, group, projectionGroup; let outputContainer; let abortController; init(); async function init() { outputContainer = document.getElementById( 'output' ); const bgColor = 0xeeeeee; // renderer setup renderer = new WebGPURenderer( { antialias: true } ); renderer.setPixelRatio( window.devicePixelRatio ); renderer.setSize( window.innerWidth, window.innerHeight ); renderer.setClearColor( bgColor, 1 ); await renderer.init(); document.body.appendChild( renderer.domElement ); // scene setup scene = new Scene(); // lights const light = new DirectionalLight( 0xffffff, 3.5 ); light.position.set( 1, 2, 3 ); scene.add( light ); const ambientLight = new AmbientLight( 0xb0bec5, 0.5 ); scene.add( ambientLight ); // load model group = new Group(); scene.add( group ); const gltf = await new GLTFLoader() .setMeshoptDecoder( MeshoptDecoder ) .loadAsync( 'https://raw.githubusercontent.com/gkjohnson/3d-demo-data/main/models/nasa-m2020/Perseverance.glb' ); model = gltf.scene; const box = new Box3(); box.setFromObject( model, true ); box.getCenter( group.position ).multiplyScalar( - 1 ); group.position.y = Math.max( 0, - box.min.y ); group.add( model ); group.updateMatrixWorld( true ); // create projection display meshes projection = new LineSegments( new BufferGeometry(), new LineBasicMaterial( { depthWrite: false } ) ); drawThroughProjection = new LineSegments( new BufferGeometry(), new LineBasicMaterial( { depthWrite: false } ) ); drawThroughProjection.renderOrder = - 1; projectionGroup = new Group(); projectionGroup.add( projection, drawThroughProjection ); scene.add( projectionGroup ); // camera setup camera = new PerspectiveCamera( 75, window.innerWidth / window.innerHeight, 0.01, 100 ); camera.position.setScalar( 3.5 ); camera.updateProjectionMatrix(); needsRender = true; // controls controls = new OrbitControls( camera, renderer.domElement ); controls.addEventListener( 'change', () => { needsRender = true; } ); gui = new GUI(); const displayFolder = gui.addFolder( 'Display' ); displayFolder.add( params, 'displayModel' ).onChange( () => needsRender = true ).listen(); displayFolder.add( params, 'displayDrawThroughProjection' ).onChange( () => needsRender = true ); const generationFolder = gui.addFolder( 'Generation' ); generationFolder.add( params, 'webGPU' ); generationFolder.add( params, 'includeIntersectionEdges' ); generationFolder.add( params, 'regenerate' ); render(); updateEdges(); window.addEventListener( 'resize', function () { camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize( window.innerWidth, window.innerHeight ); needsRender = true; }, false ); } async function updateEdges() { if ( abortController ) { abortController.abort(); } abortController = new AbortController(); projection.geometry.dispose(); projection.material.dispose(); projection.geometry = new BufferGeometry(); drawThroughProjection.geometry.dispose(); drawThroughProjection.material.dispose(); drawThroughProjection.geometry = new BufferGeometry(); needsRender = true; const timeStart = window.performance.now(); // position the projectionGroup to map NDC output back to a camera-facing plane const FWD = new Vector3( 0, 0, - 1 ).transformDirection( camera.matrixWorld ); const distToCenter = - FWD.dot( camera.position ) + 1.75; const _v = new Vector3( 1, 1, 1 ).applyMatrix4( camera.projectionMatrixInverse ); _v.multiplyScalar( distToCenter / _v.z ); projectionGroup.rotation.copy( camera.rotation ).reorder( 'ZYX' ); projectionGroup.rotation.x += Math.PI / 2; projectionGroup.scale.set( _v.x, 1, _v.y ); projectionGroup.position.copy( camera.position ).addScaledVector( FWD, distToCenter ); // construct the generation group — encodes camera VP matrix so the generator // projects along the camera's view direction instead of world Y const scaleGroup = new Group(); const perspectiveGroup = new Group(); perspectiveGroup.matrixAutoUpdate = false; scaleGroup.add( perspectiveGroup ); model.visible = true; const clone = group.clone(); perspectiveGroup.add( clone ); clone.matrix .multiplyMatrices( camera.matrixWorldInverse, group.matrixWorld ) .decompose( clone.position, clone.quaternion, clone.scale ); perspectiveGroup.matrix.copy( camera.projectionMatrix ); scaleGroup.scale.x = - 1; scaleGroup.rotation.x = Math.PI / 2; scaleGroup.updateMatrixWorld( true ); // normalize scale so geometry is in a workable range for the GPU const box = new Box3(); box.setFromObject( perspectiveGroup ); scaleGroup.scale.z = 5 / ( box.max.y - box.min.y ); scaleGroup.position.y = - box.min.y * scaleGroup.scale.z - 0.5; scaleGroup.updateMatrixWorld( true ); const input = [ clone ]; let result; try { const onProgress = ( p, msg ) => { outputContainer.innerText = `${ msg }... ${ ( p * 100 ).toFixed( 2 ) }%`; }; const options = { signal: abortController.signal, onProgress, }; if ( params.webGPU ) { const generator = new ProjectionGeneratorCompute( renderer ); generator.includeIntersectionEdges = params.includeIntersectionEdges; result = await generator.generate( input, options ); } else { const generator = new ProjectionGenerator(); generator.includeIntersectionEdges = params.includeIntersectionEdges; result = await generator.generateAsync( input, options ); } } catch { // cancelled return; } const visGeom = result.visibleEdges.getLineGeometry(); const hidGeom = result.hiddenEdges.getLineGeometry(); projection.geometry.dispose(); projection.material.dispose(); projection.geometry = visGeom; projection.material = new LineBasicMaterial( { color: 0x030303, depthWrite: false } ); drawThroughProjection.geometry.dispose(); drawThroughProjection.material.dispose(); drawThroughProjection.geometry = hidGeom; drawThroughProjection.material = new LineBasicMaterial( { color: 0xcacaca, depthWrite: false } ); const elapsed = window.performance.now() - timeStart; outputContainer.innerText = `Generation time: ${ elapsed.toFixed( 2 ) }ms`; needsRender = true; } function render() { requestAnimationFrame( render ); model.visible = params.displayModel; drawThroughProjection.visible = params.displayDrawThroughProjection; if ( needsRender ) { renderer.render( scene, camera ); needsRender = false; } } ================================================ FILE: example/planarIntersection.html ================================================ three-edge-projection - Projected Edge Generation
Accelerated planar edge intersection.
================================================ FILE: example/planarIntersection.js ================================================ import { Box3, WebGLRenderer, Scene, DirectionalLight, AmbientLight, Group, MeshBasicMaterial, BufferGeometry, LineSegments, LineBasicMaterial, PerspectiveCamera, Mesh, PlaneGeometry, DoubleSide, } from 'three'; import { GUI } from 'three/examples/jsm/libs/lil-gui.module.min.js'; import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'; import { MeshoptDecoder } from 'three/examples/jsm/libs/meshopt_decoder.module.js'; import { PlanarIntersectionGenerator } from '..'; import { MeshBVH } from 'three-mesh-bvh'; const params = { displayModel: true, planePosition: 1, }; let renderer, camera, scene, gui, controls, bvh; let model, projection, group, plane; let outputContainer; init(); async function init() { outputContainer = document.getElementById( 'output' ); const bgColor = 0x111111; // renderer setup renderer = new WebGLRenderer( { antialias: true } ); renderer.setPixelRatio( window.devicePixelRatio ); renderer.setSize( window.innerWidth, window.innerHeight ); renderer.setClearColor( bgColor, 1 ); renderer.clear(); document.body.appendChild( renderer.domElement ); // scene setup scene = new Scene(); // lights const light = new DirectionalLight( 0xffffff, 3.5 ); light.position.set( 1, 2, 3 ); scene.add( light ); const ambientLight = new AmbientLight( 0xb0bec5, 0.5 ); scene.add( ambientLight ); // load model group = new Group(); scene.add( group ); const gltf = await new GLTFLoader() .setMeshoptDecoder( MeshoptDecoder ) .loadAsync( 'https://raw.githubusercontent.com/gkjohnson/3d-demo-data/main/models/nasa-m2020/Perseverance.glb' ); model = gltf.scene; // generate the merged geometry const geometries = []; model.updateWorldMatrix( true, true ); model.traverse( c => { if ( c.geometry ) { const clone = c.geometry.clone(); clone.applyMatrix4( c.matrixWorld ); for ( const key in clone.attributes ) { if ( key !== 'position' ) { clone.deleteAttribute( key ); } } geometries.push( clone ); } } ); const mergedGeometry = mergeGeometries( geometries, false ); bvh = new MeshBVH( mergedGeometry, { maxLeafSize: 1 } ); // center model const box = new Box3(); box.setFromObject( model, true ); box.getCenter( group.position ).multiplyScalar( - 1 ); group.position.y = Math.max( 0, - box.min.y ) + 1; group.add( model ); // create plane to display the cut location plane = new Mesh( new PlaneGeometry( 5, 5 ), new MeshBasicMaterial( { color: 0x333333, transparent: true, opacity: 0.5, side: DoubleSide } ) ); plane.rotation.x = - Math.PI / 2; group.add( plane ); // create projection display mesh projection = new LineSegments( new BufferGeometry(), new LineBasicMaterial( { color: 0xeeeeee } ) ); projection.scale.y = 0; scene.add( projection ); // camera setup camera = new PerspectiveCamera( 75, window.innerWidth / window.innerHeight, 0.01, 50 ); camera.position.setScalar( 3.5 ); camera.updateProjectionMatrix(); // controls controls = new OrbitControls( camera, renderer.domElement ); updateLines(); gui = new GUI(); gui.add( params, 'displayModel' ); gui.add( params, 'planePosition', 0, 2.5 ).onChange( () => updateLines() ); render(); window.addEventListener( 'resize', function () { camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize( window.innerWidth, window.innerHeight ); }, false ); } function updateLines() { projection.geometry.dispose(); const generator = new PlanarIntersectionGenerator(); generator.plane.constant = - params.planePosition; let start, delta; start = performance.now(); projection.geometry = generator.generate( bvh ); delta = performance.now() - start; outputContainer.innerText = `${ delta.toFixed( 2 ) }ms`; } function render() { requestAnimationFrame( render ); group.visible = params.displayModel; projection.visible = params.displayProjection; plane.position.y = params.planePosition; renderer.render( scene, camera ); } ================================================ FILE: example/silhouetteProjection.html ================================================ three-edge-projection - Projected Edge Generation
Projected silhouette generation using the "clipper2-js" package.
================================================ FILE: example/silhouetteProjection.js ================================================ import { Box3, WebGLRenderer, Scene, DirectionalLight, AmbientLight, Group, MeshStandardMaterial, MeshBasicMaterial, PerspectiveCamera, Mesh, TorusKnotGeometry, DoubleSide, LineSegments, LineBasicMaterial, } from 'three'; import { GUI } from 'three/examples/jsm/libs/lil-gui.module.min.js'; import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'; import { OUTPUT_BOTH, SilhouetteGenerator } from '../src'; import { SilhouetteGeneratorWorker } from '../src/worker/SilhouetteGeneratorWorker.js'; const params = { displaySilhouette: true, displayWireframe: false, displayOutline: false, displayModel: true, useWorker: false, rotate: () => { group.quaternion.random(); group.position.set( 0, 0, 0 ); group.updateMatrixWorld( true ); const box = new Box3(); box.setFromObject( model, true ); box.getCenter( group.position ).multiplyScalar( - 1 ); group.position.y = Math.max( 0, - box.min.y ) + 1; }, regenerate: () => { task = updateEdges(); }, }; let renderer, camera, scene, gui, controls; let model, projection, projectionWireframe, group, edges; let outputContainer; let worker; let task = null; init(); async function init() { outputContainer = document.getElementById( 'output' ); const bgColor = 0xeeeeee; // renderer setup renderer = new WebGLRenderer( { antialias: true } ); renderer.setPixelRatio( window.devicePixelRatio ); renderer.setSize( window.innerWidth, window.innerHeight ); renderer.setClearColor( bgColor, 1 ); document.body.appendChild( renderer.domElement ); // scene setup scene = new Scene(); // lights const light = new DirectionalLight( 0xffffff, 3.5 ); light.position.set( 1, 2, 3 ); scene.add( light ); const ambientLight = new AmbientLight( 0xb0bec5, 0.5 ); scene.add( ambientLight ); // load model group = new Group(); group.position.y = 2; scene.add( group ); model = new Mesh( new TorusKnotGeometry( 1, 0.4, 120, 30 ), new MeshStandardMaterial( { polygonOffset: true, polygonOffsetFactor: 1, polygonOffsetUnits: 1, } ) ); model.rotation.set( Math.PI / 4, 0, Math.PI / 8 ); group.add( model ); // create projection display mesh projection = new Mesh( undefined, new MeshBasicMaterial( { color: 0xf06292, side: DoubleSide, polygonOffset: true, polygonOffsetFactor: 1, polygonOffsetUnits: 1, } ) ); projection.position.y = - 2; scene.add( projection ); edges = new LineSegments( undefined, new LineBasicMaterial( { color: 0 } ) ); edges.position.y = - 2; scene.add( edges ); projectionWireframe = new Mesh( undefined, new MeshBasicMaterial( { color: 0xc2185b, wireframe: true } ) ); projectionWireframe.position.y = - 2; scene.add( projectionWireframe ); // camera setup camera = new PerspectiveCamera( 75, window.innerWidth / window.innerHeight, 0.01, 50 ); camera.position.setScalar( 4.5 ); camera.updateProjectionMatrix(); // controls controls = new OrbitControls( camera, renderer.domElement ); gui = new GUI(); gui.add( params, 'displayModel' ); gui.add( params, 'displaySilhouette' ); gui.add( params, 'displayOutline' ); gui.add( params, 'displayWireframe' ); gui.add( params, 'useWorker' ); gui.add( params, 'rotate' ); gui.add( params, 'regenerate' ); worker = new SilhouetteGeneratorWorker(); task = updateEdges(); render(); window.addEventListener( 'resize', function () { camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize( window.innerWidth, window.innerHeight ); }, false ); } function* updateEdges( runTime = 30 ) { outputContainer.innerText = 'processing: --'; // transform and merge geometries to project into a single model let timeStart = window.performance.now(); const geometries = []; model.updateWorldMatrix( true, true ); model.traverse( c => { if ( c.geometry ) { const clone = c.geometry.clone(); clone.applyMatrix4( c.matrixWorld ); for ( const key in clone.attributes ) { if ( key !== 'position' ) { clone.deleteAttribute( key ); } } geometries.push( clone ); } } ); const mergedGeometry = mergeGeometries( geometries, false ); const mergeTime = window.performance.now() - timeStart; yield; // generate the candidate edges timeStart = window.performance.now(); let result = null; if ( ! params.useWorker ) { const generator = new SilhouetteGenerator(); generator.iterationTime = runTime; generator.output = OUTPUT_BOTH; const task = generator.generate( mergedGeometry, { onProgress: ( p, info ) => { outputContainer.innerText = `processing: ${ parseFloat( ( p * 100 ).toFixed( 2 ) ) }%`; const result = info.getGeometry(); projection.geometry.dispose(); projection.geometry = result[ 0 ]; projectionWireframe.geometry = result[ 0 ]; edges.geometry.dispose(); edges.geometry = result[ 1 ]; if ( params.displaySilhouette || params.displayWireframe || params.displayOutline ) { projection.geometry.dispose(); projection.geometry = result[ 0 ]; projectionWireframe.geometry = result[ 0 ]; edges.geometry.dispose(); edges.geometry = result[ 1 ]; } }, } ); let res = task.next(); while ( ! res.done ) { res = task.next(); yield; } result = res.value; } else { worker .generate( mergedGeometry, { output: OUTPUT_BOTH, onProgress: p => { outputContainer.innerText = `processing: ${ parseFloat( ( p * 100 ).toFixed( 2 ) ) }%`; }, } ) .then( res => { result = res; } ); while ( result === null ) { yield; } } const trimTime = window.performance.now() - timeStart; projection.geometry.dispose(); projection.geometry = result[ 0 ]; projectionWireframe.geometry = result[ 0 ]; edges.geometry.dispose(); edges.geometry = result[ 1 ]; outputContainer.innerText = `merge geometry : ${ mergeTime.toFixed( 2 ) }ms\n` + `edge trimming : ${ trimTime.toFixed( 2 ) }ms\n` + `triangles : ${ projection.geometry.index.count / 3 } tris`; } function render() { requestAnimationFrame( render ); if ( task ) { const res = task.next(); if ( res.done ) { task = null; } } model.visible = params.displayModel; projection.visible = params.displaySilhouette; projectionWireframe.visible = params.displayWireframe; edges.visible = params.displayOutline; renderer.render( scene, camera ); } ================================================ FILE: package.json ================================================ { "name": "three-edge-projection", "version": "0.0.9", "description": "", "type": "module", "main": "src/index.js", "exports": { ".": { "import": "./src/index.js" }, "./worker": "./src/worker/index.js", "./webgpu": "./src/webgpu/index.js", "./src/*": "./src/*" }, "scripts": { "start": "vite --config ./vite.config.js", "build-examples": "vite build --config ./vite.config.js", "docs:build": "node utils/docs/build.js", "lint": "eslint .", "test": "vitest run" }, "files": [ "src/*" ], "keywords": [ "graphics", "tree", "bounds", "threejs", "three-js", "bounds-hierarchy", "performance", "geometry", "mesh", "acceleration", "projection", "edges" ], "author": "Garrett Johnson ", "license": "MIT", "repository": { "type": "git", "url": "git+https://github.com/gkjohnson/three-edge-projection.git" }, "bugs": { "url": "https://github.com/gkjohnson/three-edge-projection/issues" }, "homepage": "https://github.com/gkjohnson/three-edge-projection#readme", "peerDependencies": { "clipper2-js": "^0.9.0", "three": "^0.155.0", "three-mesh-bvh": "^0.6.0" }, "devDependencies": { "@eslint/js": "^9.0.0", "@thatopen/components": "^3.3.3", "@thatopen/fragments": "^3.3.7", "@vitest/eslint-plugin": "^1.1.22", "eslint": "^9.0.0", "eslint-config-mdcs": "^5.0.0", "eslint-plugin-jsdoc": "^62.8.1", "globals": "^16.5.0", "jsdoc": "^4.0.5", "three": ">=0.184.0", "three-mesh-bvh": ">=0.9.9", "typescript-eslint": "^8.48.1", "vite": "^6.2.2", "vitest": "^3.0.0", "web-ifc": "^0.0.77" } } ================================================ FILE: src/EdgeGenerator.js ================================================ import { Vector3, Matrix4 } from 'three'; import { MeshBVH } from 'three-mesh-bvh'; import { generateEdges } from './utils/generateEdges.js'; import { generateIntersectionEdges } from './utils/generateIntersectionEdges.js'; import { getAllMeshes } from './utils/getAllMeshes.js'; import { nextFrame } from './utils/nextFrame.js'; const _BtoA = /* @__PURE__ */ new Matrix4(); // Class for generating edges for use with the projection generator. Functions take geometries or // Object3D instances. If an Object3D is passed then lines for all child meshes will be generated // in world space // TODO: // - add support for progress functions export class EdgeGenerator { constructor() { this.projectionDirection = new Vector3( 0, 1, 0 ); this.thresholdAngle = 50; this.iterationTime = 30; this.yOffset = 1e-6; } // Functions for generating the "hard" and silhouette edges of the geometry along the projection direction getEdges( ...args ) { const currIterationTime = this.iterationTime; this.iterationTime = Infinity; const result = this.getEdgesGenerator( ...args ).next().value; this.iterationTime = currIterationTime; return result; } async getEdgesAsync( ...args ) { const task = this.getEdgesGenerator( ...args ); let res; while ( ! res || ! res.done ) { res = task.next(); await nextFrame(); } return res.value; } *getEdgesGenerator( geometry, resultEdges = [] ) { // handle arrays if ( Array.isArray( geometry ) ) { for ( let i = 0, l = geometry.length; i < l; i ++ ) { yield* this.getEdgesGenerator( geometry[ i ], resultEdges ); } return resultEdges; } const { projectionDirection, thresholdAngle, iterationTime, yOffset } = this; if ( geometry.isObject3D ) { const meshes = getAllMeshes( geometry ); let time = performance.now(); for ( let i = 0; i < meshes.length; i ++ ) { if ( performance.now() - time > iterationTime ) { yield; } const mesh = meshes[ i ]; const results = yield* generateEdges( mesh.geometry, [], { matrix: mesh.matrixWorld, thresholdAngle: thresholdAngle, iterationTime: iterationTime, } ); transformEdges( results, mesh.matrixWorld, yOffset ); // push the edges individually to avoid stack overflow for ( let i = 0; i < results.length; i ++ ) { results[ i ].mesh = mesh; resultEdges.push( results[ i ] ); } } return resultEdges; } else { return yield* generateEdges( geometry, resultEdges, { projectionDirection: projectionDirection, thresholdAngle: thresholdAngle, iterationTime: iterationTime, } ); } } // Functions for generating a set of "intersection" edges within an existing geometry // TODO: these needs to support generating "intersection edges" within a set of other geometries, as well getIntersectionEdges( ...args ) { const currIterationTime = this.iterationTime; this.iterationTime = Infinity; const result = this.getIntersectionEdgesGenerator( ...args ).next().value; this.iterationTime = currIterationTime; return result; } async getIntersectionEdgesAsync( ...args ) { const task = this.getIntersectionEdgesGenerator( ...args ); let res; while ( ! res || ! res.done ) { res = task.next(); await nextFrame(); } return res.value; } *getIntersectionEdgesGenerator( geometry, resultEdges = [] ) { // handle arrays if ( Array.isArray( geometry ) ) { for ( let i = 0, l = geometry.length; i < l; i ++ ) { yield* this.getIntersectionEdgesGenerator( geometry[ i ], resultEdges ); } return resultEdges; } const { iterationTime, yOffset } = this; if ( geometry.isObject3D ) { // get the bounds trees from all geometry const meshes = getAllMeshes( geometry ); const bvhs = new Map(); let time = performance.now(); for ( let i = 0; i < meshes.length; i ++ ) { if ( performance.now() - time > iterationTime ) { yield; time = performance.now(); } const mesh = meshes[ i ]; const geometry = mesh.geometry; if ( ! bvhs.has( geometry ) ) { const bvh = geometry.boundsTree || new MeshBVH( geometry, { maxLeafSize: 1 } ); bvhs.set( geometry, bvh ); } } // check each mesh against all others time = performance.now(); for ( let i = 0; i < meshes.length; i ++ ) { for ( let j = i; j < meshes.length; j ++ ) { if ( performance.now() - time > iterationTime ) { yield; time = performance.now(); } const meshA = meshes[ i ]; const meshB = meshes[ j ]; const bvhA = bvhs.get( meshA.geometry ); const bvhB = bvhs.get( meshB.geometry ); // A-1 * B * v _BtoA .copy( meshA.matrixWorld ) .invert() .multiply( meshB.matrixWorld ); const results = generateIntersectionEdges( bvhA, bvhB, _BtoA, [], { iterationTime } ); transformEdges( results, meshA.matrixWorld, yOffset ); // push the edges individually to avoid stack overflow for ( let i = 0; i < results.length; i ++ ) { results[ i ].mesh = meshA; resultEdges.push( results[ i ] ); } } } return resultEdges; } else { let bvh; if ( geometry.isBufferGeometry ) { bvh = geometry.boundsTree || new MeshBVH( geometry, { maxLeafSize: 1 } ); } else { bvh = geometry; geometry = bvh.geometry; } _BtoA.identity(); return generateIntersectionEdges( bvh, bvh, _BtoA, resultEdges, { iterationTime } ); } } } // add an offset to avoid precision errors when detecting intersections and clipping function transformEdges( list, matrix, offset = 0 ) { for ( let i = 0; i < list.length; i ++ ) { const line = list[ i ]; line.applyMatrix4( matrix ); line.start.y += offset; line.end.y += offset; } } ================================================ FILE: src/MeshVisibilityCuller.js ================================================ /** @import { WebGLRenderer, Object3D } from 'three' */ import { ShaderMaterial, GLSL3, WebGLRenderTarget, Box3, Vector3, Vector4, OrthographicCamera, Color, Mesh, NoBlending, } from 'three'; import { getAllMeshes } from './utils/getAllMeshes.js'; // RGBA8 ID encoding - supports up to 16,777,215 objects (2^24 - 1) // ID 0 is valid, background is indicated by alpha = 0 function encodeId( id, target ) { target.x = ( id & 0xFF ) / 255; target.y = ( ( id >> 8 ) & 0xFF ) / 255; target.z = ( ( id >> 16 ) & 0xFF ) / 255; target.w = 1; } function decodeId( buffer, index ) { return buffer[ index ] | ( buffer[ index + 1 ] << 8 ) | ( buffer[ index + 2 ] << 16 ); } // TODO: WebGPU or occlusion queries would let us accelerate this. Ideally would we "contract" the depth buffer by one pixel by // taking the lowest value from all surrounding pixels in order to avoid mesh misses. /** * Utility for determining visible geometry from a top down orthographic perspective. This can * be run before performing projection generation to reduce the complexity of the operation at * the cost of potentially missing small details. * * Constructor for the visibility culler that takes the renderer to use for culling. * @param {WebGLRenderer} renderer * @param {Object} [options] * @param {number} [options.pixelsPerMeter=0.1] */ export class MeshVisibilityCuller { constructor( renderer, options = {} ) { const { pixelsPerMeter = 0.1 } = options; /** * The size of a pixel on a single dimension. If this results in a texture larger than what * the graphics context can provide then the rendering is tiled. * @type {number} */ this.pixelsPerMeter = pixelsPerMeter; this.renderer = renderer; } /** * Returns the set of meshes that are visible within the given object. * @param {Object3D|Array} object * @returns {Promise>} */ async cull( objects ) { objects = getAllMeshes( objects ); const { renderer, pixelsPerMeter } = this; const size = new Vector3(); const camera = new OrthographicCamera(); const box = new Box3(); const idMesh = new Mesh( undefined, new IDMaterial() ); idMesh.matrixAutoUpdate = false; idMesh.matrixWorldAutoUpdate = false; const target = new WebGLRenderTarget( 1, 1 ); // get the bounds of the image box.makeEmpty(); objects.forEach( o => { box.expandByObject( o ); } ); // get the bounds dimensions box.getSize( size ); // calculate the tile and target size const maxTextureSize = Math.min( renderer.capabilities.maxTextureSize, 2 ** 13 ); const pixelWidth = Math.ceil( size.x / pixelsPerMeter ); const pixelHeight = Math.ceil( size.z / pixelsPerMeter ); const tilesX = Math.ceil( pixelWidth / maxTextureSize ); const tilesY = Math.ceil( pixelHeight / maxTextureSize ); target.setSize( Math.ceil( pixelWidth / tilesX ), Math.ceil( pixelHeight / tilesY ) ); // set the camera bounds camera.rotation.x = - Math.PI / 2; camera.far = ( box.max.y - box.min.y ) + camera.near; camera.position.y = box.max.y + camera.near; // save render state const color = renderer.getClearColor( new Color() ); const alpha = renderer.getClearAlpha(); const renderTarget = renderer.getRenderTarget(); const autoClear = renderer.autoClear; // render ids const readBuffer = new Uint8Array( target.width * target.height * 4 ); const visibleSet = new Set(); const stepX = size.x / tilesX; const stepZ = size.z / tilesY; for ( let x = 0; x < tilesX; x ++ ) { for ( let y = 0; y < tilesY; y ++ ) { // update camera camera.left = box.min.x + stepX * x; camera.top = - ( box.min.z + stepZ * y ); camera.right = camera.left + stepX; camera.bottom = camera.top - stepZ; camera.updateProjectionMatrix(); // clear the camera renderer.autoClear = false; renderer.setClearColor( 0, 0 ); renderer.setRenderTarget( target ); renderer.clear(); for ( let i = 0; i < objects.length; i ++ ) { const object = objects[ i ]; idMesh.matrixWorld.copy( object.matrixWorld ); idMesh.geometry = object.geometry; idMesh.material.objectId = i; renderer.render( idMesh, camera ); } // reset render state before async operation to avoid corruption renderer.setClearColor( color, alpha ); renderer.setRenderTarget( renderTarget ); renderer.autoClear = autoClear; const buffer = await renderer.readRenderTargetPixelsAsync( target, 0, 0, target.width, target.height, readBuffer ); // find all visible objects - decode RGBA to ID for ( let i = 0, l = buffer.length; i < l; i += 4 ) { // alpha = 0 indicates background (no object) if ( buffer[ i + 3 ] === 0 ) continue; const id = decodeId( buffer, i ); visibleSet.add( objects[ id ] ); } } } // dispose of intermediate values idMesh.material.dispose(); target.dispose(); return Array.from( visibleSet ); } } class IDMaterial extends ShaderMaterial { set objectId( v ) { encodeId( v, this.uniforms.objectId.value ); } constructor( params ) { super( { glslVersion: GLSL3, blending: NoBlending, uniforms: { objectId: { value: new Vector4() }, }, vertexShader: /* glsl */` void main() { gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 ); } `, fragmentShader: /* glsl */` layout(location = 0) out vec4 out_id; uniform vec4 objectId; void main() { out_id = objectId; } `, } ); this.setValues( params ); } } ================================================ FILE: src/PlanarIntersectionGenerator.js ================================================ import { BufferAttribute, BufferGeometry, Line3, Plane, Vector3 } from 'three'; import { MeshBVH } from 'three-mesh-bvh'; const _line = new Line3(); const _target = new Line3(); const _vec = new Vector3(); const EPS = 1e-16; /** * Utility for generating the line segments produced by a planar intersection with geometry. */ export class PlanarIntersectionGenerator { constructor() { /** * Plane that defaults to y up plane at the origin. * @type {Plane} */ this.plane = new Plane( new Vector3( 0, 1, 0 ), 0 ); } /** * Generates a geometry of the resulting line segments from the planar intersection. * @param {MeshBVH|BufferGeometry} geometry * @returns {BufferGeometry} */ generate( bvh ) { const { plane } = this; if ( bvh instanceof BufferGeometry ) { bvh = new MeshBVH( bvh, { maxLeafSize: 1 } ); } const edgesArray = []; bvh.shapecast( { intersectsBounds: box => { return plane.intersectsBox( box ); }, intersectsTriangle: tri => { const { points } = tri; let foundPoints = 0; for ( let i = 0; i < 3; i ++ ) { const ni = ( i + 1 ) % 3; _line.start.copy( points[ i ] ); _line.end.copy( points[ ni ] ); if ( plane.intersectLine( _line, _vec ) ) { if ( foundPoints === 1 ) { if ( _vec.distanceTo( _target.start ) > EPS ) { _target.end.copy( _vec ); foundPoints ++; break; } } else { _target.start.copy( _vec ); foundPoints ++; } } } if ( foundPoints === 2 ) { edgesArray.push( ..._target.start, ..._target.end ); } }, } ); // generate and return line geometry const edgeGeom = new BufferGeometry(); const edgeBuffer = new BufferAttribute( new Float32Array( edgesArray ), 3, true ); edgeGeom.setAttribute( 'position', edgeBuffer ); return edgeGeom; } } ================================================ FILE: src/ProjectionGenerator.js ================================================ /** @import { Object3D } from 'three' */ import { BufferGeometry, Vector3, BufferAttribute, Mesh, } from 'three'; import { MeshBVH, SAH } from 'three-mesh-bvh'; import { isYProjectedLineDegenerate } from './utils/triangleLineUtils.js'; import { overlapsToLines } from './utils/overlapUtils.js'; import { EdgeGenerator } from './EdgeGenerator.js'; import { LineObjectsBVH } from './utils/LineObjectsBVH.js'; import { bvhcastEdges } from './utils/bvhcastEdges.js'; import { getAllMeshes } from './utils/getAllMeshes.js'; import { nextFrame } from './utils/nextFrame.js'; const UP_VECTOR = /* @__PURE__ */ new Vector3( 0, 1, 0 ); function toLineGeometry( edges, ranges = null ) { // if no ranges provided, treat the whole array as one range const activeRanges = ranges ?? [ { start: 0, count: edges.length } ]; let totalCount = 0; for ( let i = 0; i < activeRanges.length; i ++ ) { totalCount += activeRanges[ i ].count; } const edgeArray = new Float32Array( totalCount * 6 ); let c = 0; for ( let r = 0; r < activeRanges.length; r ++ ) { const { start, count } = activeRanges[ r ]; for ( let i = start, l = start + count; i < l; i ++ ) { const line = edges[ i ]; edgeArray[ c ++ ] = line[ 0 ]; edgeArray[ c ++ ] = 0; edgeArray[ c ++ ] = line[ 2 ]; edgeArray[ c ++ ] = line[ 3 ]; edgeArray[ c ++ ] = 0; edgeArray[ c ++ ] = line[ 5 ]; } } const edgeGeom = new BufferGeometry(); const edgeBuffer = new BufferAttribute( edgeArray, 3, false ); edgeGeom.setAttribute( 'position', edgeBuffer ); return edgeGeom; } /** * Set of projected edges produced by ProjectionGenerator. */ export class EdgeSet { constructor() { this.meshToSegments = new Map(); this._rangeCache = null; } /** * Returns a new BufferGeometry representing the edges. * * Pass a list of meshes in to extract edges from a specific subset of meshes in the given * order. Returns all edges if null. * @param {Array|null} [meshes=null] * @returns {BufferGeometry} */ getLineGeometry( meshes = null ) { const activeMeshes = meshes !== null ? meshes : Array.from( this.meshToSegments.keys() ); const segments = []; for ( let i = 0; i < activeMeshes.length; i ++ ) { const segs = this.meshToSegments.get( activeMeshes[ i ] ); if ( segs ) { for ( let j = 0; j < segs.length; j ++ ) segments.push( segs[ j ] ); } } return toLineGeometry( segments ); } /** * Returns the range of vertices associated with the given mesh in the geometry returned from * getLineGeometry. The `start` value is only relevant if lines are generated with the default * order and set of meshes. * * Can be used to add extra vertex attributes in a geometry associated with a specific subrange * of the geometry. * @param {Mesh} mesh * @returns {{ start: number, count: number }|null} */ getRangeForMesh( mesh ) { if ( ! this._rangeCache ) { this._rangeCache = new Map(); let start = 0; for ( const [ m, segs ] of this.meshToSegments ) { this._rangeCache.set( m, { start: start * 2, count: segs.length * 2 } ); start += segs.length; } } return this._rangeCache.get( mesh ) ?? null; } } /** * Result object returned by ProjectionGenerator containing visible and hidden edge sets. */ export class ProjectionResult { constructor() { /** @type {EdgeSet} */ this.visibleEdges = new EdgeSet(); /** @type {EdgeSet} */ this.hiddenEdges = new EdgeSet(); } } class ProjectedEdgeCollector { constructor( scene ) { this.meshes = getAllMeshes( scene ); this.bvhs = new Map(); this.result = new ProjectionResult(); this.iterationTime = 30; } addEdges( ...args ) { const currIterationTime = this.iterationTime; this.iterationTime = Infinity; const result = this.addEdgesGenerator( ...args ).next().value; this.iterationTime = currIterationTime; return result; } // all edges are expected to be in world coordinates *addEdgesGenerator( edges, options = {} ) { const { meshes, bvhs, iterationTime } = this; let time = performance.now(); for ( let i = 0; i < meshes.length; i ++ ) { if ( performance.now() - time > iterationTime ) { yield; time = performance.now(); } const mesh = meshes[ i ]; const geometry = mesh.geometry; if ( ! bvhs.has( geometry ) ) { const bvh = geometry.boundsTree || new MeshBVH( geometry ); bvhs.set( geometry, bvh ); } } // initialize hidden line object const hiddenOverlapMap = {}; for ( let i = 0; i < edges.length; i ++ ) { hiddenOverlapMap[ i ] = []; } // construct bvh const edgesBvh = new LineObjectsBVH( edges, { maxLeafSize: 2, strategy: SAH } ); time = performance.now(); for ( let m = 0; m < meshes.length; m ++ ) { if ( performance.now() - time > iterationTime ) { if ( options.onProgress ) { options.onProgress( m, meshes.length ); } yield; time = performance.now(); } // use bvhcast to compare all edges against all meshes const mesh = meshes[ m ]; bvhcastEdges( edgesBvh, bvhs.get( mesh.geometry ), mesh, hiddenOverlapMap ); } // construct the projections const { result } = this; for ( let i = 0; i < edges.length; i ++ ) { if ( performance.now() - time > iterationTime ) { yield; time = performance.now(); } // convert the overlap points to proper lines const line = edges[ i ]; const mesh = line.mesh; const hiddenOverlaps = hiddenOverlapMap[ i ]; if ( ! result.visibleEdges.meshToSegments.has( mesh ) ) { result.visibleEdges.meshToSegments.set( mesh, [] ); result.hiddenEdges.meshToSegments.set( mesh, [] ); } overlapsToLines( line, hiddenOverlaps, false, result.visibleEdges.meshToSegments.get( mesh ) ); overlapsToLines( line, hiddenOverlaps, true, result.hiddenEdges.meshToSegments.get( mesh ) ); } } } /** * @callback ProjectionProgressCallback * @param {number} percent * @param {string} message */ /** * Utility for generating 2D projections of 3D geometry. */ export class ProjectionGenerator { constructor() { /** * How long to spend trimming edges before yielding. * @type {number} */ this.iterationTime = 30; /** * The threshold angle in degrees at which edges are generated. * @type {number} */ this.angleThreshold = 50; /** * Whether to generate edges representing the intersections between triangles. * @type {boolean} */ this.includeIntersectionEdges = true; } /** * Generate the geometry with a promise-style API. * @async * @param {Object3D|BufferGeometry|Array} geometry * @param {Object} [options] * @param {ProjectionProgressCallback} [options.onProgress] * @param {AbortSignal} [options.signal] * @returns {ProjectionResult} */ async generateAsync( geometry, options = {} ) { const { signal } = options; const task = this.generate( geometry, options ); let res; while ( ! res || ! res.done ) { res = task.next(); await nextFrame(); signal.throwIfAborted(); } return res.value; } /** * Generate the edge geometry result using a generator function. * @param {Object3D|BufferGeometry|Array} scene * @param {Object} [options] * @param {ProjectionProgressCallback} [options.onProgress] * @yields {void} * @returns {ProjectionResult} */ *generate( scene, options = {} ) { const { iterationTime, angleThreshold, includeIntersectionEdges } = this; const { onProgress = () => {} } = options; if ( scene.isBufferGeometry ) { scene = new Mesh( scene ); } const edgeGenerator = new EdgeGenerator(); edgeGenerator.iterationTime = iterationTime; edgeGenerator.thresholdAngle = angleThreshold; edgeGenerator.projectionDirection.copy( UP_VECTOR ); onProgress( 0, 'Extracting edges' ); let edges = []; yield* edgeGenerator.getEdgesGenerator( scene, edges ); if ( includeIntersectionEdges ) { onProgress( 0, 'Extracting self-intersecting edges' ); yield* edgeGenerator.getIntersectionEdgesGenerator( scene, edges ); } // filter out any degenerate projected edges onProgress( 0, 'Filtering edges' ); edges = edges.filter( e => ! isYProjectedLineDegenerate( e ) ); edges.sort( ( a, b ) => { const uuidA = a.mesh.uuid; const uuidB = b.mesh.uuid; if ( uuidA === uuidB ) { return 0; } else { return uuidA < uuidB ? - 1 : 1; } } ); yield; const collector = new ProjectedEdgeCollector( scene ); collector.iterationTime = iterationTime; onProgress( 0, 'Clipping edges' ); yield* collector.addEdgesGenerator( edges, { onProgress: ! onProgress ? null : ( prog, tot ) => { onProgress( prog / tot, 'Clipping edges', collector.result ); }, } ); return collector.result; } } ================================================ FILE: src/SilhouetteGenerator.js ================================================ import { Path64, Clipper, FillRule } from 'clipper2-js'; import { ShapeGeometry, Vector3, Shape, Vector2, Triangle, ShapeUtils, BufferGeometry } from 'three'; import { compressPoints } from './utils/compressPoints.js'; import { triangleIsInsidePaths } from './utils/triangleIsInsidePaths.js'; import { getSizeSortedTriList } from './utils/getSizeSortedTriList.js'; import { getTriCount } from './utils/geometryUtils.js'; const AREA_EPSILON = 1e-8; const UP_VECTOR = /* @__PURE__ */ new Vector3( 0, 1, 0 ); const _tri = /* @__PURE__ */ new Triangle(); const _normal = /* @__PURE__ */ new Vector3(); const _center = /* @__PURE__ */ new Vector3(); const _vec = /* @__PURE__ */ new Vector3(); function convertPathToGeometry( path, scale ) { const vector2s = path.map( points => { return points.flatMap( v => new Vector2( v.x / scale, v.y / scale ) ); } ); const holesShapes = vector2s .filter( p => ShapeUtils.isClockWise( p ) ) .map( p => new Shape( p ) ); const solidShapes = vector2s .filter( p => ! ShapeUtils.isClockWise( p ) ) .map( p => { const shape = new Shape( p ); shape.holes = holesShapes; return shape; } ); // flip the triangles so they're facing in the right direction const result = new ShapeGeometry( solidShapes ).rotateX( Math.PI / 2 ); result.index.array.reverse(); return result; } function convertPathToLineSegments( path, scale ) { const arr = []; path.forEach( points => { for ( let i = 0, l = points.length; i < l; i ++ ) { const i1 = ( i + 1 ) % points.length; const p0 = points[ i ]; const p1 = points[ i1 ]; arr.push( new Vector3( p0.x / scale, 0, p0.y / scale ), new Vector3( p1.x / scale, 0, p1.y / scale ) ); } } ); const result = new BufferGeometry(); result.setFromPoints( arr ); return result; } /** @type {number} */ export const OUTPUT_MESH = 0; /** @type {number} */ export const OUTPUT_LINE_SEGMENTS = 1; /** @type {number} */ export const OUTPUT_BOTH = 2; /** * @callback SilhouetteProgressCallback * @param {number} percent */ /** * Used for generating a projected silhouette of a geometry using the clipper2-js project. Performing * these operations can be extremely slow with more complex geometry and not always yield a stable result. */ export class SilhouetteGenerator { constructor() { /** * How long to spend trimming edges before yielding. * @type {number} */ this.iterationTime = 30; this.intScalar = 1e9; /** * If `false` then only the triangles facing upwards are included in the silhouette. * @type {boolean} */ this.doubleSided = false; /** * Whether to sort triangles and project them large-to-small. In some cases this can cause * the performance to drop since the union operation is best performed with smooth, simple * edge shapes. * @type {boolean} */ this.sortTriangles = false; /** * Whether to output mesh geometry, line segments geometry, or both in an array * ( `[ mesh, line segments ]` ). * @type {number} */ this.output = OUTPUT_MESH; } /** * Generate the silhouette geometry with a promise-style API. * @async * @param {BufferGeometry} geometry * @param {Object} [options] * @param {SilhouetteProgressCallback} [options.onProgress] * @param {AbortSignal} [options.signal] * @returns {BufferGeometry|Array} */ generateAsync( geometry, options = {} ) { return new Promise( ( resolve, reject ) => { const { signal } = options; const task = this.generate( geometry, options ); run(); function run() { if ( signal && signal.aborted ) { reject( new Error( 'SilhouetteGenerator: Process aborted via AbortSignal.' ) ); return; } const result = task.next(); if ( result.done ) { resolve( result.value ); } else { requestAnimationFrame( run ); } } } ); } /** * Generate the geometry using a generator function. * @param {BufferGeometry} geometry * @param {Object} [options] * @param {SilhouetteProgressCallback} [options.onProgress] * @yields {void} * @returns {BufferGeometry|Array} */ *generate( geometry, options = {} ) { const { iterationTime, intScalar, doubleSided, output, sortTriangles } = this; const { onProgress } = options; const power = Math.log10( intScalar ); const extendMultiplier = Math.pow( 10, - ( power - 2 ) ); const index = geometry.index; const posAttr = geometry.attributes.position; const triCount = getTriCount( geometry ); let overallPath = null; const triList = sortTriangles ? getSizeSortedTriList( geometry ) : new Array( triCount ).fill().map( ( v, i ) => i ); const handle = { getGeometry() { if ( output === OUTPUT_MESH ) { return convertPathToGeometry( overallPath, intScalar ); } else if ( output === OUTPUT_LINE_SEGMENTS ) { return convertPathToLineSegments( overallPath, intScalar ); } else { return [ convertPathToGeometry( overallPath, intScalar ), convertPathToLineSegments( overallPath, intScalar ), ]; } } }; let time = performance.now(); for ( let ti = 0; ti < triCount; ti ++ ) { const i = triList[ ti ] * 3; let i0 = i + 0; let i1 = i + 1; let i2 = i + 2; if ( index ) { i0 = index.getX( i0 ); i1 = index.getX( i1 ); i2 = index.getX( i2 ); } // get the triangle const { a, b, c } = _tri; a.fromBufferAttribute( posAttr, i0 ); b.fromBufferAttribute( posAttr, i1 ); c.fromBufferAttribute( posAttr, i2 ); if ( ! doubleSided ) { _tri.getNormal( _normal ); if ( _normal.dot( UP_VECTOR ) < 0 ) { continue; } } // flatten the triangle a.y = 0; b.y = 0; c.y = 0; if ( _tri.getArea() < AREA_EPSILON ) { continue; } // expand the triangle by a small degree to ensure overlap _center .copy( a ) .add( b ) .add( c ) .multiplyScalar( 1 / 3 ); _vec.subVectors( a, _center ).normalize(); a.addScaledVector( _vec, extendMultiplier ); _vec.subVectors( b, _center ).normalize(); b.addScaledVector( _vec, extendMultiplier ); _vec.subVectors( c, _center ).normalize(); c.addScaledVector( _vec, extendMultiplier ); // create the path const path = new Path64(); path.push( Clipper.makePath( [ a.x * intScalar, a.z * intScalar, b.x * intScalar, b.z * intScalar, c.x * intScalar, c.z * intScalar, ] ) ); a.multiplyScalar( intScalar ); b.multiplyScalar( intScalar ); c.multiplyScalar( intScalar ); if ( overallPath && triangleIsInsidePaths( _tri, overallPath ) ) { continue; } // perform union if ( overallPath === null ) { overallPath = path; } else { overallPath = Clipper.Union( overallPath, path, FillRule.NonZero ); overallPath.forEach( path => compressPoints( path ) ); } const delta = performance.now() - time; if ( delta > iterationTime ) { if ( onProgress ) { const progress = ti / triCount; onProgress( progress, handle ); } yield; time = performance.now(); } } return handle.getGeometry(); } } ================================================ FILE: src/index.js ================================================ export * from './MeshVisibilityCuller.js'; export * from './ProjectionGenerator.js'; export * from './SilhouetteGenerator.js'; export * from './PlanarIntersectionGenerator.js'; ================================================ FILE: src/utils/LineObjectsBVH.js ================================================ import { BVH } from 'three-mesh-bvh'; export class LineObjectsBVH extends BVH { get lines() { return this.primitiveBuffer; } constructor( lines, options ) { super( options ); this.primitiveBuffer = lines; this.primitiveBufferStride = 1; this.heightOffset = options.heightOffset ?? 1e3; this.init( options ); } writePrimitiveBounds( i, targetBuffer, writeOffset ) { const { primitiveBuffer, heightOffset } = this; const { start, end } = primitiveBuffer[ i ]; targetBuffer[ writeOffset + 0 ] = Math.min( start.x, end.x ); targetBuffer[ writeOffset + 1 ] = Math.min( start.y, end.y ); targetBuffer[ writeOffset + 2 ] = Math.min( start.z, end.z ); targetBuffer[ writeOffset + 3 ] = Math.max( start.x, end.x ); targetBuffer[ writeOffset + 4 ] = Math.max( start.y, end.y ) + heightOffset; targetBuffer[ writeOffset + 5 ] = Math.max( start.z, end.z ); } getRootRanges() { return [ { offset: 0, count: this.primitiveBuffer.length } ]; } } ================================================ FILE: src/utils/ProjectionEdge.js ================================================ import { Line3 } from 'three'; export class ProjectionEdge extends Line3 { constructor( start, end ) { super( start, end ); this.mesh = null; } copy( source ) { super.copy( source ); this.mesh = source.mesh || null; return this; } } ================================================ FILE: src/utils/bvhcastEdges.js ================================================ import { isLineTriangleEdge } from './triangleLineUtils.js'; import { trimToBeneathTriPlane } from './trimToBeneathTriPlane.js'; import { getProjectedLineOverlap } from './getProjectedLineOverlap.js'; import { appendOverlapRange } from './getProjectedOverlaps.js'; import { BackSide, DoubleSide, Line3, Vector3 } from 'three'; import { ExtendedTriangle } from 'three-mesh-bvh'; const UP_VECTOR = new Vector3( 0, 1, 0 ); const DIST_THRESHOLD = 1e-10; const _beneathLine = /* @__PURE__ */ new Line3(); const _overlapLine = /* @__PURE__ */ new Line3(); const _tri = /* @__PURE__ */ new ExtendedTriangle(); _tri.update = () => { // override the "update" function so we only calculate the piece we need _tri.plane.setFromCoplanarPoints( ..._tri.points ); }; export function bvhcastEdges( edgesBvh, bvh, mesh, hiddenOverlapMap ) { const { geometry, matrixWorld, material } = mesh; const side = material.side; const inverted = matrixWorld.determinant() < 0; const edges = edgesBvh.lines; edgesBvh.bvhcast( bvh, matrixWorld, { intersectsRanges: ( edgeOffset, edgeCount, meshOffset, meshCount ) => { for ( let i = meshOffset, l = meshCount + meshOffset; i < l; i ++ ) { let i0 = 3 * i + 0; let i1 = 3 * i + 1; let i2 = 3 * i + 2; if ( geometry.index ) { i0 = geometry.index.getX( i0 ); i1 = geometry.index.getX( i1 ); i2 = geometry.index.getX( i2 ); } // Transform mesh triangle to world space const { a, b, c } = _tri; a.fromBufferAttribute( geometry.attributes.position, i0 ).applyMatrix4( matrixWorld ); b.fromBufferAttribute( geometry.attributes.position, i1 ).applyMatrix4( matrixWorld ); c.fromBufferAttribute( geometry.attributes.position, i2 ).applyMatrix4( matrixWorld ); _tri.needsUpdate = true; _tri.update(); // back face culling if ( side !== DoubleSide ) { const faceUp = _tri.plane.normal.dot( UP_VECTOR ) !== inverted; if ( faceUp === ( side === BackSide ) ) { continue; } } const triMaxY = Math.max( a.y, b.y, c.y ); const triMinY = Math.min( a.y, b.y, c.y ); for ( let e = edgeOffset, le = edgeCount + edgeOffset; e < le; e ++ ) { const _line = edges[ e ]; // Calculate edge and triangle bounds const lineMinY = Math.min( _line.start.y, _line.end.y ); const lineMaxY = Math.max( _line.start.y, _line.end.y ); // Skip if triangle is completely below the line if ( triMaxY <= lineMinY ) { continue; } // Skip if this line lies on a triangle edge if ( isLineTriangleEdge( _tri, _line ) ) { continue; } // Retrieve the portion of line that is below the triangle plane if ( lineMaxY < triMinY ) { _beneathLine.copy( _line ); } else if ( ! trimToBeneathTriPlane( _tri, _line, _beneathLine ) ) { continue; } // Cull overly small edges if ( _beneathLine.distance() < DIST_THRESHOLD ) { continue; } // Calculate projected overlap and store in hiddenOverlapMap if ( getProjectedLineOverlap( _beneathLine, _tri, _overlapLine ) ) { appendOverlapRange( _line, _overlapLine, hiddenOverlapMap[ e ] ); } } } }, } ); } ================================================ FILE: src/utils/compressPoints.js ================================================ const DIRECTION_EPSILON = 1e-3; const DIST_EPSILON = 1e2; function sameDirection( p0, p1, p2 ) { const dx1 = p1.x - p0.x; const dy1 = p1.y - p0.y; const dx2 = p2.x - p1.x; const dy2 = p2.y - p1.y; const s1 = dx1 / dy1; const s2 = dx2 / dy2; return Math.abs( s1 - s2 ) < DIRECTION_EPSILON; } function areClose( p0, p1 ) { const dx = p1.x - p0.x; const dy = p1.y - p0.y; return Math.sqrt( dx * dx + dy * dy ) < DIST_EPSILON; } function areEqual( p0, p1 ) { return p0.x === p1.x && p0.y === p1.y; } export function compressPoints( points ) { for ( let k = 0; k < points.length; k ++ ) { // remove points that are equal or very close to each other const v = points[ k ]; while ( true ) { const k1 = k + 1; if ( points.length > k1 && ( areEqual( v, points[ k1 ] ) || areClose( v, points[ k1 ] ) ) ) { points.splice( k1, 1 ); } else { break; } } // join lines that are almost the same direction while ( true ) { const k1 = k + 1; const k2 = k + 2; if ( points.length > k2 && sameDirection( v, points[ k1 ], points[ k2 ] ) ) { points.splice( k + 1, 1 ); } else { break; } } } } ================================================ FILE: src/utils/generateEdges.js ================================================ import { Vector3, Triangle, MathUtils, Matrix4 } from 'three'; import { ProjectionEdge } from './ProjectionEdge.js'; // Modified version of js EdgesGeometry logic to handle silhouette edges const EPSILON = 1e-10; const UP_VECTOR = /* @__PURE__ */ new Vector3( 0, 1, 0 ); const _v0 = /* @__PURE__ */ new Vector3(); const _v1 = /* @__PURE__ */ new Vector3(); const _normal = /* @__PURE__ */ new Vector3(); const _triangle = /* @__PURE__ */ new Triangle(); const _triangleLocal = /* @__PURE__ */ new Triangle(); const _localProjection = /* @__PURE__ */ new Vector3(); const _invMat = /* @__PURE__ */ new Matrix4(); export function* generateEdges( geometry, target = [], options = {} ) { const { matrix = null, thresholdAngle = 1, iterationTime = 30, } = options; _localProjection.copy( UP_VECTOR ); let isAffine = true; if ( matrix ) { isAffine = matrix.elements[ 3 ] === 0 && matrix.elements[ 7 ] === 0 && matrix.elements[ 11 ] === 0 && matrix.elements[ 15 ] === 1; if ( isAffine ) { _invMat.copy( matrix ).invert(); _localProjection.transformDirection( _invMat ); } } const precisionPoints = 4; const precision = Math.pow( 10, precisionPoints ); const thresholdDot = Math.cos( MathUtils.DEG2RAD * thresholdAngle ); const indexAttr = geometry.getIndex(); const positionAttr = geometry.getAttribute( 'position' ); const indexCount = indexAttr ? indexAttr.count : positionAttr.count; const indexArr = [ 0, 0, 0 ]; const vertKeys = [ 'a', 'b', 'c' ]; const hashes = new Array( 3 ); const edgeData = {}; let time = performance.now(); for ( let i = 0; i < indexCount; i += 3 ) { if ( performance.now() - time > iterationTime ) { yield; time = performance.now(); } if ( indexAttr ) { indexArr[ 0 ] = indexAttr.getX( i ); indexArr[ 1 ] = indexAttr.getX( i + 1 ); indexArr[ 2 ] = indexAttr.getX( i + 2 ); } else { indexArr[ 0 ] = i; indexArr[ 1 ] = i + 1; indexArr[ 2 ] = i + 2; } const { a, b, c } = _triangleLocal; _triangleLocal.a.fromBufferAttribute( positionAttr, indexArr[ 0 ] ); _triangleLocal.b.fromBufferAttribute( positionAttr, indexArr[ 1 ] ); _triangleLocal.c.fromBufferAttribute( positionAttr, indexArr[ 2 ] ); // create hashes for the edge from the vertices hashes[ 0 ] = `${ Math.round( a.x * precision ) },${ Math.round( a.y * precision ) },${ Math.round( a.z * precision ) }`; hashes[ 1 ] = `${ Math.round( b.x * precision ) },${ Math.round( b.y * precision ) },${ Math.round( b.z * precision ) }`; hashes[ 2 ] = `${ Math.round( c.x * precision ) },${ Math.round( c.y * precision ) },${ Math.round( c.z * precision ) }`; // skip degenerate triangles if ( hashes[ 0 ] === hashes[ 1 ] || hashes[ 1 ] === hashes[ 2 ] || hashes[ 2 ] === hashes[ 0 ] ) { continue; } // compute normal — fast path uses local-space normal with pre-transformed // projection direction; slow path transforms vertices for world-space normal if ( matrix && ! isAffine ) { _triangle.copy( _triangleLocal ); _triangle.a.applyMatrix4( matrix ); _triangle.b.applyMatrix4( matrix ); _triangle.c.applyMatrix4( matrix ); _triangle.getNormal( _normal ); } else { _triangleLocal.getNormal( _normal ); } // iterate over every edge for ( let j = 0; j < 3; j ++ ) { // get the first and next vertex making up the edge const jNext = ( j + 1 ) % 3; const vecHash0 = hashes[ j ]; const vecHash1 = hashes[ jNext ]; const v0 = _triangleLocal[ vertKeys[ j ] ]; const v1 = _triangleLocal[ vertKeys[ jNext ] ]; const hash = `${ vecHash0 }_${ vecHash1 }`; const reverseHash = `${ vecHash1 }_${ vecHash0 }`; if ( reverseHash in edgeData && edgeData[ reverseHash ] ) { // if we found a sibling edge add it into the vertex array if // it meets the angle threshold and delete the edge from the map. const otherNormal = edgeData[ reverseHash ].normal; const meetsThreshold = _normal.dot( otherNormal ) <= thresholdDot; // get the dot product relative to the projection angle and // add an epsilon for nearly vertical triangles const _projDir = _localProjection; let normDot = _projDir.dot( _normal ); normDot = Math.abs( normDot ) < EPSILON ? 0 : normDot; let otherDot = _projDir.dot( otherNormal ); otherDot = Math.abs( otherDot ) < EPSILON ? 0 : otherDot; const projectionThreshold = Math.sign( normDot ) !== Math.sign( otherDot ); if ( meetsThreshold || projectionThreshold ) { const line = new ProjectionEdge(); line.start.copy( v0 ); line.end.copy( v1 ); target.push( line ); } edgeData[ reverseHash ] = null; } else if ( ! ( hash in edgeData ) ) { // if we've already got an edge here then skip adding a new one edgeData[ hash ] = { index0: indexArr[ j ], index1: indexArr[ jNext ], normal: _normal.clone(), }; } } } // iterate over all remaining, unmatched edges and add them to the vertex array for ( const key in edgeData ) { if ( edgeData[ key ] ) { const { index0, index1 } = edgeData[ key ]; _v0.fromBufferAttribute( positionAttr, index0 ); _v1.fromBufferAttribute( positionAttr, index1 ); const line = new ProjectionEdge(); line.start.copy( _v0 ); line.end.copy( _v1 ); target.push( line ); } } return target; } ================================================ FILE: src/utils/generateIntersectionEdges.js ================================================ import { Line3 } from 'three'; import { isLineTriangleEdge } from './triangleLineUtils.js'; import { ProjectionEdge } from './ProjectionEdge.js'; // TODO: How can we add support for "iterationTime"? const _line = /* @__PURE__ */ new Line3(); export function generateIntersectionEdges( bvhA, bvhB, matrixBToA, target = [] ) { bvhA.bvhcast( bvhB, matrixBToA, { intersectsTriangles: ( tri1, tri2 ) => { if ( areTrianglesOnEdge( tri1, tri2 ) ) { return false; } if ( tri1.needsUpdate ) { tri1.update(); } if ( tri2.needsUpdate ) { tri2.update(); } if ( Math.abs( tri1.plane.normal.dot( tri2.plane.normal ) ) > 1 - 1e-6 ) { return false; } if ( tri1.intersectsTriangle( tri2, _line, true ) && ! isLineTriangleEdge( tri1, _line ) && ! isLineTriangleEdge( tri2, _line ) ) { target.push( new ProjectionEdge().copy( _line ) ); } } } ); return target; } function areVectorsEqual( a, b ) { return a.distanceTo( b ) < 1e-10; } function areTrianglesOnEdge( t1, t2 ) { const indices = [ 'a', 'b', 'c' ]; let tot = 0; for ( let i = 0; i < 3; i ++ ) { for ( let j = 0; j < 3; j ++ ) { const v0 = t1[ indices[ i ] ]; const v1 = t2[ indices[ j ] ]; if ( areVectorsEqual( v0, v1 ) ) { tot ++; } } } return tot >= 2; } ================================================ FILE: src/utils/geometryUtils.js ================================================ export function getTriCount( geometry ) { const { index } = geometry; const posAttr = geometry.attributes.position; return index ? index.count / 3 : posAttr.count / 3; } ================================================ FILE: src/utils/getAllMeshes.js ================================================ export function getAllMeshes( scene ) { let arr; if ( Array.isArray( scene ) ) { arr = scene; } else { arr = [ scene ]; } const result = new Set(); for ( let i = 0, l = arr.length; i < l; i ++ ) { arr[ i ].traverse( c => { if ( c.geometry && c.visible ) { result.add( c ); } } ); } return Array.from( result ); } ================================================ FILE: src/utils/getProjectedLineOverlap.js ================================================ import { Vector3, Line3, Plane } from 'three'; import { ExtendedTriangle } from 'three-mesh-bvh'; const AREA_EPSILON = 1e-16; const DIST_EPSILON = 1e-16; const _orthoPlane = /* @__PURE__ */ new Plane(); const _edgeLine = /* @__PURE__ */ new Line3(); const _point = /* @__PURE__ */ new Vector3(); const _vec = /* @__PURE__ */ new Vector3(); const _tri = /* @__PURE__ */ new ExtendedTriangle(); const _line = /* @__PURE__ */ new Line3(); const _triLine = /* @__PURE__ */ new Line3(); const _dir = /* @__PURE__ */ new Vector3(); const _ortho = /* @__PURE__ */ new Vector3(); const _triDir = /* @__PURE__ */ new Vector3(); // Returns the portion of the line that is overlapping the triangle when projected // TODO: rename this, remove need for tri update, plane export function getProjectedLineOverlap( line, triangle, lineTarget = new Line3() ) { // flatten the shapes _tri.copy( triangle ); _tri.a.y = 0; _tri.b.y = 0; _tri.c.y = 0; _tri.update(); _line.copy( line ); _line.start.y = 0; _line.end.y = 0; // if the triangle is degenerate then return no overlap if ( _tri.getArea() <= AREA_EPSILON ) { return null; } const lineDistance = _line.distance(); _line.delta( _dir ).divideScalar( lineDistance ); _ortho.copy( _dir ).cross( _tri.plane.normal ).normalize(); _orthoPlane.setFromNormalAndCoplanarPoint( _ortho, _line.start ); // find the line of intersection of the triangle along the plane if it exists let intersectCount = 0; const { points } = _tri; for ( let i = 0; i < 3; i ++ ) { const p1 = points[ i ]; const p2 = points[ ( i + 1 ) % 3 ]; const distToStart = _orthoPlane.distanceToPoint( p1 ); const distToEnd = _orthoPlane.distanceToPoint( p2 ); const startIntersects = Math.abs( distToStart ) < DIST_EPSILON; const endIntersects = Math.abs( distToEnd ) < DIST_EPSILON; if ( startIntersects && endIntersects ) { continue; } else if ( startIntersects ) { _point.copy( p1 ); } else if ( endIntersects ) { continue; } else if ( ( distToStart < 0.0 ) == ( distToEnd < 0.0 ) ) { continue; } else { // manual edge-plane intersection (faster than Plane.intersectLine) const t = distToStart / ( distToStart - distToEnd ); _point.lerpVectors( p1, p2, t ); } if ( intersectCount == 0 ) { _triLine.start.copy( _point ); } else if ( intersectCount == 1 ) { _triLine.end.copy( _point ); } intersectCount ++; if ( intersectCount === 2 ) { break; } } if ( intersectCount === 2 ) { // find the intersect line if any _triLine.delta( _triDir ).normalize(); // swap edges so they're facing in the same direction if ( _dir.dot( _triDir ) < 0 ) { const tmp = _triLine.start; _triLine.start = _triLine.end; _triLine.end = tmp; } // check if the edges are overlapping const s1 = 0; const e1 = _vec.subVectors( _line.end, _line.start ).dot( _dir ); const s2 = _vec.subVectors( _triLine.start, _line.start ).dot( _dir ); const e2 = _vec.subVectors( _triLine.end, _line.start ).dot( _dir ); const separated1 = e1 <= s2; const separated2 = e2 <= s1; if ( separated1 || separated2 ) { return null; } line.at( Math.max( s1, s2 ) / lineDistance, lineTarget.start, ); line.at( Math.min( e1, e2 ) / lineDistance, lineTarget.end, ); return lineTarget; } return null; } ================================================ FILE: src/utils/getProjectedOverlaps.js ================================================ import { Vector3 } from 'three'; const DIST_EPSILON = 1e-16; const _dir = /* @__PURE__ */ new Vector3(); const _v0 = /* @__PURE__ */ new Vector3(); const _v1 = /* @__PURE__ */ new Vector3(); export function appendOverlapRange( line, overlapLine, overlapsTarget ) { const result = getOverlapRange( line, overlapLine ); if ( result ) { insertOverlap( result, overlapsTarget ); return true; } return false; } // Returns the overlap range without pushing to array (for binary insertion) export function getOverlapRange( line, overlapLine ) { line.delta( _dir ); _v0.subVectors( overlapLine.start, line.start ); _v1.subVectors( overlapLine.end, line.start ); const length = _dir.length(); let t0 = _v0.length() / length; let t1 = _v1.length() / length; t0 = Math.min( Math.max( t0, 0 ), 1 ); t1 = Math.min( Math.max( t1, 0 ), 1 ); if ( Math.abs( t0 - t1 ) <= DIST_EPSILON ) { return null; } return [ t0, t1 ]; } export function insertOverlap( result, overlapsTarget ) { let [ start, end ] = result; // binary search to find where the for loop should begin iteration let left = 0; let right = overlapsTarget.length; while ( left < right ) { const mid = ( left + right ) >>> 1; if ( overlapsTarget[ mid ][ 0 ] <= start ) { left = mid + 1; } else { right = mid; } } // start iteration from one position before (in case previous overlap // extends into ours) let insertPoint = Math.max( 0, left - 1 ); let deleteCount = 0; for ( let i = insertPoint, l = overlapsTarget.length; i < l; i ++ ) { const [ otherStart, otherEnd ] = overlapsTarget[ i ]; if ( start <= otherEnd && end >= otherStart ) { // check if there's overlap start = Math.min( otherStart, start ); end = Math.max( otherEnd, end ); deleteCount ++; } else if ( start >= otherStart ) { // otherwise move the insertion point forward insertPoint = i + 1; } else { break; } } overlapsTarget.splice( insertPoint, deleteCount, [ start, end ] ); } ================================================ FILE: src/utils/getSizeSortedTriList.js ================================================ import { Triangle } from 'three'; import { getTriCount } from './geometryUtils.js'; const _tri = new Triangle(); export function getSizeSortedTriList( geometry ) { const index = geometry.index; const posAttr = geometry.attributes.position; const triCount = getTriCount( geometry ); return new Array( triCount ) .fill() .map( ( v, i ) => { let i0 = i * 3 + 0; let i1 = i * 3 + 1; let i2 = i * 3 + 2; if ( index ) { i0 = index.getX( i0 ); i1 = index.getX( i1 ); i2 = index.getX( i2 ); } _tri.a.fromBufferAttribute( posAttr, i0 ); _tri.b.fromBufferAttribute( posAttr, i1 ); _tri.c.fromBufferAttribute( posAttr, i2 ); _tri.a.y = 0; _tri.b.y = 0; _tri.c.y = 0; // get the projected area of the triangle to sort largest triangles first return { area: _tri.getArea(), index: i, }; } ) .sort( ( a, b ) => { // sort the triangles largest to smallest return b.area - a.area; } ) .map( o => { // map to the triangle index return o.index; } ); } ================================================ FILE: src/utils/nextFrame.js ================================================ export const nextFrame = () => new Promise( resolve => { let rafHandle; let timeoutHandle; const cb = () => { cancelAnimationFrame( rafHandle ); clearTimeout( timeoutHandle ); resolve(); }; rafHandle = requestAnimationFrame( cb ); timeoutHandle = setTimeout( cb, 16 ); } ); ================================================ FILE: src/utils/overlapUtils.js ================================================ import { Line3 } from 'three'; const _line = /* @__PURE__ */ new Line3(); // Converts the given array of overlaps into line segments export function overlapsToLines( line, overlaps, invert = false, target = [] ) { // Function assumes the line overlaps are already compressed let invOverlaps = [[ 0, 1 ]]; for ( let i = 0, l = overlaps.length; i < l; i ++ ) { const invOverlap = invOverlaps[ i ]; const overlap = overlaps[ i ]; invOverlap[ 1 ] = overlap[ 0 ]; invOverlaps.push( [ overlap[ 1 ], 1 ] ); } if ( invert ) { [ overlaps, invOverlaps ] = [ invOverlaps, overlaps ]; } for ( let i = 0, l = invOverlaps.length; i < l; i ++ ) { const { start, end } = line; _line.start.lerpVectors( start, end, invOverlaps[ i ][ 0 ] ); _line.end.lerpVectors( start, end, invOverlaps[ i ][ 1 ] ); target.push( new Float32Array( [ _line.start.x, _line.start.y, _line.start.z, _line.end.x, _line.end.y, _line.end.z, ] ) ); } return invOverlaps.length; } ================================================ FILE: src/utils/planeUtils.js ================================================ import { Vector3, Line3 } from "three"; const _line = /* @__PURE__ */ new Line3(); const _v0 = /* @__PURE__ */ new Vector3(); const _v1 = /* @__PURE__ */ new Vector3(); // returns the the y value on the plane at the given point x, z export function getPlaneYAtPoint( plane, point, target = null ) { _line.start.copy( point ); _line.end.copy( point ); _line.start.y += 1e5; _line.end.y -= 1e5; plane.intersectLine( _line, target ); } // returns whether the given line is above the given triangle plane export function isLineAbovePlane( plane, line ) { const linePoint = _v0; const planePoint = _v1; linePoint.lerpVectors( line.start, line.end, 0.5 ); getPlaneYAtPoint( plane, linePoint, planePoint ); return planePoint.y < linePoint.y; } ================================================ FILE: src/utils/triangleIsInsidePaths.js ================================================ import { Line3, Ray } from 'three'; function xzToXzCopy( v, target ) { target.x = v.x; target.y = v.z; } function epsEquals( a, b ) { return Math.abs( a - b ) <= 500; } function vectorEpsEquals( v0, v1 ) { return epsEquals( v0.x, v1.x ) && epsEquals( v0.y, v1.y ) && epsEquals( v0.z, v1.z ); } export function triangleIsInsidePaths( tri, paths ) { const indices = [ 'a', 'b', 'c' ]; const edges = [ new Line3(), new Line3(), new Line3() ]; const line = new Line3(); const ray = new Line3(); ray.start .set( 0, 0, 0 ) .addScaledVector( tri.a, 1 / 3 ) .addScaledVector( tri.b, 1 / 3 ) .addScaledVector( tri.c, 1 / 3 ); xzToXzCopy( ray.start, ray.start ); ray.end.copy( ray.start ); ray.end.y += 1e10; // get all triangle edges for ( let i = 0; i < 3; i ++ ) { const i1 = ( i + 1 ) % 3; const p0 = tri[ indices[ i ] ]; const p1 = tri[ indices[ i1 ] ]; const edge = edges[ i ]; xzToXzCopy( p0, edge.start ); xzToXzCopy( p1, edge.end ); } let crossCount = 0; for ( let p = 0, pl = paths.length; p < pl; p ++ ) { const points = paths[ p ]; for ( let i = 0, l = points.length; i < l; i ++ ) { const i1 = ( i + 1 ) % l; line.start.copy( points[ i ] ); line.start.z = 0; line.end.copy( points[ i1 ] ); line.end.z = 0; if ( lineCrossesLine( ray, line ) ) { crossCount ++; } for ( let e = 0; e < 3; e ++ ) { const edge = edges[ e ]; if ( lineCrossesLine( edge, line ) || vectorEpsEquals( edge.start, line.start ) || vectorEpsEquals( edge.end, line.end ) || vectorEpsEquals( edge.end, line.start ) || vectorEpsEquals( edge.start, line.end ) ) { return false; } } } } return crossCount % 2 === 1; } // https://stackoverflow.com/questions/3838329/how-can-i-check-if-two-segments-intersect function lineCrossesLine( l1, l2 ) { function ccw( A, B, C ) { return ( C.y - A.y ) * ( B.x - A.x ) > ( B.y - A.y ) * ( C.x - A.x ); } const A = l1.start; const B = l1.end; const C = l2.start; const D = l2.end; return ccw( A, C, D ) !== ccw( B, C, D ) && ccw( A, B, C ) !== ccw( A, B, D ); } ================================================ FILE: src/utils/triangleLineUtils.js ================================================ import { Vector3 } from 'three'; const EPSILON = 1e-16; const UP_VECTOR = /* @__PURE__ */ new Vector3( 0, 1, 0 ); const _dir = new Vector3(); export function isYProjectedLineDegenerate( line ) { line.delta( _dir ).normalize(); return Math.abs( _dir.dot( UP_VECTOR ) ) >= 1.0 - EPSILON; } // checks whether the y-projected triangle will be degenerate export function isYProjectedTriangleDegenerate( tri ) { if ( tri.needsUpdate ) { tri.update(); } return Math.abs( tri.plane.normal.dot( UP_VECTOR ) ) <= EPSILON; } // Is the provided line exactly an edge on the triangle export function isLineTriangleEdge( tri, line ) { // if this is the same line as on the triangle const { start, end } = line; const triPoints = tri.points; let startMatches = false; let endMatches = false; for ( let i = 0; i < 3; i ++ ) { const tp = triPoints[ i ]; if ( ! startMatches && start.distanceToSquared( tp ) <= EPSILON ) { startMatches = true; } if ( ! endMatches && end.distanceToSquared( tp ) <= EPSILON ) { endMatches = true; } if ( startMatches && endMatches ) { return true; } } return startMatches && endMatches; } ================================================ FILE: src/utils/trimToBeneathTriPlane.js ================================================ import { Plane, Vector3, MathUtils } from 'three'; const EPSILON = 1e-16; const UP_VECTOR = /* @__PURE__ */ new Vector3( 0, 1, 0 ); const _plane = /* @__PURE__ */ new Plane(); const _planeHit = /* @__PURE__ */ new Vector3(); const _lineDirection = /* @__PURE__ */ new Vector3(); export function trimToBeneathTriPlane( tri, line, lineTarget ) { // update triangle if needed if ( tri.needsUpdate ) { tri.update(); } // if the plane is not facing up then flip the direction _plane.copy( tri.plane ); if ( _plane.normal.dot( UP_VECTOR ) < 0 ) { _plane.normal.multiplyScalar( - 1 ); _plane.constant *= - 1; } const startDist = _plane.distanceToPoint( line.start ); const endDist = _plane.distanceToPoint( line.end ); const isStartOnPlane = Math.abs( startDist ) < EPSILON; const isEndOnPlane = Math.abs( endDist ) < EPSILON; const isStartBelow = startDist < 0; const isEndBelow = endDist < 0; // if the line and plane are coplanar then return that we can't trim line.delta( _lineDirection ).normalize(); if ( Math.abs( _plane.normal.dot( _lineDirection ) ) < EPSILON ) { // if the line is definitely above or on the plane then skip it if ( isStartOnPlane || ! isStartBelow ) { return false; } else { lineTarget.copy( line ); return true; } } // find the point that's below the plane. If both points are below the plane // then we assume we're dealing with floating point error if ( isStartBelow && isEndBelow ) { // if the whole line is below then just copy that lineTarget.copy( line ); return true; } else if ( ! isStartBelow && ! isEndBelow ) { // if it's wholly above then skip it return false; } else { const t = MathUtils.mapLinear( 0, startDist, endDist, 0, 1 ); line.at( t, _planeHit ); if ( isStartBelow ) { lineTarget.start.copy( line.start ); lineTarget.end.copy( _planeHit ); return true; } else if ( isEndBelow ) { lineTarget.end.copy( line.end ); lineTarget.start.copy( _planeHit ); return true; } } return false; } ================================================ FILE: src/webgpu/MeshVisibilityCuller.js ================================================ /** @import { Object3D } from 'three' */ /** @import { WebGPURenderer } from 'three/webgpu' */ import { Box3, Vector3, Vector4, OrthographicCamera, Color, Mesh, } from 'three'; import { RenderTarget, MeshBasicNodeMaterial } from 'three/webgpu'; import { uniform } from 'three/tsl'; import { getAllMeshes } from '../utils/getAllMeshes.js'; // RGBA8 ID encoding - supports up to 16,777,215 objects (2^24 - 1) // ID 0 is valid, background is indicated by alpha = 0 function encodeId( id, target ) { target.x = ( id & 0xFF ) / 255; target.y = ( ( id >> 8 ) & 0xFF ) / 255; target.z = ( ( id >> 16 ) & 0xFF ) / 255; target.w = 1; } function decodeId( buffer, index ) { return buffer[ index ] | ( buffer[ index + 1 ] << 8 ) | ( buffer[ index + 2 ] << 16 ); } /** * Utility for determining visible geometry from a top down orthographic perspective. This can * be run before performing projection generation to reduce the complexity of the operation at * the cost of potentially missing small details. * * Takes the WebGPURenderer instance used to render. * @param {WebGPURenderer} renderer * @param {Object} [options] * @param {number} [options.pixelsPerMeter=0.1] */ export class MeshVisibilityCuller { constructor( renderer, options = {} ) { const { pixelsPerMeter = 0.1 } = options; /** * The size of a pixel on a single dimension. If this results in a texture larger than what * the graphics context can provide then the rendering is tiled. * @type {number} */ this.pixelsPerMeter = pixelsPerMeter; this.renderer = renderer; } /** * Returns the set of meshes that are visible within the given object. * @param {Object3D|Array} object * @returns {Promise>} */ async cull( objects ) { objects = getAllMeshes( objects ); const { renderer, pixelsPerMeter } = this; const size = new Vector3(); const camera = new OrthographicCamera(); const box = new Box3(); const idValue = new Vector4(); const idUniform = uniform( idValue ); const idMaterial = new MeshBasicNodeMaterial(); idMaterial.colorNode = idUniform; const idMesh = new Mesh( undefined, idMaterial ); idMesh.matrixAutoUpdate = false; idMesh.matrixWorldAutoUpdate = false; // get the bounds of the image box.makeEmpty(); objects.forEach( o => { box.expandByObject( o ); } ); // get the bounds dimensions box.getSize( size ); // calculate the tile and target size const maxTextureSize = Math.min( renderer.backend.device.limits.maxTextureDimension2D, 2 ** 13 ); const pixelWidth = Math.ceil( size.x / pixelsPerMeter ); const pixelHeight = Math.ceil( size.z / pixelsPerMeter ); const tilesX = Math.ceil( pixelWidth / maxTextureSize ); const tilesY = Math.ceil( pixelHeight / maxTextureSize ); const target = new RenderTarget( Math.ceil( pixelWidth / tilesX ), Math.ceil( pixelHeight / tilesY ) ); // set the camera bounds camera.rotation.x = - Math.PI / 2; camera.far = ( box.max.y - box.min.y ) + camera.near; camera.position.y = box.max.y + camera.near; // save render state const color = renderer.getClearColor( new Color() ); const alpha = renderer.getClearAlpha(); const renderTarget = renderer.getRenderTarget(); const autoClear = renderer.autoClear; // render ids const visibleSet = new Set(); const stepX = size.x / tilesX; const stepZ = size.z / tilesY; for ( let x = 0; x < tilesX; x ++ ) { for ( let y = 0; y < tilesY; y ++ ) { // update camera camera.left = box.min.x + stepX * x; camera.top = - ( box.min.z + stepZ * y ); camera.right = camera.left + stepX; camera.bottom = camera.top - stepZ; camera.updateProjectionMatrix(); // clear the render target renderer.autoClear = false; renderer.setClearColor( 0, 0 ); renderer.setRenderTarget( target ); renderer.clear(); for ( let i = 0; i < objects.length; i ++ ) { const object = objects[ i ]; idMesh.matrixWorld.copy( object.matrixWorld ); idMesh.geometry = object.geometry; encodeId( i, idValue ); renderer.render( idMesh, camera ); } // reset render state before async operation to avoid corruption renderer.setClearColor( color, alpha ); renderer.setRenderTarget( renderTarget ); renderer.autoClear = autoClear; const buffer = new Uint8Array( await renderer.readRenderTargetPixelsAsync( target, 0, 0, target.width, target.height ) ); // find all visible objects - decode RGBA to ID for ( let i = 0, l = buffer.length; i < l; i += 4 ) { // alpha = 0 indicates background (no object) if ( buffer[ i + 3 ] === 0 ) continue; const id = decodeId( buffer, i ); visibleSet.add( objects[ id ] ); } } } // dispose of intermediate values idMaterial.dispose(); target.dispose(); return Array.from( visibleSet ); } } ================================================ FILE: src/webgpu/ProjectionGenerator.js ================================================ /** @import { Object3D, BufferGeometry } from 'three' */ /** @import { WebGPURenderer } from 'three/webgpu' */ import { IndirectStorageBufferAttribute, ReadbackBuffer, StorageBufferAttribute } from 'three/webgpu'; import { storage } from 'three/tsl'; import { getAllMeshes } from '../utils/getAllMeshes.js'; import { EdgeGenerator } from '../EdgeGenerator.js'; import { isYProjectedLineDegenerate } from '../utils/triangleLineUtils.js'; import { ProjectionGeneratorBVHComputeData } from './ProjectionGeneratorBVHComputeData.js'; import { edgeStruct, overlapRecordStruct } from './nodes/structs.wgsl.js'; import { EdgeOverlapsKernel } from './kernels/EdgeOverlapsKernel.js'; import { overlapsToLines } from '../utils/overlapUtils.js'; import { insertOverlap } from '../utils/getProjectedOverlaps.js'; import { ProjectionResult } from '../ProjectionGenerator.js'; import { ZeroOutBufferKernel } from './kernels/ZeroOutBufferKernel.js'; import { nextFrame } from '../utils/nextFrame.js'; // TODO: Consider storing the ranges with multiple edges clipped per thread to reduce the array size needed const MAX_BUFFER_SIZE = 134217728; const MAX_OVERLAPS_COUNT = Math.floor( MAX_BUFFER_SIZE / ( overlapRecordStruct.getLength() * 4 ) ); /** * @callback ProjectionProgressCallback * @param {number} percent * @param {string} message */ /** * Takes the WebGPURenderer instance used to run compute kernels. * @param {WebGPURenderer} renderer */ export class ProjectionGenerator { constructor( renderer ) { this.renderer = renderer; /** * The threshold angle in degrees at which edges are generated. * @type {number} * @default 50 */ this.angleThreshold = 50; /** * The number of edges to process in one compute kernel pass. Larger values can process * faster but may cause internal buffers to overflow, resulting in extra kernel executions, * taking more time. * @type {number} * @default 100000 */ this.batchSize = 100000; /** * Whether to generate edges representing the intersections between triangles. * @type {boolean} * @default true */ this.includeIntersectionEdges = true; /** * How long to spend generating edges. * @type {number} * @default 300 */ this.iterationTime = 300; /** * How many compute jobs to perform in parallel. * @type {number} * @default 3 */ this.parallelJobs = 3; } /** * Asynchronously generate the edge geometry result. * @param {Object3D|BufferGeometry|Array} scene * @param {Object} [options] * @param {ProjectionProgressCallback} [options.onProgress] * @param {AbortSignal} [options.signal] * @returns {Promise} */ async generate( scene, options = {} ) { const { renderer, angleThreshold, includeIntersectionEdges, batchSize, iterationTime, parallelJobs } = this; const { onProgress = null, signal = null } = options; // collect meshes const meshes = getAllMeshes( scene ); // generate edges const edgeGenerator = new EdgeGenerator(); edgeGenerator.thresholdAngle = angleThreshold; edgeGenerator.iterationTime = iterationTime; // adjust the offset to account for floating point error in the edge processing and intersections. // NOTE: Ideally we should be applying this relative to the scale of the values being used rather that // using a fixed offset. edgeGenerator.yOffset = 5 * 1e-5; if ( onProgress ) { onProgress( 0, 'Generating Edges' ); } let edges = []; await edgeGenerator.getEdgesAsync( scene, edges ); signal?.throwIfAborted(); if ( includeIntersectionEdges ) { if ( onProgress ) { onProgress( 0, 'Generating Intersection Edges' ); } await edgeGenerator.getIntersectionEdgesAsync( scene, edges ); signal?.throwIfAborted(); } edges = edges.filter( e => ! isYProjectedLineDegenerate( e ) ); if ( edges.length === 0 ) { return new ProjectionResult(); } onProgress( 0, 'Projecting Edges' ); // // allocate a buffer of edges for at most the requested capacity const batchCapacity = Math.min( batchSize, edges.length ); const edgeBufferData = new Float32Array( batchCapacity * edgeStruct.getLength() ); const edgeBufferDataU32 = new Uint32Array( edgeBufferData.buffer ); const edgeBufferAttribute = new StorageBufferAttribute( edgeBufferData, edgeStruct.getLength() ); // overlap output buffer and atomic counter const overlapsAttribute = new IndirectStorageBufferAttribute( MAX_OVERLAPS_COUNT, overlapRecordStruct.getLength(), Uint32Array ); const bufferPointersAttribute = new IndirectStorageBufferAttribute( 1, 1 ); const overflowFlagAttribute = new IndirectStorageBufferAttribute( 1, 1 ); const overlapsStorage = storage( overlapsAttribute, overlapRecordStruct ).setName( 'overlaps' ); const bufferPointersStorage = storage( bufferPointersAttribute, 'uint' ).toAtomic(); const overflowFlagStorage = storage( overflowFlagAttribute, 'uint' ).setName( 'overflowFlag' ).toAtomic(); // // set up scene data const bvhComputeData = new ProjectionGeneratorBVHComputeData( meshes ); bvhComputeData.update(); bvhComputeData.fns.collectEdgeOverlaps = bvhComputeData.getCollectEdgeOverlapsFn( { overlapsStorage: overlapsStorage, bufferPointersStorage: bufferPointersStorage, overflowFlagStorage: overflowFlagStorage, } ); // initialize kernels const edgeOverlapsKernel = new EdgeOverlapsKernel(); edgeOverlapsKernel.setWorkgroupSize( 64, 1, 1 ); edgeOverlapsKernel.edges = edgeBufferAttribute; edgeOverlapsKernel.bvhData = bvhComputeData; const zeroOutKernel = new ZeroOutBufferKernel(); zeroOutKernel.setWorkgroupSize( 1, 1, 1 ); // const intervalsByEdge = new Map(); let progress = 0; const promises = []; const edgeStructStride = edgeStruct.getLength(); // register abort callback const onAbort = () => jobQueue.cancelAll(); signal?.addEventListener( 'abort', onAbort ); // job queue and readback buffers to save memory, improve performance const readbackBufferPool = []; const jobQueue = new JobQueue(); jobQueue.maxJobs = parallelJobs; const runJob = async ( start, count ) => { if ( signal?.aborted ) { return; } // fill out the edges array for ( let i = 0; i < count; i ++ ) { const edge = edges[ start + i ]; const offset = i * edgeStructStride; edge.start.toArray( edgeBufferData, offset ); edge.end.toArray( edgeBufferData, offset + 3 ); edgeBufferDataU32[ offset + 6 ] = i; } edgeBufferAttribute.needsUpdate = true; // clear the overlaps counter and overflow flag zeroOutKernel.target = bufferPointersAttribute; renderer.compute( zeroOutKernel.kernel, [ 1, 1, 1 ] ); zeroOutKernel.target = overflowFlagAttribute; renderer.compute( zeroOutKernel.kernel, [ 1, 1, 1 ] ); // traverse BVH and write overlaps directly edgeOverlapsKernel.edgesToProcess = count; renderer.compute( edgeOverlapsKernel.kernel, edgeOverlapsKernel.getDispatchSize( count ) ); let readbackBuffer; if ( readbackBufferPool.length !== 0 ) { readbackBuffer = readbackBufferPool.pop(); } else { readbackBuffer = new ReadbackBuffer( MAX_BUFFER_SIZE ); } const [ overlaps, bufferPointers, overflowBuffer ] = await Promise.all( [ renderer.getArrayBufferAsync( overlapsAttribute, readbackBuffer ), renderer.getArrayBufferAsync( bufferPointersAttribute ), renderer.getArrayBufferAsync( overflowFlagAttribute ), ] ); // add the readback buffer back to the pool if we've aborted this run if ( signal?.aborted ) { readbackBuffer.release(); readbackBufferPool.push( readbackBuffer ); return; } const overflow = new Uint32Array( overflowBuffer )[ 0 ]; if ( overflow > 0 ) { if ( count === 1 ) { console.error( `ProjectionGenerator: Overlaps buffer insufficient size to store all segments. Please report to three-edge-projection.` ); } else { // split the job in half and re-queue both halves const half = Math.ceil( count / 2 ); promises.push( jobQueue.add( runJob, [ start, half ] ) ); promises.push( jobQueue.add( runJob, [ start + half, count - half ] ) ); readbackBuffer.release(); readbackBufferPool.push( readbackBuffer ); return; } } // read buffers const overlapsF32 = new Float32Array( overlaps.buffer ); const overlapsU32 = new Uint32Array( overlaps.buffer ); const bufferPointersU32 = new Uint32Array( bufferPointers ); const stride = overlapRecordStruct.getLength(); // push the overlaps for ( let oi = 0, ol = bufferPointersU32[ 0 ]; oi < ol; oi ++ ) { const index = oi * stride; const ei = start + overlapsU32[ index + 0 ]; const t0 = overlapsF32[ index + 1 ]; const t1 = overlapsF32[ index + 2 ]; if ( ! intervalsByEdge.has( ei ) ) { intervalsByEdge.set( ei, [] ); } insertOverlap( [ t0, t1 ], intervalsByEdge.get( ei ) ); } progress += count; // fire progress if ( onProgress ) { onProgress( progress / edges.length, 'Projecting Edges' ); } // release the buffer to the pool readbackBuffer.release(); readbackBufferPool.push( readbackBuffer ); }; // enqueue initial jobs for ( let e = 0; e < edges.length; e += batchCapacity ) { promises.push( jobQueue.add( runJob, [ e, Math.min( batchCapacity, edges.length - e ) ] ) ); } // drain — sequential iteration naturally picks up overflow sub-jobs added to promises try { for ( let i = 0; i < promises.length; i ++ ) { await promises[ i ]; } } finally { signal?.removeEventListener( 'abort', onAbort ); // overlapsAttribute.dispose(); // bufferPointersAttribute.dispose(); // overflowFlagAttribute.dispose(); // edgeBufferAttribute.dispose(); // dispose of all the readback buffers readbackBufferPool.forEach( rb => rb.dispose() ); } signal?.throwIfAborted(); // push all edges to the "results" object const collector = new ProjectionResult(); for ( let i = 0; i < edges.length; i ++ ) { const mesh = edges[ i ].mesh; if ( ! collector.visibleEdges.meshToSegments.has( mesh ) ) { collector.visibleEdges.meshToSegments.set( mesh, [] ); collector.hiddenEdges.meshToSegments.set( mesh, [] ); } const intervals = intervalsByEdge.get( i ) || []; overlapsToLines( edges[ i ], intervals, false, collector.visibleEdges.meshToSegments.get( mesh ) ); overlapsToLines( edges[ i ], intervals, true, collector.hiddenEdges.meshToSegments.get( mesh ) ); } return collector; } } class JobQueue { constructor() { this.queue = []; this.maxJobs = 3; this.currJobs = 0; this._scheduled = false; } add( cb, args ) { return new Promise( ( resolve, reject ) => { this.queue.push( { run: () => { const res = cb( ...args ); res .then( resolve ) .catch( reject ); return res; }, reject, } ); this.scheduleRun(); } ); } cancelAll() { const { queue } = this; while ( queue.length > 0 ) { const entry = queue.shift(); entry.reject( new Error( 'JobQueue: cancelled' ) ); } } async runJobs() { const { queue } = this; while ( this.currJobs < this.maxJobs ) { if ( queue.length === 0 ) { return; } this.currJobs ++; await nextFrame(); const entry = queue.shift(); entry.run() .finally( () => { this.currJobs --; this.scheduleRun(); } ); } } scheduleRun() { if ( this._scheduled ) { return; } this._scheduled = true; requestAnimationFrame( async () => { await this.runJobs(); this._scheduled = false; } ); } } ================================================ FILE: src/webgpu/ProjectionGeneratorBVHComputeData.js ================================================ import { BackSide, DoubleSide, FrontSide } from 'three'; import { StructTypeNode } from 'three/webgpu'; import { BVHComputeData } from './lib/BVHComputeData.js'; import { wgslTagFn } from './lib/nodes/WGSLTagFnNode.js'; import { bvhNodeBoundsStruct } from './lib/wgsl/structs.wgsl.js'; import { transformBVHBounds } from './nodes/utils.wgsl.js'; import { constants as overlapConstants } from './nodes/common.wgsl.js'; import { getProjectedOverlapRange, isLineTriangleEdge, trimToBeneathTriPlane } from './nodes/overlapFunctions.wgsl.js'; import { LineWGSL, TriWGSL } from './nodes/primitives.js'; // Shape struct carrying world-space line endpoints plus the object-to-world // matrix (set by transformShapeFn; identity at top level so world-space // bounds pass through unchanged) and the transform buffer index. const edgeLineShapeStruct = new StructTypeNode( { worldStart: 'vec3f', worldEnd: 'vec3f', matrixWorld: 'mat4x4f', objectIndex: 'uint', edgeIndex: 'uint', }, 'EdgeLineShape' ); // Extended transform struct that adds a per-object "side" field for back-face // culling. Values: 0 = DoubleSide (no cull), 1 = FrontSide, -1 = BackSide. const projectionTransformStruct = new StructTypeNode( { matrixWorld: 'mat4x4f', inverseMatrixWorld: 'mat4x4f', nodeOffset: 'uint', visible: 'uint', side: 'int', _alignment0: 'uint', }, 'ProjectionTransformStruct' ); // Projection-generator-specific BVHComputeData that only requires position // attributes and auto-generates missing BVHs. export class ProjectionGeneratorBVHComputeData extends BVHComputeData { constructor( bvh, options = {} ) { super( bvh, { attributes: { position: 'vec4f' }, ...options, } ); this.bvhMap = new Map(); this.structs.transform = projectionTransformStruct; this._sharedFns = null; this._fns = null; } writeTransformData( info, premultiplyMatrix, writeOffset, targetBuffer ) { super.writeTransformData( info, premultiplyMatrix, writeOffset, targetBuffer ); const { object, root } = info; let material = object.material; if ( Array.isArray( material ) ) { material = material[ object.geometry.groups[ root ].materialIndex ]; } let sideValue; switch ( material.side ) { case DoubleSide: sideValue = 0; break; case FrontSide: sideValue = 1; break; case BackSide: sideValue = - 1; break; } const transformBufferU32 = new Uint32Array( targetBuffer ); transformBufferU32[ writeOffset * projectionTransformStruct.getLength() + 34 ] = sideValue; } update() { super.update(); this.bvhMap.clear(); this._sharedFns = null; this._fns = null; } // Returns a WGSL function — fn traverse( edgeIndex, lineStart, lineEnd ) -> void — // that traverses the BVH for one edge and writes qualifying { edgeIndex, objectIndex, triIndex } // records to the pairs buffer using atomic slot claiming. // // pairsCountsStorage is a 2-element array>: // [0] write offset — claimed unconditionally via atomicAdd // [1] dispatch count — incremented only when the claimed slot is within capacity; equals // the number of valid pair records written and is used as K3's dispatch bound // // overflowFlagStorage is a 1-element array> that accumulates the number of // pairs that could not be written due to buffer overflow. // // NOTE: pairsCountsStorage must be bound as array> (read_write storage). getCollectEdgeOverlapsFn( { overlapsStorage, bufferPointersStorage, overflowFlagStorage } ) { const { storage } = this; const { DOUBLE_SIDE, BACK_SIDE, DIST_THRESHOLD } = overlapConstants; const intersectsBoundsFn = wgslTagFn/* wgsl */` fn intersectsBounds( shape: ${ edgeLineShapeStruct }, bounds: ${ bvhNodeBoundsStruct } ) -> u32 { // TODO: a proper 3D Line / AABB check with the bottom of the bounds extended downward // would be best here since we are getting some false positives. // Transform bounds to world space. At the top level the shape matrix // is identity, so world-space bounds pass through unchanged. let aabb = ${ transformBVHBounds }( bounds, shape.matrixWorld ); let aabbMin = vec3( aabb.min[ 0 ], aabb.min[ 1 ], aabb.min[ 2 ] ); let aabbMax = vec3( aabb.max[ 0 ], aabb.max[ 1 ], aabb.max[ 2 ] ); // Y-cull: bounds entirely below the line if ( aabbMax.y <= min( shape.worldStart.y, shape.worldEnd.y ) ) { return 0u; } // AABB vs AABB test let lineMinX = min( shape.worldStart.x, shape.worldEnd.x ); let lineMaxX = max( shape.worldStart.x, shape.worldEnd.x ); let lineMinZ = min( shape.worldStart.z, shape.worldEnd.z ); let lineMaxZ = max( shape.worldStart.z, shape.worldEnd.z ); if ( aabbMax.x < lineMinX || aabbMin.x > lineMaxX || aabbMax.z < lineMinZ || aabbMin.z > lineMaxZ ) { return 0u; } // edge SAT axis let segDelta = shape.worldEnd.xz - shape.worldStart.xz; let segNormal = vec2f( - segDelta.y, segDelta.x ); let segProj = dot( segNormal, vec2f( shape.worldStart.x, shape.worldStart.z ) ); let aabbCenter = ( aabbMin.xz + aabbMax.xz ) * 0.5; let aabbHalf = ( aabbMax.xz - aabbMin.xz ) * 0.5; let aabbCenterProj = dot( segNormal, aabbCenter ); let aabbHalfProj = dot( abs( segNormal ), aabbHalf ); if ( abs( aabbCenterProj - segProj ) > aabbHalfProj ) { return 0u; } return 1u; } `; const transformShapeFn = wgslTagFn/* wgsl */` fn transformShape( localShape: ptr, objectIndex: u32 ) -> void { localShape.matrixWorld = ${ storage.transforms }[ objectIndex ].matrixWorld; localShape.objectIndex = objectIndex; } `; const intersectRangeFn = wgslTagFn/* wgsl */` fn traverseRange( shape: ${ edgeLineShapeStruct }, offset: u32, count: u32 ) -> bool { var tri: ${ TriWGSL.struct }; var line: ${ LineWGSL.struct }; line.start = shape.worldStart; line.end = shape.worldEnd; let lineMinY = min( line.start.y, line.end.y ); let lineMaxY = max( line.start.y, line.end.y ); let matrixWorld = shape.matrixWorld; let side = ${ storage.transforms }[ shape.objectIndex ].side; let inverted = determinant( matrixWorld ) < 0.0; for ( var ti = offset; ti < offset + count; ti = ti + 1u ) { let i0 = ${ storage.index }[ ti * 3u + 0u ]; let i1 = ${ storage.index }[ ti * 3u + 1u ]; let i2 = ${ storage.index }[ ti * 3u + 2u ]; let ta = matrixWorld * vec4f( ${ storage.attributes }[ i0 ].position.xyz, 1.0 ); let tb = matrixWorld * vec4f( ${ storage.attributes }[ i1 ].position.xyz, 1.0 ); let tc = matrixWorld * vec4f( ${ storage.attributes }[ i2 ].position.xyz, 1.0 ); tri.a = ta.xyz / ta.w; tri.b = tb.xyz / tb.w; tri.c = tc.xyz / tc.w; // back-face cull if ( side != ${ DOUBLE_SIDE } ) { let triNormal = ${ TriWGSL.getNormal }( tri ); let faceUp = ( triNormal.y > 0.0 ) != inverted; if ( faceUp == ( side == ${ BACK_SIDE } ) ) { continue; } } let triMaxY = max( max( tri.a.y, tri.b.y ), tri.c.y ); let triMinY = min( min( tri.a.y, tri.b.y ), tri.c.y ); // skip triangles entirely below the edge if ( triMaxY <= lineMinY ) { continue; } // skip if the edge lies on this triangle if ( ${ isLineTriangleEdge }( tri, line ) ) { continue; } // trim edge to the portion below the triangle plane; if the // entire line is already below the triangle, use the full line var beneathLine: ${ LineWGSL.struct }; if ( lineMaxY < triMinY ) { beneathLine = line; } else if ( ! ${ trimToBeneathTriPlane }( tri, line, &beneathLine ) ) { continue; } // skip degenerate trimmed segments // TODO: add a "distant" utility function if ( length( beneathLine.end - beneathLine.start ) < ${ DIST_THRESHOLD } ) { continue; } var overlapLine: ${ LineWGSL.struct }; if ( ! ${ getProjectedOverlapRange }( beneathLine, tri, &overlapLine ) ) { continue; } // compute t0/t1 parametric positions along the original edge let lineDir = line.end - line.start; let lineLen = length( lineDir ); var t0 = length( overlapLine.start - line.start ) / lineLen; var t1 = length( overlapLine.end - line.start ) / lineLen; t0 = clamp( t0, 0.0, 1.0 ); t1 = clamp( t1, 0.0, 1.0 ); if ( abs( t0 - t1 ) <= ${ DIST_THRESHOLD } ) { continue; } // claim a slot and write the overlap record directly let slot = atomicAdd( &${ bufferPointersStorage }[ 0 ], 1u ); if ( slot < arrayLength( &${ overlapsStorage } ) ) { ${ overlapsStorage }[ slot ].edgeIndex = shape.edgeIndex; ${ overlapsStorage }[ slot ].t0 = t0; ${ overlapsStorage }[ slot ].t1 = t1; } else { atomicAdd( &${ overflowFlagStorage }[ 0 ], 1u ); } } return false; } `; const traversalFn = this.getShapecastFn( { name: 'collectEdgeOverlaps', shapeStruct: edgeLineShapeStruct, intersectsBoundsFn, intersectRangeFn, transformShapeFn, } ); return wgslTagFn/* wgsl */` fn traverse( edgeIndex: u32, lineStart: vec3f, lineEnd: vec3f ) -> void { var shape: ${ edgeLineShapeStruct }; shape.worldStart = lineStart; shape.worldEnd = lineEnd; shape.matrixWorld = mat4x4f( 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0 ); shape.objectIndex = 0u; shape.edgeIndex = edgeIndex; ${ traversalFn }( shape ); } `; } } ================================================ FILE: src/webgpu/index.js ================================================ export * from './ProjectionGenerator.js'; export * from './MeshVisibilityCuller.js'; ================================================ FILE: src/webgpu/kernels/EdgeOverlapsKernel.js ================================================ import { globalId, storage, uniform } from 'three/tsl'; import { wgslTagFn } from '../lib/nodes/WGSLTagFnNode.js'; import { ComputeKernel } from '../utils/ComputeKernel.js'; import { proxyFn } from '../lib/nodes/NodeProxy.js'; import { StorageBufferAttribute } from 'three/webgpu'; import { edgeStruct } from '../nodes/structs.wgsl.js'; // One thread per edge — traverses the BVH and writes overlap intervals directly // to the overlaps buffer via atomic slot claiming. export class EdgeOverlapsKernel extends ComputeKernel { constructor() { const params = { bvhData: { value: null }, globalId: globalId, edgesToProcess: uniform( 1, 'uint' ), edges: storage( new StorageBufferAttribute( 1, 1, Uint32Array ), edgeStruct ).toReadOnly().setName( 'edges' ), }; const edges = params.edges; const traversalFn = proxyFn( 'bvhData.value.fns.collectEdgeOverlaps', params ); const shader = wgslTagFn/* wgsl */` fn compute( globalId: vec3u, edgesToProcess: u32 ) -> void { let edgeIndex = globalId.x; let edgeListLength = arrayLength( &${ edges } ); if ( edgeIndex >= edgeListLength || edgeIndex >= edgesToProcess ) { return; } let edgeStart = vec3f( ${ edges }[ edgeIndex ].start[ 0 ], ${ edges }[ edgeIndex ].start[ 1 ], ${ edges }[ edgeIndex ].start[ 2 ] ); let edgeEnd = vec3f( ${ edges }[ edgeIndex ].end[ 0 ], ${ edges }[ edgeIndex ].end[ 1 ], ${ edges }[ edgeIndex ].end[ 2 ] ); ${ traversalFn }( edgeIndex, edgeStart, edgeEnd ); } `; super( shader( params ) ); this.defineUniformAccessors( params ); } } ================================================ FILE: src/webgpu/kernels/ZeroOutBufferKernel.js ================================================ import { IndirectStorageBufferAttribute } from 'three/webgpu'; import { storage, globalId } from 'three/tsl'; import { wgslTagFn } from '../lib/nodes/WGSLTagFnNode.js'; import { ComputeKernel } from '../utils/ComputeKernel.js'; export class ZeroOutBufferKernel extends ComputeKernel { constructor( options = {} ) { const { type = 'u32', } = options; const params = { globalId: globalId, outputTarget: storage( new IndirectStorageBufferAttribute( 1, 1 ), type ), }; const fn = wgslTagFn/* wgsl */` fn compute( globalId: vec3u ) -> void { ${ params.outputTarget }[ globalId.x ] = 0; } `; super( fn( params ) ); this.defineUniformAccessors( { target: params.outputTarget, } ); } } ================================================ FILE: src/webgpu/lib/BVHComputeData.js ================================================ import { Matrix4, SkinnedMesh, Vector4 } from 'three'; import { Mesh, StorageBufferAttribute, StructTypeNode } from 'three/webgpu'; import { storage, wgsl } from 'three/tsl'; import { constants } from './wgsl/common.wgsl.js'; import { rayStruct, bvhNodeStruct, bvhNodeBoundsStruct } from './wgsl/structs.wgsl.js'; import { wgslTagCode, wgslTagFn } from './nodes/WGSLTagFnNode.js'; import { MeshBVH, SkinnedMeshBVH, GeometryBVH, ObjectBVH, SAH } from 'three-mesh-bvh'; // TODO: add ability to easily update a single matrix / scene rearrangement (partial update) // TODO: add material support w/ function to easily update material // - add a callback for writing a property for a geometry to a range // TODO: Add support for other geometry types (tris, lines, custom BVHs etc) // temporary shim so StructTypeNodes can be passed to storage functions until // this is fixed in three.js Object.defineProperty( StructTypeNode.prototype, 'layout', { get() { return this; } } ); StructTypeNode.prototype.isStruct = true; // const isVisible = object => { let curr = object; while ( curr ) { if ( curr.visible === false ) { return false; } curr = curr.parent; } return true; }; const applyBoneTransform = ( () => { // a vec4-compatible version of SkinnedMesh.applyBoneTransform to support directions, positions const _base = new Vector4(); const _skinIndex = new Vector4(); const _skinWeight = new Vector4(); const _matrix4 = new Matrix4(); const _vector4 = new Vector4(); return function applyBoneTransform( mesh, index, target ) { const skeleton = mesh.skeleton; const geometry = mesh.geometry; _skinIndex.fromBufferAttribute( geometry.attributes.skinIndex, index ); _skinWeight.fromBufferAttribute( geometry.attributes.skinWeight, index ); if ( target.isVector4 ) { _base.copy( target ); target.set( 0, 0, 0, 0 ); } else { _base.set( ...target, 1 ); target.set( 0, 0, 0 ); } _base.applyMatrix4( mesh.bindMatrix ); for ( let i = 0; i < 4; i ++ ) { const weight = _skinWeight.getComponent( i ); if ( weight !== 0 ) { const boneIndex = _skinIndex.getComponent( i ); _matrix4.multiplyMatrices( skeleton.bones[ boneIndex ].matrixWorld, skeleton.boneInverses[ boneIndex ] ); target.addScaledVector( _vector4.copy( _base ).applyMatrix4( _matrix4 ), weight ); } } if ( target.isVector4 ) { target.w = _base.w; } return target.applyMatrix4( mesh.bindMatrixInverse ); }; } )(); // // structs const transformStruct = new StructTypeNode( { matrixWorld: 'mat4x4f', inverseMatrixWorld: 'mat4x4f', nodeOffset: 'uint', visible: 'uint', _alignment0: 'uint', _alignment1: 'uint', }, 'TransformStruct' ); const intersectionResultStruct = new StructTypeNode( { indices: 'vec4u', normal: 'vec3f', didHit: 'bool', barycoord: 'vec3f', objectIndex: 'uint', side: 'float', dist: 'float', }, 'IntersectionResult' ); // // node constants const BYTES_PER_NODE = 6 * 4 + 4 + 4; const UINT32_PER_NODE = BYTES_PER_NODE / 4; const IS_LEAFNODE_FLAG = 0xFFFF; // scratch const _def = /* @__PURE__ */ new Vector4(); const _vec = /* @__PURE__ */ new Vector4(); const _matrix = /* @__PURE__ */ new Matrix4(); const _inverseMatrix = /* @__PURE__ */ new Matrix4(); // functions function dereferenceIndex( indexAttr, indirectBuffer ) { const indexArray = indexAttr ? indexAttr.array : null; const result = new Uint32Array( indirectBuffer.length * 3 ); for ( let i = 0, l = indirectBuffer.length; i < l; i ++ ) { const i3 = 3 * i; const v3 = 3 * indirectBuffer[ i ]; for ( let c = 0; c < 3; c ++ ) { result[ i3 + c ] = indexArray ? indexArray[ v3 + c ] : v3 + c; } } return result; } function getTotalBVHByteLength( bvh ) { return bvh._roots.reduce( ( v, root ) => v + root.byteLength, 0 ); } const intersectsTriangle = wgslTagFn/* wgsl */ ` // fn fn intersectsTriangle( ray: ${ rayStruct }, a: vec3f, b: vec3f, c: vec3f ) -> ${ intersectionResultStruct } { // TODO: see if we can remove the "DIST" epsilon and account for it on ray origin bounce positioning const DET_EPSILON = 1e-15; const DIST_EPSILON = 1e-5; var result: ${ intersectionResultStruct }; result.didHit = false; let edge1 = b - a; let edge2 = c - a; let n = cross( edge1, edge2 ); let det = - dot( ray.direction, n ); if ( abs( det ) < DET_EPSILON ) { return result; } let invdet = 1.0 / det; let AO = ray.origin - a; let DAO = cross( AO, ray.direction ); let u = dot( edge2, DAO ) * invdet; if ( u < 0.0 || u > 1.0 ) { return result; } let v = - dot( edge1, DAO ) * invdet; if ( v < 0.0 || u + v > 1.0 ) { return result; } let t = dot( AO, n ) * invdet; let w = 1.0 - u - v; if ( t < DIST_EPSILON ) { return result; } result.didHit = true; result.barycoord = vec3f( w, u, v ); result.dist = t; result.side = sign( det ); result.normal = result.side * normalize( n ); return result; } `; export class BVHComputeData { constructor( bvh, options = {} ) { // convert the bvh argument to an ObjectBVH. Supports the following as arguments // - Object3D // - BufferGeometry // - GeometryBVH // - Array of the above if ( ! ( bvh instanceof ObjectBVH ) ) { if ( ! Array.isArray( bvh ) ) { bvh = [ bvh ]; } const objects = bvh.map( item => { if ( item.isObject3D ) { return item; } else if ( item.isBufferGeometry ) { return new Mesh( item ); } else if ( item instanceof GeometryBVH ) { const dummy = new Mesh(); dummy.geometry.boundsTree = item; return dummy; } } ); bvh = new ObjectBVH( objects, { strategy: SAH, maxLeafSize: 1 } ); } const { attributes = { position: 'vec4f' }, autogenerateBvh = true, } = options; this._bvhCache = new Map(); this.autogenerateBvh = autogenerateBvh; this.attributes = attributes; this.bvh = bvh; this.storage = { index: null, attributes: null, nodes: null, transforms: null, }; this.structs = { transform: transformStruct, attributes: null, }; this.fns = { raycastFirstHit: null, }; } getShapecastFn( options ) { const { name = `bvh_${ Math.random().toString( 36 ).substring( 2, 7 ) }`, shapeStruct, resultStruct = null, boundsOrderFn = null, intersectsBoundsFn, intersectRangeFn, transformShapeFn = null, transformResultFn = null, } = options; const { storage } = this; const { BVH_STACK_DEPTH } = constants; // handle optional functions let transformResultSnippet = ''; if ( transformResultFn ) { transformResultSnippet = wgslTagCode/* wgsl */`${ transformResultFn }( result, i );`; } let transformShapeSnippet = ''; if ( transformShapeFn ) { transformShapeSnippet = wgslTagCode/* wgsl */`${ transformShapeFn }( &localShape, i );`; } let leftToRightSnippet = ''; if ( boundsOrderFn ) { leftToRightSnippet = wgslTagCode/* wgsl */` let leftToRight = ${ boundsOrderFn }( shape, splitAxis, node ); c1 = select( rightIndex, leftIndex, leftToRight ); c2 = select( leftIndex, rightIndex, leftToRight ); `; } const resultPtrSnippet = resultStruct ? wgslTagCode/* wgsl */`result: ptr` : ''; const resultArg = resultStruct ? 'result' : ''; const getFnBody = leafSnippet => { // returns a function with a snippet inserted for the leaf intersection test return wgslTagCode/* wgsl */` var pointer: i32 = 0; var stack: array; stack[ 0 ] = rootNodeIndex; loop { if ( pointer < 0 || pointer >= i32( ${ BVH_STACK_DEPTH } ) ) { break; } let nodeIndex = stack[ pointer ]; let node = ${ storage.nodes }[ nodeIndex ]; pointer = pointer - 1; if ( ${ intersectsBoundsFn }( shape, node.bounds, ${ resultArg } ) == 0u ) { continue; } let infoX = node.splitAxisOrTriangleCount; let infoY = node.rightChildOrTriangleOffset; let isLeaf = ( infoX & 0xffff0000u ) != 0u; if ( isLeaf ) { let count = infoX & 0x0000ffffu; let offset = infoY; ${ leafSnippet } } else { let leftIndex = nodeIndex + 1u; let splitAxis = infoX & 0x0000ffffu; let rightIndex = nodeIndex + infoY; var c1 = rightIndex; var c2 = leftIndex; ${ leftToRightSnippet } pointer = pointer + 1; stack[ pointer ] = c2; pointer = pointer + 1; stack[ pointer ] = c1; } } `; }; const blasFn = wgslTagFn/* wgsl */` // fn fn ${ name }_blas( shape: ${ shapeStruct }, rootNodeIndex: u32, ${ resultPtrSnippet } ) -> bool { var didHit = false; ${ getFnBody( wgslTagCode/* wgsl */` didHit = ${ intersectRangeFn }( shape, offset, count, ${ resultArg } ) || didHit; ` ) } return didHit; } `; const tlasFn = wgslTagFn/* wgsl */` // fn fn ${ name }( shape: ${ shapeStruct }, ${ resultPtrSnippet } ) -> bool { const rootNodeIndex = 0u; var didHit = false; ${ getFnBody( wgslTagCode/* wgsl */` for ( var i = offset; i < offset + count; i ++ ) { let transform = ${ storage.transforms }[ i ]; if ( transform.visible == 0u ) { continue; } // Transform shape into object local space var localShape = shape; ${ transformShapeSnippet } if ( ${ blasFn }( localShape, transform.nodeOffset, ${ resultArg } ) ) { ${ transformResultSnippet } didHit = true; } } ` ) } return didHit; } `; tlasFn.outputType = resultStruct; tlasFn.functionName = name; return tlasFn; } update() { const self = this; const { attributes, structs, bvh } = this; // collect the BVHs const bvhInfo = []; const transformInfo = []; // accumulate the sizes of the bvh nodes buffer, number of objects, and geometry buffers let bvhNodesBufferLength = getTotalBVHByteLength( bvh ); let indexBufferLength = 0; let attributesBufferLength = 0; bvh.primitiveBuffer.forEach( compositeId => { const object = bvh.getObjectFromId( compositeId ); const instanceId = bvh.getInstanceFromId( compositeId ); const range = { start: 0, count: 0, vertexStart: 0, vertexCount: 0 }; const primBvh = this.getBVH( object, instanceId, range ); if ( ! primBvh ) { throw new Error( 'BVHComputeData: BVH not found.' ); } // if we haven't added this bvh, yet if ( ! bvhInfo.find( info => info.bvh === primBvh ) ) { // save the geometry info to write later and increment the buffer sizes const info = { index: bvhInfo.length, bvh: primBvh, range: range, bvhNodeOffsets: null, indexBufferOffset: null, }; // increase the buffer sizes for bvh and geometry bvhNodesBufferLength += getTotalBVHByteLength( primBvh ); indexBufferLength += info.range.count; attributesBufferLength += info.range.vertexCount; bvhInfo.push( info ); } // save the index of the bvh associated with this transform const data = bvhInfo.find( info => primBvh === info.bvh ); primBvh._roots.forEach( ( root, i ) => { transformInfo.push( { data, root: i, object, instanceId, compositeId, } ); } ); } ); // // NOTE: These buffer lengths are increased to a minimum size of 2 to avoid the TSL of converting storage buffers // with length 1 being converted to a scalar value. // TODO: remove this when fixed in three const transformBufferLength = Math.max( transformInfo.length, 2 ); indexBufferLength = Math.max( indexBufferLength, 2 ); attributesBufferLength = Math.max( attributesBufferLength, 2 ); // construct the attribute struct const attributeStruct = new StructTypeNode( attributes, 'bvh_GeometryStruct' ); // write the geometry buffer attributes & bvh data let attributesOffset = 0; let indexOffset = 0; let nodeWriteOffset = 0; const indexBuffer = new Uint32Array( indexBufferLength ); const attributesBuffer = new ArrayBuffer( attributesBufferLength * attributeStruct.getLength() * 4 ); const bvhNodesBuffer = new ArrayBuffer( bvhNodesBufferLength ); // append TLAS data appendBVHData( bvh, 0, transformInfo, 0, bvhNodesBuffer, true ); nodeWriteOffset += getTotalBVHByteLength( bvh ) / BYTES_PER_NODE; bvhInfo.forEach( info => { // append bvh data const bvhNodeOffsets = appendBVHData( info.bvh, indexOffset / 3, transformInfo, nodeWriteOffset, bvhNodesBuffer, false ); info.bvhNodeOffsets = bvhNodeOffsets; // append geometry data appendIndexData( info.bvh, info.range, attributesOffset, indexOffset, indexBuffer ); appendGeometryData( info.bvh, info.range, attributesOffset, attributesBuffer ); info.indexBufferOffset = indexOffset; // step the write offsets forward indexOffset += info.range.count; attributesOffset += info.range.vertexCount; nodeWriteOffset += getTotalBVHByteLength( info.bvh ) / BYTES_PER_NODE; } ); // // write the transforms const transformArrayBuffer = new ArrayBuffer( structs.transform.getLength() * transformBufferLength * 4 ); transformInfo.forEach( ( info, i ) => { _inverseMatrix.copy( bvh.matrixWorld ).invert(); this.writeTransformData( info, _inverseMatrix, i, transformArrayBuffer ); } ); // // set up the storage buffers // if itemSize for StorageBufferAttribute == arraySize, // then buffer is treated not as array of structs, but as a single struct // And that breaks code. For now itemSize = 1 does not seem to break anything const bvhNodesStorage = storage( new StorageBufferAttribute( new Uint32Array( bvhNodesBuffer ), 1 ), bvhNodeStruct ).toReadOnly().setName( 'bvh_nodes' ); const transformsBuffer = new StorageBufferAttribute( new Uint32Array( transformArrayBuffer ), 1 ); const transformsStorage = storage( transformsBuffer, structs.transform ).toReadOnly().setName( 'bvh_transforms' ); const indexStorage = storage( new StorageBufferAttribute( indexBuffer, 1 ), 'uint' ).toReadOnly().setName( 'bvh_index' ); const attributesStorage = storage( new StorageBufferAttribute( new Uint32Array( attributesBuffer ), attributeStruct.getLength() ), attributeStruct ).toReadOnly().setName( 'bvh_attributes' ); this.storage.transforms = transformsStorage; this.storage.nodes = bvhNodesStorage; this.storage.index = indexStorage; this.storage.attributes = attributesStorage; this.structs.attributes = attributeStruct; this._initFns(); this._bvhCache.clear(); function appendBVHData( bvh, geometryOffset, transformInfo, nodeWriteOffset, target, tlas = false ) { const targetU16 = new Uint16Array( target ); const targetU32 = new Uint32Array( target ); const targetF32 = new Float32Array( target ); const result = []; let tlasOffset = 0; bvh._roots.forEach( root => { const rootBuffer16 = new Uint16Array( root ); const rootBuffer32 = new Uint32Array( root ); result.push( nodeWriteOffset ); for ( let i = 0, l = root.byteLength / BYTES_PER_NODE; i < l; i ++ ) { const r32 = i * UINT32_PER_NODE; const r16 = r32 * 2; const n32 = nodeWriteOffset * UINT32_PER_NODE; const n16 = n32 * 2; // write bounds const view = new Float32Array( root, i * BYTES_PER_NODE, 6 ); if ( i === 0 ) { // if we're copying the root then check for cases where there are no primitives and therefore // be a bounds of [ Infinity, - Infinity ]. Convert this to [ 1, - 1 ] for reliable GPU behavior. for ( let i = 0; i < 3; i ++ ) { const vMin = view[ i + 0 ]; const vMax = view[ i + 3 ]; if ( vMin > vMax ) { targetF32[ n32 + i + 0 ] = 1; targetF32[ n32 + i + 3 ] = - 1; } else { targetF32[ n32 + i + 0 ] = vMin; targetF32[ n32 + i + 3 ] = vMax; } } } else { targetF32.set( view, n32 ); } const isLeaf = IS_LEAFNODE_FLAG === rootBuffer16[ r16 + 15 ]; if ( isLeaf ) { if ( tlas ) { // 0xFFFF == mesh leaf, 0xFF00 == TLAS leaf targetU32[ n32 + 6 ] = tlasOffset; targetU16[ n16 + 15 ] = 0xFF00; const count = rootBuffer16[ r16 + 14 ]; // const offset = rootBuffer32[ r32 + 6 ]; // each root is expanded into a separate transform so we need to expand // the embedded offsets and counts. let rootsCount = 0; for ( let o = 0; o < count; o ++ ) { const roots = transformInfo[ tlasOffset ].data.bvh._roots.length; tlasOffset += roots; rootsCount += roots; } targetU16[ n16 + 14 ] = rootsCount; } else { targetU32[ n32 + 6 ] = rootBuffer32[ r32 + 6 ] + geometryOffset; targetU16[ n16 + 14 ] = rootBuffer16[ r16 + 14 ]; targetU16[ n16 + 15 ] = IS_LEAFNODE_FLAG; } } else { targetU32[ n32 + 6 ] = rootBuffer32[ r32 + 6 ]; targetU32[ n32 + 7 ] = rootBuffer32[ r32 + 7 ]; } nodeWriteOffset ++; } } ); return result; } function appendIndexData( bvh, range, valueOffset, writeOffset, target ) { const { geometry } = bvh; const { start, count, vertexStart } = range; if ( bvh.indirect ) { const dereferencedIndex = dereferenceIndex( geometry.index, bvh._indirectBuffer ); for ( let i = 0; i < dereferencedIndex.length; i ++ ) { target[ i + writeOffset ] = dereferencedIndex[ i ] - vertexStart + valueOffset; } } else if ( geometry.index ) { for ( let i = 0; i < count; i ++ ) { target[ i + writeOffset ] = geometry.index.getX( i + start ) - vertexStart + valueOffset; } } else { for ( let i = 0; i < count; i ++ ) { target[ i + writeOffset ] = i + start + valueOffset; } } } function appendGeometryData( bvh, range, writeOffset, target ) { // if "mesh" is present then it is assumed to be a SkinnedMeshBVH const { geometry, mesh = null } = bvh; const { vertexStart, vertexCount } = range; const attributesBufferF32 = new Float32Array( target ); const attrStructLength = attributeStruct.getLength(); attributeStruct.membersLayout.forEach( ( { name }, interleavedOffset ) => { // TODO: we should be able to have access to memory layout offsets here via the struct // API but it's not currently available. const attr = geometry.attributes[ name ]; self.getDefaultAttributeValue( name, _def ); for ( let i = 0; i < vertexCount; i ++ ) { if ( attr ) { _vec.fromBufferAttribute( attr, i + vertexStart ); switch ( attr.itemSize ) { case 1: _vec.y = _def.y; _vec.z = _def.z; _vec.w = _def.w; break; case 2: _vec.z = _def.z; _vec.w = _def.w; break; case 3: _vec.w = _def.w; break; } if ( mesh && ( name === 'position' || name === 'normal' || name === 'tangent' ) ) { applyBoneTransform( mesh, i + vertexStart, _vec ); } } else { _vec.copy( _def ); } _vec.toArray( attributesBufferF32, ( writeOffset + i ) * attrStructLength + interleavedOffset * 4 ); } } ); } } _initFns() { const { storage, structs, fns } = this; // raycast first hit const scratchRayScalar = wgsl( /* wgsl */` var bvh_rayScalar = 1.0; ` ); fns.raycastFirstHit = this.getShapecastFn( { name: 'bvh_RaycastFirstHit', shapeStruct: rayStruct, resultStruct: intersectionResultStruct, boundsOrderFn: wgslTagFn/* wgsl */` fn getBoundsOrder( ray: ${ rayStruct }, splitAxis: u32, node: ${ bvhNodeStruct } ) -> bool { return ray.direction[ splitAxis ] >= 0.0; } `, intersectsBoundsFn: wgslTagFn/* wgsl */` ${ [ scratchRayScalar ] } fn rayIntersectsBounds( ray: ${ rayStruct }, bounds: ${ bvhNodeBoundsStruct }, result: ptr ) -> u32 { let boundsMin = vec3( bounds.min[0], bounds.min[1], bounds.min[2] ); let boundsMax = vec3( bounds.max[0], bounds.max[1], bounds.max[2] ); let invDir = 1.0 / ray.direction; let tMinPlane = ( boundsMin - ray.origin ) * invDir; let tMaxPlane = ( boundsMax - ray.origin ) * invDir; let tMinHit = vec3f( min( tMinPlane.x, tMaxPlane.x ), min( tMinPlane.y, tMaxPlane.y ), min( tMinPlane.z, tMaxPlane.z ) ); let tMaxHit = vec3f( max( tMinPlane.x, tMaxPlane.x ), max( tMinPlane.y, tMaxPlane.y ), max( tMinPlane.z, tMaxPlane.z ) ); let t0 = max( max( tMinHit.x, tMinHit.y ), tMinHit.z ); let t1 = min( min( tMaxHit.x, tMaxHit.y ), tMaxHit.z ); let dist = max( t0, 0.0 ); if ( t1 < dist ) { return 0u; } else if ( result.didHit && dist * bvh_rayScalar >= result.dist ) { return 0u; } else { return 1u; } } `, intersectRangeFn: wgslTagFn/* wgsl */` ${ [ scratchRayScalar ] } fn intersectRange( ray: ${ rayStruct }, offset: u32, count: u32, result: ptr ) -> bool { var didHit = false; for ( var ti = offset; ti < offset + count; ti = ti + 1u ) { let i0 = ${ storage.index }[ ti * 3u ]; let i1 = ${ storage.index }[ ti * 3u + 1u ]; let i2 = ${ storage.index }[ ti * 3u + 2u ]; let a = ${ storage.attributes }[ i0 ].position.xyz; let b = ${ storage.attributes }[ i1 ].position.xyz; let c = ${ storage.attributes }[ i2 ].position.xyz; var triResult = ${ intersectsTriangle }( ray, a, b, c ); triResult.dist *= bvh_rayScalar; if ( triResult.didHit && ( ! result.didHit || triResult.dist < result.dist ) ) { result.didHit = true; result.dist = triResult.dist; result.normal = triResult.normal; result.side = triResult.side; result.barycoord = triResult.barycoord; result.indices = vec4u( i0, i1, i2, ti ); didHit = true; } } return didHit; } `, transformShapeFn: wgslTagFn/* wgsl */` ${ [ scratchRayScalar ] } fn transformRay( ray: ptr, objectIndex: u32 ) -> void { let toLocal = ${ storage.transforms }[ objectIndex ].inverseMatrixWorld; ray.origin = ( toLocal * vec4f( ray.origin, 1.0 ) ).xyz; ray.direction = ( toLocal * vec4f( ray.direction, 0.0 ) ).xyz; let len = length( ray.direction ); ray.direction /= len; bvh_rayScalar = 1.0 / len; } `, transformResultFn: wgslTagFn/* wgsl */` fn transformResult( hit: ptr, objectIndex: u32 ) -> void { let toLocal = ${ storage.transforms }[ objectIndex ].inverseMatrixWorld; hit.normal = normalize( ( transpose( toLocal ) * vec4f( hit.normal, 0.0 ) ).xyz ); hit.objectIndex = objectIndex; } `, } ); const interpolateBody = structs .attributes .membersLayout .map( ( { name } ) => { return `result.${ name } = a0.${ name } * barycoord.x + a1.${ name } * barycoord.y + a2.${ name } * barycoord.z;`; } ).join( '\n' ); fns.sampleTrianglePoint = wgslTagFn/* wgsl */` // fn fn bvh_sampleTrianglePoint( barycoord: vec3f, indices: vec3u ) -> ${ structs.attributes } { var result: ${ structs.attributes }; var a0 = ${ storage.attributes }[ indices.x ]; var a1 = ${ storage.attributes }[ indices.y ]; var a2 = ${ storage.attributes }[ indices.z ]; ${ interpolateBody } return result; } `; } writeTransformData( info, premultiplyMatrix, writeOffset, targetBuffer ) { const { structs } = this; const transformBufferF32 = new Float32Array( targetBuffer ); const transformBufferU32 = new Uint32Array( targetBuffer ); const { object, instanceId, root, data } = info; const { bvhNodeOffsets } = data; if ( object.isInstancedMesh || object.isBatchedMesh ) { object.getMatrixAt( instanceId, _matrix ); _matrix.premultiply( object.matrixWorld ); } else { _matrix.copy( object.matrixWorld ); } // write transform _matrix.premultiply( premultiplyMatrix ); _matrix.toArray( transformBufferF32, writeOffset * structs.transform.getLength() ); // write inverse transform _matrix.invert(); _matrix.toArray( transformBufferF32, writeOffset * structs.transform.getLength() + 16 ); // write node offset transformBufferU32[ writeOffset * structs.transform.getLength() + 32 ] = bvhNodeOffsets[ root ]; let visible = isVisible( object ); if ( object.isBatchedMesh ) { visible = visible && object.getVisibleAt( instanceId ); } transformBufferU32[ writeOffset * structs.transform.getLength() + 33 ] = visible ? 1 : 0; } getBVH( object, instanceId, rangeTarget ) { const { autogenerateBvh, _bvhCache } = this; let bvh = null; if ( object.boundsTree || object.isSkinnedMesh ) { // this is a case where a mesh has morph targets and skinned meshes const geometry = object.geometry; rangeTarget.count = geometry.index ? geometry.index.count : geometry.attributes.position.count; rangeTarget.vertexCount = geometry.attributes.position.count; bvh = object.boundsTree || null; if ( bvh === null && autogenerateBvh ) { const id = object.uuid; bvh = _bvhCache.get( id ) || new SkinnedMeshBVH( object ); _bvhCache.set( id, bvh ); } } else if ( object.isBatchedMesh ) { const geometryId = object.getGeometryIdAt( instanceId ); const range = object.getGeometryRangeAt( geometryId ); Object.assign( rangeTarget, range ); bvh = object.boundsTrees[ geometryId ] || null; if ( bvh === null && autogenerateBvh ) { const id = `batched_${ object.geometry.uuid }_${ range.start }_${ range.count }`; bvh = _bvhCache.get( id ) || new MeshBVH( object.geometry, { range: { ...rangeTarget } } ); _bvhCache.set( id, bvh ); } } else { const geometry = object.geometry; rangeTarget.count = geometry.index ? geometry.index.count : geometry.attributes.position.count; rangeTarget.vertexCount = geometry.attributes.position.count; bvh = object.geometry.boundsTree || null; if ( bvh === null && autogenerateBvh ) { const id = geometry.uuid; bvh = _bvhCache.get( id ) || new MeshBVH( geometry ); _bvhCache.set( id, bvh ); } } return bvh; } getDefaultAttributeValue( key, target ) { switch ( key ) { case 'position': case 'color': target.set( 1, 1, 1, 1 ); break; default: target.set( 0, 0, 0, 0 ); } return target; } dispose() { // TODO: dispose buffers } } ================================================ FILE: src/webgpu/lib/nodes/NodeProxy.js ================================================ import { Node } from 'three/webgpu'; class ProxyCallNode extends Node { static get type() { return 'ProxyCallNode'; } constructor( proxyNode, params ) { super(); this.proxyNode = proxyNode; this.params = params; } setup() { return this.proxyNode.proxyNode.call( ...this.params ); } } export class NodeProxy { get isNode() { return true; } // getter for the node being proxied to get proxyNode() { const { proxyObject, proxyProperty } = this; const properties = proxyProperty.split( '.' ); let value = proxyObject; for ( let i = 0, l = properties.length; i < l; i ++ ) { value = value[ properties[ i ] ]; } if ( 'functionNode' in value ) { return value.functionNode; } else { return value; } } constructor( property, object = null ) { // store the proxy property and objects so they can be changed later this.proxyObject = object; this.proxyProperty = property; // set up a proxy to redirect all calls to the proxied node in order to avoid replicating // expected members for all node types. return new Proxy( this, { get( target, property ) { if ( property in target ) { return Reflect.get( target, property ); } else { const value = Reflect.get( target.proxyNode, property ); if ( typeof value === 'function' ) { return value.bind( target.proxyNode ); } else { return value; } } }, set( target, property, value ) { if ( property in target ) { return Reflect.set( target, property, value ); } else { throw new Error( 'NodeProxy: Cannot set members of proxied nodes.' ); } }, } ); } } export const proxy = ( ...args ) => { return new NodeProxy( ...args ); }; export const proxyFn = ( ...args ) => { const nodeProxy = new NodeProxy( ...args ); const fn = ( ...params ) => new ProxyCallNode( nodeProxy, params ); fn.functionNode = nodeProxy; return fn; }; ================================================ FILE: src/webgpu/lib/nodes/WGSLTagFnNode.js ================================================ import { CodeNode, FunctionNode, Node } from 'three/webgpu'; // minimal node that outputs a raw WGSL expression verbatim when built class LiteralExpression extends Node { constructor( literal ) { super(); this.literal = literal; } build() { return this.literal; } } // wraps a FunctionNode so that build() returns just the function name class PropertyRefNode extends Node { constructor( node, output = 'property' ) { super(); this.node = node; this.output = output; } build( builder ) { return this.node.build( builder, this.output ); } } // wraps a FunctionCallNode so that build() returns the inline call expression, // bypassing TempNode's variable wrapping class InlineCallNode extends Node { constructor( node ) { super(); this.node = node; } build( builder ) { return this.node.generate( builder ); } } // returns the node that should be registered as an include for the given arg function getIncludeNode( arg ) { if ( typeof arg === 'function' ) { if ( arg.functionNode ) return arg.functionNode; if ( arg.isStruct ) return arg.layout; else return null; } else if ( arg.isNode ) { return new PropertyRefNode( arg ); } else { return null; } } // extract dependency nodes from template args for include registration function extractIncludes( args ) { const includes = []; for ( const arg of args ) { if ( Array.isArray( arg ) ) { for ( const element of arg ) { const node = getIncludeNode( element ); if ( node ) includes.push( node ); } } else { // WGSLTagCodeNodes should be inlined if found in a template so skip it here if ( ! ( arg instanceof WGSLTagCodeNode ) ) { const node = getIncludeNode( arg ); if ( node ) includes.push( node ); } } } return includes; } // normalize args so generate can resolve them uniformly with build(): // - callable wrappers > PropertyRefNode (emits just the function name) // - struct callables > StructTypeNode (emits the type name via build) // - FunctionCallNodes > InlineCallNode (emits inline call) function normalizeArgs( args ) { return args.map( arg => { if ( typeof arg === 'function' && arg.functionNode ) return new PropertyRefNode( arg.functionNode ); if ( typeof arg === 'function' && arg.isStruct ) return arg.layout; if ( arg && arg.isNode && arg.functionNode ) return new InlineCallNode( arg ); if ( arg && arg.isNode ) { if ( arg instanceof WGSLTagCodeNode ) { // use a custom flag for this node to inline the output return new PropertyRefNode( arg, 'inline' ); } else { return new PropertyRefNode( arg ); } } return arg; } ); } // interleave static tokens with resolved arg values function assembleTemplate( tokens, args, builder ) { let code = ''; for ( let i = 0, l = tokens.length; i < l; i ++ ) { code += tokens[ i ]; if ( i < args.length ) { const arg = args[ i ]; if ( Array.isArray( arg ) ) { // include array — no text output } else if ( typeof arg === 'string' || typeof arg === 'number' ) { code += String( arg ); } else { code += arg.build( builder ); } } } return code; } export class WGSLTagFnNode extends FunctionNode { static get type() { return 'WGSLTagFnNode'; } constructor( tokens, args, lang = 'wgsl' ) { super( '', extractIncludes( args ), lang ); this.tokens = tokens; this.args = args; } // assemble the signature from tokens and arg names then parse getNodeFunction( builder ) { const { tokens } = this; const args = normalizeArgs( this.args ); const nodeData = builder.getDataFromNode( this ); let nodeFunction = nodeData.nodeFunction; if ( nodeFunction === undefined ) { // reconstruct the full code with known names for struct args // and dummy identifiers for everything else let fullCode = ''; for ( let i = 0, l = tokens.length; i < l; i ++ ) { fullCode += tokens[ i ]; if ( i < args.length ) { const arg = args[ i ]; if ( Array.isArray( arg ) ) { // include array — no text output } else if ( typeof arg === 'string' || typeof arg === 'number' ) { // literals fullCode += String( arg ); } else if ( arg.isStructLayoutNode ) { // struct type node fullCode += arg.getNodeType( builder ); } else if ( arg.isStruct ) { // struct fullCode += arg.layout.getNodeType( builder ); } else { fullCode += '_arg' + i; } } } // remove comments fullCode = fullCode.replace( /\/\/.+[\n\r]/g, '' ); // parse it so we have the signature defined - we will define the body content after nodeFunction = builder.parser.parseFunction( fullCode ); nodeData.nodeFunction = nodeFunction; } return nodeFunction; } // get the code for the function generate( builder, output ) { const result = super.generate( builder, output ); const fullCode = assembleTemplate( this.tokens, normalizeArgs( this.args ), builder ); const { type } = this.getNodeFunction( builder ); const nodeCode = builder.getCodeFromNode( this, type ); nodeCode.code = fullCode.replace( /\/\/.+[\n\r]/g, '' ).replace( /->\s*void/, '' ).trim(); return result; } } export class WGSLTagCodeNode extends CodeNode { static get type() { return 'WGSLTagCodeNode'; } constructor( tokens, args, lang = 'wgsl' ) { super( '', extractIncludes( args ), lang ); this.tokens = tokens; this.args = args; } build( builder, output ) { if ( output === 'inline' ) { return assembleTemplate( this.tokens, normalizeArgs( this.args ), builder ); } else { return super.build( builder, output ); } } generate( builder ) { super.generate( builder ); const nodeCode = builder.getCodeFromNode( this, this.getNodeType( builder ) ); nodeCode.code = assembleTemplate( this.tokens, normalizeArgs( this.args ), builder ); return nodeCode.code; } } const getFn = functionNode => { const fn = ( ...params ) => { // wrap string parameter values as raw WGSL expressions so they // output verbatim as identifiers like local variable names if ( params.length === 1 && params[ 0 ] && typeof params[ 0 ] === 'object' && ! params[ 0 ].isNode ) { const obj = params[ 0 ]; for ( const key in obj ) { if ( typeof obj[ key ] === 'string' ) { obj[ key ] = new LiteralExpression( obj[ key ] ); } } } return functionNode.call( ...params ); }; fn.functionNode = functionNode; return fn; }; // template tag literal function version of "wgslFn" & "wgsl" to generate // functions & code snippets respectively export const wgslTagFn = ( tokens, ...args ) => getFn( new WGSLTagFnNode( tokens, args ) ); export const wgslTagCode = ( tokens, ...args ) => new WGSLTagCodeNode( tokens, args ); // glsl versions export const glslTagFn = ( tokens, ...args ) => getFn( new WGSLTagFnNode( tokens, args, 'glsl' ) ); export const glslTagCode = ( tokens, ...args ) => new WGSLTagCodeNode( tokens, args, 'glsl' ); ================================================ FILE: src/webgpu/lib/wgsl/common.wgsl.js ================================================ import { wgslFn, uint, float } from 'three/tsl'; import { rayStruct } from './structs.wgsl.js'; export const constants = { BVH_STACK_DEPTH: uint( 60 ), INFINITY: float( 1e20 ), }; export const ndcToCameraRay = wgslFn( /* wgsl*/` fn ndcToCameraRay( ndc: vec2f, inverseModelViewProjection: mat4x4f ) -> Ray { // Calculate the ray by picking the points at the near and far plane and deriving the ray // direction from the two points. This approach works for both orthographic and perspective // camera projection matrices. // The returned ray direction is not normalized and extends to the camera far plane. var homogeneous = vec4f(); var ray = Ray(); homogeneous = inverseModelViewProjection * vec4f( ndc, 0.0, 1.0 ); ray.origin = homogeneous.xyz / homogeneous.w; homogeneous = inverseModelViewProjection * vec4f( ndc, 1.0, 1.0 ); ray.direction = ( homogeneous.xyz / homogeneous.w ) - ray.origin; return ray; } `, [ rayStruct ] ); ================================================ FILE: src/webgpu/lib/wgsl/structs.wgsl.js ================================================ import { StructTypeNode } from 'three/webgpu'; export const rayStruct = new StructTypeNode( { origin: 'vec3f', direction: 'vec3f', }, 'Ray' ); export const bvhNodeBoundsStruct = new StructTypeNode( { min: 'array', max: 'array', }, 'BVHBoundingBox' ); bvhNodeBoundsStruct.getLength = () => 6; export const bvhNodeStruct = new StructTypeNode( { bounds: 'BVHBoundingBox', rightChildOrTriangleOffset: 'uint', splitAxisOrTriangleCount: 'uint', }, 'BVHNode' ); bvhNodeStruct.getLength = () => bvhNodeBoundsStruct.getLength() + 2; export const intersectionResultStruct = new StructTypeNode( { didHit: 'bool', indices: 'vec4u', normal: 'vec3f', barycoord: 'vec3f', side: 'float', dist: 'float', }, 'IntersectionResult' ); ================================================ FILE: src/webgpu/nodes/common.wgsl.js ================================================ import { float, int } from 'three/tsl'; export const constants = { PARALLEL_EPSILON: float( 1e-10 ), AREA_EPSILON: float( 1e-10 ), DIST_THRESHOLD: float( 1e-10 ), VERTEX_EPSILON: float( 1e-10 ), DOUBLE_SIDE: int( 0 ), BACK_SIDE: int( - 1 ), FRONT_SIDE: int( 1 ), }; ================================================ FILE: src/webgpu/nodes/overlapFunctions.wgsl.js ================================================ import { wgslTagFn } from '../lib/nodes/WGSLTagFnNode.js'; import { constants } from './common.wgsl.js'; import { TriWGSL, LineWGSL, PlaneWGSL } from './primitives.js'; import { clipResultStruct } from './structs.wgsl.js'; const { PARALLEL_EPSILON, AREA_EPSILON, DIST_THRESHOLD, VERTEX_EPSILON } = constants; // Clips triangle (a, b, c) against a plane (plane.xyz = normal, plane.w = constant, // equation: dot(normal, p) + constant >= 0 is the kept side). // Returns 0, 1, or 2 sub-triangles covering the kept portion. export const clipTriangleToPlane = wgslTagFn/* wgsl */` fn clipTriangleToPlane( a: vec3f, b: vec3f, c: vec3f, plane: vec4f ) -> ${ clipResultStruct } { var result: ${ clipResultStruct }; let da = dot( plane.xyz, a ) + plane.w; let db = dot( plane.xyz, b ) + plane.w; let dc = dot( plane.xyz, c ) + plane.w; let aKept = da >= 0.0; let bKept = db >= 0.0; let cKept = dc >= 0.0; let keptCount = u32( aKept ) + u32( bKept ) + u32( cKept ); // all kept - return the original triangle if ( keptCount == 3u ) { result.count = 1u; result.a0 = a; result.b0 = b; result.c0 = c; return result; } // all discarded if ( keptCount == 0u ) { return result; } // vertex positions and plane distances packed into arrays for index-based access let pts = array( a, b, c ); let dists = array( da, db, dc ); if ( keptCount == 1u ) { // apex is the lone kept vertex; the other two are clipped away var apexIdx = 0u; if ( bKept ) { apexIdx = 1u; } else if ( cKept ) { apexIdx = 2u; } let apex = pts[ apexIdx ]; let clipped0 = pts[ ( apexIdx + 1u ) % 3u ]; let clipped1 = pts[ ( apexIdx + 2u ) % 3u ]; let apexDist = dists[ apexIdx ]; let clipped0Dist = dists[ ( apexIdx + 1u ) % 3u ]; let clipped1Dist = dists[ ( apexIdx + 2u ) % 3u ]; // parametric intersection along apex->clipped0 and apex->clipped1 let t0 = apexDist / ( apexDist - clipped0Dist ); let t1 = apexDist / ( apexDist - clipped1Dist ); result.count = 1u; result.a0 = apex; result.b0 = mix( apex, clipped0, t0 ); result.c0 = mix( apex, clipped1, t1 ); return result; } // the lone discarded vertex is cut off, leaving a quad that we split into two triangles var discardedIdx = 2u; if ( ! aKept ) { discardedIdx = 0u; } else if ( ! bKept ) { discardedIdx = 1u; } // kept0 and kept1 are the two vertices on the kept side; discarded is the one being cut off let kept0 = pts[ ( discardedIdx + 1u ) % 3u ]; let kept1 = pts[ ( discardedIdx + 2u ) % 3u ]; let discarded = pts[ discardedIdx ]; let kept0Dist = dists[ ( discardedIdx + 1u ) % 3u ]; let kept1Dist = dists[ ( discardedIdx + 2u ) % 3u ]; let discardedDist = dists[ discardedIdx ]; // parametric intersections along kept0->discarded and kept1->discarded let t0 = kept0Dist / ( kept0Dist - discardedDist ); let t1 = kept1Dist / ( kept1Dist - discardedDist ); let edge0Cut = mix( kept0, discarded, t0 ); let edge1Cut = mix( kept1, discarded, t1 ); // quad (kept0, kept1, edge1Cut, edge0Cut) split into two triangles result.count = 2u; result.a0 = kept0; result.b0 = kept1; result.c0 = edge1Cut; result.a1 = kept0; result.b1 = edge1Cut; result.c1 = edge0Cut; return result; } `; // Clips the edge (lineStart -> lineEnd) to the portion lying at or below the // plane of triangle (a, b, c). The plane is always treated as up-facing. // Returns TrimResult.valid = false if the entire edge is above the plane. export const trimToBeneathTriPlane = wgslTagFn/* wgsl */` fn trimToBeneathTriPlane( tri: ${ TriWGSL.struct }, line: ${ LineWGSL.struct }, output: ptr ) -> bool { // compute the triangle plane, ensuring the normal faces up let triNormal = ${ TriWGSL.getNormal }( tri ); var plane = ${ PlaneWGSL.fromNormalAndCoplanarPoint }( triNormal, tri.a ); if ( plane.normal.y < 0.0 ) { plane.normal *= - 1.0; plane.constant *= - 1.0; } let startDist = ${ PlaneWGSL.distanceToPoint }( plane, line.start ); let endDist = ${ PlaneWGSL.distanceToPoint }( plane, line.end ); let isStartOnPlane = abs( startDist ) < ${ PARALLEL_EPSILON }; let isEndOnPlane = abs( endDist ) < ${ PARALLEL_EPSILON }; let isStartBelow = ! isStartOnPlane && startDist < 0.0; let isEndBelow = ! isEndOnPlane && endDist < 0.0; // coplanar/parallel - only valid if the line is below the plane let lineDir = normalize( line.end - line.start ); if ( abs( dot( plane.normal, lineDir ) ) < ${ PARALLEL_EPSILON } ) { // if the line is definitely above or on the plane then skip it if ( isStartOnPlane || ! isStartBelow ) { return false; } else { output.start = line.start; output.end = line.end; return true; } } if ( isStartBelow && isEndBelow ) { // both below - keep the full edge output.start = line.start; output.end = line.end; return true; } else if ( ! isStartBelow && ! isEndBelow ) { // both above - discard return false; } else { // straddling - clip at the plane intersection let t = - startDist / ( endDist - startDist ); let planeHit = mix( line.start, line.end, t ); if ( isStartBelow ) { output.start = line.start; output.end = planeHit; return true; } else if ( isEndBelow ) { output.end = line.end; output.start = planeHit; return true; } } return false; } `; // Returns the parametric overlap [t0, t1] of the edge (lineStart -> lineEnd) // against triangle (a, b, c) projected onto the XZ plane. // t0 and t1 are in [0, 1] along the original edge. valid = false if no overlap. export const getProjectedOverlapRange = wgslTagFn/* wgsl */` fn getProjectedOverlapRange( line: ${ LineWGSL.struct }, tri: ${ TriWGSL.struct }, output: ptr ) -> bool { // project everything to XZ var _tri = tri; _tri.a.y = 0.0; _tri.b.y = 0.0; _tri.c.y = 0.0; var _line = line; _line.start.y = 0.0; _line.end.y = 0.0; // skip degenerate projected triangles if ( ${ TriWGSL.getArea }( _tri ) <= ${ AREA_EPSILON } ) { return false; } var dir = _line.end - _line.start; let lineDistance = length( dir ); dir = dir / lineDistance; // cutting plane: orthogonal to the edge direction in XZ, passing through ls let normal = ${ TriWGSL.getNormal }( _tri ); let orthoNormal = normalize( cross( dir, normal ) ); let orthoPlane = ${ PlaneWGSL.fromNormalAndCoplanarPoint }( orthoNormal, _line.start ); // find the two intersections of triangle edges with the cutting plane var intersectCount = 0u; var triLineStart = vec3f( 0.0 ); var triLineEnd = vec3f( 0.0 ); let triPts = array( _tri.a, _tri.b, _tri.c ); for ( var i = 0u; i < 3u; i ++ ) { let p1 = triPts[ i ]; let p2 = triPts[ ( i + 1u ) % 3u ]; let distToStart = ${ PlaneWGSL.distanceToPoint }( orthoPlane, p1 ); let distToEnd = ${ PlaneWGSL.distanceToPoint }( orthoPlane, p2 ); let startIntersects = abs( distToStart ) < ${ DIST_THRESHOLD }; let endIntersects = abs( distToEnd ) < ${ DIST_THRESHOLD }; // check of the edge intersects var point = vec3f( 0.0 ); if ( startIntersects && endIntersects ) { continue; } else if ( startIntersects ) { point = p1; } else if ( endIntersects ) { continue; } else if ( ( distToStart < 0.0 ) == ( distToEnd < 0.0 ) ) { continue; } else { let t = distToStart / ( distToStart - distToEnd ); point = mix( p1, p2, t ); } if ( intersectCount == 0u ) { triLineStart = point; } else if ( intersectCount == 1u ) { triLineEnd = point; } intersectCount ++; if ( intersectCount == 2u ) { break; } } if ( intersectCount == 2u ) { let triDir = normalize( triLineEnd - triLineStart ); if ( dot( dir, triDir ) < 0.0 ) { let tmp = triLineStart; triLineStart = triLineEnd; triLineEnd = tmp; } // project both segments onto dir and compute the overlap let s1 = 0.0; let e1 = dot( _line.end - _line.start, dir ); let s2 = dot( triLineStart - _line.start, dir ); let e2 = dot( triLineEnd - _line.start, dir ); let separated1 = e1 <= s2; let separated2 = e2 <= s1; if ( separated1 || separated2 ) { return false; } output.start = mix( line.start, line.end, max( s1, s2 ) / lineDistance ); output.end = mix( line.start, line.end, min( e1, e2 ) / lineDistance ); return true; } return false; } `; // Returns true if the edge (lineStart -> lineEnd) lies entirely along the Y axis // when projected to XZ — i.e. the line direction is nearly (0, ±1, 0). export const isYProjectedLineDegenerate = wgslTagFn/* wgsl */` fn isYProjectedLineDegenerate( lineStart: vec3f, lineEnd: vec3f ) -> bool { let dir = normalize( lineEnd - lineStart ); return abs( dir.y ) >= 1.0 - ${ VERTEX_EPSILON }; } `; // Returns true if both endpoints of the edge (lineStart -> lineEnd) coincide // with two vertices of triangle (a, b, c) — i.e. the edge is a triangle edge. export const isLineTriangleEdge = wgslTagFn/* wgsl */` fn isLineTriangleEdge( tri: ${ TriWGSL.struct }, line: ${ LineWGSL.struct } ) -> bool { let triPts = array( tri.a, tri.b, tri.c ); var startMatches = false; var endMatches = false; let start = line.start; let end = line.end; for ( var i = 0u; i < 3u; i ++ ) { // dot is sq length let tp = triPts[ i ]; let ds = start - tp; let de = end - tp; if ( ! startMatches && dot( ds, ds ) <= ${ VERTEX_EPSILON } ) { startMatches = true; } if ( ! endMatches && dot( de, de ) <= ${ VERTEX_EPSILON } ) { endMatches = true; } if ( startMatches && endMatches ) { return true; } } return startMatches && endMatches; } `; ================================================ FILE: src/webgpu/nodes/primitives.js ================================================ import { StructTypeNode } from 'three/webgpu'; import { wgslTagFn } from '../lib/nodes/WGSLTagFnNode.js'; const lineStruct = new StructTypeNode( { start: 'vec3', end: 'vec3', } ); const triStruct = new StructTypeNode( { a: 'vec3', b: 'vec3', c: 'vec3', } ); const planeStruct = new StructTypeNode( { normal: 'vec3', constant: 'float', } ); export const LineWGSL = { struct: lineStruct, }; export const TriWGSL = { struct: triStruct, getNormal: wgslTagFn/* wgsl */` fn tri_getNormal( tri: ${ triStruct } ) -> vec3f { let n = cross( tri.c - tri.b, tri.a - tri.b ); let lenSq = dot( n, n ); if ( lenSq < 1e-12 ) { return vec3( 0.0 ); } return n * inverseSqrt( lenSq ); } `, getArea: wgslTagFn/* wgsl */` fn tri_getArea( tri: ${ triStruct } ) -> f32 { let n = cross( tri.c - tri.b, tri.a - tri.b ); let lenSq = dot( n, n ); return sqrt( lenSq ) * 0.5; } ` }; export const PlaneWGSL = { struct: planeStruct, fromNormalAndCoplanarPoint: wgslTagFn/* wgsl */` fn plane_fromNormalAndCoplanarPoint( norm: vec3f, point: vec3f ) -> ${ planeStruct } { var plane: ${ planeStruct }; plane.normal = norm; plane.constant = - dot( point, norm ); return plane; } `, distanceToPoint: wgslTagFn/* wgsl */` fn plane_distanceToPoint( plane: ${ planeStruct }, point: vec3f ) -> f32 { return dot( plane.normal, point ) + plane.constant; } `, }; ================================================ FILE: src/webgpu/nodes/structs.wgsl.js ================================================ import { StructTypeNode } from 'three/webgpu'; export const edgeStruct = new StructTypeNode( { start: 'array', end: 'array', index: 'uint', }, 'Edge' ); edgeStruct.getLength = () => 7; export const clipResultStruct = new StructTypeNode( { count: 'uint', a0: 'vec3f', b0: 'vec3f', c0: 'vec3f', a1: 'vec3f', b1: 'vec3f', c1: 'vec3f', }, 'ClipResult' ); // One entry per qualifying (edge, triangle) pair recorded during kernel 2. export const triEdgePairStruct = new StructTypeNode( { edgeIndex: 'uint', objectIndex: 'uint', triIndex: 'uint', _alignment0: 'uint', }, 'TriEdgePair' ); // One entry per visible overlap interval recorded during kernel 3. export const overlapRecordStruct = new StructTypeNode( { edgeIndex: 'uint', t0: 'float', t1: 'float', _alignment0: 'uint', }, 'OverlapRecord' ); ================================================ FILE: src/webgpu/nodes/utils.wgsl.js ================================================ import { wgslTagFn } from '../lib/nodes/WGSLTagFnNode.js'; import { bvhNodeBoundsStruct } from '../lib/wgsl/structs.wgsl.js'; // Transform all 8 corners of a BVH bounding box by the given matrix and // return the world-space AABB that encloses the result. export const transformBVHBounds = wgslTagFn/* wgsl */` fn transformBVHBounds( bounds: ${ bvhNodeBoundsStruct }, matrix: mat4x4f ) -> ${ bvhNodeBoundsStruct } { let bMin = bounds.min; let bMax = bounds.max; var wMin = vec3f( 3e38, 3e38, 3e38 ); var wMax = vec3f( - 3e38, - 3e38, - 3e38 ); for ( var ci = 0u; ci < 8u; ci = ci + 1u ) { let corner = vec3f( select( bMin[ 0 ], bMax[ 0 ], ( ci & 1u ) != 0u ), select( bMin[ 1 ], bMax[ 1 ], ( ci & 2u ) != 0u ), select( bMin[ 2 ], bMax[ 2 ], ( ci & 4u ) != 0u ) ); var wc = matrix * vec4f( corner, 1.0 ); wc = wc / wc.w; wMin = min( wMin, wc.xyz ); wMax = max( wMax, wc.xyz ); } var result: ${ bvhNodeBoundsStruct }; result.min[ 0 ] = wMin.x; result.min[ 1 ] = wMin.y; result.min[ 2 ] = wMin.z; result.max[ 0 ] = wMax.x; result.max[ 1 ] = wMax.y; result.max[ 2 ] = wMax.z; return result; } `; ================================================ FILE: src/webgpu/utils/ComputeKernel.js ================================================ export class ComputeKernel { get computeNode() { return this.kernel.computeNode; } get workgroupSize() { return this.kernel.workgroupSize; } set needsUpdate( v ) { // TODO: hack to force the kernel to rebuild since "needsUpdate" is not respected this.setWorkgroupSize( ...this.workgroupSize ); } constructor( fn, options = {} ) { const { workgroupSize = [ 64 ], } = options; // this.workgroupSize = [ ...workgroupSize ]; this._fn = fn; this.kernel = null; this.setWorkgroupSize( ...workgroupSize ); } defineUniformAccessors( parameters ) { for ( const key in parameters ) { if ( key in this ) { throw new Error( `ComputeNode: Uniform name ${ key } is already defined.` ); } const node = parameters[ key ]; if ( 'value' in node ) { Object.defineProperty( this, key, { get() { return parameters[ key ].value; }, set( v ) { parameters[ key ].value = v; }, } ); } } } setWorkgroupSize( x = 64, y = 1, z = 1 ) { this.kernel = this._fn.computeKernel( [ x, y, z ] ); return this; } getDispatchSize( tx = 1, ty = 1, tz = 1, target = [] ) { const [ wgx, wgy, wgz ] = this.workgroupSize; target.length = 3; target[ 0 ] = Math.ceil( tx / wgx ); target[ 1 ] = Math.ceil( ty / wgy ); target[ 2 ] = Math.ceil( tz / wgz ); return target; } } ================================================ FILE: src/worker/SilhouetteGeneratorWorker.js ================================================ import { BufferAttribute, BufferGeometry } from 'three'; import { OUTPUT_BOTH } from '../SilhouetteGenerator'; const NAME = 'SilhouetteGeneratorWorker'; export class SilhouetteGeneratorWorker { constructor() { this.running = false; this.worker = new Worker( new URL( './silhouetteAsync.worker.js', import.meta.url ), { type: 'module' } ); this.worker.onerror = e => { if ( e.message ) { throw new Error( `${ NAME }: Could not create Web Worker with error "${ e.message }"` ); } else { throw new Error( `${ NAME }: Could not create Web Worker.` ); } }; } generate( geometry, options = {} ) { if ( this.running ) { throw new Error( `${ NAME }: Already running job.` ); } if ( this.worker === null ) { throw new Error( `${ NAME }: Worker has been disposed.` ); } const { worker } = this; this.running = true; return new Promise( ( resolve, reject ) => { worker.onerror = e => { reject( new Error( `${ NAME }: ${ e.message }` ) ); this.running = false; }; worker.onmessage = e => { this.running = false; const { data } = e; if ( data.error ) { reject( new Error( data.error ) ); worker.onmessage = null; } else if ( data.result ) { if ( options.output === OUTPUT_BOTH ) { const result = data.result.map( info => { const geometry = new BufferGeometry(); geometry.setAttribute( 'position', new BufferAttribute( info.position, 3, false ) ); if ( info.index ) { geometry.setIndex( new BufferAttribute( info.index, 1, false ) ); } return geometry; } ); resolve( result ); } else { const geometry = new BufferGeometry(); geometry.setAttribute( 'position', new BufferAttribute( data.result.position, 3, false ) ); geometry.setIndex( new BufferAttribute( data.result.index, 1, false ) ); resolve( geometry ); } worker.onmessage = null; } else if ( options.onProgress ) { options.onProgress( data.progress ); } }; const index = geometry.index ? geometry.index.array.slice() : null; const position = geometry.attributes.position.array.slice(); const transfer = [ position.buffer ]; if ( index ) { transfer.push( index.buffer ); } worker.postMessage( { index, position, options: { ...options, onProgress: null, includedProgressCallback: Boolean( options.onProgress ), }, }, transfer ); } ); } dispose() { this.worker.terminate(); this.worker = null; } } ================================================ FILE: src/worker/silhouetteAsync.worker.js ================================================ import { BufferAttribute, BufferGeometry } from 'three'; import { OUTPUT_BOTH, SilhouetteGenerator } from '../SilhouetteGenerator.js'; onmessage = function ( { data } ) { let prevTime = performance.now(); function onProgressCallback( progress ) { const currTime = performance.now(); if ( currTime - prevTime >= 10 || progress === 1.0 ) { postMessage( { error: null, progress, } ); prevTime = currTime; } } try { const { index, position, options } = data; const geometry = new BufferGeometry(); geometry.setIndex( new BufferAttribute( index, 1, false ) ); geometry.setAttribute( 'position', new BufferAttribute( position, 3, false ) ); const generator = new SilhouetteGenerator(); generator.doubleSided = options.doubleSided ?? generator.doubleSided; generator.output = options.output ?? generator.output; generator.intScalar = options.intScalar ?? generator.intScalar; generator.sortTriangles = options.sortTriangles ?? generator.sortTriangles; const task = generator.generate( geometry, { onProgress: onProgressCallback, } ); let result = task.next(); while ( ! result.done ) { result = task.next(); } let buffers, output; if ( generator.output === OUTPUT_BOTH ) { buffers = []; output = []; result.value.forEach( g => { console.log( g ); const posArr = g.attributes.position.array; const indexArr = g.index?.array || null; output.push( { position: posArr, index: indexArr, } ); buffers.push( posArr.buffer, indexArr?.buffer, ); } ); } else { const posArr = result.value.attributes.position.array; const indexArr = result.value.index.array; output = { position: posArr, index: indexArr, }; buffers = [ posArr.buffer, indexArr.buffer ]; } postMessage( { result: output, error: null, progress: 1, }, buffers.filter( b => ! ! b ) ); } catch ( error ) { postMessage( { error, progress: 1, } ); } }; ================================================ FILE: test/Utils.getProjectedLineOverlap.test.js ================================================ import { getProjectedLineOverlap } from '../src/utils/getProjectedLineOverlap.js'; import { ExtendedTriangle } from 'three-mesh-bvh'; import { Vector3, Line3 } from 'three'; describe( 'getProjectedLineOverlap', () => { it( 'should return portion of the line that overlaps on projection.', () => { const triangle = new ExtendedTriangle( new Vector3( 1, 0, 1 ), new Vector3( - 1, 1, 0 ), new Vector3( 1, 0, - 1 ), ); let line, target; target = new Line3(); line = new Line3( new Vector3( 2, - 1, 0 ), new Vector3( - 2, 1, 0 ) ); expect( getProjectedLineOverlap( line, triangle, target ) ).toBeTruthy(); expect( [ ...target.start ] ).toEqual( [ 1, - 0.5, 0 ] ); expect( [ ...target.end ] ).toEqual( [ - 1, 0.5, 0 ] ); } ); it( 'should return null if there is no overlap.', () => { const triangle = new ExtendedTriangle( new Vector3( 1, 0, 1 ), new Vector3( - 1, 1, 0 ), new Vector3( 1, 0, - 1 ), ); let line, target; target = new Line3(); line = new Line3( new Vector3( 3, - 1, 0 ), new Vector3( 1, 1, 0 ) ); expect( getProjectedLineOverlap( line, triangle, target ) ).toBeFalsy(); } ); } ); ================================================ FILE: test/Utils.triangleLineUtils.test.js ================================================ import { Line3, Vector3 } from 'three'; import { ExtendedTriangle } from 'three-mesh-bvh'; import { isYProjectedTriangleDegenerate, isLineTriangleEdge } from '../src/utils/triangleLineUtils.js'; describe( 'isYProjectedTriangleDegenerate', () => { it( 'should return that a vertical triangle is degenerate.', () => { const triangle = new ExtendedTriangle( new Vector3( 1, 1, 0 ), new Vector3( 0, 0, 0 ), new Vector3( 1, - 1, 0 ), ); expect( isYProjectedTriangleDegenerate( triangle ) ).toBe( true ); } ); it( 'should return that an almost vertical triangle is degenerate.', () => { const triangle = new ExtendedTriangle( new Vector3( 1, 1, 1e-16 ), new Vector3( 0, 0, 0 ), new Vector3( 1, - 1, - 1e-16 ), ); expect( isYProjectedTriangleDegenerate( triangle ) ).toBe( true ); } ); it( 'should return that a non vertical triangle is not degenerate.', () => { const triangle = new ExtendedTriangle( new Vector3( 1, 1, 1 ), new Vector3( 0, 0, 0 ), new Vector3( 1, - 1, - 1 ), ); expect( isYProjectedTriangleDegenerate( triangle ) ).toBe( false ); } ); } ); describe( 'isLineTriangleEdge', () => { it( 'should return true if the line is on the triangle edge.', () => { const triangle = new ExtendedTriangle( new Vector3( 1, 1, 1 ), new Vector3( 0, 0, 0 ), new Vector3( 1, - 1, - 1 ), ); let l1, l2, l3; l1 = new Line3( triangle.a, triangle.b ); l2 = new Line3( triangle.b, triangle.c ); l3 = new Line3( triangle.c, triangle.a ); expect( isLineTriangleEdge( triangle, l1 ) ).toBe( true ); expect( isLineTriangleEdge( triangle, l2 ) ).toBe( true ); expect( isLineTriangleEdge( triangle, l3 ) ).toBe( true ); l1 = new Line3( triangle.b, triangle.a ); l2 = new Line3( triangle.c, triangle.b ); l3 = new Line3( triangle.a, triangle.c ); expect( isLineTriangleEdge( triangle, l1 ) ).toBe( true ); expect( isLineTriangleEdge( triangle, l2 ) ).toBe( true ); expect( isLineTriangleEdge( triangle, l3 ) ).toBe( true ); } ); it( 'should return false if the line is not on the triangle edge.', () => { const triangle = new ExtendedTriangle( new Vector3( 1, 1, 1 ), new Vector3( 0, 0, 0 ), new Vector3( 1, - 1, - 1 ), ); const line = new Line3( new Vector3( 0, 0, 1 ), new Vector3( 0, 0, - 1 ) ); expect( isLineTriangleEdge( triangle, line ) ).toBe( false ); } ); } ); ================================================ FILE: test/Utils.trimToBeneathTriPlane.test.js ================================================ import { ExtendedTriangle } from 'three-mesh-bvh'; import { trimToBeneathTriPlane } from '../src/utils/trimToBeneathTriPlane.js'; import { Vector3, Line3 } from 'three'; describe( 'trimToBeneathTriPlane', () => { it( 'should trim a line to beneath the triangle.', () => { const triangle = new ExtendedTriangle( new Vector3( 1, 0, 0 ), new Vector3( 0, 0, 1 ), new Vector3( - 1, 0, 0 ), ); let line, target, vec; vec = new Vector3(); target = new Line3(); line = new Line3( new Vector3( 0, - 1, 0.5 ), new Vector3( 0, 1, 0.5 ) ); expect( trimToBeneathTriPlane( triangle, line, target ) ).toBe( true ); expect( target.distance() ).toBe( 1 ); expect( target.at( 0.5, vec ).y ).toBeLessThan( 0 ); expect( triangle.getNormal( vec ).y ).toBe( - 1 ); } ); it( 'should trim a line to beneath the triangle if flipped.', () => { const triangle = new ExtendedTriangle( new Vector3( - 1, 0, 0 ), new Vector3( 0, 0, 1 ), new Vector3( 1, 0, 0 ), ); let line, target, vec; vec = new Vector3(); target = new Line3(); line = new Line3( new Vector3( 0, - 1, 0.5 ), new Vector3( 0, 1, 0.5 ) ); expect( trimToBeneathTriPlane( triangle, line, target ) ).toBe( true ); expect( target.distance() ).toBe( 1 ); expect( target.at( 0.5, vec ).y ).toBeLessThan( 0 ); expect( triangle.getNormal( vec ).y ).toBe( 1 ); } ); it( 'should return the whole line is completely beneath the triangle.', () => { const triangle = new ExtendedTriangle( new Vector3( - 1, 0, 0 ), new Vector3( 0, 0, 1 ), new Vector3( 1, 0, 0 ), ); let line, target; target = new Line3(); line = new Line3( new Vector3( 0, - 2, 0.5 ), new Vector3( 0, - 0.5, 0.5 ) ); expect( trimToBeneathTriPlane( triangle, line, target ) ).toBe( true ); expect( target.distance() ).toBe( 1.5 ); expect( target ).toEqual( line ); } ); it( 'should not return anything if the whole line is completely above the triangle.', () => { const triangle = new ExtendedTriangle( new Vector3( - 1, 0, 0 ), new Vector3( 0, 0, 1 ), new Vector3( 1, 0, 0 ), ); let line, target; target = new Line3(); line = new Line3( new Vector3( 0, 2, 0.5 ), new Vector3( 0, 0.5, 0.5 ) ); expect( trimToBeneathTriPlane( triangle, line, target ) ).toBe( false ); } ); } ); ================================================ FILE: utils/CommandUtils.js ================================================ import { existsSync } from 'fs'; import { dirname, join } from 'path'; import { fileURLToPath } from 'url'; /** * Walks up the directory hierarchy from the given path or file URL until a package.json is found. * Throws if no package.json is found before reaching the filesystem root. * @param {string} urlOrPath - Directory path or file URL (e.g. import.meta.url) to start searching from * @returns {string} */ export function findRootDir( urlOrPath = import.meta.url ) { const dir = urlOrPath.startsWith( 'file://' ) ? dirname( fileURLToPath( urlOrPath ) ) : urlOrPath; if ( existsSync( join( dir, 'package.json' ) ) ) return dir; const parent = dirname( dir ); if ( parent === dir ) throw new Error( 'Could not find package.json' ); return findRootDir( parent ); } ================================================ FILE: utils/docs/RenderDocsUtils.js ================================================ // Converts {@link url text} inline tags in a string to Markdown [text](url) links. export function resolveLinks( str ) { if ( ! str ) return str; return str.replace( /\{@link\s+(\S+?)(?:\s+([^}]*?))?\}/g, ( _, url, text ) => { return text ? `[${ text }](${ url })` : `[${ url }](${ url })`; } ); } // Renders any @warn / @note custom tags from a doclet as GFM alert blocks. function renderAlertTags( doc ) { const lines = []; for ( const tag of ( doc.tags || [] ) ) { if ( tag.title === 'warn' || tag.title === 'note' ) { const type = tag.title === 'warn' ? 'WARNING' : 'NOTE'; lines.push( `> [!${ type }]` ); for ( const line of tag.value.split( '\n' ) ) { lines.push( `> ${ line }` ); } lines.push( '' ); } } return lines.join( '\n' ); } // Converts a heading name to its GitHub Markdown anchor id. export function toAnchor( name ) { return name.toLowerCase().replace( /[^a-z0-9]+/g, '' ); } // Formats a callback typedef into an inline arrow-function type string. // e.g. "( a: any, b: any ) => number" function formatCallbackType( callbackDoc, callbackMap ) { const params = ( callbackDoc.params || [] ).map( p => { const type = formatType( p.type, callbackMap ); return `${ p.name }: ${ type }`; } ); const ret = ( callbackDoc.returns && callbackDoc.returns[ 0 ] ) ? formatType( callbackDoc.returns[ 0 ].type, callbackMap ) : 'void'; const sig = params.length > 0 ? ` ${ params.join( ', ' ) } ` : ''; return `(${ sig }) => ${ ret }`; } // Formats a JSDoc type object into a type string, e.g. "string | Object | null". // Strips JSDoc's dot-generic syntax: Promise. -> Promise // Substitutes @callback typedef names with their inline arrow-function signature. export function formatType( typeObj, callbackMap = {} ) { if ( ! typeObj || ! typeObj.names || typeObj.names.length === 0 ) return ''; return typeObj.names .map( t => { if ( callbackMap[ t ] ) return formatCallbackType( callbackMap[ t ], callbackMap ); return t.replace( /\. ! p.name.includes( '.' ) ); const nestedMap = {}; for ( const p of allParams ) { if ( p.name.includes( '.' ) ) { const topName = p.name.split( '.' )[ 0 ]; if ( ! nestedMap[ topName ] ) nestedMap[ topName ] = []; nestedMap[ topName ].push( p ); } } const hasAnyNested = topLevel.some( p => nestedMap[ p.name ] ); if ( ! hasAnyNested ) return null; // caller should use simple inline form const lines = []; topLevel.forEach( ( p, i ) => { const nested = nestedMap[ p.name ]; const comma = i < topLevel.length - 1 ? ',' : ''; if ( nested ) { lines.push( '\t{' ); for ( const opt of nested ) { const name = opt.name.split( '.' ).pop(); const defStr = opt.defaultvalue !== undefined ? ` = ${ opt.defaultvalue }` : ''; const optional = opt.optional && opt.defaultvalue === undefined ? '?' : ''; const typeName = opt.type && opt.type.names && opt.type.names[ 0 ]; const callbackDoc = typeName && callbackMap[ typeName ]; if ( callbackDoc ) { const cbParams = callbackDoc.params || []; const cbRet = ( callbackDoc.returns && callbackDoc.returns[ 0 ] ) ? formatType( callbackDoc.returns[ 0 ].type, callbackMap ) : 'void'; lines.push( `\t\t${ name }${ defStr }${ optional }: (` ); cbParams.forEach( ( cp, ci ) => { const cpType = formatType( cp.type, callbackMap ); const cpComma = ci < cbParams.length - 1 ? ',' : ''; lines.push( `\t\t\t${ cp.name }: ${ cpType }${ cpComma }` ); } ); lines.push( `\t\t) => ${ cbRet },` ); } else { const type = formatType( opt.type, callbackMap ); lines.push( `\t\t${ name }${ defStr }${ optional }: ${ type },` ); } } lines.push( `\t}${ comma }` ); } else { lines.push( `\t${ formatParam( p, callbackMap ) }${ comma }` ); } } ); return lines; } export function renderConstructor( classDoc, callbackMap = {} ) { const lines = []; lines.push( '### .constructor' ); lines.push( '' ); lines.push( '```js' ); const paramLines = renderParamLines( classDoc.params || [], callbackMap ); if ( paramLines ) { lines.push( 'constructor(' ); lines.push( ...paramLines ); lines.push( ')' ); } else { const sig = ( classDoc.params || [] ) .filter( p => ! p.name.includes( '.' ) ) .map( p => formatParam( p, callbackMap ) ) .join( ', ' ); lines.push( `constructor( ${ sig } )` ); } lines.push( '```' ); lines.push( '' ); // Constructor description (JSDoc puts it in `description`, not `classdesc`) if ( classDoc.description ) { lines.push( classDoc.description ); lines.push( '' ); } return lines.join( '\n' ); } export function renderMember( doc, callbackMap = {} ) { const lines = []; lines.push( `### .${ doc.name }` ); lines.push( '' ); lines.push( '```js' ); const type = formatType( doc.type, callbackMap ); const readonly = doc.readonly ? 'readonly ' : ''; lines.push( `${ readonly }${ doc.name }: ${ type }` ); lines.push( '```' ); lines.push( '' ); if ( doc.description ) { lines.push( doc.description ); lines.push( '' ); } lines.push( renderAlertTags( doc ) ); return lines.join( '\n' ); } function renderCallable( doc, heading, sigPrefix, callbackMap ) { const lines = []; lines.push( heading ); lines.push( '' ); lines.push( '```js' ); const allParams = doc.params || []; const topLevel = allParams.filter( p => ! p.name.includes( '.' ) ); const ret = ( doc.returns && doc.returns[ 0 ] ) ? formatType( doc.returns[ 0 ].type, callbackMap ) : 'void'; const paramLines = renderParamLines( allParams, callbackMap ); if ( paramLines ) { lines.push( `${ sigPrefix }${ doc.name }(` ); lines.push( ...paramLines ); lines.push( `): ${ ret }` ); } else { const params = topLevel.map( p => formatParam( p, callbackMap ) ); const singleLine = params.length ? `${ sigPrefix }${ doc.name }( ${ params.join( ', ' ) } ): ${ ret }` : `${ sigPrefix }${ doc.name }(): ${ ret }`; if ( singleLine.length > 80 ) { lines.push( `${ sigPrefix }${ doc.name }(` ); params.forEach( ( p, i ) => { const comma = i < params.length - 1 ? ',' : ''; lines.push( `\t${ p }${ comma }` ); } ); lines.push( `): ${ ret }` ); } else { lines.push( singleLine ); } } lines.push( '```' ); lines.push( '' ); if ( doc.description ) { lines.push( doc.description ); lines.push( '' ); } lines.push( renderAlertTags( doc ) ); return lines.join( '\n' ); } export function renderMethod( doc, callbackMap = {} ) { const isStatic = doc.scope === 'static'; const headingPrefix = isStatic ? 'static ' : ''; const sigPrefix = isStatic ? `static ${ doc.async ? 'async ' : '' }` : ( doc.async ? 'async ' : '' ); return renderCallable( doc, `### ${ headingPrefix }.${ doc.name }`, sigPrefix, callbackMap ); } export function renderFunction( doc, callbackMap = {} ) { return renderCallable( doc, `### ${ doc.name }`, '', callbackMap ); } export function renderFunctions( funcs, title = 'Functions', callbackMap = {}, typedefs = [], typedefCallbackMap = {}, resolveLink = null ) { if ( funcs.length === 0 && typedefs.length === 0 ) return ''; const lines = []; lines.push( `## ${ title }` ); lines.push( '' ); for ( const td of typedefs ) { lines.push( renderTypedef( td, typedefCallbackMap, resolveLink, 3 ) ); } for ( const fn of funcs ) { lines.push( renderFunction( fn, callbackMap ) ); } return lines.join( '\n' ); } export function renderConstants( constants, title = 'Constants', callbackMap = {} ) { if ( constants.length === 0 ) return ''; const lines = []; lines.push( `## ${ title }` ); lines.push( '' ); for ( const c of constants ) { const type = formatType( c.type, callbackMap ) || 'number'; lines.push( `### ${ c.name }` ); lines.push( '' ); lines.push( '```js' ); lines.push( `${ c.name }: ${ type }` ); lines.push( '```' ); lines.push( '' ); if ( c.description ) { lines.push( c.description ); lines.push( '' ); } } return lines.join( '\n' ); } export function renderTypedef( typeDoc, callbackMap = {}, resolveLink = null, headingLevel = 2 ) { const h = '#'.repeat( headingLevel ); const hSub = '#'.repeat( headingLevel + 1 ); const lines = []; lines.push( `${ h } ${ typeDoc.name }` ); lines.push( '' ); // If the typedef's base type is not plain Object, treat it as an extension const baseType = typeDoc.type.names[ 0 ]; if ( baseType && baseType !== 'Object' ) { const link = resolveLink && resolveLink( baseType ); const ref = link ? `[\`${ baseType }\`](${ link })` : `\`${ baseType }\``; lines.push( `_extends ${ ref }_` ); lines.push( '' ); } if ( typeDoc.description ) { lines.push( typeDoc.description ); lines.push( '' ); } lines.push( renderAlertTags( typeDoc ) ); for ( const prop of ( typeDoc.properties || [] ) ) { const type = formatType( prop.type, callbackMap ); const optional = prop.optional ? '?' : ''; lines.push( `${ hSub } .${ prop.name }` ); lines.push( '' ); lines.push( '```js' ); lines.push( `${ prop.name }${ optional }: ${ type }` ); lines.push( '```' ); lines.push( '' ); if ( prop.description ) { lines.push( prop.description ); lines.push( '' ); } } return lines.join( '\n' ); } export function renderEvents( events, callbackMap = {} ) { const lines = []; lines.push( '### events' ); lines.push( '' ); lines.push( '```js' ); for ( let i = 0; i < events.length; i ++ ) { const event = events[ i ]; if ( event.description ) { for ( const descLine of event.description.split( '\n' ) ) { lines.push( `// ${ descLine }` ); } } const props = event.properties || []; const propStr = props.map( p => { const type = formatType( p.type, callbackMap ); const optional = p.optional ? '?' : ''; return `${ p.name }${ optional }: ${ type }`; } ).join( ', ' ); if ( propStr ) { lines.push( `{ type: '${ event.name }', ${ propStr } }` ); } else { lines.push( `{ type: '${ event.name }' }` ); } if ( i < events.length - 1 ) lines.push( '' ); } lines.push( '```' ); lines.push( '' ); return lines.join( '\n' ); } export function renderComponent( doc, callbackMap = {} ) { const lines = []; lines.push( `## ${ doc.name }` ); lines.push( '' ); if ( doc.description ) { lines.push( doc.description ); lines.push( '' ); } const props = ( doc.params || [] ).filter( p => p.name.includes( '.' ) ); if ( props.length > 0 ) { lines.push( '### Props' ); lines.push( '' ); lines.push( '```jsx' ); lines.push( `<${ doc.name }` ); for ( const prop of props ) { const name = prop.name.split( '.' ).pop(); const type = formatType( prop.type, callbackMap ); const optional = prop.optional ? '?' : ''; const defStr = prop.defaultvalue !== undefined ? ` = ${ prop.defaultvalue }` : ''; lines.push( `\t${ name }${ optional }: ${ type }${ defStr }` ); } lines.push( '/>' ); lines.push( '```' ); lines.push( '' ); for ( const prop of props ) { const name = prop.name.split( '.' ).pop(); const type = formatType( prop.type, callbackMap ); const optional = prop.optional ? '?' : ''; const defStr = prop.defaultvalue !== undefined ? ` = ${ prop.defaultvalue }` : ''; lines.push( `### .${ name }` ); lines.push( '' ); lines.push( '```jsx' ); lines.push( `${ name }${ optional }: ${ type }${ defStr }` ); lines.push( '```' ); lines.push( '' ); if ( prop.description ) { lines.push( prop.description ); lines.push( '' ); } } } return lines.join( '\n' ); } export function renderClass( classDoc, members, callbackMap = {}, resolveLink = null ) { const lines = []; lines.push( `## ${ classDoc.name }` ); lines.push( '' ); if ( classDoc.augments && classDoc.augments.length > 0 ) { const base = classDoc.augments[ 0 ]; const link = resolveLink && resolveLink( base ); const ref = link ? `[\`${ base }\`](${ link })` : `\`${ base }\``; lines.push( `_extends ${ ref }_` ); lines.push( '' ); } const classDesc = classDoc.classdesc || classDoc.description; if ( classDesc ) { lines.push( classDesc ); lines.push( '' ); } lines.push( renderAlertTags( classDoc ) ); const visible = members.filter( m => m.access !== 'private' ); // Treat function doclets that carry an explicit @type tag as properties // (e.g. arrow-function assignments like `this.schedulingCallback = func => ...`) const isProperty = m => m.kind === 'member' || ( m.kind === 'function' && m.type ); const properties = visible .filter( isProperty ) .sort( ( a, b ) => a.meta.lineno - b.meta.lineno ); const allMethods = visible .filter( m => m.kind === 'function' && ! m.type ) .sort( ( a, b ) => a.meta.lineno - b.meta.lineno ); const staticMethods = allMethods.filter( m => m.scope === 'static' ); const instanceMethods = allMethods.filter( m => m.scope !== 'static' ); const events = visible .filter( m => m.kind === 'event' ) .sort( ( a, b ) => a.meta.lineno - b.meta.lineno ); if ( events.length > 0 ) { lines.push( renderEvents( events, callbackMap ) ); } // Static methods appear first for ( const method of staticMethods ) { lines.push( renderMethod( method, callbackMap ) ); } for ( const member of properties ) { lines.push( renderMember( member, callbackMap ) ); } // Constructor before instance methods if ( classDoc.params && classDoc.params.length > 0 ) { lines.push( renderConstructor( classDoc, callbackMap ) ); } for ( const method of instanceMethods ) { lines.push( renderMethod( method, callbackMap ) ); } return lines.join( '\n' ); } ================================================ FILE: utils/docs/build.js ================================================ import { execSync } from 'child_process'; import fs from 'fs'; import path from 'path'; import { renderClass, renderComponent, renderTypedef, renderConstants, renderFunctions, toAnchor, resolveLinks } from './RenderDocsUtils.js'; import { findRootDir } from '../CommandUtils.js'; const ROOT_DIR = findRootDir(); const ENTRY_POINTS = [ { output: 'API.md', title: 'three-edge-projection', source: 'src', exclude: 'src/webgpu', }, { output: 'API.md', title: 'WebGPU API', source: 'src/webgpu', }, ]; // Run JSDoc for all entry points and build a global type registry for cross-file links const results = ENTRY_POINTS.map( entry => { let jsdoc = filterDocumented( runJsDoc( path.resolve( ROOT_DIR, entry.source ) ) ); if ( entry.exclude ) { const excludePath = path.resolve( ROOT_DIR, entry.exclude ); jsdoc = jsdoc.filter( d => ! d.meta || ! d.meta.path || ! d.meta.path.startsWith( excludePath ) ); } return { entry, jsdoc }; } ); // Doclet type predicates const isClass = d => d.kind === 'class'; const isObjectTypedef = d => d.kind === 'typedef' && d.type.names[ 0 ] !== 'function'; const isCallbackTypedef = d => d.kind === 'typedef' && d.type.names[ 0 ] === 'function'; const isReactComponent = d => ( d.kind === 'function' || d.kind === 'constant' ) && d.tags && d.tags.some( t => t.title === 'component' ); const isConstant = d => d.kind === 'constant' && ! d.memberof && ! isReactComponent( d ); const isFunction = d => d.kind === 'function' && ! d.memberof && ! isReactComponent( d ); // Only classes, non-callback typedefs, and React components get sections (and therefore anchors) in the output. const typeRegistry = {}; // name -> output path for ( const { entry, jsdoc } of results ) { for ( const d of jsdoc ) { if ( isClass( d ) || isObjectTypedef( d ) || isReactComponent( d ) ) { typeRegistry[ d.name ] = entry.output; } } } // Pass 2: render each entry point and accumulate sections per output file. const outputSections = {}; // output file -> accumulated sections array for ( const { entry, jsdoc } of results ) { const resolveLink = name => { // no link const targetFile = typeRegistry[ name ]; if ( ! targetFile ) { return null; } const anchor = `#${ toAnchor( name ) }`; if ( targetFile === entry.output ) { // anchor is in the same file return anchor; } // relative path + anchor for a different file const fromDir = path.dirname( path.join( ROOT_DIR, entry.output ) ); const toFile = path.join( ROOT_DIR, targetFile ); const relativePath = path.relative( fromDir, toFile ).replace( /\\/g, '/' ); return relativePath + anchor; }; // Sort classes topologically so every parent appears before its subclasses. // Within the same "depth level" classes are sorted alphabetically. const classes = topologicalSortClasses( jsdoc.filter( d => isClass( d ) ) ); // collect @callback typedefs into a map for inline substitution const callbackMap = {}; for ( const d of jsdoc ) { if ( isCallbackTypedef( d ) ) { callbackMap[ d.name ] = d; } } // Sort typedefs so plain-object bases appear before derived types; exclude @callback entries const allTypedefs = jsdoc .filter( d => isObjectTypedef( d ) ) .sort( ( a, b ) => { const aIsBase = a.type.names[ 0 ] === 'Object'; const bIsBase = b.type.names[ 0 ] === 'Object'; if ( aIsBase && ! bIsBase ) return - 1; if ( ! aIsBase && bIsBase ) return 1; return a.name.localeCompare( b.name ); } ); // Typedefs tagged with @section are injected before their matching function group const typedefsBySection = {}; const typedefs = []; for ( const d of allTypedefs ) { const sectionTag = d.tags && d.tags.find( t => t.title === 'section' ); if ( sectionTag ) { const key = sectionTag.value; if ( ! typedefsBySection[ key ] ) typedefsBySection[ key ] = []; typedefsBySection[ key ].push( d ); } else { typedefs.push( d ); } } // sort components by source line order const components = jsdoc .filter( d => isReactComponent( d ) ) .sort( ( a, b ) => a.meta.lineno - b.meta.lineno ); const constsByGroup = groupByTag( jsdoc, isConstant, 'Constants' ); const funcsByGroup = groupByTag( jsdoc, isFunction, 'Functions' ); // cache all fields by associated class name const classMembers = {}; for ( const doc of jsdoc ) { if ( doc.memberof && doc.kind !== 'class' ) { if ( ! classMembers[ doc.memberof ] ) { classMembers[ doc.memberof ] = []; } classMembers[ doc.memberof ].push( doc ); } } // construct sections for this entry point const sections = [ `# ${ entry.title }`, '' ]; for ( const [ groupName, consts ] of Object.entries( constsByGroup ) ) { sections.push( renderConstants( consts, groupName, callbackMap ) ); } for ( const component of components ) { sections.push( renderComponent( component, callbackMap ) ); } for ( const cls of classes ) { sections.push( renderClass( cls, classMembers[ cls.name ] || [], callbackMap, resolveLink ) ); } for ( const typedef of typedefs ) { sections.push( renderTypedef( typedef, callbackMap, resolveLink ) ); } for ( const [ groupName, funcs ] of Object.entries( funcsByGroup ) ) { const sectionTypedefs = typedefsBySection[ groupName ] || []; sections.push( renderFunctions( funcs, groupName, callbackMap, sectionTypedefs, callbackMap, resolveLink ) ); } if ( ! outputSections[ entry.output ] ) outputSections[ entry.output ] = []; outputSections[ entry.output ].push( ...sections ); } // Write each output file once, after all entry points have been processed. const header = '\n'; for ( const [ outputFile, sections ] of Object.entries( outputSections ) ) { const output = header + resolveLinks( sections.join( '\n' ) ); fs.writeFileSync( path.join( ROOT_DIR, outputFile ), output ); console.log( `Written: ${ outputFile }` ); } // function groupByTag( docs, predicate, defaultGroup ) { const groups = {}; for ( const d of docs.filter( predicate ).sort( ( a, b ) => a.meta.lineno - b.meta.lineno ) ) { const groupTag = d.tags && d.tags.find( t => t.title === 'section' ); const groupName = groupTag ? groupTag.value : defaultGroup; if ( ! groups[ groupName ] ) groups[ groupName ] = []; groups[ groupName ].push( d ); } return groups; } function runJsDoc( source ) { // Default maxBuffer is 1 MB; large source directories can exceed that, so raise it to 32 MB. const result = execSync( `npx jsdoc -X -r "${ source }"`, { maxBuffer: 32 * 1024 * 1024 } ).toString(); return JSON.parse( result ); } // Topological sort: every parent class appears before its subclasses. // Siblings (subclasses sharing the same parent) are kept together and ordered alphabetically. function topologicalSortClasses( classes ) { const byName = Object.fromEntries( classes.map( c => [ c.name, c ] ) ); const result = []; const visited = new Set(); // Build parent -> children map so siblings can be visited eagerly const childrenMap = {}; for ( const cls of classes ) { for ( const parent of ( cls.augments || [] ) ) { if ( ! childrenMap[ parent ] ) childrenMap[ parent ] = []; childrenMap[ parent ].push( cls ); } } function visit( cls ) { if ( visited.has( cls.name ) ) return; visited.add( cls.name ); // Visit parent(s) first for ( const parent of ( cls.augments || [] ) ) { if ( byName[ parent ] ) visit( byName[ parent ] ); } result.push( cls ); // Eagerly visit children alphabetically so all siblings stay grouped together const children = ( childrenMap[ cls.name ] || [] ) .slice() .sort( ( a, b ) => a.name.localeCompare( b.name ) ); for ( const child of children ) { visit( child ); } } // Alphabetical pre-sort for deterministic output within the same generation [ ...classes ] .sort( ( a, b ) => a.name.localeCompare( b.name ) ) .forEach( visit ); return result; } function filterDocumented( json ) { return json.filter( d => d.undocumented !== true && d.ignore !== true && d.kind !== 'package' && d.access !== 'private' && d.inherited !== true && ! d.deprecated ); } ================================================ FILE: vite.config.js ================================================ import { searchForWorkspaceRoot } from 'vite'; import fs from 'fs'; export default { root: './example/', base: '', build: { target: 'es2022', sourcemap: true, outDir: './dist/', minify: false, terserOptions: { compress: false, mangle: false, }, rollupOptions: { input: fs .readdirSync( './example/' ) .filter( p => /\.html$/.test( p ) ) .map( p => `./example/${ p }` ), }, }, server: { fs: { allow: [ // search up for workspace root searchForWorkspaceRoot( process.cwd() ), ], }, } }; ================================================ FILE: vitest.config.js ================================================ import { defineConfig } from 'vitest/config'; export default defineConfig( { test: { globals: true, environment: 'node', include: [ 'test/**/*.test.js', 'test/**/*.spec.js' ], }, } );