├── .github ├── FUNDING.yml ├── codecov.yml ├── pull_request_template.md └── workflows │ └── ci.yml ├── .gitignore ├── test ├── tsconfig.json ├── MeshSubject.test.ts ├── SceneSubject.test.ts ├── InstancedMeshSubject.test.ts ├── PrimitiveSubject.test.ts ├── SkinSubject.test.ts ├── TextureSubject.test.ts ├── NodeSubject.test.ts ├── LightSubject.test.ts ├── MaterialSubject.test.ts └── DocumentView.test.ts ├── assets ├── DamagedHelmet.glb ├── view_architecture.png ├── royal_esplanade_1k.hdr └── view_architecture.json ├── src ├── observers │ ├── index.ts │ ├── RefMapObserver.ts │ ├── RefListObserver.ts │ └── RefObserver.ts ├── pools │ ├── index.ts │ ├── SingleUserPool.ts │ ├── Pool.ts │ ├── TexturePool.ts │ └── MaterialPool.ts ├── index.ts ├── subjects │ ├── index.ts │ ├── ExtensionSubject.ts │ ├── SceneSubject.ts │ ├── AccessorSubject.ts │ ├── TextureSubject.ts │ ├── MeshSubject.ts │ ├── LightSubject.ts │ ├── SkinSubject.ts │ ├── InstancedMeshSubject.ts │ ├── Subject.ts │ ├── NodeSubject.ts │ ├── PrimitiveSubject.ts │ └── MaterialSubject.ts ├── constants.ts ├── utils │ ├── Observable.ts │ └── index.ts ├── DocumentView.ts ├── ImageProvider.ts └── DocumentViewImpl.ts ├── .prettierrc.json ├── examples ├── vite.config.js ├── 1-model.html ├── 2-material.html ├── index.html ├── 3-diff.html ├── stats-pane.ts ├── style.css ├── util.ts ├── dropzone.ts ├── 1-model.ts ├── 2-material.ts ├── 3-diff.ts └── material-pane.ts ├── renovate.json ├── tsconfig.json ├── README.md ├── eslint.config.js ├── LICENSE.md ├── CONTRIBUTING.md ├── CONTRIBUTOR_LICENSE_AGREEMENT.md └── package.json /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [donmccurdy] 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | dist 4 | .cache 5 | -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["./**/*.test.ts"], 3 | "extends": "../tsconfig.json" 4 | } 5 | -------------------------------------------------------------------------------- /assets/DamagedHelmet.glb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/donmccurdy/glTF-Transform-View/HEAD/assets/DamagedHelmet.glb -------------------------------------------------------------------------------- /assets/view_architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/donmccurdy/glTF-Transform-View/HEAD/assets/view_architecture.png -------------------------------------------------------------------------------- /assets/royal_esplanade_1k.hdr: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/donmccurdy/glTF-Transform-View/HEAD/assets/royal_esplanade_1k.hdr -------------------------------------------------------------------------------- /src/observers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './RefObserver'; 2 | export * from './RefListObserver'; 3 | export * from './RefMapObserver'; 4 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "useTabs": true, 4 | "tabWidth": 4, 5 | "printWidth": 120, 6 | "bracketSpacing": true 7 | } 8 | -------------------------------------------------------------------------------- /src/pools/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Pool'; 2 | export * from './TexturePool'; 3 | export * from './MaterialPool'; 4 | export * from './SingleUserPool'; 5 | -------------------------------------------------------------------------------- /examples/vite.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | root: 'examples', 3 | publicDir: '../assets', 4 | resolve: { alias: { '@gltf-transform/view': '../' } }, 5 | }; 6 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { DocumentView } from './DocumentView'; 2 | export { DefaultImageProvider as ImageProvider, NullImageProvider } from './ImageProvider'; 3 | -------------------------------------------------------------------------------- /examples/1-model.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | @gltf-transform/view : examples : 1-model.html 5 | 6 | 7 | 8 |
9 | ⏮   back 10 | 11 | 12 | -------------------------------------------------------------------------------- /examples/2-material.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | @gltf-transform/view : examples : 2-material.html 5 | 6 | 7 | 8 |
9 | ⏮   back 10 | 11 | 12 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["github>donmccurdy/renovate-config"], 3 | "packageRules": [ 4 | { 5 | "description": "Tests failing in Ava", 6 | "matchPackageNames": ["jsdom"], 7 | "enabled": false 8 | }, 9 | { 10 | "description": "Requires updates to tweakpane-plugin-thumbnail-list", 11 | "matchPackageNames": ["tweakpane"], 12 | "enabled": false 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src/**/*.ts"], 3 | "compilerOptions": { 4 | "paths": { 5 | "@gltf-transform/view": ["./"] 6 | }, 7 | "esModuleInterop": true, 8 | "moduleResolution": "node", 9 | "lib": ["es2020", "dom"], 10 | "target": "es2020", 11 | "module": "esnext", 12 | "declaration": true, 13 | "typeRoots": ["node_modules/@types"], 14 | "strict": true, 15 | "noImplicitAny": false 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/subjects/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Subject'; 2 | export * from './AccessorSubject'; 3 | export * from './ExtensionSubject'; 4 | export * from './InstancedMeshSubject'; 5 | export * from './LightSubject'; 6 | export * from './MaterialSubject'; 7 | export * from './MeshSubject'; 8 | export * from './NodeSubject'; 9 | export * from './PrimitiveSubject'; 10 | export * from './SceneSubject'; 11 | export * from './SkinSubject'; 12 | export * from './TextureSubject'; 13 | -------------------------------------------------------------------------------- /.github/codecov.yml: -------------------------------------------------------------------------------- 1 | # Based on https://docs.codecov.com/docs/codecov-yaml#default-yaml 2 | codecov: 3 | require_ci_to_pass: yes 4 | 5 | comment: false 6 | 7 | coverage: 8 | precision: 2 9 | round: down 10 | range: "50...95" 11 | status: 12 | patch: off 13 | project: 14 | default: 15 | target: 80% 16 | threshold: 1% 17 | 18 | parsers: 19 | gcov: 20 | branch_detection: 21 | conditional: yes 22 | loop: yes 23 | method: no 24 | macro: no 25 | -------------------------------------------------------------------------------- /src/subjects/ExtensionSubject.ts: -------------------------------------------------------------------------------- 1 | import type { ExtensionProperty as ExtensionPropertyDef } from '@gltf-transform/core'; 2 | import type { DocumentViewImpl } from '../DocumentViewImpl'; 3 | import { Subject } from './Subject'; 4 | 5 | /** @internal */ 6 | export class ExtensionSubject extends Subject { 7 | constructor(documentView: DocumentViewImpl, def: ExtensionPropertyDef) { 8 | super(documentView, def, def, documentView.extensionPool); 9 | } 10 | update() {} 11 | } 12 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ### Description 2 | 3 | _Describe the purpose of this pull request._ 4 | 5 | ### Related issues 6 | 7 | _Links to any related bugs or feature requests._ 8 | 9 | ### Contributor license agreement 10 | 11 | _Read the [contributor license agreement](https://github.com/donmccurdy/glTF-Transform-View/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). 12 | If you agree to the terms of the agreement, check the box below._ 13 | 14 | - [ ] I have read the contributor license agreement in full, and I am contributing this code under the terms of the agreement. 15 | -------------------------------------------------------------------------------- /examples/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | @gltf-transform/view : examples : index.html 5 | 16 | 17 | 18 |

@gltf-transform/view : examples

19 | 24 | 25 | -------------------------------------------------------------------------------- /examples/3-diff.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | @gltf-transform/view : examples : 3-diff.html 5 | 6 | 7 | 8 |
9 | ⏮   back 10 |
11 |

Select .GLTF or .GLB file.

12 | 13 |
14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /examples/stats-pane.ts: -------------------------------------------------------------------------------- 1 | import { WebGLRenderer } from 'three'; 2 | import { Pane } from 'tweakpane'; 3 | 4 | export function createStatsPane(renderer: WebGLRenderer, pane: Pane) { 5 | const stats = {info: ''}; 6 | const monitorFolder = pane.addFolder({index: 0, title: 'Monitor'}) 7 | monitorFolder.addMonitor(stats, 'info', {bufferSize: 1, multiline: true, lineCount: 3}); 8 | 9 | 10 | 11 | return () => { 12 | const info = renderer.info; 13 | stats.info = ` 14 | programs ${info.programs.length} 15 | geometries ${info.memory.geometries} 16 | textures ${info.memory.textures} 17 | `.trim(); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BufferGeometry, 3 | DirectionalLight, 4 | Line, 5 | LineLoop, 6 | LineSegments, 7 | Material, 8 | Mesh, 9 | PointLight, 10 | Points, 11 | SkinnedMesh, 12 | SpotLight, 13 | } from 'three'; 14 | 15 | export type Subscription = () => void; 16 | 17 | export type MeshLike = 18 | | Mesh 19 | | SkinnedMesh 20 | | Points 21 | | Line 22 | | LineSegments 23 | | LineLoop; 24 | 25 | export type LightLike = PointLight | SpotLight | DirectionalLight; 26 | 27 | export interface THREEObject { 28 | name: string; 29 | type: string; 30 | } 31 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: [push] 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | strategy: 7 | matrix: 8 | node-version: [20.x] 9 | env: 10 | CI: true 11 | steps: 12 | # Setup. 13 | - uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4 14 | - name: Use Node.js ${{ matrix.node-version }} 15 | uses: actions/setup-node@v4 16 | with: 17 | node-version: ${{ matrix.node-version }} 18 | - run: yarn install 19 | - run: yarn dist 20 | - run: yarn test 21 | - run: yarn lint 22 | 23 | # Coverage. 24 | - run: yarn coverage 25 | - run: yarn coverage:report 26 | - uses: codecov/codecov-action@v4 27 | with: 28 | files: coverage/coverage.lcov 29 | -------------------------------------------------------------------------------- /src/utils/Observable.ts: -------------------------------------------------------------------------------- 1 | import { Subscription } from '../constants'; 2 | 3 | export class Observable { 4 | public value: Value; 5 | private _subscriber: ((next: Value, prev: Value) => void) | null = null; 6 | 7 | constructor(value: Value) { 8 | this.value = value; 9 | } 10 | 11 | public subscribe(subscriber: (next: Value, prev: Value) => void): Subscription { 12 | if (this._subscriber) { 13 | throw new Error('Observable: Limit one subscriber per Observable.'); 14 | } 15 | 16 | this._subscriber = subscriber; 17 | return () => (this._subscriber = null); 18 | } 19 | 20 | public next(value: Value) { 21 | const prevValue = this.value; 22 | this.value = value; 23 | if (this._subscriber) { 24 | this._subscriber(this.value, prevValue); 25 | } 26 | } 27 | 28 | public dispose() { 29 | this._subscriber = null; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @gltf-transform/view 2 | 3 | > ⚠️ Package moved to the [glTF-Transform monorepo](https://github.com/donmccurdy/glTF-Transform). 4 | 5 | ## Overview 6 | 7 | Creates three.js objects from a glTF Transform [Document](https://gltf-transform.donmccurdy.com/classes/core.document.html), then keeps the three.js scene graph updated — in realtime ⚡️ — as changes are made to the Document. Combined with import/export using [WebIO](https://gltf-transform.donmccurdy.com/classes/core.platformio.html), `@gltf-transform/view` provides a lossless workflow to load, view, edit, and export glTF assets. It's meant for editor-like applications on the web. Unlike using [THREE.GLTFExporter](https://threejs.org/docs/index.html#examples/en/loaders/GLTFExporter), any glTF features that three.js doesn't support won't be lost, they just aren't rendered in the preview. 8 | 9 | ## License 10 | 11 | Published under [Blue Oak Model License 1.0.0](/LICENSE.md). 12 | -------------------------------------------------------------------------------- /examples/style.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --tp-plugin-thumbnail-list-thumb-size: 64px; 3 | --tp-plugin-thumbnail-list-width: 325px; 4 | --tp-plugin-thumbnail-list-height: 300px; 5 | } 6 | 7 | html, body { 8 | padding: 0; 9 | margin: 0; 10 | } 11 | 12 | .back { 13 | position: fixed; 14 | top: 1em; 15 | left: 1em; 16 | z-index: 1; 17 | color: #FFFFFF; 18 | text-decoration: none; 19 | opacity: 0.75; 20 | } 21 | 22 | .back:hover { 23 | opacity: 1; 24 | } 25 | 26 | /* Dropzone */ 27 | 28 | .dropzone-placeholder { 29 | width: 400px; 30 | height: 200px; 31 | position: absolute; 32 | top: calc(50% - 100px); 33 | left: calc(50% - 200px); 34 | background: rgba(255, 255, 255, 0.75); 35 | border-radius: 8px; 36 | display: flex; 37 | flex-direction: column; 38 | justify-content: center; 39 | padding: 3em; 40 | box-sizing: border-box; 41 | } 42 | 43 | .dropzone-placeholder p { 44 | margin-top: 0; 45 | } 46 | 47 | /* Tweakpane */ 48 | 49 | .tp-dfwv { 50 | width: 350px !important; 51 | } 52 | 53 | .tp-colv_t .tp-txtv { 54 | position: relative; 55 | } 56 | 57 | .tp-colv_t .tp-txtv::after { 58 | content: 'sRGB'; 59 | position: absolute; 60 | right: 0.5em; 61 | top: 0.3em; 62 | color: #888; 63 | font-size: 1em; 64 | } 65 | -------------------------------------------------------------------------------- /src/subjects/SceneSubject.ts: -------------------------------------------------------------------------------- 1 | import { Group, Object3D } from 'three'; 2 | import type { Node as NodeDef, Scene as SceneDef } from '@gltf-transform/core'; 3 | import type { DocumentViewSubjectAPI } from '../DocumentViewImpl'; 4 | import { Subject } from './Subject'; 5 | import { RefListObserver } from '../observers'; 6 | 7 | /** @internal */ 8 | export class SceneSubject extends Subject { 9 | protected children = new RefListObserver('children', this._documentView); 10 | 11 | constructor(documentView: DocumentViewSubjectAPI, def: SceneDef) { 12 | super(documentView, def, documentView.scenePool.requestBase(new Group()), documentView.scenePool); 13 | this.children.subscribe((nextChildren, prevChildren) => { 14 | if (prevChildren.length) this.value.remove(...prevChildren); 15 | if (nextChildren.length) this.value.add(...nextChildren); 16 | this.publishAll(); 17 | }); 18 | } 19 | 20 | update() { 21 | const def = this.def; 22 | const target = this.value; 23 | 24 | if (def.getName() !== target.name) { 25 | target.name = def.getName(); 26 | } 27 | 28 | this.children.update(def.listChildren()); 29 | } 30 | 31 | dispose() { 32 | this.children.dispose(); 33 | super.dispose(); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/subjects/AccessorSubject.ts: -------------------------------------------------------------------------------- 1 | import { BufferAttribute } from 'three'; 2 | import { Accessor as AccessorDef } from '@gltf-transform/core'; 3 | import type { DocumentViewImpl } from '../DocumentViewImpl'; 4 | import { Subject } from './Subject'; 5 | import { ValuePool } from '../pools'; 6 | 7 | /** @internal */ 8 | export class AccessorSubject extends Subject { 9 | constructor(documentView: DocumentViewImpl, def: AccessorDef) { 10 | super( 11 | documentView, 12 | def, 13 | AccessorSubject.createValue(def, documentView.accessorPool), 14 | documentView.accessorPool, 15 | ); 16 | } 17 | 18 | private static createValue(def: AccessorDef, pool: ValuePool) { 19 | return pool.requestBase(new BufferAttribute(def.getArray()!, def.getElementSize(), def.getNormalized())); 20 | } 21 | 22 | update() { 23 | const def = this.def; 24 | const value = this.value; 25 | 26 | if ( 27 | def.getArray() !== value.array || 28 | def.getElementSize() !== value.itemSize || 29 | def.getNormalized() !== value.normalized 30 | ) { 31 | this.pool.releaseBase(value); 32 | this.value = AccessorSubject.createValue(def, this.pool); 33 | } else { 34 | value.needsUpdate = true; 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | import { MeshStandardMaterial } from 'three'; 2 | 3 | export * from './Observable'; 4 | 5 | export function eq(a: number[], b: number[]): boolean { 6 | if (a.length !== b.length) return false; 7 | for (let i = 0; i < a.length; i++) { 8 | if (a[i] !== b[i]) return false; 9 | } 10 | return true; 11 | } 12 | 13 | // https://github.com/KhronosGroup/glTF/blob/master/specification/2.0/README.md#default-material 14 | export const DEFAULT_MATERIAL = new MeshStandardMaterial({ 15 | name: '__DefaultMaterial', 16 | color: 0xffffff, 17 | roughness: 1.0, 18 | metalness: 1.0, 19 | }); 20 | 21 | export function semanticToAttributeName(semantic: string): string { 22 | switch (semantic) { 23 | case 'POSITION': 24 | return 'position'; 25 | case 'NORMAL': 26 | return 'normal'; 27 | case 'TANGENT': 28 | return 'tangent'; 29 | case 'COLOR_0': 30 | return 'color'; 31 | case 'JOINTS_0': 32 | return 'skinIndex'; 33 | case 'WEIGHTS_0': 34 | return 'skinWeight'; 35 | case 'TEXCOORD_0': 36 | return 'uv'; 37 | case 'TEXCOORD_1': 38 | return 'uv1'; 39 | case 'TEXCOORD_2': 40 | return 'uv2'; 41 | case 'TEXCOORD_3': 42 | return 'uv3'; 43 | default: 44 | return '_' + semantic.toLowerCase(); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /test/MeshSubject.test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { Document } from '@gltf-transform/core'; 3 | import { DocumentView, NullImageProvider } from '@gltf-transform/view'; 4 | 5 | const imageProvider = new NullImageProvider(); 6 | 7 | test('MeshSubject', async t => { 8 | const document = new Document(); 9 | const position = document.createAccessor() 10 | .setType('VEC3') 11 | .setArray(new Float32Array([ 12 | 0, 0, 0, 13 | 0, 0, 1, 14 | 0, 1, 1, 15 | 0, 1, 0, 16 | 0, 0, 0, 17 | ])); 18 | const primDef = document.createPrimitive() 19 | .setAttribute('POSITION', position); 20 | const meshDef = document.createMesh() 21 | .setName('MyMesh') 22 | .addPrimitive(primDef); 23 | 24 | const documentView = new DocumentView(document, {imageProvider}); 25 | const mesh = documentView.view(meshDef); 26 | 27 | t.is(mesh.name, 'MyMesh', 'mesh → name'); 28 | 29 | meshDef.setName('MyMeshRenamed'); 30 | t.is(mesh.name, 'MyMeshRenamed', 'mesh → name (2)'); 31 | 32 | t.is(mesh.children[0].type, 'Mesh', 'mesh → prim (initial)'); 33 | 34 | meshDef.removePrimitive(primDef); 35 | t.is(mesh.children.length, 0, 'mesh → prim (remove)'); 36 | 37 | meshDef.addPrimitive(primDef); 38 | t.is(mesh.children.length, 1, 'mesh → prim (add)'); 39 | }); 40 | -------------------------------------------------------------------------------- /src/subjects/TextureSubject.ts: -------------------------------------------------------------------------------- 1 | import { Texture } from 'three'; 2 | import { Texture as TextureDef } from '@gltf-transform/core'; 3 | import type { DocumentViewSubjectAPI } from '../DocumentViewImpl'; 4 | import { Subject } from './Subject'; 5 | import { LOADING_TEXTURE } from '../ImageProvider'; 6 | 7 | /** @internal */ 8 | export class TextureSubject extends Subject { 9 | private _image: ArrayBuffer | null = null; 10 | 11 | constructor(documentView: DocumentViewSubjectAPI, def: TextureDef) { 12 | super(documentView, def, LOADING_TEXTURE, documentView.texturePool); 13 | } 14 | 15 | update() { 16 | const def = this.def; 17 | const value = this.value; 18 | 19 | if (def.getName() !== value.name) { 20 | value.name = def.getName(); 21 | } 22 | 23 | const image = def.getImage() as ArrayBuffer; 24 | if (image !== this._image) { 25 | this._image = image; 26 | if (this.value !== LOADING_TEXTURE) { 27 | this.pool.releaseBase(this.value); 28 | } 29 | this._documentView.imageProvider.getTexture(def).then((texture) => { 30 | this.value = this.pool.requestBase(texture); 31 | this.publishAll(); // TODO(perf): Might be wasting cycles here. 32 | }); 33 | } 34 | } 35 | 36 | dispose() { 37 | super.dispose(); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import eslint from "@eslint/js"; 3 | import prettier from "eslint-config-prettier"; 4 | import tseslint from "typescript-eslint"; 5 | 6 | export default tseslint.config( 7 | eslint.configs.recommended, 8 | ...tseslint.configs.recommended, 9 | prettier, 10 | { 11 | rules: { 12 | "@typescript-eslint/no-explicit-any": ["off"], // TODO 13 | "@typescript-eslint/no-non-null-assertion": ["off"], 14 | "@typescript-eslint/no-this-alias": ["off"], 15 | "@typescript-eslint/no-unused-vars": [ 16 | "warn", 17 | { argsIgnorePattern: "^_" }, 18 | ], 19 | "@typescript-eslint/no-use-before-define": "off", 20 | "@typescript-eslint/no-var-requires": ["off"], 21 | "array-bracket-spacing": ["warn"], 22 | "comma-spacing": ["warn"], 23 | "max-len": [ 24 | "warn", 25 | { 26 | code: 120, 27 | tabWidth: 4, 28 | ignoreUrls: true, 29 | ignorePattern: "^import|^export", 30 | }, 31 | ], 32 | "no-irregular-whitespace": ["warn"], 33 | "space-infix-ops": ["warn"], 34 | eqeqeq: ["warn", "smart"], 35 | semi: ["error"], 36 | }, 37 | }, 38 | { 39 | files: ["test/**/*.ts"], 40 | rules: { 41 | "@typescript-eslint/no-explicit-any": "off", 42 | }, 43 | }, 44 | ); 45 | -------------------------------------------------------------------------------- /test/SceneSubject.test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { Document } from '@gltf-transform/core'; 3 | import { DocumentView, NullImageProvider } from '@gltf-transform/view'; 4 | 5 | const imageProvider = new NullImageProvider(); 6 | 7 | test('SceneBinding', async t => { 8 | const document = new Document(); 9 | let nodeDef; 10 | const sceneDef = document.createScene('MyScene') 11 | .addChild(document.createNode('Node1')) 12 | .addChild(nodeDef = document.createNode('Node2')) 13 | .addChild(document.createNode('Node3')); 14 | nodeDef.addChild(document.createNode('Node4')); 15 | 16 | const documentView = new DocumentView(document, {imageProvider}); 17 | const scene = documentView.view(sceneDef); 18 | 19 | t.is(scene.name, 'MyScene', 'scene → name'); 20 | sceneDef.setName('MySceneRenamed'); 21 | t.is(scene.name, 'MySceneRenamed', 'scene → name (renamed)'); 22 | t.is(scene.children.length, 3, 'scene → children → 3'); 23 | 24 | t.is(scene.children[1].children[0].name, 'Node4', 'scene → ... → grandchild'); 25 | nodeDef.listChildren()[0].dispose(); 26 | t.is(scene.children[1].children.length, 0, 'scene → ... → grandchild (dispose)'); 27 | 28 | nodeDef.dispose(); 29 | t.is(scene.children.length, 2, 'scene → children → 2'); 30 | sceneDef.removeChild(sceneDef.listChildren()[0]); 31 | t.is(scene.children.length, 1, 'scene → children → 1'); 32 | }); 33 | -------------------------------------------------------------------------------- /src/subjects/MeshSubject.ts: -------------------------------------------------------------------------------- 1 | import { Group } from 'three'; 2 | import { Mesh as MeshDef, Primitive as PrimitiveDef } from '@gltf-transform/core'; 3 | import type { DocumentViewSubjectAPI } from '../DocumentViewImpl'; 4 | import { Subject } from './Subject'; 5 | import { RefListObserver } from '../observers'; 6 | import { MeshLike } from '../constants'; 7 | import { SingleUserParams, SingleUserPool } from '../pools'; 8 | 9 | /** @internal */ 10 | export class MeshSubject extends Subject { 11 | protected primitives = new RefListObserver( 12 | 'primitives', 13 | this._documentView, 14 | ).setParamsFn(() => SingleUserPool.createParams(this.def)); 15 | 16 | constructor(documentView: DocumentViewSubjectAPI, def: MeshDef) { 17 | super(documentView, def, documentView.meshPool.requestBase(new Group()), documentView.meshPool); 18 | 19 | this.primitives.subscribe((nextPrims, prevPrims) => { 20 | if (prevPrims.length) this.value.remove(...prevPrims); 21 | if (nextPrims.length) this.value.add(...nextPrims); 22 | this.publishAll(); 23 | }); 24 | } 25 | 26 | update() { 27 | const def = this.def; 28 | const value = this.value; 29 | 30 | if (def.getName() !== value.name) { 31 | value.name = def.getName(); 32 | } 33 | 34 | this.primitives.update(def.listPrimitives()); 35 | } 36 | 37 | dispose() { 38 | this.primitives.dispose(); 39 | super.dispose(); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /test/InstancedMeshSubject.test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { Document } from '@gltf-transform/core'; 3 | import { EXTMeshGPUInstancing } from '@gltf-transform/extensions'; 4 | import { DocumentView, NullImageProvider } from '@gltf-transform/view'; 5 | import { Group, InstancedMesh, Object3D } from 'three'; 6 | 7 | const imageProvider = new NullImageProvider(); 8 | 9 | test('InstancedMeshSubject', async (t) => { 10 | const document = new Document(); 11 | const batchExt = document.createExtension(EXTMeshGPUInstancing); 12 | const batchTranslation = document.createAccessor() 13 | .setType('VEC3') 14 | .setArray(new Float32Array([0, 0, 0, 10, 0, 0, 20, 0, 0])); 15 | const batchDef = batchExt.createInstancedMesh() 16 | .setAttribute('TRANSLATION', batchTranslation); 17 | const position = document.createAccessor() 18 | .setType('VEC3') 19 | .setArray(new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0])); 20 | const primDef = document.createPrimitive().setAttribute('POSITION', position); 21 | const meshDef = document.createMesh().addPrimitive(primDef); 22 | const nodeDef = document.createNode() 23 | .setMesh(meshDef) 24 | .setExtension('EXT_mesh_gpu_instancing', batchDef); 25 | 26 | const documentView = new DocumentView(document, {imageProvider}); 27 | const node = documentView.view(nodeDef); 28 | const group = node.children[0] as Group; 29 | const mesh = group.children[0] as InstancedMesh; 30 | 31 | t.deepEqual(node.children.map(toType), ['Group'], 'node.children → [Group]'); 32 | t.deepEqual(group.children.map(toType), ['Mesh'], 'group.children → [Mesh]'); 33 | t.is(mesh.isInstancedMesh, true, 'isInstancedMesh → true'); 34 | t.is(mesh.count, 3, 'count → 3'); 35 | }); 36 | 37 | function toType(object: Object3D): string { 38 | return object.type; 39 | } -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # Blue Oak Model License 2 | 3 | Version 1.0.0 4 | 5 | ## Purpose 6 | 7 | This license gives everyone as much permission to work with 8 | this software as possible, while protecting contributors 9 | from liability. 10 | 11 | ## Acceptance 12 | 13 | In order to receive this license, you must agree to its 14 | rules. The rules of this license are both obligations 15 | under that agreement and conditions to your license. 16 | You must not do anything with this software that triggers 17 | a rule that you cannot or will not follow. 18 | 19 | ## Copyright 20 | 21 | Each contributor licenses you to do everything with this 22 | software that would otherwise infringe that contributor's 23 | copyright in it. 24 | 25 | ## Notices 26 | 27 | You must ensure that everyone who gets a copy of 28 | any part of this software from you, with or without 29 | changes, also gets the text of this license or a link to 30 | . 31 | 32 | ## Excuse 33 | 34 | If anyone notifies you in writing that you have not 35 | complied with [Notices](#notices), you can keep your 36 | license by taking all practical steps to comply within 30 37 | days after the notice. If you do not do so, your license 38 | ends immediately. 39 | 40 | ## Patent 41 | 42 | Each contributor licenses you to do everything with this 43 | software that would otherwise infringe any patent claims 44 | they can license or become able to license. 45 | 46 | ## Reliability 47 | 48 | No contributor can revoke this license. 49 | 50 | ## No Liability 51 | 52 | ***As far as the law allows, this software comes as is, 53 | without any warranty or condition, and no contributor 54 | will be liable to anyone for any damages related to this 55 | software or this license, under any kind of legal claim.*** 56 | -------------------------------------------------------------------------------- /src/pools/SingleUserPool.ts: -------------------------------------------------------------------------------- 1 | import { Property as PropertyDef, Mesh as MeshDef, Node as NodeDef, uuid } from '@gltf-transform/core'; 2 | import { Object3D } from 'three'; 3 | import { LightLike } from '../constants'; 4 | import { Pool } from './Pool'; 5 | 6 | export interface SingleUserParams { 7 | id: string; 8 | } 9 | 10 | /** @internal */ 11 | export class SingleUserPool extends Pool { 12 | private static _parentIDs = new WeakMap(); 13 | 14 | /** Generates a unique Object3D for every parent. */ 15 | static createParams(property: MeshDef | NodeDef): SingleUserParams { 16 | const id = this._parentIDs.get(property) || uuid(); 17 | this._parentIDs.set(property, id); 18 | return { id }; 19 | } 20 | 21 | requestVariant(base: T, params: SingleUserParams): T { 22 | return this._request(this._createVariant(base, params)); 23 | } 24 | 25 | protected _createVariant(srcObject: T, _params: SingleUserParams): T { 26 | // With a deep clone of a NodeDef or MeshDef value, we're cloning 27 | // any PrimitiveDef values (e.g. Mesh, Lines, Points) within it. 28 | // Record the new outputs. 29 | const dstObject = srcObject.clone(); 30 | parallelTraverse(srcObject, dstObject, (base, variant) => { 31 | if (base === srcObject) return; // Skip root; recorded elsewhere. 32 | if ((srcObject as unknown as LightLike).isLight) return; // Skip light target. 33 | this.documentView.recordOutputVariant(base, variant); 34 | }); 35 | return dstObject; 36 | } 37 | 38 | protected _updateVariant(_srcObject: T, _dstObject: T): T { 39 | throw new Error('Not implemented'); 40 | } 41 | } 42 | 43 | function parallelTraverse(a: Object3D, b: Object3D, callback: (a: Object3D, b: Object3D) => void) { 44 | callback(a, b); 45 | for (let i = 0; i < a.children.length; i++) { 46 | parallelTraverse(a.children[i], b.children[i], callback); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /test/PrimitiveSubject.test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { Document, Primitive as PrimitiveDef } from '@gltf-transform/core'; 3 | import { DocumentView, NullImageProvider } from '@gltf-transform/view'; 4 | 5 | const imageProvider = new NullImageProvider(); 6 | 7 | test('PrimitiveSubject', async t => { 8 | const document = new Document(); 9 | const position = document.createAccessor() 10 | .setType('VEC3') 11 | .setArray(new Float32Array([ 12 | 0, 0, 0, 13 | 0, 0, 1, 14 | 0, 1, 1, 15 | 0, 1, 0, 16 | 0, 0, 0, 17 | ])); 18 | const materialDef = document.createMaterial('MyMaterial'); 19 | const primDef = document.createPrimitive() 20 | .setAttribute('POSITION', position) 21 | .setMaterial(materialDef); 22 | 23 | const documentView = new DocumentView(document, {imageProvider}); 24 | let prim = documentView.view(primDef); 25 | const geometry = prim.geometry; 26 | 27 | const disposed = new Set(); 28 | geometry.addEventListener('dispose', () => disposed.add(geometry)); 29 | 30 | t.is(prim.type, 'Mesh', 'Mesh'); 31 | 32 | primDef.setMode(PrimitiveDef.Mode.POINTS); 33 | prim = documentView.view(primDef); 34 | 35 | t.is(prim.type, 'Points', 'Points'); 36 | 37 | primDef.setMode(PrimitiveDef.Mode.LINES); 38 | prim = documentView.view(primDef); 39 | 40 | t.is(prim.type, 'LineSegments', 'LineSegments'); 41 | 42 | primDef.setMode(PrimitiveDef.Mode.LINE_LOOP); 43 | prim = documentView.view(primDef); 44 | 45 | t.is(prim.type, 'LineLoop', 'LineLoop'); 46 | 47 | primDef.setMode(PrimitiveDef.Mode.LINE_STRIP); 48 | prim = documentView.view(primDef); 49 | 50 | t.is(prim.type, 'Line', 'Line'); 51 | 52 | t.is(prim.material.name, 'MyMaterial', 'prim.material → material'); 53 | 54 | primDef.setMaterial(null); 55 | 56 | t.is(prim.material.name, '__DefaultMaterial', 'prim.material → null'); 57 | 58 | t.is(disposed.size, 0, 'preserve geometry'); 59 | 60 | primDef.dispose(); 61 | 62 | t.is(disposed.size, 1, 'dispose geometry'); 63 | }); 64 | -------------------------------------------------------------------------------- /examples/util.ts: -------------------------------------------------------------------------------- 1 | import { WebIO } from '@gltf-transform/core'; 2 | import { PMREMGenerator, REVISION, Texture, WebGLRenderer } from 'three'; 3 | import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'; 4 | import { KTX2Loader } from 'three/examples/jsm/loaders/KTX2Loader'; 5 | import { RGBELoader } from 'three/examples/jsm/loaders/RGBELoader'; 6 | import { MeshoptDecoder, MeshoptEncoder } from 'meshoptimizer'; 7 | import { ALL_EXTENSIONS } from '@gltf-transform/extensions'; 8 | 9 | const TRANSCODER_PATH = `https://unpkg.com/three@0.${REVISION}.x/examples/jsm/libs/basis/`; 10 | 11 | // await MeshoptDecoder.ready; 12 | // await MeshoptEncoder.ready; 13 | 14 | let _ktx2Loader: KTX2Loader; 15 | export function createKTX2Loader() { 16 | if (_ktx2Loader) return _ktx2Loader; 17 | const renderer = new WebGLRenderer(); 18 | const loader = new KTX2Loader() 19 | .detectSupport(renderer) 20 | .setTranscoderPath(TRANSCODER_PATH); 21 | renderer.dispose(); 22 | return (_ktx2Loader = loader); 23 | } 24 | 25 | let _gltfLoader: GLTFLoader; 26 | export function createGLTFLoader() { 27 | if (_gltfLoader) return _gltfLoader; 28 | const loader = new GLTFLoader() 29 | .setMeshoptDecoder(MeshoptDecoder) 30 | .setKTX2Loader(createKTX2Loader()); 31 | return (_gltfLoader = loader); 32 | } 33 | 34 | let _io: WebIO; 35 | export function createIO() { 36 | if (_io) return _io; 37 | const io = new WebIO() 38 | .registerExtensions(ALL_EXTENSIONS) 39 | .registerDependencies({ 40 | 'meshopt.encoder': MeshoptEncoder, 41 | 'meshopt.decoder': MeshoptDecoder, 42 | }); 43 | return (_io = io); 44 | } 45 | 46 | export function createEnvironment(renderer: WebGLRenderer): Promise { 47 | const pmremGenerator = new PMREMGenerator(renderer); 48 | pmremGenerator.compileEquirectangularShader(); 49 | 50 | return new Promise((resolve, reject) => { 51 | new RGBELoader() 52 | .load( './royal_esplanade_1k.hdr', ( texture ) => { 53 | const envMap = pmremGenerator.fromEquirectangular( texture ).texture; 54 | texture.dispose(); 55 | pmremGenerator.dispose(); 56 | resolve(envMap); 57 | }, undefined, reject ); 58 | }) as Promise; 59 | } 60 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Quickstart 4 | 5 | ```shell 6 | # Install dependencies. 7 | yarn 8 | 9 | # Build source, watch for changes. 10 | yarn watch 11 | 12 | # Build source, watch for changes, and run examples. 13 | yarn dev 14 | 15 | # Run tests. 16 | yarn test 17 | ``` 18 | 19 | ## Concepts 20 | 21 | The project is designed around a few common object types, loosely based on reactive programming patterns. 22 | 23 | - **Subject:** Each glTF definition (e.g. `Material`) is bound to a single Subject (e.g. `MaterialSubject`). 24 | The Subject is responsible for receiving change events published by the definition, generating a 25 | derived three.js object (e.g. `THREE.Material`), and publishing the new value to all Observers. More 26 | precisely, this is a [*BehaviorSubject*](https://reactivex.io/documentation/subject.html), which holds 27 | a single current value at any given time. 28 | - **Observer:** An Observer is subscribed to the values published by a particular Subject, and 29 | passes those events along to a parent — usually another Subject. For example, a MaterialSubject 30 | subscribes to updates from a TextureSubject using an Observer. Observers are parameterized: 31 | for example, a single Texture may be used by many Materials, with different offset/scale/encoding 32 | parameters in each. The TextureSubject treats each of these Observers as a different "output", and 33 | uses the parameters associated with the Observer to publish the appropriate value. This library 34 | implements three observer types: `RefObserver`, `RefListObserver`, and `RefMapObserver`; the latter 35 | two are collections of `RefObservers` used to collate events from multiple Subjects (e.g. lists of Nodes). 36 | - **Pool:** As Subjects publish many variations of the same values to Observers, it's important to 37 | allocate those variations efficiently, reuse instances where possible, and clean up unused 38 | instances. That bookkeeping is assigned to Pools (not a Reactive concept). 39 | 40 | The diagram below shows a subset of the data flow sequence connecting Texture, Material, Primitive, and Node 41 | data types. 42 | 43 | ![View architecture, showing Subject and Observer event sequence](./assets/view_architecture.png) 44 | -------------------------------------------------------------------------------- /test/SkinSubject.test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { Document } from '@gltf-transform/core'; 3 | import { DocumentView, NullImageProvider } from '@gltf-transform/view'; 4 | import { Bone, Mesh, SkinnedMesh } from 'three'; 5 | 6 | const imageProvider = new NullImageProvider(); 7 | 8 | test('SkinSubject', async t => { 9 | const document = new Document(); 10 | const positionDef = document.createAccessor('POSITION') 11 | .setArray(new Float32Array([0, 0, 0, 1, 1, 1, 2, 2, 2])) 12 | .setType('VEC3'); 13 | const jointsDef = document.createAccessor('JOINTS_0') 14 | .setArray(new Uint16Array([0, 1, 0, 0])) 15 | .setType('VEC4'); 16 | const weightsDef = document.createAccessor('WEIGHTS_0') 17 | .setArray(new Float32Array([0.5, 0.5, 0, 0])) 18 | .setType('VEC4'); 19 | const primDef = document.createPrimitive() 20 | .setAttribute('POSITION', positionDef) 21 | .setAttribute('JOINTS_0', jointsDef) 22 | .setAttribute('WEIGHTS_0', weightsDef); 23 | const meshDef = document.createMesh('Mesh').addPrimitive(primDef); 24 | const jointBDef = document.createNode('JointB'); 25 | const jointADef = document.createNode('JointA').addChild(jointBDef); 26 | const skin = document.createSkin() 27 | .addJoint(jointADef) 28 | .addJoint(jointBDef); 29 | const armatureDef = document.createNode('Armature') 30 | .addChild(jointADef) 31 | .setSkin(skin) 32 | .setMesh(meshDef); 33 | 34 | const documentView = new DocumentView(document, {imageProvider}); 35 | const armature = documentView.view(armatureDef); 36 | const boneA = armature.children.find((child) => child.name === 'JointA') as Bone; 37 | const boneB = boneA.children[0]; 38 | const mesh = armature.children.find((child) => child.name === 'Mesh') as Mesh; 39 | const prim = mesh.children.find((child) => child.type === 'SkinnedMesh') as SkinnedMesh; 40 | 41 | t.is(armature.name, 'Armature', 'armature → name'); 42 | t.is(mesh.type, 'Group', 'armature → mesh'); 43 | t.is(prim.type, 'SkinnedMesh', 'armature → mesh → prim'); 44 | t.is(boneA.type, 'Bone', 'armature → jointA'); 45 | t.is(boneB.type, 'Bone', 'armature → jointA → jointB'); 46 | t.truthy(prim.skeleton, 'skeleton'); 47 | t.deepEqual(prim.skeleton.bones, [boneA, boneB], 'skeleton.bones'); 48 | t.is(prim.skeleton.boneInverses.length, 2, 'skeleton.boneInverses'); 49 | }); 50 | -------------------------------------------------------------------------------- /src/pools/Pool.ts: -------------------------------------------------------------------------------- 1 | import type { DocumentViewSubjectAPI } from '../DocumentViewImpl'; 2 | 3 | export type EmptyParams = object | null | undefined; 4 | 5 | export interface ValuePool { 6 | requestBase(base: Value): Value; 7 | releaseBase(base: Value): void; 8 | 9 | requestVariant(base: Value, params: Params): Value; 10 | releaseVariant(variant: Value): void; 11 | 12 | gc(): void; 13 | size(): number; 14 | dispose(): void; 15 | } 16 | 17 | /** 18 | * Pool of (optionally reusable) resources and resource variations. 19 | * 20 | * As Subjects publish many variations of the same values to Observers, it's important to 21 | * allocate those variations efficiently, reuse instances where possible, and clean up unused 22 | * instances. That bookkeeping is assigned to Pools (not a Reactive concept). 23 | * 24 | * @internal 25 | */ 26 | export class Pool implements ValuePool { 27 | readonly name: string; 28 | readonly documentView: DocumentViewSubjectAPI; 29 | 30 | protected _users = new Map(); 31 | 32 | constructor(name: string, documentView: DocumentViewSubjectAPI) { 33 | this.name = name; 34 | this.documentView = documentView; 35 | } 36 | 37 | protected _request(value: Value): Value { 38 | let users = this._users.get(value) || 0; 39 | this._users.set(value, ++users); 40 | return value; 41 | } 42 | 43 | protected _release(value: Value): Value { 44 | let users = this._users.get(value) || 0; 45 | this._users.set(value, --users); 46 | return value; 47 | } 48 | 49 | protected _disposeValue(value: Value): void { 50 | this._users.delete(value); 51 | } 52 | 53 | requestBase(base: Value): Value { 54 | return this._request(base); 55 | } 56 | 57 | releaseBase(base: Value): void { 58 | this._release(base); 59 | } 60 | 61 | requestVariant(base: Value, _params: EmptyParams) { 62 | return this._request(base); 63 | } 64 | 65 | releaseVariant(variant: Value): void { 66 | this._release(variant); 67 | } 68 | 69 | gc(): void { 70 | for (const [value, users] of this._users) { 71 | if (users <= 0) this._disposeValue(value); 72 | } 73 | } 74 | 75 | size(): number { 76 | return this._users.size; 77 | } 78 | 79 | dispose(): void { 80 | for (const [value] of this._users) { 81 | this._disposeValue(value); 82 | } 83 | this._users.clear(); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/subjects/LightSubject.ts: -------------------------------------------------------------------------------- 1 | import { DirectionalLight, PointLight, SpotLight } from 'three'; 2 | import { Light as LightDef } from '@gltf-transform/extensions'; 3 | import type { DocumentViewSubjectAPI } from '../DocumentViewImpl'; 4 | import { Subject } from './Subject'; 5 | import { ValuePool } from '../pools'; 6 | import { LightLike } from '../constants'; 7 | 8 | /** @internal */ 9 | export class LightSubject extends Subject { 10 | constructor(documentView: DocumentViewSubjectAPI, def: LightDef) { 11 | super(documentView, def, LightSubject.createValue(def, documentView.lightPool), documentView.lightPool); 12 | } 13 | 14 | private static createValue(def: LightDef, pool: ValuePool): LightLike { 15 | switch (def.getType()) { 16 | case LightDef.Type.POINT: 17 | return pool.requestBase(new PointLight()); 18 | case LightDef.Type.SPOT: 19 | return pool.requestBase(new SpotLight()); 20 | case LightDef.Type.DIRECTIONAL: 21 | return pool.requestBase(new DirectionalLight()); 22 | default: 23 | throw new Error(`Unexpected light type: ${def.getType()}`); 24 | } 25 | } 26 | 27 | update() { 28 | const def = this.def; 29 | let value = this.value; 30 | 31 | if (getType(def) !== value.type) { 32 | this.pool.releaseBase(value); 33 | this.value = value = LightSubject.createValue(def, this.pool); 34 | } 35 | 36 | value.name = def.getName(); 37 | value.color.fromArray(def.getColor()); 38 | value.intensity = def.getIntensity(); 39 | value.position.set(0, 0, 0); // non-default for SpotLight 40 | 41 | if (value instanceof PointLight) { 42 | value.distance = def.getRange() || 0; 43 | value.decay = 2; 44 | } else if (value instanceof SpotLight) { 45 | value.distance = def.getRange() || 0; 46 | value.angle = def.getOuterConeAngle(); 47 | value.penumbra = 1.0 - def.getInnerConeAngle() / def.getOuterConeAngle(); 48 | value.decay = 2; 49 | value.target.position.set(0, 0, -1); 50 | value.add(value.target); 51 | } else if (value instanceof DirectionalLight) { 52 | value.target.position.set(0, 0, -1); 53 | value.add(value.target); 54 | } 55 | } 56 | } 57 | 58 | function getType(def: LightDef): string { 59 | switch (def.getType()) { 60 | case LightDef.Type.POINT: 61 | return 'PointLight'; 62 | case LightDef.Type.SPOT: 63 | return 'SpotLight'; 64 | case LightDef.Type.DIRECTIONAL: 65 | return 'DirectionalLight'; 66 | default: 67 | throw new Error(`Unexpected light type: ${def.getType()}`); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /test/TextureSubject.test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { MeshStandardMaterial, NoColorSpace, SRGBColorSpace, Texture } from 'three'; 3 | import { Document } from '@gltf-transform/core'; 4 | import { DocumentView, NullImageProvider } from '@gltf-transform/view'; 5 | 6 | const imageProvider = new NullImageProvider(); 7 | 8 | test('TextureBinding', async t => { 9 | const document = new Document(); 10 | const textureDef = document.createTexture('MyTexture') 11 | .setImage(new Uint8Array(0)) 12 | .setMimeType('image/png') 13 | .setExtras({textureExtras: true}); 14 | const materialDef = document.createMaterial() 15 | .setBaseColorTexture(textureDef) 16 | .setMetallicRoughnessTexture(textureDef); 17 | materialDef.getMetallicRoughnessTextureInfo()! 18 | .setTexCoord(1); 19 | 20 | const documentView = new DocumentView(document, {imageProvider}); 21 | const texture = documentView.view(textureDef); 22 | const material = documentView.view(materialDef) as MeshStandardMaterial; 23 | const map = material.map as Texture; 24 | const metalnessMap = material.metalnessMap as Texture; 25 | const roughnessMap = material.roughnessMap as Texture; 26 | 27 | t.truthy(texture, 'texture'); 28 | t.is(map.colorSpace, SRGBColorSpace, 'map.colorSpace = "srgb"'); 29 | t.is(map.channel, 0, 'map.channel = 0'); 30 | t.is(roughnessMap.colorSpace, NoColorSpace, 'no color space'); 31 | t.is(metalnessMap.colorSpace, NoColorSpace, 'no color space'); 32 | t.is(roughnessMap.channel, 1, 'roughnessMap.channel = 1'); 33 | t.is(metalnessMap.channel, 1, 'metalnessMap.channel = 1'); 34 | t.true(map.source === metalnessMap.source, 'map.source === metalnessMap.source'); 35 | t.true(metalnessMap === roughnessMap, 'metalnessMap === roughnessMap'); 36 | t.falsy(texture.flipY || map.flipY || roughnessMap.flipY || metalnessMap.flipY, 'flipY=false'); 37 | 38 | const disposed = new Set(); 39 | texture.addEventListener('dispose', () => disposed.add(texture)); 40 | map.addEventListener('dispose', () => disposed.add(map)); 41 | metalnessMap.addEventListener('dispose', () => disposed.add(metalnessMap)); 42 | roughnessMap.addEventListener('dispose', () => disposed.add(roughnessMap)); 43 | 44 | materialDef.setBaseColorTexture(null); 45 | documentView.gc(); 46 | 47 | t.is(disposed.size, 1, 'dispose count (1/3)'); 48 | t.true(disposed.has(map), 'dispose map'); 49 | 50 | materialDef.dispose(); 51 | documentView.gc(); 52 | 53 | t.is(disposed.size, 2, 'dispose count (2/3)'); 54 | t.true(disposed.has(map), 'dispose roughnessMap, metalnessMap'); 55 | 56 | textureDef.dispose(); 57 | documentView.gc(); 58 | 59 | t.is(disposed.size, 3, 'dispose count (3/3)'); 60 | t.true(disposed.has(texture), 'dispose roughnessMap, metalnessMap'); 61 | }); 62 | -------------------------------------------------------------------------------- /src/subjects/SkinSubject.ts: -------------------------------------------------------------------------------- 1 | import { Bone, BufferAttribute, Matrix4, Skeleton } from 'three'; 2 | import { Accessor as AccessorDef, Node as NodeDef, Skin as SkinDef } from '@gltf-transform/core'; 3 | import type { DocumentViewSubjectAPI } from '../DocumentViewImpl'; 4 | import { Subject } from './Subject'; 5 | import { RefListObserver, RefObserver } from '../observers'; 6 | import { ValuePool } from '../pools'; 7 | 8 | /** 9 | * SkinSubject transforms `nodeDef.skin` into a THREE.Skeleton instance. The upstream 10 | * {@link NodeSubject} will bind the skeleton to the mesh, where {@link PrimitiveSubject} 11 | * is responsible for emitting a THREE.SkinnedMesh if it contains skinning-related attributes. 12 | * 13 | * This subject does not guard against certain invalid states — missing bones, missing 14 | * vertex weights — and should be used accordingly. 15 | * 16 | * @internal 17 | */ 18 | export class SkinSubject extends Subject { 19 | protected joints = new RefListObserver('children', this._documentView); 20 | protected inverseBindMatrices = new RefObserver( 21 | 'inverseBindMatrices', 22 | this._documentView, 23 | ); 24 | 25 | /** Output (Skeleton) is never cloned by an observer. */ 26 | protected _outputSingleton = true; 27 | 28 | constructor(documentView: DocumentViewSubjectAPI, def: SkinDef) { 29 | super(documentView, def, SkinSubject.createValue(def, [], null, documentView.skinPool), documentView.skinPool); 30 | 31 | this.joints.subscribe((joints) => { 32 | this.pool.releaseBase(this.value); 33 | this.value = SkinSubject.createValue(def, joints, this.inverseBindMatrices.value, this.pool); 34 | this.publishAll(); 35 | }); 36 | this.inverseBindMatrices.subscribe((inverseBindMatrices) => { 37 | this.pool.releaseBase(this.value); 38 | this.value = SkinSubject.createValue(def, this.joints.value, inverseBindMatrices, this.pool); 39 | this.publishAll(); 40 | }); 41 | } 42 | 43 | private static createValue( 44 | _def: SkinDef, 45 | bones: Bone[], 46 | ibm: BufferAttribute | null, 47 | pool: ValuePool, 48 | ): Skeleton { 49 | const boneInverses: Matrix4[] = []; 50 | 51 | for (let i = 0; i < bones.length; i++) { 52 | const matrix = new Matrix4(); 53 | if (ibm) matrix.fromArray(ibm.array, i * 16); 54 | boneInverses.push(matrix); 55 | } 56 | 57 | return pool.requestBase(new Skeleton(bones, boneInverses)); 58 | } 59 | 60 | update() { 61 | this.joints.update(this.def.listJoints()); 62 | this.inverseBindMatrices.update(this.def.getInverseBindMatrices()); 63 | } 64 | 65 | dispose() { 66 | this.joints.dispose(); 67 | this.inverseBindMatrices.dispose(); 68 | super.dispose(); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /CONTRIBUTOR_LICENSE_AGREEMENT.md: -------------------------------------------------------------------------------- 1 | # Blue Oak Contributor License 2 | 3 | Version 1.0.0 4 | 5 | ## Purpose 6 | 7 | This license gives the steward of a software project 8 | the legal rights and assurances it needs to accept 9 | contributions covered by intellectual property rights. 10 | 11 | ## Copyright 12 | 13 | The contributor licenses the steward to do everything with 14 | their contributions that would otherwise infringe their 15 | copyrights in them. 16 | 17 | ## Patent 18 | 19 | The contributor licenses the steward to do everything with 20 | their contributions that would otherwise infringe any patent 21 | claims they can license or become able to license. 22 | 23 | ## Sublicensing 24 | 25 | The steward can license the contributions to others in 26 | turn, under whatever terms it chooses. 27 | 28 | ## Reliability 29 | 30 | The contributor cannot revoke this license. 31 | 32 | ## Termination 33 | 34 | The contributor can stop this license from covering 35 | contributions submitted in the future by writing the steward 36 | through one of the channels it uses to receive contributions. 37 | Contributions the contributor submitted before remain under 38 | this license. 39 | 40 | ## Awareness 41 | 42 | The contributor is aware that because of legal rules and 43 | contracts, they may need to get someone else's permission to 44 | submit contributions, even of their own work. For example, 45 | "work made for hire" rules can make employers and clients 46 | the owners of copyrights in the contributor's work. 47 | Contracts with clients, or to form new companies, may make 48 | them the owners of copyrights in the contributor's work. 49 | 50 | ## Rights 51 | 52 | All work in contributions the contributor submits for 53 | inclusion in the project will be either: 54 | 55 | - their own work, which they own the copyrights in or 56 | have permission to submit 57 | 58 | - work licensed by others under permissive license 59 | terms that Blue Oak Council rates "bronze" or better at 60 | 61 | 62 | ## Notices 63 | 64 | The contributor promises to copy copyright notices, 65 | license terms, and other licensing-related notices for 66 | work licensed by others in their contributions, so the 67 | steward can tell what work is licensed by others under 68 | what terms and follow their notice requirements. 69 | 70 | ## No Liability 71 | 72 | ***As far as the law allows, contributions under this 73 | license come as is, without any warranty or condition, 74 | except those under "Rights" above.*** 75 | 76 | ***As far as the law allows, the contributor will not be 77 | liable to anyone for any damages related to contributions 78 | under this license, under any kind of legal claim, except 79 | for claims under "Rights" above.*** 80 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@gltf-transform/view", 3 | "version": "0.15.0", 4 | "repository": "github:donmccurdy/glTF-Transform-View", 5 | "description": "Syncs a glTF-Transform Document with a three.js scene graph", 6 | "author": "Don McCurdy ", 7 | "license": "BlueOak-1.0.0", 8 | "type": "module", 9 | "sideEffects": false, 10 | "source": "./src/index.ts", 11 | "types": "./dist/index.d.ts", 12 | "module": "./dist/view.modern.js", 13 | "exports": { 14 | "types": "./dist/index.d.ts", 15 | "default": "./dist/view.modern.js" 16 | }, 17 | "peerDependencies": { 18 | "@gltf-transform/core": ">=3.3.0", 19 | "@gltf-transform/extensions": ">=3.3.0", 20 | "@types/three": ">=0.152.0", 21 | "three": ">=0.152.0" 22 | }, 23 | "devDependencies": { 24 | "@gltf-transform/core": "3.10.1", 25 | "@gltf-transform/extensions": "3.10.1", 26 | "@gltf-transform/functions": "3.10.1", 27 | "@tweakpane/core": "2.0.3", 28 | "@types/node": "^20.11.16", 29 | "@types/three": "0.164.0", 30 | "@typescript-eslint/eslint-plugin": "7.8.0", 31 | "@typescript-eslint/parser": "7.8.0", 32 | "ava": "^6.1.1", 33 | "c8": "^9.1.0", 34 | "concurrently": "8.2.2", 35 | "eslint": "^9.0.0", 36 | "eslint-config-prettier": "^9.1.0", 37 | "jsdom": "^22.0.0", 38 | "jsdom-global": "^3.0.2", 39 | "meshoptimizer": "^0.20.0", 40 | "microbundle": "0.15.1", 41 | "rimraf": "^5.0.5", 42 | "simple-dropzone": "0.8.3", 43 | "three": "0.164.1", 44 | "tsx": "^4.7.3", 45 | "tweakpane": "^3.0.0", 46 | "tweakpane-plugin-thumbnail-list": "0.3.0", 47 | "typescript": "5.4.5", 48 | "typescript-eslint": "^7.8.0", 49 | "vite": "5.2.10" 50 | }, 51 | "scripts": { 52 | "dist": "microbundle --no-compress --format modern", 53 | "watch": "microbundle watch --no-compress --format modern", 54 | "dev": "concurrently \"yarn watch\" \"vite -c examples/vite.config.js\"", 55 | "clean": "rimraf dist/**", 56 | "test": "ava --no-worker-threads test/**/*.test.ts", 57 | "lint": "eslint \"{src,test}/**/*.ts\"", 58 | "coverage": "c8 --reporter=lcov --reporter=text yarn test --tap", 59 | "coverage:report": "c8 report --reporter=text-lcov > coverage/coverage.lcov", 60 | "version": "yarn dist && yarn test", 61 | "postversion": "git push && git push --tags && npm publish" 62 | }, 63 | "browserslist": [ 64 | "last 2 and_chr versions", 65 | "last 2 chrome versions", 66 | "last 2 opera versions", 67 | "last 2 ios_saf versions", 68 | "last 2 safari versions", 69 | "last 2 firefox versions" 70 | ], 71 | "files": [ 72 | "dist/", 73 | "src/", 74 | "README.md", 75 | "LICENSE.md" 76 | ], 77 | "ava": { 78 | "extensions": { 79 | "ts": "module" 80 | }, 81 | "nodeArguments": [ 82 | "--import=tsx", 83 | "--require=jsdom-global/register" 84 | ] 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/subjects/InstancedMeshSubject.ts: -------------------------------------------------------------------------------- 1 | import { BufferAttribute, BufferGeometry, InstancedMesh, Matrix4, Quaternion, Vector3 } from 'three'; 2 | import { Accessor as AccessorDef } from '@gltf-transform/core'; 3 | import type { DocumentViewSubjectAPI } from '../DocumentViewImpl'; 4 | import { Subject } from './Subject'; 5 | import { RefMapObserver } from '../observers'; 6 | import { ValuePool } from '../pools'; 7 | import { InstancedMesh as InstancedMeshDef } from '@gltf-transform/extensions'; 8 | import { DEFAULT_MATERIAL, semanticToAttributeName } from '../utils'; 9 | 10 | const _t = new Vector3(); 11 | const _r = new Quaternion(); 12 | const _s = new Vector3(); 13 | const _matrix = new Matrix4(); 14 | 15 | /** @internal */ 16 | export class InstancedMeshSubject extends Subject { 17 | protected attributes = new RefMapObserver('attributes', this._documentView); 18 | 19 | constructor(documentView: DocumentViewSubjectAPI, def: InstancedMeshDef) { 20 | super( 21 | documentView, 22 | def, 23 | InstancedMeshSubject.createValue(getCount({}), documentView.instancedMeshPool), 24 | documentView.instancedMeshPool, 25 | ); 26 | 27 | this.attributes.subscribe((nextAttributes) => { 28 | let value = this.value; 29 | if (value) this.pool.releaseBase(value); 30 | 31 | value = InstancedMeshSubject.createValue(getCount(nextAttributes), documentView.instancedMeshPool); 32 | 33 | let translation: BufferAttribute | null = null; 34 | let rotation: BufferAttribute | null = null; 35 | let scale: BufferAttribute | null = null; 36 | 37 | for (const key in nextAttributes) { 38 | if (key === 'TRANSLATION') { 39 | translation = nextAttributes[key]; 40 | } else if (key === 'ROTATION') { 41 | rotation = nextAttributes[key]; 42 | } else if (key === 'SCALE') { 43 | scale = nextAttributes[key]; 44 | } else { 45 | value.geometry.setAttribute(semanticToAttributeName(key), nextAttributes[key]); 46 | } 47 | } 48 | 49 | _t.set(0, 0, 0); 50 | _r.set(0, 0, 0, 1); 51 | _s.set(1, 1, 1); 52 | 53 | for (let i = 0; i < value.count; i++) { 54 | if (translation) _t.fromBufferAttribute(translation, i); 55 | if (rotation) _r.fromBufferAttribute(rotation, i); 56 | if (scale) _s.fromBufferAttribute(scale, i); 57 | _matrix.compose(_t, _r, _s); 58 | value.setMatrixAt(i, _matrix); 59 | } 60 | 61 | this.value = value; 62 | 63 | this.publishAll(); 64 | }); 65 | } 66 | 67 | update() { 68 | const def = this.def; 69 | 70 | this.attributes.update(def.listSemantics(), def.listAttributes()); 71 | } 72 | 73 | private static createValue(count: number, pool: ValuePool): InstancedMesh { 74 | return pool.requestBase(new InstancedMesh(new BufferGeometry(), DEFAULT_MATERIAL, count)); 75 | } 76 | 77 | dispose() { 78 | this.value.geometry.dispose(); 79 | this.attributes.dispose(); 80 | super.dispose(); 81 | } 82 | } 83 | 84 | function getCount(attributes: Record): number { 85 | for (const key in attributes) { 86 | return attributes[key].count; 87 | } 88 | return 1; 89 | } 90 | -------------------------------------------------------------------------------- /src/observers/RefMapObserver.ts: -------------------------------------------------------------------------------- 1 | import type { Property as PropertyDef } from '@gltf-transform/core'; 2 | import type { DocumentViewSubjectAPI } from '../DocumentViewImpl'; 3 | import type { Subject } from '../subjects'; 4 | import type { Subscription } from '../constants'; 5 | import { Observable } from '../utils'; 6 | import { EmptyParams } from '../pools'; 7 | import { RefObserver } from './RefObserver'; 8 | 9 | /** @internal */ 10 | export class RefMapObserver< 11 | Def extends PropertyDef, 12 | Value, 13 | Params extends EmptyParams = EmptyParams, 14 | > extends Observable> { 15 | readonly name: string; 16 | 17 | protected readonly _documentView: DocumentViewSubjectAPI; 18 | 19 | private readonly _observers: Record> = {}; 20 | private readonly _subscriptions: Record = {}; 21 | 22 | constructor(name: string, documentView: DocumentViewSubjectAPI) { 23 | super({}); 24 | this.name = name; 25 | this._documentView = documentView; 26 | } 27 | 28 | update(keys: string[], defs: Def[]) { 29 | const nextKeys = new Set(keys); 30 | const nextDefs = {} as Record; 31 | for (let i = 0; i < keys.length; i++) nextDefs[keys[i]] = defs[i]; 32 | 33 | let needsUpdate = false; 34 | 35 | for (const key in this._observers) { 36 | if (!nextKeys.has(key)) { 37 | this._remove(key); 38 | needsUpdate = true; 39 | } 40 | } 41 | 42 | for (const key of keys) { 43 | const observer = this._observers[key]; 44 | if (!observer) { 45 | this._add(key, this._documentView.bind(nextDefs[key]) as Subject); 46 | needsUpdate = true; 47 | } else if (observer.getDef() !== nextDefs[key]) { 48 | observer.update(nextDefs[key]); 49 | needsUpdate = true; 50 | } 51 | } 52 | 53 | if (needsUpdate) { 54 | this._publish(); 55 | } 56 | } 57 | 58 | setParamsFn(paramsFn: () => Params): this { 59 | for (const key in this._observers) { 60 | const observer = this._observers[key]; 61 | observer.setParamsFn(paramsFn); 62 | } 63 | return this; 64 | } 65 | 66 | private _add(key: string, subject: Subject) { 67 | const observer = new RefObserver(this.name + `[${key}]`, this._documentView) as RefObserver; 68 | observer.update(subject.def); 69 | 70 | this._observers[key] = observer; 71 | this._subscriptions[key] = observer.subscribe((next) => { 72 | if (!next) { 73 | this._remove(key); 74 | } 75 | this._publish(); 76 | }); 77 | } 78 | 79 | private _remove(key: string) { 80 | const observer = this._observers[key]; 81 | const unsub = this._subscriptions[key]; 82 | 83 | unsub(); 84 | observer.dispose(); 85 | 86 | delete this._subscriptions[key]; 87 | delete this._observers[key]; 88 | } 89 | 90 | private _publish() { 91 | const entries = Object.entries(this._observers).map(([key, observer]) => [key, observer.value]); 92 | this.next(Object.fromEntries(entries)); 93 | } 94 | 95 | dispose() { 96 | for (const key in this._observers) { 97 | const observer = this._observers[key]; 98 | const unsub = this._subscriptions[key]; 99 | unsub(); 100 | observer.dispose(); 101 | delete this._subscriptions[key]; 102 | delete this._observers[key]; 103 | } 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/observers/RefListObserver.ts: -------------------------------------------------------------------------------- 1 | import type { Property as PropertyDef } from '@gltf-transform/core'; 2 | import type { DocumentViewSubjectAPI } from '../DocumentViewImpl'; 3 | import type { Subject } from '../subjects'; 4 | import type { Subscription } from '../constants'; 5 | import { Observable } from '../utils'; 6 | import { EmptyParams } from '../pools'; 7 | import { RefObserver } from './RefObserver'; 8 | 9 | /** @internal */ 10 | export class RefListObserver< 11 | Def extends PropertyDef, 12 | Value, 13 | Params extends EmptyParams = EmptyParams, 14 | > extends Observable { 15 | readonly name: string; 16 | 17 | protected readonly _documentView: DocumentViewSubjectAPI; 18 | 19 | private readonly _observers: RefObserver[] = []; 20 | private readonly _subscriptions: Subscription[] = []; 21 | 22 | constructor(name: string, documentView: DocumentViewSubjectAPI) { 23 | super([]); 24 | this.name = name; 25 | this._documentView = documentView; 26 | } 27 | 28 | update(defs: Def[]) { 29 | const added = new Set>(); 30 | const removed = new Set(); 31 | 32 | let needsUpdate = false; 33 | 34 | // TODO(perf): Is this an many next()s as it looks like? Maybe 35 | // only when an early index is removed from a longer list? 36 | for (let i = 0; i < defs.length || i < this._observers.length; i++) { 37 | const def = defs[i]; 38 | const observer = this._observers[i]; 39 | 40 | if (!def) { 41 | removed.add(i); 42 | needsUpdate = true; 43 | } else if (!observer) { 44 | added.add(this._documentView.bind(def) as Subject); 45 | needsUpdate = true; 46 | } else if (def !== observer.getDef()) { 47 | observer.update(def); 48 | needsUpdate = true; 49 | } 50 | } 51 | 52 | for (let i = this._observers.length; i >= 0; i--) { 53 | if (removed.has(i)) { 54 | this._remove(i); 55 | } 56 | } 57 | 58 | for (const add of added) { 59 | this._add(add); 60 | } 61 | 62 | if (needsUpdate) { 63 | this._publish(); 64 | } 65 | } 66 | 67 | setParamsFn(paramsFn: () => Params): this { 68 | for (const observer of this._observers) { 69 | observer.setParamsFn(paramsFn); 70 | } 71 | return this; 72 | } 73 | 74 | private _add(subject: Subject) { 75 | const observer = new RefObserver(this.name + '[]', this._documentView) as RefObserver; 76 | observer.update(subject.def); 77 | this._observers.push(observer); 78 | this._subscriptions.push( 79 | observer.subscribe((next) => { 80 | if (!next) { 81 | this._remove(this._observers.indexOf(observer)); 82 | } 83 | this._publish(); 84 | }), 85 | ); 86 | } 87 | 88 | private _remove(index: number) { 89 | const observer = this._observers[index]; 90 | const unsub = this._subscriptions[index]; 91 | 92 | unsub(); 93 | observer.dispose(); 94 | 95 | this._observers.splice(index, 1); 96 | this._subscriptions.splice(index, 1); 97 | } 98 | 99 | private _publish() { 100 | this.next(this._observers.map((o) => o.value!)); 101 | } 102 | 103 | dispose() { 104 | for (const unsub of this._subscriptions) unsub(); 105 | for (const observer of this._observers) observer.dispose(); 106 | this._subscriptions.length = 0; 107 | this._observers.length = 0; 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /test/NodeSubject.test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { Document } from '@gltf-transform/core'; 3 | import { DocumentView, NullImageProvider } from '@gltf-transform/view'; 4 | 5 | const imageProvider = new NullImageProvider(); 6 | 7 | test('NodeSubject', async t => { 8 | const document = new Document(); 9 | const nodeDef1 = document.createNode('Node1') 10 | .setTranslation([0, 2, 0]) 11 | .setRotation([0, 0, .707, .707]) 12 | .setScale([0.5, 0.5, 0.5]) 13 | .addChild(document.createNode('Node2').setTranslation([5, 0, 0])); 14 | 15 | const documentView = new DocumentView(document, {imageProvider}); 16 | const node1 = documentView.view(nodeDef1); 17 | 18 | t.is(node1.name, 'Node1', 'node1 → name'); 19 | t.is(node1.children.length, 1, 'node1 → children'); 20 | t.deepEqual(node1.position.toArray(), [0, 2, 0], 'node1 → position'); 21 | t.deepEqual(node1.quaternion.toArray(), [0, 0, .707, .707], 'node1 → quaternion'); 22 | t.deepEqual(node1.scale.toArray(), [0.5, 0.5, 0.5], 'node1 → scale'); 23 | 24 | const node2 = node1.children[0]; 25 | t.is(node2.name, 'Node2', 'node2 → name'); 26 | t.is(node2.children.length, 0, 'node2 → children'); 27 | t.deepEqual(node2.position.toArray(), [5, 0, 0], 'node2 → position'); 28 | t.deepEqual(node2.quaternion.toArray(), [0, 0, 0, 1], 'node2 → quaternion'); 29 | t.deepEqual(node2.scale.toArray(), [1, 1, 1], 'node2 → scale'); 30 | 31 | nodeDef1 32 | .setName('RenamedNode') 33 | .setTranslation([0, 0, 0,]); 34 | 35 | t.is(node1.name, 'RenamedNode', 'node1 → name'); 36 | t.deepEqual(node1.position.toArray(), [0, 0, 0], 'node1 → position'); 37 | }); 38 | 39 | test('NodeSubject | update in place', async t => { 40 | const document = new Document(); 41 | const meshDef = document.createMesh().setName('Mesh.v1'); 42 | const nodeDef1 = document.createNode('Node1').setMesh(meshDef); 43 | const nodeDef2 = document.createNode('Node2').setMesh(meshDef).addChild(nodeDef1); 44 | const sceneDef = document.createScene().addChild(nodeDef2); 45 | 46 | const documentView = new DocumentView(document, {imageProvider}); 47 | const scene = documentView.view(sceneDef); 48 | const node1 = documentView.view(nodeDef1); 49 | const node2 = documentView.view(nodeDef2); 50 | const mesh = node1.children[0]; 51 | 52 | t.truthy(scene, 'scene ok'); 53 | t.truthy(node1, 'node1 ok'); 54 | t.truthy(node2, 'node2 ok'); 55 | t.truthy(mesh, 'mesh ok'); 56 | 57 | t.is(scene.children[0], node2, 'node2 view'); 58 | t.is(scene.children[0].children[0], node1, 'node1 view'); 59 | t.is(scene.children[0].children[0].children[0], mesh, 'mesh view'); 60 | 61 | nodeDef1.setScale([2, 2, 2]); 62 | nodeDef2.setScale([3, 3, 3]); 63 | 64 | t.is(scene.children[0], node2, 'node2 view after update'); 65 | t.is(scene.children[0].children[0], node1, 'node1 view after update'); 66 | t.is(scene.children[0].children[0].children[0], mesh, 'mesh view'); 67 | 68 | t.deepEqual(node1.scale.toArray([]), [2, 2, 2], 'node1 scale'); 69 | t.deepEqual(node2.scale.toArray([]), [3, 3, 3], 'node2 scale'); 70 | 71 | t.truthy(node1.children.some((o) => o.name === 'Mesh.v1'), 'node1.mesh.name'); 72 | t.truthy(node2.children.some((o) => o.name === 'Mesh.v1'), 'node2.mesh.name'); 73 | meshDef.setName('Mesh.v2'); 74 | t.truthy(node1.children.some((o) => o.name === 'Mesh.v2'), 'node1.mesh.name'); 75 | t.truthy(node2.children.some((o) => o.name === 'Mesh.v2'), 'node2.mesh.name'); 76 | }); 77 | -------------------------------------------------------------------------------- /examples/dropzone.ts: -------------------------------------------------------------------------------- 1 | import { Document, JSONDocument } from '@gltf-transform/core'; 2 | import { SimpleDropzone } from 'simple-dropzone'; 3 | import { createIO } from './util'; 4 | 5 | /** 6 | * Dropzone 7 | * 8 | * Utility file to load glTF/GLB models and emit a Document. 9 | */ 10 | 11 | const dropEl = document.querySelector('body')!; 12 | const placeholderEl = document.querySelector('.dropzone-placeholder')!; 13 | const inputEl = document.querySelector('#file-input')!; 14 | 15 | document.addEventListener('DOMContentLoaded', () => { 16 | const dropzone = new SimpleDropzone(dropEl, inputEl) as any; 17 | dropzone.on('drop', async ({files}) => { 18 | try { 19 | await load(files); 20 | placeholderEl.style.display = 'none'; 21 | } catch (e) { 22 | alert(e.message); 23 | } 24 | }); 25 | }); 26 | 27 | async function load (fileMap: Map) { 28 | let rootFile: File | null = null; 29 | let rootPath = ''; 30 | let images: File[] = []; 31 | Array.from(fileMap).forEach(([path, file]) => { 32 | if (file.name.match(/\.(gltf|glb)$/)) { 33 | rootFile = file; 34 | rootPath = path.replace(file.name, ''); 35 | } else if (file.name.match(/\.(png|jpg|jpeg|ktx2|webp)$/)) { 36 | images.push(file); 37 | } 38 | }); 39 | 40 | if (rootFile) return loadDocument(fileMap, rootFile, rootPath); 41 | throw new Error('No .gltf, .glb, or texture asset found.'); 42 | } 43 | 44 | async function loadDocument(fileMap: Map, rootFile: File, rootPath: string) { 45 | const io = createIO(); 46 | let jsonDocument: JSONDocument; 47 | let doc: Document; 48 | 49 | if (rootFile.name.match(/\.(glb)$/)) { 50 | const arrayBuffer = await rootFile.arrayBuffer(); 51 | jsonDocument = await io.binaryToJSON(new Uint8Array(arrayBuffer)); 52 | } else { 53 | jsonDocument = { 54 | json: JSON.parse(await rootFile.text()), 55 | resources: {}, 56 | }; 57 | for (const [fileName, file] of fileMap.entries()) { 58 | const path = fileName.replace(rootPath, ''); 59 | const arrayBuffer = await file.arrayBuffer(); 60 | jsonDocument.resources[path] = new Uint8Array(arrayBuffer); 61 | } 62 | } 63 | 64 | normalizeURIs(jsonDocument); 65 | doc = await io.readJSON(jsonDocument); 66 | removeCompression(doc); 67 | document.body.dispatchEvent(new CustomEvent('gltf-document', {detail: doc})); 68 | } 69 | 70 | /** 71 | * Normalize URIs to match expected output from simple-dropzone, for folders or 72 | * ZIP archives. URIs in a glTF file may be escaped, or not. Assume that assetMap is 73 | * from an un-escaped source, and decode all URIs before lookups. 74 | * See: https://github.com/donmccurdy/three-gltf-viewer/issues/146 75 | */ 76 | function normalizeURIs (jsonDocument: JSONDocument) { 77 | const images = jsonDocument.json.images || []; 78 | const buffers = jsonDocument.json.buffers || []; 79 | for (const resource of [...images, ...buffers]) { 80 | if (!resource.uri) continue; 81 | if (resource.uri.startsWith('data:')) continue; 82 | 83 | resource.uri = decodeURI(resource.uri).replace(/^(\.?\/)/, ''); 84 | if (!(resource.uri in jsonDocument.resources)) { 85 | throw new Error(`Missing resource: ${resource.uri}`); 86 | } 87 | } 88 | } 89 | 90 | /** Remove compression extensions now so further writes don't recompress. */ 91 | function removeCompression(document: Document) { 92 | for (const extensionName of ['KHR_draco_mesh_compression', 'EXT_meshopt_compression']) { 93 | const extension = document.getRoot().listExtensionsUsed() 94 | .find((extension) => extension.extensionName === extensionName); 95 | if (extension) extension.dispose(); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /test/LightSubject.test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { Document } from '@gltf-transform/core'; 3 | import { Light as LightDef, KHRLightsPunctual } from '@gltf-transform/extensions'; 4 | import { DocumentView, NullImageProvider } from '@gltf-transform/view'; 5 | import { DirectionalLight, PointLight, SpotLight } from 'three'; 6 | 7 | const imageProvider = new NullImageProvider(); 8 | 9 | test('LightSubject | point', async t => { 10 | const document = new Document(); 11 | const lightExt = document.createExtension(KHRLightsPunctual); 12 | const lightDef = lightExt.createLight('MyLight') 13 | .setColor([1, 0, 0]) 14 | .setIntensity(2000) 15 | .setRange(100) 16 | .setType(LightDef.Type.POINT); 17 | const nodeDef = document.createNode('Node') 18 | .setExtension('KHR_lights_punctual', lightDef); 19 | 20 | const documentView = new DocumentView(document, {imageProvider}); 21 | const node = documentView.view(nodeDef); 22 | const light = node.children[0] as PointLight; 23 | 24 | t.is(light.name, 'MyLight', 'node → light → name'); 25 | t.is(light.type, 'PointLight', 'node → light → type'); 26 | t.deepEqual(light.position.toArray(), [0, 0, 0], 'node → light → position'); 27 | t.is(light.intensity, 2000, 'node → light → intensity'); 28 | t.is(light.distance, 100, 'node → light → range'); 29 | t.deepEqual(light.color.toArray(), [1, 0, 0], 'node → light → color'); 30 | t.is(light.decay, 2, 'node → light → decay'); 31 | }); 32 | 33 | test('LightSubject | spot', async t => { 34 | const document = new Document(); 35 | const lightExt = document.createExtension(KHRLightsPunctual); 36 | const lightDef = lightExt.createLight('MyLight') 37 | .setColor([1, 1, 0]) 38 | .setIntensity(2000) 39 | .setRange(null) 40 | .setInnerConeAngle(Math.PI / 4) 41 | .setOuterConeAngle(Math.PI / 2) 42 | .setType(LightDef.Type.SPOT); 43 | const nodeDef = document.createNode('Node') 44 | .setExtension('KHR_lights_punctual', lightDef); 45 | 46 | const documentView = new DocumentView(document, {imageProvider}); 47 | const node = documentView.view(nodeDef); 48 | const light = node.children[0] as SpotLight; 49 | 50 | t.is(light.name, 'MyLight', 'node → light → name'); 51 | t.is(light.type, 'SpotLight', 'node → light → type'); 52 | t.deepEqual(light.position.toArray(), [0, 0, 0], 'node → light → position'); 53 | t.is(light.intensity, 2000, 'node → light → intensity'); 54 | t.is(light.distance, 0, 'node → light → range'); 55 | t.is(light.angle, Math.PI / 2, 'node → light → angle'); 56 | t.is(light.penumbra, 1.0 - (Math.PI / 4) / (Math.PI / 2), 'node → light → penumbra'); 57 | t.deepEqual(light.color.toArray(), [1, 1, 0], 'node → light → color'); 58 | t.is(light.decay, 2, 'node → light → decay'); 59 | }); 60 | 61 | test('LightSubject | directional', async t => { 62 | const document = new Document(); 63 | const lightExt = document.createExtension(KHRLightsPunctual); 64 | const lightDef = lightExt.createLight('MyLight') 65 | .setColor([1, 1, 1]) 66 | .setIntensity(1.5) 67 | .setType(LightDef.Type.DIRECTIONAL); 68 | const nodeDef = document.createNode('Node') 69 | .setExtension('KHR_lights_punctual', lightDef); 70 | 71 | const documentView = new DocumentView(document, {imageProvider}); 72 | const node = documentView.view(nodeDef); 73 | const light = node.children[0] as DirectionalLight; 74 | 75 | t.is(light.name, 'MyLight', 'node → light → name'); 76 | t.is(light.type, 'DirectionalLight', 'node → light → type'); 77 | t.deepEqual(light.position.toArray(), [0, 0, 0], 'node → light → position'); 78 | t.is(light.intensity, 1.5, 'node → light → intensity'); 79 | t.deepEqual(light.color.toArray(), [1, 1, 1], 'node → light → color'); 80 | }); 81 | -------------------------------------------------------------------------------- /src/pools/TexturePool.ts: -------------------------------------------------------------------------------- 1 | import { TextureInfo, vec2 } from '@gltf-transform/core'; 2 | import { 3 | ClampToEdgeWrapping, 4 | ColorSpace, 5 | LinearFilter, 6 | LinearMipmapLinearFilter, 7 | LinearMipmapNearestFilter, 8 | MagnificationTextureFilter, 9 | MinificationTextureFilter, 10 | MirroredRepeatWrapping, 11 | NearestFilter, 12 | NearestMipmapLinearFilter, 13 | NearestMipmapNearestFilter, 14 | RepeatWrapping, 15 | Texture, 16 | TextureFilter, 17 | Wrapping, 18 | } from 'three'; 19 | import type { Transform } from '@gltf-transform/extensions'; 20 | import { Pool } from './Pool'; 21 | 22 | const WEBGL_FILTERS: Record = { 23 | 9728: NearestFilter, 24 | 9729: LinearFilter, 25 | 9984: NearestMipmapNearestFilter, 26 | 9985: LinearMipmapNearestFilter, 27 | 9986: NearestMipmapLinearFilter, 28 | 9987: LinearMipmapLinearFilter, 29 | }; 30 | 31 | const WEBGL_WRAPPINGS: Record = { 32 | 33071: ClampToEdgeWrapping, 33 | 33648: MirroredRepeatWrapping, 34 | 10497: RepeatWrapping, 35 | }; 36 | 37 | export interface TextureParams { 38 | colorSpace: ColorSpace; 39 | channel: number; 40 | minFilter: TextureFilter; 41 | magFilter: TextureFilter; 42 | wrapS: Wrapping; 43 | wrapT: Wrapping; 44 | offset: vec2; 45 | rotation: number; 46 | repeat: vec2; 47 | } 48 | 49 | const _VEC2 = { ZERO: [0, 0] as vec2, ONE: [1, 1] as vec2 }; 50 | 51 | /** @internal */ 52 | export class TexturePool extends Pool { 53 | static createParams(textureInfo: TextureInfo, colorSpace: ColorSpace): TextureParams { 54 | const transform = textureInfo.getExtension('KHR_texture_transform'); 55 | return { 56 | colorSpace: colorSpace, 57 | channel: textureInfo.getTexCoord(), 58 | minFilter: WEBGL_FILTERS[textureInfo.getMinFilter() as number] || LinearMipmapLinearFilter, 59 | magFilter: WEBGL_FILTERS[textureInfo.getMagFilter() as number] || LinearFilter, 60 | wrapS: WEBGL_WRAPPINGS[textureInfo.getWrapS()] || RepeatWrapping, 61 | wrapT: WEBGL_WRAPPINGS[textureInfo.getWrapT()] || RepeatWrapping, 62 | offset: transform?.getOffset() || _VEC2.ZERO, 63 | rotation: transform?.getRotation() || 0, 64 | repeat: transform?.getScale() || _VEC2.ONE, 65 | }; 66 | } 67 | 68 | requestVariant(base: Texture, params: TextureParams): Texture { 69 | return this._request(this._createVariant(base, params)); 70 | } 71 | 72 | protected _disposeValue(value: Texture): void { 73 | value.dispose(); 74 | super._disposeValue(value); 75 | } 76 | 77 | protected _createVariant(srcTexture: Texture, params: TextureParams): Texture { 78 | return this._updateVariant(srcTexture, srcTexture.clone(), params); 79 | } 80 | 81 | protected _updateVariant(srcTexture: Texture, dstTexture: Texture, params: TextureParams): Texture { 82 | const needsUpdate = 83 | srcTexture.image !== dstTexture.image || 84 | dstTexture.colorSpace !== params.colorSpace || 85 | dstTexture.wrapS !== params.wrapS || 86 | dstTexture.wrapT !== params.wrapT; 87 | 88 | dstTexture.copy(srcTexture); 89 | dstTexture.colorSpace = params.colorSpace; 90 | dstTexture.channel = params.channel; 91 | dstTexture.minFilter = params.minFilter as MinificationTextureFilter; 92 | dstTexture.magFilter = params.magFilter as MagnificationTextureFilter; 93 | dstTexture.wrapS = params.wrapS; 94 | dstTexture.wrapT = params.wrapT; 95 | dstTexture.offset.fromArray(params.offset || _VEC2.ZERO); 96 | dstTexture.rotation = params.rotation || 0; 97 | dstTexture.repeat.fromArray(params.repeat || _VEC2.ONE); 98 | 99 | if (needsUpdate) { 100 | dstTexture.needsUpdate = true; 101 | } 102 | 103 | return dstTexture; 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /examples/1-model.ts: -------------------------------------------------------------------------------- 1 | import { ACESFilmicToneMapping, AmbientLight, DirectionalLight, PMREMGenerator, PerspectiveCamera, Scene, WebGLRenderer } from 'three'; 2 | import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; 3 | import { GLTF, Material } from '@gltf-transform/core'; 4 | import { DocumentView } from '../dist/view.modern.js'; 5 | import {Pane} from 'tweakpane'; 6 | import * as TweakpanePluginThumbnailList from 'tweakpane-plugin-thumbnail-list'; 7 | import { createStatsPane } from './stats-pane.js'; 8 | import { createMaterialPane } from './material-pane.js'; 9 | import { createEnvironment, createIO } from './util.js'; 10 | 11 | const renderer = new WebGLRenderer({antialias: true}); 12 | renderer.setPixelRatio(window.devicePixelRatio); 13 | renderer.setSize(window.innerWidth, window.innerHeight); 14 | renderer.useLegacyLights = false; 15 | renderer.toneMapping = ACESFilmicToneMapping; 16 | renderer.toneMappingExposure = 1; 17 | 18 | const containerEl = document.querySelector('#container')!; 19 | containerEl.appendChild(renderer.domElement); 20 | 21 | const pmremGenerator = new PMREMGenerator(renderer); 22 | pmremGenerator.compileEquirectangularShader(); 23 | 24 | const scene = new Scene(); 25 | 26 | const light1 = new AmbientLight(); 27 | const light2 = new DirectionalLight(); 28 | light2.position.set(1, 2, 3); 29 | scene.add(light1, light2); 30 | 31 | createEnvironment(renderer) 32 | .then((environment) => { 33 | scene.environment = environment; 34 | scene.background = environment; 35 | render(); 36 | }); 37 | 38 | const camera = new PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.25, 20); 39 | camera.position.set(-1.8, 0.6, 2.7); 40 | camera.lookAt(scene.position); 41 | 42 | const controls = new OrbitControls(camera, renderer.domElement); 43 | controls.addEventListener('change', render); 44 | controls.minDistance = 2; 45 | controls.maxDistance = 10; 46 | controls.target.set(0, 0, - 0.2); 47 | controls.update(); 48 | 49 | window.addEventListener( 'resize', onWindowResize ); 50 | 51 | // 52 | 53 | let material: Material; 54 | let documentView: DocumentView; 55 | 56 | const pane = new Pane({title: 'DamagedHelmet.glb'}); 57 | pane.registerPlugin(TweakpanePluginThumbnailList); 58 | const updateStats = createStatsPane(renderer, pane); 59 | 60 | 61 | const io = createIO(); 62 | io.read('./DamagedHelmet.glb').then(async (doc) => { 63 | console.time('DocumentView::init'); 64 | documentView = new DocumentView(doc); 65 | console.timeEnd('DocumentView::init'); 66 | 67 | window['doc'] = doc; 68 | const modelDef = doc.getRoot().getDefaultScene() || doc.getRoot().listScenes()[0]; 69 | const model = window['model'] = documentView.view(modelDef); 70 | 71 | scene.add(model); 72 | animate(); 73 | 74 | // GUI. 75 | 76 | material = doc.getRoot().listMaterials().pop()!; 77 | createMaterialPane(pane, doc, material); 78 | 79 | const prim = doc.getRoot().listMeshes().pop()!.listPrimitives().pop()!; 80 | const primFolder = pane.addFolder({title: 'Primitive'}); 81 | primFolder.addInput({mode: 4}, 'mode', { 82 | options: { 83 | POINTS: 0, 84 | LINES: 1, 85 | TRIANGLES: 4, 86 | } 87 | }).on('change', (ev) => { 88 | prim.setMode(ev.value as GLTF.MeshPrimitiveMode); 89 | }); 90 | 91 | pane.addButton({title: 'stats'}).on('click', () => { 92 | documentView.gc(); 93 | console.table(documentView.stats()); 94 | }); 95 | }); 96 | 97 | // 98 | 99 | function animate() { 100 | requestAnimationFrame(animate); 101 | render(); 102 | updateStats(); 103 | } 104 | 105 | function render() { 106 | renderer.render(scene, camera); 107 | } 108 | 109 | function onWindowResize() { 110 | camera.aspect = window.innerWidth / window.innerHeight; 111 | camera.updateProjectionMatrix(); 112 | renderer.setSize(window.innerWidth, window.innerHeight); 113 | render(); 114 | } 115 | -------------------------------------------------------------------------------- /src/observers/RefObserver.ts: -------------------------------------------------------------------------------- 1 | import type { Property as PropertyDef } from '@gltf-transform/core'; 2 | import type { DocumentViewSubjectAPI } from '../DocumentViewImpl'; 3 | import type { Subject } from '../subjects'; 4 | import { Observable } from '../utils'; 5 | import { EmptyParams } from '../pools'; 6 | 7 | /** 8 | * Exposes a limited view of the RefObserver interface to objects 9 | * using it as an output socket. 10 | */ 11 | export interface Output extends Observable { 12 | detach(): void; 13 | } 14 | 15 | /** 16 | * Observable connecting one Subject's output to another Subject's input. 17 | * 18 | * An Observer is subscribed to the values published by a particular Subject, and 19 | * passes those events along to a parent — usually another Subject. For example, a MaterialSubject 20 | * subscribes to updates from a TextureSubject using an Observer. Observers are parameterized: 21 | * for example, a single Texture may be used by many Materials, with different offset/scale/encoding 22 | * parameters in each. The TextureSubject treats each of these Observers as a different "output", and 23 | * uses the parameters associated with the Observer to publish the appropriate value. 24 | * 25 | * RefObserver should let the Subject call .next(), generally avoiding calling .next() itself. The 26 | * RefObserver is a passive pipe. 27 | * 28 | * @internal 29 | */ 30 | export class RefObserver 31 | extends Observable 32 | implements Output 33 | { 34 | readonly name: string; 35 | 36 | private _subject: Subject | null = null; 37 | private _subjectParamsFn: () => Params = () => ({}) as Params; 38 | 39 | private readonly _documentView: DocumentViewSubjectAPI; 40 | 41 | constructor(name: string, documentView: DocumentViewSubjectAPI) { 42 | super(null); 43 | this.name = name; 44 | this._documentView = documentView; 45 | } 46 | 47 | /************************************************************************** 48 | * Child interface. (Subject (Child)) 49 | */ 50 | 51 | detach() { 52 | this._clear(); 53 | } 54 | 55 | next(value: Value | null) { 56 | // Prevent publishing updates during disposal. 57 | if (this._documentView.isDisposed()) return; 58 | 59 | super.next(value); 60 | } 61 | 62 | /************************************************************************** 63 | * Parent interface. (Subject (Parent), ListObserver, MapObserver) 64 | */ 65 | 66 | setParamsFn(paramsFn: () => Params): this { 67 | this._subjectParamsFn = paramsFn; 68 | return this; 69 | } 70 | 71 | getDef(): Def | null { 72 | return this._subject ? this._subject.def : null; 73 | } 74 | 75 | update(def: Def | null) { 76 | const subject = def ? (this._documentView.bind(def) as Subject) : null; 77 | if (subject === this._subject) return; 78 | 79 | this._clear(); 80 | 81 | if (subject) { 82 | this._subject = subject; 83 | this._subject.addOutput(this, this._subjectParamsFn); 84 | this._subject.publish(this); 85 | } else { 86 | // In most cases RefObserver should let the Subject call .next() itself, 87 | // but this is the exception since the Subject is gone. 88 | this.next(null); 89 | } 90 | } 91 | 92 | /** 93 | * Forces the observed Subject to re-evaluate the output. For use when 94 | * output parameters are likely to have changed. 95 | */ 96 | invalidate() { 97 | if (this._subject) { 98 | this._subject.publish(this); 99 | } 100 | } 101 | 102 | dispose() { 103 | this._clear(); 104 | } 105 | 106 | /************************************************************************** 107 | * Internal. 108 | */ 109 | 110 | private _clear() { 111 | if (this._subject) { 112 | this._subject.removeOutput(this); 113 | this._subject = null; 114 | } 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/DocumentView.ts: -------------------------------------------------------------------------------- 1 | import { Group, Material, Object3D, Texture } from 'three'; 2 | import { 3 | Document, 4 | Scene as SceneDef, 5 | Node as NodeDef, 6 | Material as MaterialDef, 7 | Mesh as MeshDef, 8 | Primitive as PrimitiveDef, 9 | Property as PropertyDef, 10 | Texture as TextureDef, 11 | } from '@gltf-transform/core'; 12 | import { Light as LightDef } from '@gltf-transform/extensions'; 13 | import { DocumentViewConfig, DocumentViewImpl } from './DocumentViewImpl'; 14 | import { LightLike, MeshLike } from './constants'; 15 | 16 | /** 17 | * Constructs a three.js subtree from a glTF-Transform Document, and maintains a 18 | * 1:1 mapping between every three.js/glTF object pair. Supports full and partial 19 | * updates with significantly lower latency than serializing and reloading to 20 | * THREE.GLTFLoader each time. 21 | */ 22 | export class DocumentView { 23 | /** @internal */ private _ready = false; 24 | /** @internal */ private _document: Document; 25 | /** @internal */ private _impl: DocumentViewImpl; 26 | 27 | /** Constructs a new DocumentView. */ 28 | public constructor(document: Document, config = {} as DocumentViewConfig) { 29 | this._document = document; 30 | this._impl = new DocumentViewImpl(config); 31 | this._ready = true; 32 | } 33 | 34 | /** 35 | * For a given glTF-Transform Property definition, returns a corresponding 36 | * three.js view into the object. For example, given a glTF Transform scene, 37 | * returns a THREE.Group representing that scene. Repeated calls with the 38 | * same input will yield the same output objects. 39 | */ 40 | public view(def: TextureDef): Texture; 41 | public view(def: LightDef): LightLike; 42 | public view(def: MaterialDef): Material; 43 | public view(def: PrimitiveDef): MeshLike; 44 | public view(def: MeshDef): Group; 45 | public view(def: NodeDef): Object3D; 46 | public view(def: SceneDef): Group; 47 | public view(def: PropertyDef): object { 48 | assert(this._ready); 49 | const value = this._impl.bind(def).value as Group; 50 | this._impl.recordOutputValue(def, value); 51 | return value; 52 | } 53 | 54 | /** 55 | * For a given source glTF-Transform Property definition, returns a list of rendered three.js 56 | * objects. 57 | */ 58 | public listViews(source: TextureDef): Texture[]; 59 | public listViews(source: LightDef): LightLike[]; 60 | public listViews(source: MaterialDef): Material[]; 61 | public listViews(source: PrimitiveDef): MeshLike[]; 62 | public listViews(source: MeshDef): Group[]; 63 | public listViews(source: NodeDef): Object3D[]; 64 | public listViews(source: SceneDef): Group[]; 65 | public listViews(source: PropertyDef): object[] { 66 | assert(this._ready); 67 | return this._impl.findValues(source as any); 68 | } 69 | 70 | /** For a given Object3D target, finds the source glTF-Transform Property definition. */ 71 | public getProperty(view: Texture): TextureDef | null; 72 | public getProperty(view: LightLike): LightDef | null; 73 | public getProperty(view: Material): MaterialDef | null; 74 | public getProperty(view: MeshLike): PrimitiveDef | null; 75 | public getProperty(view: Object3D): MeshDef | NodeDef | SceneDef | null; 76 | public getProperty(view: object): PropertyDef | null { 77 | assert(this._ready); 78 | return this._impl.findDef(view as any); 79 | } 80 | 81 | public stats(): Record { 82 | assert(this._ready); 83 | return this._impl.stats(); 84 | } 85 | 86 | public gc(): void { 87 | assert(this._ready); 88 | this._impl.gc(); 89 | } 90 | 91 | /** 92 | * Destroys the renderer and cleans up its resources. 93 | * 94 | * Lifecycle: For resources associated with... 95 | * - ...used Properties, dispose with renderer. 96 | * - ...unused Properties, dispose with renderer. 97 | * - ...disposed Properties, dispose immediately. 98 | */ 99 | public dispose(): void { 100 | assert(this._ready); 101 | this._impl.dispose(); 102 | } 103 | } 104 | 105 | function assert(ready: boolean) { 106 | if (!ready) { 107 | throw new Error('DocumentView must be initialized before use.'); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/pools/MaterialPool.ts: -------------------------------------------------------------------------------- 1 | import { GLTF, Primitive as PrimitiveDef } from '@gltf-transform/core'; 2 | import { 3 | LineBasicMaterial, 4 | Material, 5 | MeshBasicMaterial, 6 | MeshPhysicalMaterial, 7 | MeshStandardMaterial, 8 | PointsMaterial, 9 | } from 'three'; 10 | import { Pool } from './Pool'; 11 | 12 | export type BaseMaterial = MeshBasicMaterial | MeshStandardMaterial | MeshPhysicalMaterial; 13 | export type VariantMaterial = 14 | | MeshBasicMaterial 15 | | MeshStandardMaterial 16 | | MeshPhysicalMaterial 17 | | LineBasicMaterial 18 | | PointsMaterial; 19 | 20 | export interface MaterialParams { 21 | mode: GLTF.MeshPrimitiveMode; 22 | useVertexTangents: boolean; 23 | useVertexColors: boolean; 24 | useMorphTargets: boolean; 25 | useFlatShading: boolean; 26 | } 27 | 28 | /** @internal */ 29 | export class MaterialPool extends Pool { 30 | static createParams(primitive: PrimitiveDef): MaterialParams { 31 | return { 32 | mode: primitive.getMode(), 33 | useVertexTangents: !!primitive.getAttribute('TANGENT'), 34 | useVertexColors: !!primitive.getAttribute('COLOR_0'), 35 | useFlatShading: !primitive.getAttribute('NORMAL'), 36 | useMorphTargets: primitive.listTargets().length > 0, 37 | }; 38 | } 39 | 40 | requestVariant(srcMaterial: Material, params: MaterialParams): Material { 41 | return this._request(this._createVariant(srcMaterial as BaseMaterial, params)); 42 | } 43 | 44 | protected _disposeValue(value: Material): void { 45 | value.dispose(); 46 | super._disposeValue(value); 47 | } 48 | 49 | /** Creates a variant material for given source material and MaterialParams. */ 50 | protected _createVariant(srcMaterial: BaseMaterial, params: MaterialParams): VariantMaterial { 51 | switch (params.mode) { 52 | case PrimitiveDef.Mode.TRIANGLES: 53 | case PrimitiveDef.Mode.TRIANGLE_FAN: 54 | case PrimitiveDef.Mode.TRIANGLE_STRIP: 55 | return this._updateVariant(srcMaterial, srcMaterial.clone(), params); 56 | case PrimitiveDef.Mode.LINES: 57 | case PrimitiveDef.Mode.LINE_LOOP: 58 | case PrimitiveDef.Mode.LINE_STRIP: 59 | return this._updateVariant(srcMaterial, new LineBasicMaterial(), params); 60 | case PrimitiveDef.Mode.POINTS: 61 | return this._updateVariant(srcMaterial, new PointsMaterial(), params); 62 | default: 63 | throw new Error(`Unexpected primitive mode: ${params.mode}`); 64 | } 65 | } 66 | 67 | /** 68 | * Updates a variant material to match new changes to the source material. 69 | * 70 | * NOTICE: Changes to MaterialParams should _NOT_ be applied with this method. 71 | * Instead, create a new variant and dispose the old if unused. 72 | */ 73 | protected _updateVariant( 74 | srcMaterial: BaseMaterial, 75 | dstMaterial: VariantMaterial, 76 | params: MaterialParams, 77 | ): VariantMaterial { 78 | if (srcMaterial.type === dstMaterial.type) { 79 | dstMaterial.copy(srcMaterial); 80 | } else if (dstMaterial instanceof LineBasicMaterial) { 81 | Material.prototype.copy.call(dstMaterial, srcMaterial); 82 | dstMaterial.color.copy(srcMaterial.color); 83 | } else if (dstMaterial instanceof PointsMaterial) { 84 | Material.prototype.copy.call(dstMaterial, srcMaterial); 85 | dstMaterial.color.copy(srcMaterial.color); 86 | dstMaterial.map = srcMaterial.map; 87 | dstMaterial.sizeAttenuation = false; 88 | } 89 | 90 | dstMaterial.vertexColors = params.useVertexColors; 91 | if (dstMaterial instanceof MeshStandardMaterial) { 92 | dstMaterial.flatShading = params.useFlatShading; 93 | // https://github.com/mrdoob/three.js/issues/11438#issuecomment-507003995 94 | dstMaterial.normalScale.y = params.useVertexTangents 95 | ? Math.abs(dstMaterial.normalScale.y) 96 | : -1 * dstMaterial.normalScale.y; 97 | if (dstMaterial instanceof MeshPhysicalMaterial) { 98 | dstMaterial.clearcoatNormalScale.y = params.useVertexTangents 99 | ? Math.abs(dstMaterial.clearcoatNormalScale.y) 100 | : -1 * dstMaterial.clearcoatNormalScale.y; 101 | } 102 | } 103 | 104 | if (dstMaterial.version < srcMaterial.version) { 105 | dstMaterial.version = srcMaterial.version; 106 | } 107 | 108 | return dstMaterial; 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/ImageProvider.ts: -------------------------------------------------------------------------------- 1 | import { Texture as TextureDef } from '@gltf-transform/core'; 2 | import { CompressedTexture, Texture, WebGLRenderer, REVISION } from 'three'; 3 | import { KTX2Loader } from 'three/examples/jsm/loaders/KTX2Loader.js'; 4 | 5 | const TRANSCODER_PATH = `https://unpkg.com/three@0.${REVISION}.x/examples/jsm/libs/basis/`; 6 | 7 | // Use a single KTX2Loader instance to pool Web Workers. 8 | function createKTX2Loader() { 9 | const renderer = new WebGLRenderer(); 10 | const loader = new KTX2Loader().detectSupport(renderer).setTranscoderPath(TRANSCODER_PATH); 11 | renderer.dispose(); 12 | return loader; 13 | } 14 | 15 | /** Generates a Texture from a Data URI, or otherh URL. */ 16 | function createTexture(name: string, uri: string): Texture { 17 | const imageEl = document.createElement('img'); 18 | imageEl.src = uri; 19 | const texture = new Texture(imageEl); 20 | texture.name = name; 21 | texture.flipY = false; 22 | return texture; 23 | } 24 | 25 | // Placeholder images. 26 | const NULL_IMAGE_URI = 27 | // eslint-disable-next-line max-len 28 | 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAQAAAAECAYAAACp8Z5+AAAAAXNSR0IArs4c6QAAABNJREFUGFdj/M9w9z8DEmAkXQAAyCMLcU6pckIAAAAASUVORK5CYII='; 29 | const LOADING_IMAGE_URI = 30 | // eslint-disable-next-line max-len 31 | 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAA1JREFUGFdj+P///38ACfsD/QVDRcoAAAAASUVORK5CYII='; 32 | 33 | export const NULL_TEXTURE = createTexture('__NULL_TEXTURE', NULL_IMAGE_URI); 34 | export const LOADING_TEXTURE = createTexture('__LOADING_TEXTURE', LOADING_IMAGE_URI); 35 | 36 | export interface ImageProvider { 37 | initTexture(textureDef: TextureDef): Promise; 38 | getTexture(textureDef: TextureDef): Promise; 39 | setKTX2Loader(loader: KTX2Loader): this; 40 | clear(): void; 41 | } 42 | 43 | export class NullImageProvider implements ImageProvider { 44 | async initTexture(_textureDef: TextureDef): Promise {} 45 | async getTexture(_: TextureDef): Promise { 46 | return NULL_TEXTURE; 47 | } 48 | setKTX2Loader(_loader: KTX2Loader): this { 49 | return this; 50 | } 51 | clear(): void {} 52 | } 53 | 54 | export class DefaultImageProvider implements ImageProvider { 55 | private _cache = new Map(); 56 | private _ktx2Loader: KTX2Loader | null = null; 57 | 58 | async initTexture(textureDef: TextureDef): Promise { 59 | await this.getTexture(textureDef); 60 | } 61 | 62 | async getTexture(textureDef: TextureDef): Promise { 63 | const image = textureDef.getImage()!; 64 | const mimeType = textureDef.getMimeType(); 65 | 66 | let texture = this._cache.get(image); 67 | 68 | if (texture) return texture; 69 | 70 | texture = mimeType === 'image/ktx2' ? await this._loadKTX2Image(image) : await this._loadImage(image, mimeType); 71 | 72 | this._cache.set(image, texture); 73 | return texture; 74 | } 75 | 76 | setKTX2Loader(loader: KTX2Loader): this { 77 | this._ktx2Loader = loader; 78 | return this; 79 | } 80 | 81 | clear(): void { 82 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 83 | for (const [_, texture] of this._cache) { 84 | texture.dispose(); 85 | } 86 | this._cache.clear(); 87 | } 88 | 89 | dispose() { 90 | this.clear(); 91 | if (this._ktx2Loader) this._ktx2Loader.dispose(); 92 | } 93 | 94 | /** Load PNG, JPEG, or other browser-suppored image format. */ 95 | private async _loadImage(image: ArrayBuffer, mimeType: string): Promise { 96 | return new Promise((resolve, reject) => { 97 | const blob = new Blob([image], { type: mimeType }); 98 | const imageURL = URL.createObjectURL(blob); 99 | const imageEl = document.createElement('img'); 100 | 101 | const texture = new Texture(imageEl); 102 | texture.flipY = false; 103 | 104 | imageEl.src = imageURL; 105 | imageEl.onload = () => { 106 | URL.revokeObjectURL(imageURL); 107 | resolve(texture); 108 | }; 109 | imageEl.onerror = reject; 110 | }); 111 | } 112 | 113 | /** Load KTX2 + Basis Universal compressed texture format. */ 114 | private async _loadKTX2Image(image: ArrayBuffer): Promise { 115 | this._ktx2Loader ||= createKTX2Loader(); 116 | const blob = new Blob([image], { type: 'image/ktx2' }); 117 | const imageURL = URL.createObjectURL(blob); 118 | const texture = await this._ktx2Loader.loadAsync(imageURL); 119 | URL.revokeObjectURL(imageURL); 120 | return texture; 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /examples/2-material.ts: -------------------------------------------------------------------------------- 1 | import { ACESFilmicToneMapping, AmbientLight, DirectionalLight, PMREMGenerator, PerspectiveCamera, Scene, WebGLRenderer, TorusKnotGeometry } from 'three'; 2 | import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; 3 | import { Document, Material } from '@gltf-transform/core'; 4 | import { DocumentView, NullImageProvider } from '../dist/view.modern.js'; 5 | import { createMaterialPane } from './material-pane'; 6 | import { createStatsPane } from './stats-pane.js'; 7 | import { Pane } from 'tweakpane'; 8 | import { createEnvironment } from './util.js'; 9 | 10 | const renderer = new WebGLRenderer({antialias: true}); 11 | renderer.setPixelRatio(window.devicePixelRatio); 12 | renderer.setSize(window.innerWidth, window.innerHeight); 13 | renderer.useLegacyLights = false; 14 | renderer.toneMapping = ACESFilmicToneMapping; 15 | renderer.toneMappingExposure = 1; 16 | 17 | const containerEl = document.querySelector('#container')!; 18 | containerEl.appendChild(renderer.domElement); 19 | 20 | const pmremGenerator = new PMREMGenerator(renderer); 21 | pmremGenerator.compileEquirectangularShader(); 22 | 23 | const scene = new Scene(); 24 | 25 | const light1 = new AmbientLight(); 26 | const light2 = new DirectionalLight(); 27 | light2.position.set(1, 2, 3); 28 | scene.add(light1, light2); 29 | 30 | createEnvironment(renderer) 31 | .then((environment) => { 32 | scene.environment = environment; 33 | scene.background = environment; 34 | render(); 35 | }); 36 | 37 | const camera = new PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.25, 20); 38 | camera.position.set(-4, 1.2, 5.4); 39 | camera.lookAt(scene.position); 40 | 41 | const controls = new OrbitControls(camera, renderer.domElement); 42 | controls.addEventListener('change', render); 43 | controls.minDistance = 2; 44 | controls.maxDistance = 10; 45 | controls.target.set(0, 0, - 0.2); 46 | controls.update(); 47 | 48 | window.addEventListener('resize', onWindowResize); 49 | 50 | // 51 | 52 | let material: Material; 53 | 54 | const doc = (() => { 55 | const doc = new Document(); 56 | material = doc.createMaterial('Material'); 57 | const primTemplate = new TorusKnotGeometry(1, 0.4, 100, 16); 58 | const indicesArray = primTemplate.index!.array as Uint16Array; 59 | const positionArray = primTemplate.attributes.position.array as Float32Array; 60 | const normalArray = primTemplate.attributes.normal.array as Float32Array; 61 | const texcoordArray = primTemplate.attributes.uv.array as Float32Array; 62 | const prim = doc.createPrimitive() 63 | .setIndices(doc.createAccessor('indices').setType('SCALAR').setArray(indicesArray)) 64 | .setAttribute('POSITION', doc.createAccessor('p').setType('VEC3').setArray(positionArray)) 65 | .setAttribute('NORMAL', doc.createAccessor('n').setType('VEC3').setArray(normalArray)) 66 | .setAttribute('TEXCOORD_0', doc.createAccessor('t').setType('VEC2').setArray(texcoordArray)) 67 | .setMaterial(material); 68 | const mesh = doc.createMesh().addPrimitive(prim); 69 | const node = doc.createNode().setMesh(mesh); 70 | doc.createScene().addChild(node); 71 | return doc; 72 | })(); 73 | 74 | const imageProvider = new NullImageProvider(); 75 | const documentView = new DocumentView(doc, {imageProvider}); 76 | const modelDef = doc.getRoot().getDefaultScene() || doc.getRoot().listScenes()[0]; 77 | const model = documentView.view(modelDef); 78 | scene.add(model); 79 | 80 | // 81 | 82 | const pane = new Pane({title: 'DamagedHelmet.glb'}); 83 | createMaterialPane(pane, doc, material); 84 | const updateStats = createStatsPane(renderer, pane); 85 | 86 | // 87 | 88 | animate(); 89 | 90 | // 91 | 92 | function animate() { 93 | requestAnimationFrame(animate); 94 | render(); 95 | updateStats(); 96 | } 97 | 98 | function render() { 99 | renderer.render(scene, camera); 100 | } 101 | 102 | function onWindowResize() { 103 | camera.aspect = window.innerWidth / window.innerHeight; 104 | camera.updateProjectionMatrix(); 105 | renderer.setSize(window.innerWidth, window.innerHeight); 106 | render(); 107 | } 108 | 109 | function printGraph(node) { 110 | console.group(' <' + node.type + '> ' + node.name + '#' + node.uuid.substr(0, 6)); 111 | node.children.forEach((child) => printGraph(child)); 112 | if (node.isMesh) { 113 | console.group(' <' + node.geometry.type + '> ' + node.geometry.name + '#' + node.geometry.uuid.substr(0, 6)); 114 | console.groupEnd(); 115 | console.group(' <' + node.material.type + '> ' + node.material.name + '#' + node.material.uuid.substr(0, 6)); 116 | console.groupEnd(); 117 | } 118 | console.groupEnd(); 119 | } 120 | -------------------------------------------------------------------------------- /src/subjects/Subject.ts: -------------------------------------------------------------------------------- 1 | import { Property as PropertyDef } from '@gltf-transform/core'; 2 | import { Output } from '../observers'; 3 | import type { DocumentViewSubjectAPI } from '../DocumentViewImpl'; 4 | import type { Subscription, THREEObject } from '../constants'; 5 | import { EmptyParams, ValuePool } from '../pools'; 6 | 7 | /** 8 | * Implementation of BehaviorSubject pattern, emitting three.js objects when changes 9 | * occur in glTF definitions. 10 | * 11 | * Each glTF definition (e.g. `Material`) is bound to a single Subject (e.g. `MaterialSubject`). 12 | * The Subject is responsible for receiving change events published by the definition, generating a 13 | * derived three.js object (e.g. `THREE.Material`), and publishing the new value to all Observers. More 14 | * precisely, this is a [*BehaviorSubject*](https://reactivex.io/documentation/subject.html), which holds 15 | * a single current value at any given time. 16 | * 17 | * @internal 18 | */ 19 | export abstract class Subject { 20 | def: Def; 21 | value: Value; 22 | pool: ValuePool; 23 | 24 | protected _documentView: DocumentViewSubjectAPI; 25 | protected _subscriptions: Subscription[] = []; 26 | protected _outputs = new Set>(); 27 | protected _outputParamsFns = new Map, () => Params>(); 28 | 29 | /** 30 | * Indicates that the output value of this subject is a singleton, and will not 31 | * be cloned by any observer. For some types (NodeSubject), declaring this can 32 | * avoid the need to republish after an in-place update to the value. 33 | */ 34 | protected _outputSingleton = false; 35 | 36 | protected constructor(documentView: DocumentViewSubjectAPI, def: Def, value: Value, pool: ValuePool) { 37 | this._documentView = documentView; 38 | this.def = def; 39 | this.value = value; 40 | this.pool = pool; 41 | 42 | const onChange = () => { 43 | const prevValue = this.value; 44 | this.update(); 45 | if (this.value !== prevValue || !this._outputSingleton) { 46 | this.publishAll(); 47 | } 48 | }; 49 | const onDispose = () => this.dispose(); 50 | 51 | def.addEventListener('change', onChange); 52 | def.addEventListener('dispose', onDispose); 53 | 54 | this._subscriptions.push( 55 | () => def.removeEventListener('change', onChange), 56 | () => def.removeEventListener('dispose', onDispose), 57 | ); 58 | } 59 | 60 | /************************************************************************** 61 | * Lifecycle. 62 | */ 63 | 64 | // TODO(perf): Many publishes during an update (e.g. Material). Consider batching or scheduling. 65 | abstract update(): void; 66 | 67 | publishAll(): void { 68 | // Prevent publishing updates during disposal. 69 | if (this._documentView.isDisposed()) return; 70 | 71 | for (const output of this._outputs) { 72 | this.publish(output); 73 | } 74 | } 75 | 76 | publish(output: Output): void { 77 | // Prevent publishing updates during disposal, which would cause loops. 78 | if (this._documentView.isDisposed()) return; 79 | 80 | if (output.value) { 81 | this.pool.releaseVariant(output.value); 82 | } 83 | 84 | // Map value to the requirements associated with the output. 85 | const paramsFn = this._outputParamsFns.get(output)!; 86 | const value = this.pool.requestVariant(this.value, paramsFn()); 87 | 88 | // Record for lookups before advancing the value. SingleUserPool 89 | // requires this order to preserve PrimitiveDef output lookups. 90 | this._documentView.recordOutputValue(this.def, value as unknown as THREEObject); 91 | 92 | // Advance next value. 93 | output.next(value); 94 | } 95 | 96 | dispose(): void { 97 | for (const unsub of this._subscriptions) unsub(); 98 | if (this.value) { 99 | this.pool.releaseBase(this.value); 100 | } 101 | 102 | for (const output of this._outputs) { 103 | const value = output.value; 104 | output.detach(); 105 | output.next(null); 106 | if (value) this.pool.releaseVariant(value); 107 | } 108 | } 109 | 110 | /************************************************************************** 111 | * Output API — Used by RefObserver.ts 112 | */ 113 | 114 | /** 115 | * Adds an output, which will receive future published values. 116 | * _Only for use of RefObserver.ts._ 117 | */ 118 | addOutput(output: Output, paramsFn: () => Params) { 119 | this._outputs.add(output); 120 | this._outputParamsFns.set(output, paramsFn); 121 | } 122 | 123 | /** 124 | * Removes an output, which will no longer receive published values. 125 | * _Only for use of RefObserver.ts._ 126 | */ 127 | removeOutput(output: Output) { 128 | const value = output.value; 129 | this._outputs.delete(output); 130 | this._outputParamsFns.delete(output); 131 | if (value) this.pool.releaseVariant(value); 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/subjects/NodeSubject.ts: -------------------------------------------------------------------------------- 1 | import { Bone, Group, Matrix4, Object3D, Skeleton, SkinnedMesh, InstancedMesh, Mesh } from 'three'; 2 | import { Mesh as MeshDef, Node as NodeDef, Skin as SkinDef, vec3, vec4 } from '@gltf-transform/core'; 3 | import { Light as LightDef, InstancedMesh as InstancedMeshDef } from '@gltf-transform/extensions'; 4 | import type { DocumentViewSubjectAPI } from '../DocumentViewImpl'; 5 | import { eq } from '../utils'; 6 | import { Subject } from './Subject'; 7 | import { RefListObserver, RefObserver } from '../observers'; 8 | import { SingleUserPool } from '../pools'; 9 | import { LightLike } from '../constants'; 10 | 11 | const _vec3: vec3 = [0, 0, 0]; 12 | const _vec4: vec4 = [0, 0, 0, 0]; 13 | const IDENTITY = new Matrix4().identity(); 14 | 15 | /** @internal */ 16 | export class NodeSubject extends Subject { 17 | protected children = new RefListObserver('children', this._documentView); 18 | protected mesh = new RefObserver('mesh', this._documentView).setParamsFn(() => 19 | SingleUserPool.createParams(this.def), 20 | ); 21 | protected skin = new RefObserver('skin', this._documentView); 22 | protected light = new RefObserver('light', this._documentView); 23 | protected instancedMesh = new RefObserver('instancedMesh', this._documentView); 24 | 25 | /** Output (Object3D) is never cloned by an observer. */ 26 | protected _outputSingleton = true; 27 | 28 | constructor(documentView: DocumentViewSubjectAPI, def: NodeDef) { 29 | super( 30 | documentView, 31 | def, 32 | documentView.nodePool.requestBase(isJoint(def) ? new Bone() : new Object3D()), 33 | documentView.nodePool, 34 | ); 35 | 36 | this.children.subscribe((nextChildren, prevChildren) => { 37 | if (prevChildren.length) this.value.remove(...prevChildren); 38 | if (nextChildren.length) this.value.add(...nextChildren); 39 | this.publishAll(); 40 | }); 41 | this.mesh.subscribe(() => { 42 | this.detachMesh(); 43 | this.attachMesh(); 44 | this.bindSkeleton(); 45 | this.publishAll(); 46 | }); 47 | this.skin.subscribe(() => { 48 | this.bindSkeleton(); 49 | this.publishAll(); 50 | }); 51 | this.light.subscribe((nextLight, prevLight) => { 52 | if (prevLight) this.value.remove(prevLight); 53 | if (nextLight) this.value.add(nextLight); 54 | this.publishAll(); 55 | }); 56 | this.instancedMesh.subscribe(() => { 57 | this.detachMesh(); 58 | this.attachMesh(); 59 | this.publishAll(); 60 | }); 61 | } 62 | 63 | private detachMesh() { 64 | let group: Group | undefined; 65 | for (const child of this.value.children) { 66 | if ((child as Group).isGroup) { 67 | group = child as Group; 68 | break; 69 | } 70 | } 71 | if (group) this.value.remove(group); 72 | } 73 | 74 | private attachMesh() { 75 | const srcGroup = this.mesh.value; 76 | const srcInstancedMesh = this.instancedMesh.value; 77 | if (srcGroup && srcInstancedMesh) { 78 | const dstGroup = new Group(); 79 | for (const mesh of srcGroup.children as Mesh[]) { 80 | const instancedMesh = new InstancedMesh(mesh.geometry, mesh.material, srcInstancedMesh.count); 81 | instancedMesh.instanceMatrix.copy(srcInstancedMesh.instanceMatrix); 82 | dstGroup.add(instancedMesh); 83 | } 84 | this.value.add(dstGroup); 85 | } else if (srcGroup) { 86 | this.value.add(srcGroup); 87 | } 88 | } 89 | 90 | private bindSkeleton() { 91 | if (!this.mesh.value || !this.skin.value) return; 92 | 93 | for (const prim of this.mesh.value.children) { 94 | if (prim instanceof SkinnedMesh) { 95 | prim.bind(this.skin.value, IDENTITY); 96 | prim.normalizeSkinWeights(); // three.js#15319 97 | } 98 | } 99 | } 100 | 101 | update() { 102 | const def = this.def; 103 | const value = this.value; 104 | 105 | if (def.getName() !== value.name) { 106 | value.name = def.getName(); 107 | } 108 | 109 | if (!eq(def.getTranslation(), value.position.toArray(_vec3))) { 110 | value.position.fromArray(def.getTranslation()); 111 | } 112 | 113 | if (!eq(def.getRotation(), value.quaternion.toArray(_vec4))) { 114 | value.quaternion.fromArray(def.getRotation()); 115 | } 116 | 117 | if (!eq(def.getScale(), value.scale.toArray(_vec3))) { 118 | value.scale.fromArray(def.getScale()); 119 | } 120 | 121 | this.children.update(def.listChildren()); 122 | this.mesh.update(def.getMesh()); 123 | this.skin.update(def.getSkin()); 124 | this.light.update(def.getExtension('KHR_lights_punctual')); 125 | this.instancedMesh.update(def.getExtension('EXT_mesh_gpu_instancing')); 126 | } 127 | 128 | dispose() { 129 | this.children.dispose(); 130 | this.mesh.dispose(); 131 | this.skin.dispose(); 132 | this.light.dispose(); 133 | this.instancedMesh.dispose(); 134 | super.dispose(); 135 | } 136 | } 137 | 138 | function isJoint(def: NodeDef): boolean { 139 | return def.listParents().some((parent) => parent instanceof SkinDef); 140 | } 141 | -------------------------------------------------------------------------------- /src/subjects/PrimitiveSubject.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BufferAttribute, 3 | BufferGeometry, 4 | Line, 5 | LineLoop, 6 | LineSegments, 7 | Material, 8 | Mesh, 9 | Points, 10 | SkinnedMesh, 11 | } from 'three'; 12 | import { Accessor as AccessorDef, Material as MaterialDef, Primitive as PrimitiveDef } from '@gltf-transform/core'; 13 | import type { DocumentViewSubjectAPI } from '../DocumentViewImpl'; 14 | import { Subject } from './Subject'; 15 | import { RefMapObserver, RefObserver } from '../observers'; 16 | import { MeshLike } from '../constants'; 17 | import { MaterialParams, MaterialPool, ValuePool } from '../pools'; 18 | import { DEFAULT_MATERIAL, semanticToAttributeName } from '../utils'; 19 | 20 | /** @internal */ 21 | export class PrimitiveSubject extends Subject { 22 | protected material = new RefObserver( 23 | 'material', 24 | this._documentView, 25 | ).setParamsFn(() => MaterialPool.createParams(this.def)); 26 | protected indices = new RefObserver('indices', this._documentView); 27 | protected attributes = new RefMapObserver('attributes', this._documentView); 28 | 29 | constructor(documentView: DocumentViewSubjectAPI, def: PrimitiveDef) { 30 | super( 31 | documentView, 32 | def, 33 | PrimitiveSubject.createValue(def, new BufferGeometry(), DEFAULT_MATERIAL, documentView.primitivePool), 34 | documentView.primitivePool, 35 | ); 36 | 37 | this.material.subscribe((material) => { 38 | if (this.value.material !== material) { 39 | this.value.material = material || DEFAULT_MATERIAL; 40 | this.publishAll(); 41 | } 42 | }); 43 | this.indices.subscribe((index) => { 44 | if (this.value.geometry.index !== index) { 45 | this.value.geometry.setIndex(index); 46 | this.publishAll(); 47 | } 48 | }); 49 | this.attributes.subscribe((nextAttributes, prevAttributes) => { 50 | const geometry = this.value.geometry; 51 | for (const key in prevAttributes) { 52 | geometry.deleteAttribute(semanticToAttributeName(key)); 53 | } 54 | for (const key in nextAttributes) { 55 | geometry.setAttribute(semanticToAttributeName(key), nextAttributes[key]); 56 | } 57 | this.publishAll(); 58 | }); 59 | } 60 | 61 | update() { 62 | const def = this.def; 63 | let value = this.value; 64 | 65 | if (def.getName() !== value.name) { 66 | value.name = def.getName(); 67 | } 68 | 69 | // Order is important here: 70 | // (1) Attributes must update before material params. 71 | // (2) Material params must update before material. 72 | // (3) Mode can safely come last, but that's non-obvious. 73 | 74 | this.indices.update(def.getIndices()); 75 | this.attributes.update(def.listSemantics(), def.listAttributes()); 76 | this.material.update(def.getMaterial()); 77 | 78 | if (getType(def) !== value.type) { 79 | this.pool.releaseBase(value); 80 | this.value = value = PrimitiveSubject.createValue(def, value.geometry, value.material, this.pool); 81 | this.material.invalidate(); 82 | } 83 | } 84 | 85 | private static createValue( 86 | def: PrimitiveDef, 87 | geometry: BufferGeometry, 88 | material: Material, 89 | pool: ValuePool, 90 | ): MeshLike { 91 | switch (def.getMode()) { 92 | case PrimitiveDef.Mode.TRIANGLES: 93 | case PrimitiveDef.Mode.TRIANGLE_FAN: 94 | case PrimitiveDef.Mode.TRIANGLE_STRIP: { 95 | // TODO(feat): Support triangle fan and triangle strip. 96 | if (geometry.hasAttribute('skinIndex')) { 97 | return pool.requestBase(new SkinnedMesh(geometry, material)); 98 | } else { 99 | return pool.requestBase(new Mesh(geometry, material)); 100 | } 101 | } 102 | case PrimitiveDef.Mode.LINES: 103 | return pool.requestBase(new LineSegments(geometry, material)); 104 | case PrimitiveDef.Mode.LINE_LOOP: 105 | return pool.requestBase(new LineLoop(geometry, material)); 106 | case PrimitiveDef.Mode.LINE_STRIP: 107 | return pool.requestBase(new Line(geometry, material)); 108 | case PrimitiveDef.Mode.POINTS: 109 | return pool.requestBase(new Points(geometry, material)); 110 | default: 111 | throw new Error(`Unexpected primitive mode: ${def.getMode()}`); 112 | } 113 | } 114 | 115 | dispose() { 116 | this.value.geometry.dispose(); 117 | this.material.dispose(); 118 | this.indices.dispose(); 119 | this.attributes.dispose(); 120 | super.dispose(); 121 | } 122 | } 123 | 124 | /** Returns equivalent GL mode enum for the given THREE.Object3D type. */ 125 | // function getObject3DMode(mesh: MeshLike): GLTF.MeshPrimitiveMode { 126 | // switch (mesh.type) { 127 | // case 'Mesh': 128 | // case 'SkinnedMesh': 129 | // // TODO(feat): Support triangle fan and triangle strip. 130 | // return PrimitiveDef.Mode.TRIANGLES; 131 | // case 'LineSegments': 132 | // return PrimitiveDef.Mode.LINES; 133 | // case 'LineLoop': 134 | // return PrimitiveDef.Mode.LINE_LOOP; 135 | // case 'Line': 136 | // return PrimitiveDef.Mode.LINE_STRIP; 137 | // case 'Points': 138 | // return PrimitiveDef.Mode.POINTS; 139 | // default: 140 | // throw new Error(`Unexpected type: ${mesh.type}`); 141 | // } 142 | // } 143 | 144 | function getType(def: PrimitiveDef): string { 145 | switch (def.getMode()) { 146 | case PrimitiveDef.Mode.TRIANGLES: 147 | case PrimitiveDef.Mode.TRIANGLE_FAN: 148 | case PrimitiveDef.Mode.TRIANGLE_STRIP: { 149 | if (def.getAttribute('JOINTS_0')) { 150 | return 'SkinnedMesh'; 151 | } else { 152 | return 'Mesh'; 153 | } 154 | } 155 | case PrimitiveDef.Mode.LINES: 156 | return 'LineSegments'; 157 | case PrimitiveDef.Mode.LINE_LOOP: 158 | return 'LineLoop'; 159 | case PrimitiveDef.Mode.LINE_STRIP: 160 | return 'Line'; 161 | case PrimitiveDef.Mode.POINTS: 162 | return 'Points'; 163 | default: 164 | throw new Error(`Unexpected primitive mode: ${def.getMode()}`); 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /examples/3-diff.ts: -------------------------------------------------------------------------------- 1 | import { ACESFilmicToneMapping, AmbientLight, DirectionalLight, PerspectiveCamera, Scene, WebGLRenderer, Object3D, Mesh, Material, Box3, Vector3 } from 'three'; 2 | import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; 3 | import { Document, Material as MaterialDef } from '@gltf-transform/core'; 4 | import { metalRough } from '@gltf-transform/functions'; 5 | import { DocumentView } from '../dist/view.modern.js'; 6 | import { createEnvironment, createGLTFLoader, createIO } from './util.js'; 7 | 8 | const renderer = new WebGLRenderer({antialias: true}); 9 | renderer.setPixelRatio(window.devicePixelRatio); 10 | renderer.setSize(window.innerWidth, window.innerHeight); 11 | renderer.useLegacyLights = false; 12 | renderer.toneMapping = ACESFilmicToneMapping; 13 | renderer.toneMappingExposure = 1; 14 | 15 | const containerEl = document.querySelector('#container')!; 16 | containerEl.appendChild(renderer.domElement); 17 | 18 | const scene = new Scene(); 19 | let documentView: DocumentView; 20 | let modelBefore: Object3D; 21 | let modelAfter: Object3D; 22 | 23 | const light1 = new AmbientLight(); 24 | const light2 = new DirectionalLight(); 25 | light2.position.set(1, 2, 3); 26 | scene.add(light1, light2); 27 | 28 | createEnvironment(renderer) 29 | .then((environment) => { 30 | scene.environment = environment; 31 | scene.background = environment; 32 | render(); 33 | }); 34 | 35 | const camera = new PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.25, 20); 36 | camera.position.set(-1.8, 0.6, 2.7); 37 | camera.lookAt(scene.position); 38 | 39 | const controls = new OrbitControls(camera, renderer.domElement); 40 | controls.addEventListener('change', render); 41 | controls.update(); 42 | 43 | const io = createIO(); 44 | const loader = createGLTFLoader(); 45 | 46 | window.addEventListener('resize', onWindowResize); 47 | 48 | // 49 | 50 | document.body.addEventListener('gltf-document', async (event) => { 51 | const doc = (event as CustomEvent).detail as Document; 52 | const modelDef = doc.getRoot().getDefaultScene() || doc.getRoot().listScenes()[0]; 53 | 54 | if (modelBefore) disposeBefore(modelBefore); 55 | if (modelAfter) disposeAfter(modelAfter); 56 | 57 | await checkMaterials(doc); 58 | await checkExtensions(doc); 59 | 60 | console.time('DocumentView::init'); 61 | documentView = new DocumentView(doc); 62 | modelAfter = documentView.view(modelDef); 63 | console.timeEnd('DocumentView::init'); 64 | 65 | console.time('WebIO::writeBinary'); 66 | const glb = await io.writeBinary(doc); 67 | console.timeEnd('WebIO::writeBinary'); 68 | 69 | console.time('GLTFLoader::parse'); 70 | modelBefore = await new Promise((resolve, reject) => { 71 | loader.parse(glb.buffer, '', ({scene}) => resolve(scene), reject); 72 | }) as Object3D; 73 | console.timeEnd('GLTFLoader::parse'); 74 | 75 | frameContent(modelBefore, -1); 76 | frameContent(modelAfter, 1); 77 | 78 | controls.update(); 79 | render(); 80 | 81 | console.table(documentView.stats()); 82 | }); 83 | 84 | // 85 | 86 | function render() { 87 | renderer.render(scene, camera); 88 | } 89 | 90 | function onWindowResize() { 91 | camera.aspect = window.innerWidth / window.innerHeight; 92 | camera.updateProjectionMatrix(); 93 | renderer.setSize(window.innerWidth, window.innerHeight); 94 | render(); 95 | } 96 | 97 | function disposeBefore(model: Object3D) { 98 | scene.remove(model); 99 | model.traverse((o) => { 100 | if ((o as Mesh).isMesh) { 101 | (o as Mesh).geometry.dispose(); 102 | const material = (o as Mesh).material as Material; 103 | for (const key in material) { 104 | if (material[key] && material[key].isTexture) { 105 | material[key].dispose(); 106 | } 107 | } 108 | material.dispose(); 109 | } 110 | }); 111 | } 112 | 113 | function disposeAfter(model: Object3D) { 114 | scene.remove(model); 115 | documentView.dispose(); 116 | console.table(documentView.stats()); 117 | } 118 | 119 | function frameContent(object: Object3D, offset: 1 | -1) { 120 | const box = new Box3().setFromObject(object); 121 | const size = box.getSize(new Vector3()); 122 | const length = box.getSize(new Vector3()).length(); 123 | const center = box.getCenter(new Vector3()); 124 | 125 | controls.reset(); 126 | 127 | object.position.x += (object.position.x - center.x) + offset * size.x / 2; 128 | object.position.y += (object.position.y - center.y); 129 | object.position.z += (object.position.z - center.z); 130 | controls.maxDistance = length * 10; 131 | camera.near = length / 100; 132 | camera.far = length * 100; 133 | camera.updateProjectionMatrix(); 134 | 135 | camera.position.copy(center); 136 | camera.position.x += length / 2.0; 137 | camera.position.y += length / 5.0; 138 | camera.position.z += length / 2.0; 139 | camera.lookAt(center); 140 | 141 | controls.saveState(); 142 | 143 | scene.add(object); 144 | } 145 | 146 | /** 147 | * Adds a default PBR material to any mesh primitives without one. This is considerably simpler 148 | * than trying to handle all cases with default materials internally, because the logic for 149 | * creating material variants (vertexColors, points, ...) is part of the _MaterialSubject_ class, 150 | * and no MaterialDef exists for input. 151 | * 152 | * Context: 153 | * - https://github.com/donmccurdy/glTF-Report-Feedback/issues/43 154 | */ 155 | async function checkMaterials(document: Document) { 156 | let defaultMaterial: MaterialDef | undefined; 157 | for (const mesh of document.getRoot().listMeshes()) { 158 | for (const prim of mesh.listPrimitives()) { 159 | if (!prim.getMaterial()) { 160 | defaultMaterial ||= document.createMaterial(); 161 | prim.setMaterial(defaultMaterial); 162 | } 163 | } 164 | } 165 | } 166 | 167 | async function checkExtensions(document: Document) { 168 | const extensions = document.getRoot() 169 | .listExtensionsUsed() 170 | .map((ext) => ext.extensionName); 171 | console.debug(`EXTENSIONS: ${extensions.join() || 'None'}`); 172 | 173 | if (extensions.includes('KHR_materials_pbrSpecularGlossiness')) { 174 | await document.transform(metalRough()); 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /test/MaterialSubject.test.ts: -------------------------------------------------------------------------------- 1 | import test from "ava"; 2 | import { Document, Primitive as PrimitiveDef } from "@gltf-transform/core"; 3 | import { DocumentView, NullImageProvider } from "@gltf-transform/view"; 4 | import { 5 | KHRMaterialsClearcoat, 6 | KHRMaterialsUnlit, 7 | } from "@gltf-transform/extensions"; 8 | import { 9 | BufferGeometry, 10 | LineBasicMaterial, 11 | LineSegments, 12 | Mesh, 13 | MeshStandardMaterial, 14 | Points, 15 | PointsMaterial, 16 | Texture, 17 | } from "three"; 18 | 19 | const imageProvider = new NullImageProvider(); 20 | 21 | test("MaterialSubject", async (t) => { 22 | const document = new Document(); 23 | const texDef1 = document 24 | .createTexture("Tex1") 25 | .setMimeType("image/png") 26 | .setImage(new Uint8Array(0)); 27 | const texDef2 = document 28 | .createTexture("Tex2") 29 | .setMimeType("image/png") 30 | .setImage(new Uint8Array(0)); 31 | const materialDef = document 32 | .createMaterial("Material") 33 | .setBaseColorTexture(texDef1) 34 | .setEmissiveTexture(texDef2); 35 | const primDef = document.createPrimitive().setMaterial(materialDef); 36 | const meshDef = document.createMesh("Mesh").addPrimitive(primDef); 37 | const nodeDef = document.createNode("Node").setMesh(meshDef); 38 | const sceneDef = document.createScene("Scene").addChild(nodeDef); 39 | 40 | const documentView = new DocumentView(document, { imageProvider }); 41 | const scene = documentView.view(sceneDef); 42 | let mesh = scene.children[0].children[0].children[0] as Mesh< 43 | BufferGeometry, 44 | MeshStandardMaterial 45 | >; 46 | let material = mesh.material; 47 | 48 | t.is(material.name, "Material", "material.name → Material"); 49 | t.is( 50 | material.type, 51 | "MeshStandardMaterial", 52 | "material.type → MeshStandardMaterial", 53 | ); 54 | t.truthy(material.map, "material.map → ok"); 55 | t.truthy(material.emissiveMap, "material.emissiveMap → ok"); 56 | 57 | texDef1.dispose(); 58 | mesh = scene.children[0].children[0].children[0] as Mesh< 59 | BufferGeometry, 60 | MeshStandardMaterial 61 | >; 62 | material = mesh.material; 63 | 64 | t.falsy(material.map, "material.map → null"); 65 | t.truthy(material.emissiveMap, "material.emissiveMap → ok"); 66 | }); 67 | 68 | test("MaterialSubject | extensions", async (t) => { 69 | const document = new Document(); 70 | const unlitExtension = document.createExtension(KHRMaterialsUnlit); 71 | const clearcoatExtension = document.createExtension(KHRMaterialsClearcoat); 72 | 73 | const materialDef = document.createMaterial("Material"); 74 | const documentView = new DocumentView(document, { imageProvider }); 75 | 76 | let material = documentView.view(materialDef); 77 | 78 | t.is(material.type, "MeshStandardMaterial", "MeshStandardMaterial"); 79 | 80 | materialDef.setExtension("KHR_materials_unlit", unlitExtension.createUnlit()); 81 | material = documentView.view(materialDef); 82 | 83 | t.is(material.type, "MeshBasicMaterial", "MeshBasicMaterial"); 84 | 85 | materialDef.setExtension("KHR_materials_unlit", null); 86 | material = documentView.view(materialDef); 87 | 88 | t.is(material.type, "MeshStandardMaterial", "MeshStandardMaterial"); 89 | 90 | materialDef.setExtension( 91 | "KHR_materials_clearcoat", 92 | clearcoatExtension.createClearcoat(), 93 | ); 94 | material = documentView.view(materialDef); 95 | 96 | t.is(material.type, "MeshPhysicalMaterial", "MeshPhysicalMaterial"); 97 | }); 98 | 99 | test("MaterialSubject | dispose", async (t) => { 100 | const document = new Document(); 101 | const texDef1 = document 102 | .createTexture("Tex1") 103 | .setMimeType("image/png") 104 | .setImage(new Uint8Array(0)); 105 | const texDef2 = document 106 | .createTexture("Tex2") 107 | .setMimeType("image/png") 108 | .setImage(new Uint8Array(0)); 109 | const materialDef = document 110 | .createMaterial("Material") 111 | .setBaseColorTexture(texDef1) 112 | .setEmissiveTexture(texDef2); 113 | const meshPrimDef = document 114 | .createPrimitive() 115 | .setMode(PrimitiveDef.Mode.TRIANGLES) 116 | .setMaterial(materialDef); 117 | const pointsPrimDef = document 118 | .createPrimitive() 119 | .setMode(PrimitiveDef.Mode.POINTS) 120 | .setMaterial(materialDef); 121 | const meshDef = document 122 | .createMesh("Mesh") 123 | .addPrimitive(meshPrimDef) 124 | .addPrimitive(pointsPrimDef); 125 | const sceneDef = document 126 | .createScene("Scene") 127 | .addChild(document.createNode("Node").setMesh(meshDef)); 128 | 129 | const documentView = new DocumentView(document, { imageProvider }); 130 | const scene = documentView.view(sceneDef); 131 | const [mesh, points] = scene.getObjectByName("Mesh")!.children as [ 132 | Mesh, 133 | Points, 134 | ]; 135 | const meshMaterial = mesh.material as MeshStandardMaterial; 136 | const pointsMaterial = points.material as PointsMaterial; 137 | 138 | const disposed = new Set(); 139 | meshMaterial.addEventListener("dispose", () => disposed.add(meshMaterial)); 140 | pointsMaterial.addEventListener("dispose", () => 141 | disposed.add(pointsMaterial), 142 | ); 143 | 144 | t.is(disposed.size, 0, "initial values"); 145 | t.is( 146 | meshMaterial.type, 147 | "MeshStandardMaterial", 148 | "creates MeshStandardMaterial", 149 | ); 150 | t.is(pointsMaterial.type, "PointsMaterial", "creates PointsMaterial"); 151 | 152 | meshPrimDef.setMaterial(null); 153 | documentView.gc(); 154 | 155 | t.is(disposed.size, 1, "dispose count (1/3)"); 156 | t.truthy(disposed.has(meshMaterial), "dispose MeshStandardMaterial"); 157 | 158 | pointsPrimDef.setMode(PrimitiveDef.Mode.LINES); 159 | documentView.gc(); 160 | 161 | t.is(disposed.size, 2, "dispose count (2/3)"); 162 | t.truthy(disposed.has(pointsMaterial), "dispose PointsMaterial"); 163 | 164 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 165 | const [_, lines] = scene.getObjectByName("Mesh")!.children as [ 166 | unknown, 167 | LineSegments, 168 | ]; 169 | const lineMaterial = lines.material as LineBasicMaterial; 170 | lineMaterial.addEventListener("dispose", () => disposed.add(lineMaterial)); 171 | 172 | t.is(lineMaterial.type, "LineBasicMaterial", "creates LineBasicMaterial"); 173 | 174 | materialDef.dispose(); 175 | documentView.gc(); 176 | 177 | t.is(disposed.size, 3, "dispose count (3/3)"); 178 | t.truthy(disposed.has(pointsMaterial), "dispose LineBasicMaterial"); 179 | }); 180 | 181 | test("MaterialSubject | texture memory", async (t) => { 182 | const document = new Document(); 183 | const clearcoatExtension = document.createExtension(KHRMaterialsClearcoat); 184 | const texDef1 = document 185 | .createTexture("Tex1") 186 | .setMimeType("image/png") 187 | .setImage(new Uint8Array(0)); 188 | const texDef2 = document 189 | .createTexture("Tex2") 190 | .setMimeType("image/png") 191 | .setImage(new Uint8Array(0)); 192 | const materialDef = document 193 | .createMaterial("Material") 194 | .setBaseColorTexture(texDef1) 195 | .setEmissiveTexture(texDef2); 196 | 197 | const documentView = new DocumentView(document, { imageProvider }); 198 | let material = documentView.view(materialDef); 199 | const { map, emissiveMap } = material as unknown as { 200 | map: Texture; 201 | emissiveMap: Texture; 202 | }; 203 | 204 | t.is(material.type, "MeshStandardMaterial", "original material"); 205 | t.truthy( 206 | map.source === emissiveMap.source, 207 | "map.source === emissiveMap.source", 208 | ); 209 | 210 | const baseVersion = map.version; 211 | t.is(map.version, baseVersion, "map.version"); 212 | t.is(emissiveMap.version, baseVersion, "emissiveMap.version"); 213 | 214 | materialDef.setExtension( 215 | "KHR_materials_clearcoat", 216 | clearcoatExtension.createClearcoat(), 217 | ); 218 | material = documentView.view(materialDef); 219 | 220 | t.is(material.type, "MeshPhysicalMaterial", "new material"); 221 | t.truthy( 222 | map.source === emissiveMap.source, 223 | "map.source === emissiveMap.source", 224 | ); 225 | t.is(map.version, baseVersion, "map.version"); 226 | t.is(emissiveMap.version, baseVersion, "emissiveMap.version"); 227 | }); 228 | -------------------------------------------------------------------------------- /test/DocumentView.test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { Document } from '@gltf-transform/core'; 3 | import { DocumentView, NullImageProvider } from '@gltf-transform/view'; 4 | import { BufferGeometry, Group, Mesh, MeshStandardMaterial, Texture } from 'three'; 5 | 6 | const imageProvider = new NullImageProvider(); 7 | 8 | test('DocumentView', t => { 9 | t.truthy(new DocumentView(new Document(), {imageProvider}), 'constructor'); 10 | }); 11 | 12 | test('DocumentView | view', async t => { 13 | const document = new Document(); 14 | const textureDef = document.createTexture(); 15 | const materialDef = document.createMaterial() 16 | .setBaseColorTexture(textureDef) 17 | .setMetallicRoughnessTexture(textureDef); 18 | const primDef = document.createPrimitive() 19 | .setMaterial(materialDef); 20 | const meshDef = document.createMesh() 21 | .addPrimitive(primDef); 22 | const nodeDef = document.createNode() 23 | .setMesh(meshDef); 24 | const sceneDef = document.createScene() 25 | .addChild(nodeDef); 26 | 27 | const documentView = new DocumentView(document, {imageProvider}); 28 | 29 | const scene = documentView.view(sceneDef); 30 | const node = scene.children[0]; 31 | const mesh = node.children[0]; 32 | const prim = mesh.children[0] as Mesh; 33 | let material = prim.material; 34 | let texture = material.map as Texture; 35 | 36 | t.true(scene instanceof Group, 'scene → THREE.Group'); 37 | t.deepEqual(documentView.listViews(sceneDef), [scene], 'scene views'); 38 | t.is(documentView.listViews(nodeDef).length, 1, 'node views'); 39 | t.is(documentView.listViews(meshDef).length, 1, 'mesh views'); 40 | // 1 external prim, 1 internal prim. See SingleUserPool. 41 | t.is(documentView.listViews(primDef).length, 2, 'prim views'); 42 | t.is(documentView.listViews(materialDef).length, 1, 'material views'); 43 | t.is(documentView.listViews(textureDef).length, 2, 'texture views'); 44 | t.is(documentView.getProperty(scene), sceneDef, 'scene → source'); 45 | t.is(documentView.getProperty(node), nodeDef, 'node → source'); 46 | t.is(documentView.getProperty(mesh), meshDef, 'mesh → source'); 47 | t.is(documentView.getProperty(prim), primDef, 'prim → source'); 48 | t.is(documentView.getProperty(material), materialDef, 'material → source'); 49 | t.is(documentView.getProperty(texture), textureDef, 'texture → source'); 50 | 51 | material = documentView.view(materialDef) as MeshStandardMaterial; 52 | t.is(material.type, 'MeshStandardMaterial', 'material → THREE.MeshStandardMaterial'); 53 | t.is(documentView.listViews(materialDef).length, 2, 'material views'); 54 | t.is(documentView.listViews(textureDef).length, 2, 'texture views'); 55 | t.is(documentView.getProperty(material), materialDef, 'material → source'); 56 | 57 | texture = documentView.view(textureDef); 58 | t.true(texture.isTexture, 'texture → THREE.Texture'); 59 | t.is(documentView.listViews(textureDef).length, 3, 'texture views'); 60 | t.is(documentView.getProperty(texture), textureDef, 'texture → source'); 61 | }); 62 | 63 | test('DocumentView | dispose', async t => { 64 | const document = new Document(); 65 | const texDef1 = document.createTexture('Tex1') 66 | .setMimeType('image/png') 67 | .setImage(new Uint8Array(0)); 68 | const texDef2 = document.createTexture('Tex2') 69 | .setMimeType('image/png') 70 | .setImage(new Uint8Array(0)); 71 | const materialDef = document.createMaterial('Material') 72 | .setBaseColorTexture(texDef1) 73 | .setEmissiveTexture(texDef2); 74 | const primDef = document.createPrimitive().setMaterial(materialDef); 75 | const sceneDef = document.createScene('Scene').addChild( 76 | document.createNode('Node') 77 | .setMesh(document.createMesh('Mesh').addPrimitive(primDef)) 78 | ); 79 | 80 | const documentView = new DocumentView(document, {imageProvider}); 81 | const scene = documentView.view(sceneDef); 82 | const mesh = scene.getObjectByName('Mesh')!.children[0] as 83 | Mesh; 84 | const {geometry, material} = mesh; 85 | const {map, emissiveMap} = material as {map: Texture, emissiveMap: Texture}; 86 | 87 | const disposed = new Set(); 88 | geometry.addEventListener('dispose', () => disposed.add(geometry)); 89 | material.addEventListener('dispose', () => disposed.add(material)); 90 | map.addEventListener('dispose', () => disposed.add(map)); 91 | emissiveMap.addEventListener('dispose', () => disposed.add(emissiveMap)); 92 | 93 | t.is(disposed.size, 0, 'initial resources'); 94 | 95 | documentView.dispose(); 96 | 97 | t.is(disposed.size, 4); 98 | t.true(disposed.has(geometry), 'disposed geometry'); 99 | t.true(disposed.has(material), 'disposed material'); 100 | t.true(disposed.has(map), 'disposed baseColorTexture'); 101 | t.true(disposed.has(emissiveMap), 'disposed emissiveTexture'); 102 | }); 103 | 104 | test('DocumentView | alloc', async t => { 105 | // Parts of this test are subjective — we don't *really* need to keep a Mesh 106 | // in memory if the MeshDef is unused — but it's important that the counts 107 | // come to zero when things are disposed. And if these results change 108 | // unintentionally, that's a good time to review side effects. 109 | 110 | const document = new Document(); 111 | const positionDef = document.createAccessor() 112 | .setType('VEC3') 113 | .setArray(new Float32Array([0, 0, 0])); 114 | const primDef = document.createPrimitive().setAttribute('POSITION', positionDef); 115 | const meshDef = document.createMesh().addPrimitive(primDef); 116 | const nodeDef = document.createNode(); 117 | const sceneDef = document.createScene().addChild(nodeDef); 118 | 119 | const documentView = new DocumentView(document, {imageProvider}); 120 | 121 | t.deepEqual(getPartialStats(documentView), { 122 | scenes: 0, 123 | nodes: 0, 124 | meshes: 0, 125 | primitives: 0, 126 | }, '1. empty'); 127 | 128 | documentView.view(sceneDef); 129 | 130 | t.deepEqual(getPartialStats(documentView), { 131 | scenes: 1, 132 | nodes: 1, 133 | meshes: 0, 134 | primitives: 0, 135 | }, '2. no mesh'); 136 | 137 | nodeDef.setMesh(meshDef); 138 | 139 | t.deepEqual(getPartialStats(documentView), { 140 | scenes: 1, 141 | nodes: 1, 142 | meshes: 2, // 1 internal, 1 view 143 | primitives: 2, // 1 internal, 1 view 144 | }, '3. add mesh'); 145 | 146 | nodeDef.setMesh(null); 147 | nodeDef.setMesh(meshDef); 148 | nodeDef.setMesh(null); 149 | nodeDef.setMesh(meshDef); 150 | nodeDef.setMesh(null); 151 | nodeDef.setMesh(meshDef); 152 | 153 | t.deepEqual(getPartialStats(documentView), { 154 | scenes: 1, 155 | nodes: 1, 156 | meshes: 5, // garbage accumulation 157 | primitives: 2, 158 | }, '4. garbage accumulation'); 159 | 160 | documentView.gc(); 161 | 162 | t.deepEqual(getPartialStats(documentView), { 163 | scenes: 1, 164 | nodes: 1, 165 | meshes: 2, // garbage collection 166 | primitives: 2, 167 | }, '5. garbage collection pt 1'); 168 | 169 | nodeDef.setMesh(null); 170 | documentView.gc(); 171 | 172 | t.deepEqual(getPartialStats(documentView), { 173 | scenes: 1, 174 | nodes: 1, 175 | meshes: 1, // garbage collection 176 | primitives: 2, 177 | }, '5. garbage collection pt 2'); 178 | 179 | primDef.dispose(); 180 | meshDef.dispose(); 181 | documentView.gc(); 182 | 183 | t.deepEqual(getPartialStats(documentView), { 184 | scenes: 1, 185 | nodes: 1, 186 | meshes: 0, // garbage collection 187 | primitives: 0, // garbage collection 188 | }, '5. garbage collection pt 2'); 189 | }); 190 | 191 | interface PartialStats { 192 | scenes: number, 193 | nodes: number, 194 | meshes: number, 195 | primitives: number, 196 | } 197 | 198 | function getPartialStats(view: DocumentView): PartialStats { 199 | const {scenes, nodes, meshes, primitives} = view.stats(); 200 | return {scenes, nodes, meshes, primitives}; 201 | } -------------------------------------------------------------------------------- /src/DocumentViewImpl.ts: -------------------------------------------------------------------------------- 1 | import { PropertyType, ExtensionProperty as ExtensionPropertyDef } from '@gltf-transform/core'; 2 | import type { 3 | Accessor as AccessorDef, 4 | Material as MaterialDef, 5 | Mesh as MeshDef, 6 | Node as NodeDef, 7 | Primitive as PrimitiveDef, 8 | Property as PropertyDef, 9 | Scene as SceneDef, 10 | Skin as SkinDef, 11 | Texture as TextureDef, 12 | } from '@gltf-transform/core'; 13 | import type { Light as LightDef, InstancedMesh as InstancedMeshDef } from '@gltf-transform/extensions'; 14 | import type { Object3D, BufferAttribute, Group, Texture, Material, Skeleton, InstancedMesh } from 'three'; 15 | import { 16 | AccessorSubject, 17 | Subject, 18 | ExtensionSubject, 19 | MaterialSubject, 20 | MeshSubject, 21 | NodeSubject, 22 | PrimitiveSubject, 23 | SceneSubject, 24 | SkinSubject, 25 | TextureSubject, 26 | LightSubject, 27 | InstancedMeshSubject, 28 | } from './subjects'; 29 | import type { LightLike, MeshLike, THREEObject } from './constants'; 30 | import { DefaultImageProvider, ImageProvider } from './ImageProvider'; 31 | import { MaterialPool, SingleUserPool, Pool, TexturePool } from './pools'; 32 | 33 | export interface DocumentViewSubjectAPI { 34 | readonly accessorPool: Pool; 35 | readonly extensionPool: Pool; 36 | readonly instancedMeshPool: Pool; 37 | readonly lightPool: SingleUserPool; 38 | readonly materialPool: MaterialPool; 39 | readonly meshPool: SingleUserPool; 40 | readonly nodePool: Pool; 41 | readonly primitivePool: SingleUserPool; 42 | readonly scenePool: Pool; 43 | readonly skinPool: Pool; 44 | readonly texturePool: TexturePool; 45 | 46 | imageProvider: ImageProvider; 47 | 48 | bind(def: null): null; 49 | bind(def: AccessorDef): AccessorSubject; 50 | bind(def: InstancedMeshDef): InstancedMeshSubject; 51 | bind(def: LightDef): LightSubject; 52 | bind(def: MaterialDef): MaterialSubject; 53 | bind(def: MeshDef): MeshSubject; 54 | bind(def: NodeDef): NodeSubject; 55 | bind(def: PrimitiveDef): PrimitiveSubject; 56 | bind(def: SceneDef): SceneSubject; 57 | bind(def: SkinDef): SkinSubject; 58 | bind(def: PropertyDef): Subject; 59 | bind(def: PropertyDef | null): Subject | null; 60 | 61 | recordOutputValue(def: PropertyDef, value: THREEObject): void; 62 | recordOutputVariant(base: THREEObject, variant: THREEObject): void; 63 | 64 | isDisposed(): boolean; 65 | } 66 | 67 | export interface DocumentViewConfig { 68 | imageProvider?: ImageProvider; 69 | } 70 | 71 | /** @internal */ 72 | export class DocumentViewImpl implements DocumentViewSubjectAPI { 73 | private _disposed = false; 74 | private _subjects = new Map>(); 75 | private _outputValues = new WeakMap>(); 76 | private _outputValuesInverse = new WeakMap(); 77 | 78 | readonly accessorPool: Pool = new Pool('accessors', this); 79 | readonly extensionPool: Pool = new Pool('extensions', this); 80 | readonly materialPool: MaterialPool = new MaterialPool('materials', this); 81 | readonly instancedMeshPool: Pool = new Pool('instancedMeshes', this); 82 | readonly lightPool: SingleUserPool = new SingleUserPool('lights', this); 83 | readonly meshPool: SingleUserPool = new SingleUserPool('meshes', this); 84 | readonly nodePool: Pool = new Pool('nodes', this); 85 | readonly primitivePool: SingleUserPool = new SingleUserPool('primitives', this); 86 | readonly scenePool: Pool = new Pool('scenes', this); 87 | readonly skinPool: Pool = new Pool('skins', this); 88 | readonly texturePool: TexturePool = new TexturePool('textures', this); 89 | 90 | public imageProvider: ImageProvider; 91 | 92 | constructor(config: DocumentViewConfig) { 93 | this.imageProvider = config.imageProvider || new DefaultImageProvider(); 94 | } 95 | 96 | private _addSubject(subject: Subject): void { 97 | const def = subject.def; 98 | this._subjects.set(def, subject); 99 | def.addEventListener('dispose', () => { 100 | this._subjects.delete(def); 101 | }); 102 | } 103 | 104 | bind(def: null): null; 105 | bind(def: AccessorDef): AccessorSubject; 106 | bind(def: InstancedMeshDef): InstancedMeshSubject; 107 | bind(def: LightDef): LightSubject; 108 | bind(def: MaterialDef): MaterialSubject; 109 | bind(def: MeshDef): MeshSubject; 110 | bind(def: NodeDef): NodeSubject; 111 | bind(def: PrimitiveDef): PrimitiveSubject; 112 | bind(def: SceneDef): SceneSubject; 113 | bind(def: SkinDef): SkinSubject; 114 | bind(def: PropertyDef): Subject; 115 | bind(def: PropertyDef | null): Subject | null { 116 | if (!def) return null; 117 | if (this._subjects.has(def)) return this._subjects.get(def)!; 118 | 119 | let subject: Subject; 120 | switch (def.propertyType) { 121 | case PropertyType.ACCESSOR: 122 | subject = new AccessorSubject(this, def as AccessorDef); 123 | break; 124 | case 'InstancedMesh': 125 | subject = new InstancedMeshSubject(this, def as InstancedMeshDef); 126 | break; 127 | case 'Light': 128 | subject = new LightSubject(this, def as LightDef); 129 | break; 130 | case PropertyType.MATERIAL: 131 | subject = new MaterialSubject(this, def as MaterialDef); 132 | break; 133 | case PropertyType.MESH: 134 | subject = new MeshSubject(this, def as MeshDef); 135 | break; 136 | case PropertyType.NODE: 137 | subject = new NodeSubject(this, def as NodeDef); 138 | break; 139 | case PropertyType.PRIMITIVE: 140 | subject = new PrimitiveSubject(this, def as PrimitiveDef); 141 | break; 142 | case PropertyType.SCENE: 143 | subject = new SceneSubject(this, def as SceneDef); 144 | break; 145 | case PropertyType.SKIN: 146 | subject = new SkinSubject(this, def as SkinDef); 147 | break; 148 | case PropertyType.TEXTURE: 149 | subject = new TextureSubject(this, def as TextureDef); 150 | break; 151 | default: { 152 | if (def instanceof ExtensionPropertyDef) { 153 | subject = new ExtensionSubject(this, def as ExtensionPropertyDef); 154 | } else { 155 | throw new Error(`Unimplemented type: ${def.propertyType}`); 156 | } 157 | } 158 | } 159 | 160 | subject.update(); 161 | this._addSubject(subject); 162 | return subject; 163 | } 164 | 165 | recordOutputValue(def: PropertyDef, value: THREEObject) { 166 | const outputValues = this._outputValues.get(def) || new Set(); 167 | outputValues.add(value); 168 | this._outputValues.set(def, outputValues); 169 | this._outputValuesInverse.set(value, def); 170 | } 171 | 172 | recordOutputVariant(base: THREEObject, variant: THREEObject) { 173 | const def = this._outputValuesInverse.get(base); 174 | if (def) { 175 | this.recordOutputValue(def, variant); 176 | } else { 177 | console.warn(`Missing definition for output of type "${base.type}}"`); 178 | } 179 | } 180 | 181 | stats() { 182 | return { 183 | accessors: this.accessorPool.size(), 184 | extensions: this.extensionPool.size(), 185 | instancedMeshes: this.instancedMeshPool.size(), 186 | lights: this.lightPool.size(), 187 | materials: this.materialPool.size(), 188 | meshes: this.meshPool.size(), 189 | nodes: this.nodePool.size(), 190 | primitives: this.primitivePool.size(), 191 | scenes: this.scenePool.size(), 192 | skins: this.skinPool.size(), 193 | textures: this.texturePool.size(), 194 | }; 195 | } 196 | 197 | gc() { 198 | this.accessorPool.gc(); 199 | this.extensionPool.gc(); 200 | this.instancedMeshPool.gc(); 201 | this.lightPool.gc(); 202 | this.materialPool.gc(); 203 | this.meshPool.gc(); 204 | this.nodePool.gc(); 205 | this.primitivePool.gc(); 206 | this.scenePool.gc(); 207 | this.skinPool.gc(); 208 | this.texturePool.gc(); 209 | } 210 | 211 | /** 212 | * Given a target object (currently any THREE.Object3D), finds and returns the source 213 | * glTF-Transform Property definition. 214 | */ 215 | findDef(target: Texture): TextureDef | null; 216 | findDef(target: LightLike): LightDef | null; 217 | findDef(target: Material): MaterialDef | null; 218 | findDef(target: MeshLike): PrimitiveDef | null; 219 | findDef(target: Object3D): SceneDef | NodeDef | MeshDef | null; 220 | findDef(target: object): PropertyDef | null { 221 | return this._outputValuesInverse.get(target) || null; 222 | } 223 | 224 | /** 225 | * Given a source object (currently anything rendered as THREE.Object3D), finds and returns 226 | * the list of output THREE.Object3D instances. 227 | */ 228 | findValues(def: TextureDef): Texture[]; 229 | findValues(def: LightDef): LightLike[]; 230 | findValues(def: MaterialDef): Material[]; 231 | findValues(def: PrimitiveDef): MeshLike[]; 232 | findValues(def: SceneDef | NodeDef | MeshDef): Object3D[]; 233 | findValues(def: PropertyDef): object[] { 234 | return Array.from(this._outputValues.get(def) || []); 235 | } 236 | 237 | isDisposed(): boolean { 238 | return this._disposed; 239 | } 240 | 241 | dispose(): void { 242 | // First, to prevent updates during disposal. 243 | this._disposed = true; 244 | 245 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 246 | for (const [_, subject] of this._subjects) subject.dispose(); 247 | this._subjects.clear(); 248 | 249 | // Last, to clean up anything left after disposal. 250 | this.accessorPool.dispose(); 251 | this.instancedMeshPool.dispose(); 252 | this.lightPool.dispose(); 253 | this.materialPool.dispose(); 254 | this.meshPool.dispose(); 255 | this.nodePool.dispose(); 256 | this.primitivePool.dispose(); 257 | this.skinPool.dispose(); 258 | this.scenePool.dispose(); 259 | this.texturePool.dispose(); 260 | } 261 | } 262 | -------------------------------------------------------------------------------- /examples/material-pane.ts: -------------------------------------------------------------------------------- 1 | import { Document, Material, Texture } from '@gltf-transform/core'; 2 | import { KHRMaterialsClearcoat, KHRMaterialsIOR, KHRMaterialsSheen, KHRMaterialsSpecular, KHRMaterialsTransmission, KHRMaterialsUnlit, KHRMaterialsVolume } from '@gltf-transform/extensions'; 3 | import { FolderApi, Pane } from 'tweakpane'; 4 | import * as TweakpanePluginThumbnailList from 'tweakpane-plugin-thumbnail-list'; 5 | 6 | interface TextureOption {value: string, src: string, data: Texture} 7 | 8 | const textureFromEvent = (event): Texture | null => { 9 | const value = event.value as unknown as TextureOption | null; 10 | return value ? value.data : null; 11 | }; 12 | 13 | const textureValue = (texture: Texture | null, options: TextureOption[]): string => { 14 | if (!texture) return ''; 15 | const option = options.find((option) => option.data === texture)!; 16 | return option.value; 17 | }; 18 | 19 | export function createMaterialPane(_pane: Pane, document: Document, material: Material): FolderApi { 20 | _pane.registerPlugin(TweakpanePluginThumbnailList); 21 | const pane = _pane.addFolder({title: 'Material'}); 22 | 23 | const clearcoatExtension = document.createExtension(KHRMaterialsClearcoat); 24 | const clearcoat = clearcoatExtension.createClearcoat(); 25 | const iorExtension = document.createExtension(KHRMaterialsIOR); 26 | const ior = iorExtension.createIOR(); 27 | const sheenExtension = document.createExtension(KHRMaterialsSheen); 28 | const sheen = sheenExtension.createSheen(); 29 | const specularExtension = document.createExtension(KHRMaterialsSpecular); 30 | const specular = specularExtension.createSpecular(); 31 | const transmissionExtension = document.createExtension(KHRMaterialsTransmission); 32 | const transmission = transmissionExtension.createTransmission(); 33 | const volumeExtension = document.createExtension(KHRMaterialsVolume); 34 | const volume = volumeExtension.createVolume(); 35 | const unlitExtension = document.createExtension(KHRMaterialsUnlit); 36 | const unlit = unlitExtension.createUnlit(); 37 | 38 | const textureOptions = document.getRoot().listTextures().map((texture, index) => { 39 | return { 40 | text: texture.getName() || texture.getURI() || index.toString(), 41 | value: index.toString(), 42 | src: URL.createObjectURL(new Blob([texture.getImage()!], {type: texture.getMimeType()})), 43 | data: texture, 44 | }; 45 | }); 46 | 47 | const params = { 48 | // Core. 49 | baseColorFactor: material.getBaseColorHex(), 50 | baseColorTexture: textureValue(material.getBaseColorTexture(), textureOptions), 51 | alpha: material.getAlpha(), 52 | alphaMode: material.getAlphaMode(), 53 | emissiveFactor: material.getEmissiveHex(), 54 | emissiveTexture: textureValue(material.getEmissiveTexture(), textureOptions), 55 | roughnessFactor: material.getRoughnessFactor(), 56 | metallicFactor: material.getMetallicFactor(), 57 | metallicRoughnessTexture: textureValue(material.getMetallicRoughnessTexture(), textureOptions), 58 | occlusionStrength: material.getOcclusionStrength(), 59 | occlusionTexture: textureValue(material.getOcclusionTexture(), textureOptions), 60 | normalScale: material.getNormalScale(), 61 | normalTexture: textureValue(material.getNormalTexture(), textureOptions), 62 | 63 | // Clearcoat. 64 | clearcoatEnabled: !!material.getExtension('KHR_materials_clearcoat'), 65 | clearcoatFactor: clearcoat.getClearcoatFactor(), 66 | clearcoatTexture: textureValue(clearcoat.getClearcoatTexture(), textureOptions), 67 | clearcoatRoughnessFactor: clearcoat.getClearcoatRoughnessFactor(), 68 | clearcoatRoughnessTexture: textureValue(clearcoat.getClearcoatRoughnessTexture(), textureOptions), 69 | clearcoatNormalScale: clearcoat.getClearcoatNormalScale(), 70 | clearcoatNormalTexture: textureValue(clearcoat.getClearcoatNormalTexture(), textureOptions), 71 | 72 | // IOR. 73 | iorEnabled: !!material.getExtension('KHR_materials_ior'), 74 | ior: ior.getIOR(), 75 | 76 | // Sheen. 77 | sheenEnabled: !!material.getExtension('KHR_materials_sheen'), 78 | sheenColorFactor: sheen.getSheenColorHex(), 79 | sheenColorTexture: textureValue(sheen.getSheenColorTexture(), textureOptions), 80 | sheenRoughnessFactor: sheen.getSheenRoughnessFactor(), 81 | sheenRoughnessTexture: textureValue(sheen.getSheenRoughnessTexture(), textureOptions), 82 | 83 | // Specular. 84 | specularEnabled: !!material.getExtension('KHR_materials_specular'), 85 | specularFactor: specular.getSpecularFactor(), 86 | specularTexture: textureValue(specular.getSpecularTexture(), textureOptions), 87 | specularColorFactor: specular.getSpecularColorHex(), 88 | specularColorTexture: textureValue(specular.getSpecularColorTexture(), textureOptions), 89 | 90 | // Transmission. 91 | transmissionEnabled: !!material.getExtension('KHR_materials_transmission'), 92 | transmissionFactor: transmission.getTransmissionFactor(), 93 | transmissionTexture: textureValue(transmission.getTransmissionTexture(), textureOptions), 94 | 95 | // Volume. 96 | volumeEnabled: !!material.getExtension('KHR_materials_volume'), 97 | thicknessFactor: volume.getThicknessFactor(), 98 | thicknessTexture: textureValue(volume.getThicknessTexture(), textureOptions), 99 | attenuationColorFactor: volume.getAttenuationColorHex(), 100 | attenuationDistance: Number.isFinite(volume.getAttenuationDistance()) 101 | ? volume.getAttenuationDistance() 102 | : 0, 103 | 104 | // Unlit. 105 | unlitEnabled: !!material.getExtension('KHR_materials_unlit'), 106 | }; 107 | 108 | const coreFolder = pane.addFolder({title: 'Basic'}); 109 | coreFolder.addInput(params, 'baseColorFactor', {view: 'color'}) 110 | .on('change', () => material.setBaseColorHex(params.baseColorFactor)); 111 | coreFolder.addInput(params, 'baseColorTexture', {view: 'thumbnail-list', options: textureOptions}) 112 | .on('change', (ev) => material.setBaseColorTexture(textureFromEvent(ev))); 113 | coreFolder.addSeparator(); 114 | coreFolder.addInput(params, 'alpha', {min: 0, max: 1}) 115 | .on('change', () => material.setAlpha(params.alpha)); 116 | coreFolder.addInput(params, 'alphaMode', {options: {OPAQUE: 'OPAQUE', BLEND: 'BLEND', MASK: 'MASK'}}) 117 | .on('change', () => material.setAlphaMode(params.alphaMode)); 118 | coreFolder.addSeparator(); 119 | coreFolder.addInput(params, 'emissiveFactor', {view: 'color'}) 120 | .on('change', () => material.setEmissiveHex(params.emissiveFactor)); 121 | coreFolder.addInput(params, 'emissiveTexture', {view: 'thumbnail-list', options: textureOptions}) 122 | .on('change', (ev) => material.setEmissiveTexture(textureFromEvent(ev))); 123 | coreFolder.addSeparator(); 124 | coreFolder.addInput(params, 'metallicFactor', {min: 0, max: 1}) 125 | .on('change', () => material.setMetallicFactor(params.metallicFactor)); 126 | coreFolder.addInput(params, 'roughnessFactor', {min: 0, max: 1}) 127 | .on('change', () => material.setRoughnessFactor(params.roughnessFactor)); 128 | coreFolder.addInput(params, 'metallicRoughnessTexture', {view: 'thumbnail-list', options: textureOptions}) 129 | .on('change', (ev) => material.setMetallicRoughnessTexture(textureFromEvent(ev))); 130 | coreFolder.addSeparator(); 131 | coreFolder.addInput(params, 'occlusionStrength', {min: 0, max: 1}) 132 | .on('change', () => material.setOcclusionStrength(params.occlusionStrength)); 133 | coreFolder.addInput(params, 'occlusionTexture', {view: 'thumbnail-list', options: textureOptions}) 134 | .on('change', (ev) => material.setOcclusionTexture(textureFromEvent(ev))); 135 | coreFolder.addSeparator(); 136 | coreFolder.addInput(params, 'normalScale') 137 | .on('change', () => material.setNormalScale(params.normalScale)); 138 | coreFolder.addInput(params, 'normalTexture', {view: 'thumbnail-list', options: textureOptions}) 139 | .on('change', (ev) => material.setNormalTexture(textureFromEvent(ev))); 140 | 141 | const clearcoatFolder = pane.addFolder({title: 'KHR_materials_clearcoat', expanded: false}); 142 | clearcoatFolder.addInput(params, 'clearcoatEnabled'); 143 | clearcoatFolder.addSeparator(); 144 | clearcoatFolder.addInput(params, 'clearcoatFactor', {min: 0, max: 1}); 145 | clearcoatFolder.addInput(params, 'clearcoatTexture', {view: 'thumbnail-list', options: textureOptions}) 146 | .on('change', (ev) => clearcoat.setClearcoatTexture(textureFromEvent(ev))); 147 | clearcoatFolder.addSeparator(); 148 | clearcoatFolder.addInput(params, 'clearcoatRoughnessFactor', {min: 0, max: 1}); 149 | clearcoatFolder.addInput(params, 'clearcoatRoughnessTexture', {view: 'thumbnail-list', options: textureOptions}) 150 | .on('change', (ev) => clearcoat.setClearcoatRoughnessTexture(textureFromEvent(ev))); 151 | clearcoatFolder.addSeparator(); 152 | clearcoatFolder.addInput(params, 'clearcoatNormalScale'); 153 | clearcoatFolder.addInput(params, 'clearcoatNormalTexture', {view: 'thumbnail-list', options: textureOptions}) 154 | .on('change', (ev) => clearcoat.setClearcoatNormalTexture(textureFromEvent(ev))); 155 | clearcoatFolder.on('change', () => { 156 | material.setExtension('KHR_materials_clearcoat', params.clearcoatEnabled ? clearcoat : null); 157 | clearcoat 158 | .setClearcoatFactor(params.clearcoatFactor) 159 | .setClearcoatRoughnessFactor(params.clearcoatRoughnessFactor) 160 | .setClearcoatNormalScale(params.clearcoatNormalScale); 161 | }); 162 | 163 | const iorFolder = pane.addFolder({title: 'KHR_materials_ior', expanded: false}); 164 | iorFolder.addInput(params, 'iorEnabled'); 165 | iorFolder.addInput(params, 'ior', {min: 1, max: 2}); 166 | iorFolder.on('change', () => { 167 | material.setExtension('KHR_materials_ior', params.iorEnabled ? ior : null); 168 | ior.setIOR(params.ior); 169 | }); 170 | 171 | const sheenFolder = pane.addFolder({title: 'KHR_materials_sheen', expanded: false}); 172 | sheenFolder.addInput(params, 'sheenEnabled'); 173 | sheenFolder.addSeparator(); 174 | sheenFolder.addInput(params, 'sheenColorFactor', {view: 'color'}); 175 | sheenFolder.addInput(params, 'sheenColorTexture', {view: 'thumbnail-list', options: textureOptions}) 176 | .on('change', (ev) => sheen.setSheenColorTexture(textureFromEvent(ev))); 177 | sheenFolder.addSeparator(); 178 | sheenFolder.addInput(params, 'sheenRoughnessFactor', {min: 0, max: 1}); 179 | sheenFolder.addInput(params, 'sheenRoughnessTexture', {view: 'thumbnail-list', options: textureOptions}) 180 | .on('change', (ev) => sheen.setSheenRoughnessTexture(textureFromEvent(ev))); 181 | sheenFolder.on('change', () => { 182 | material.setExtension('KHR_materials_sheen', params.sheenEnabled ? sheen : null); 183 | sheen 184 | .setSheenColorHex(params.sheenColorFactor) 185 | .setSheenRoughnessFactor(params.sheenRoughnessFactor); 186 | }); 187 | 188 | const specularFolder = pane.addFolder({title: 'KHR_materials_specular', expanded: false}); 189 | specularFolder.addInput(params, 'specularEnabled'); 190 | specularFolder.addSeparator(); 191 | specularFolder.addInput(params, 'specularFactor', {min: 0, max: 1}); 192 | specularFolder.addInput(params, 'specularTexture', {view: 'thumbnail-list', options: textureOptions}) 193 | .on('change', (ev) => specular.setSpecularTexture(textureFromEvent(ev))); 194 | specularFolder.addSeparator(); 195 | specularFolder.addInput(params, 'specularColorFactor', {view: 'color'}); 196 | specularFolder.addInput(params, 'specularColorTexture', {view: 'thumbnail-list', options: textureOptions}) 197 | .on('change', (ev) => specular.setSpecularColorTexture(textureFromEvent(ev))); 198 | specularFolder.on('change', () => { 199 | material.setExtension('KHR_materials_specular', params.specularEnabled ? specular : null); 200 | specular 201 | .setSpecularFactor(params.specularFactor) 202 | .setSpecularColorHex(params.specularColorFactor); 203 | }); 204 | 205 | const transmissionFolder = pane.addFolder({title: 'KHR_materials_transmission', expanded: false}); 206 | transmissionFolder.addInput(params, 'transmissionEnabled'); 207 | transmissionFolder.addSeparator(); 208 | transmissionFolder.addInput(params, 'transmissionFactor', {min: 0, max: 1}); 209 | transmissionFolder.addInput(params, 'transmissionTexture', {view: 'thumbnail-list', options: textureOptions}) 210 | .on('change', (ev) => transmission.setTransmissionTexture(textureFromEvent(ev))); 211 | transmissionFolder.on('change', () => { 212 | material.setExtension('KHR_materials_transmission', params.transmissionEnabled ? transmission : null); 213 | transmission.setTransmissionFactor(params.transmissionFactor); 214 | }); 215 | 216 | const volumeFolder = pane.addFolder({title: 'KHR_materials_volume', expanded: false}); 217 | volumeFolder.addInput(params, 'volumeEnabled'); 218 | volumeFolder.addSeparator(); 219 | volumeFolder.addInput(params, 'thicknessFactor', {min: 0, max: 1}); 220 | volumeFolder.addInput(params, 'thicknessTexture', {view: 'thumbnail-list', options: textureOptions}) 221 | .on('change', (ev) => volume.setThicknessTexture(textureFromEvent(ev))); 222 | volumeFolder.addSeparator(); 223 | volumeFolder.addInput(params, 'attenuationColorFactor', {view: 'color'}); 224 | volumeFolder.addInput(params, 'attenuationDistance', {min: 0, max: 5, step: 0.01}); 225 | volumeFolder.on('change', () => { 226 | material.setExtension('KHR_materials_volume', params.volumeEnabled ? volume : null); 227 | volume 228 | .setThicknessFactor(params.thicknessFactor) 229 | .setAttenuationColorHex(params.attenuationColorFactor) 230 | .setAttenuationDistance(params.attenuationDistance); 231 | }); 232 | 233 | const unlitFolder = pane.addFolder({title: 'KHR_materials_unlit', expanded: false}); 234 | unlitFolder.addInput(params, 'unlitEnabled'); 235 | unlitFolder.on('change', () => { 236 | material.setExtension('KHR_materials_unlit', params.unlitEnabled ? unlit : null); 237 | }); 238 | 239 | return pane; 240 | } 241 | -------------------------------------------------------------------------------- /src/subjects/MaterialSubject.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DoubleSide, 3 | FrontSide, 4 | Material, 5 | MeshBasicMaterial, 6 | MeshPhysicalMaterial, 7 | MeshStandardMaterial, 8 | Texture, 9 | SRGBColorSpace, 10 | NoColorSpace, 11 | ColorSpace, 12 | } from 'three'; 13 | import { 14 | ExtensionProperty as ExtensionPropertyDef, 15 | Material as MaterialDef, 16 | Texture as TextureDef, 17 | TextureInfo as TextureInfoDef, 18 | vec3, 19 | } from '@gltf-transform/core'; 20 | import { 21 | Anisotropy, 22 | Clearcoat, 23 | EmissiveStrength, 24 | IOR, 25 | Iridescence, 26 | Sheen, 27 | Specular, 28 | Transmission, 29 | Volume, 30 | } from '@gltf-transform/extensions'; 31 | import type { DocumentViewImpl } from '../DocumentViewImpl'; 32 | import { eq } from '../utils'; 33 | import { Subject } from './Subject'; 34 | import { RefListObserver, RefObserver } from '../observers'; 35 | import { Subscription } from '../constants'; 36 | import { TextureParams, TexturePool, ValuePool } from '../pools'; 37 | 38 | const _vec3: vec3 = [0, 0, 0]; 39 | 40 | enum ShadingModel { 41 | UNLIT = 0, 42 | STANDARD = 1, 43 | PHYSICAL = 2, 44 | } 45 | 46 | // TODO(feat): Missing change listeners on TextureInfo... delegate? 47 | 48 | /** @internal */ 49 | export class MaterialSubject extends Subject { 50 | protected readonly extensions = new RefListObserver( 51 | 'extensions', 52 | this._documentView, 53 | ); 54 | 55 | protected readonly baseColorTexture = new RefObserver( 56 | 'baseColorTexture', 57 | this._documentView, 58 | ); 59 | protected readonly emissiveTexture = new RefObserver( 60 | 'emissiveTexture', 61 | this._documentView, 62 | ); 63 | protected readonly normalTexture = new RefObserver( 64 | 'normalTexture', 65 | this._documentView, 66 | ); 67 | protected readonly occlusionTexture = new RefObserver( 68 | 'occlusionTexture', 69 | this._documentView, 70 | ); 71 | protected readonly metallicRoughnessTexture = new RefObserver( 72 | 'metallicRoughnessTexture', 73 | this._documentView, 74 | ); 75 | 76 | // KHR_materials_anisotropy 77 | protected readonly anisotropyTexture = new RefObserver( 78 | 'anisotropyTexture', 79 | this._documentView, 80 | ); 81 | 82 | // KHR_materials_clearcoat 83 | protected readonly clearcoatTexture = new RefObserver( 84 | 'clearcoatTexture', 85 | this._documentView, 86 | ); 87 | protected readonly clearcoatRoughnessTexture = new RefObserver( 88 | 'clearcoatRoughnessTexture', 89 | this._documentView, 90 | ); 91 | protected readonly clearcoatNormalTexture = new RefObserver( 92 | 'clearcoatNormalTexture', 93 | this._documentView, 94 | ); 95 | 96 | // KHR_materials_iridescence 97 | protected readonly iridescenceTexture = new RefObserver( 98 | 'iridescenceTexture', 99 | this._documentView, 100 | ); 101 | protected readonly iridescenceThicknessTexture = new RefObserver( 102 | 'iridescenceThicknessTexture', 103 | this._documentView, 104 | ); 105 | 106 | // KHR_materials_sheen 107 | protected readonly sheenColorTexture = new RefObserver( 108 | 'sheenColorTexture', 109 | this._documentView, 110 | ); 111 | protected readonly sheenRoughnessTexture = new RefObserver( 112 | 'sheenRoughnessTexture', 113 | this._documentView, 114 | ); 115 | 116 | // KHR_materials_specular 117 | protected readonly specularTexture = new RefObserver( 118 | 'specularTexture', 119 | this._documentView, 120 | ); 121 | protected readonly specularColorTexture = new RefObserver( 122 | 'specularColorTexture', 123 | this._documentView, 124 | ); 125 | 126 | // KHR_materials_transmission 127 | protected readonly transmissionTexture = new RefObserver( 128 | 'transmissionTexture', 129 | this._documentView, 130 | ); 131 | 132 | // KHR_materials_volume 133 | protected readonly thicknessTexture = new RefObserver( 134 | 'thicknessTexture', 135 | this._documentView, 136 | ); 137 | 138 | private readonly _textureObservers: RefObserver[] = []; 139 | private readonly _textureUpdateFns: (() => void)[] = []; 140 | private readonly _textureApplyFns: (() => void)[] = []; 141 | 142 | constructor(documentView: DocumentViewImpl, def: MaterialDef) { 143 | super( 144 | documentView, 145 | def, 146 | MaterialSubject.createValue(def, documentView.materialPool), 147 | documentView.materialPool, 148 | ); 149 | 150 | this.extensions.subscribe(() => { 151 | this.update(); 152 | this.publishAll(); 153 | }); 154 | 155 | this.bindTexture( 156 | ['map'], 157 | this.baseColorTexture, 158 | () => def.getBaseColorTexture(), 159 | () => def.getBaseColorTextureInfo(), 160 | SRGBColorSpace, 161 | ); 162 | this.bindTexture( 163 | ['emissiveMap'], 164 | this.emissiveTexture, 165 | () => def.getEmissiveTexture(), 166 | () => def.getEmissiveTextureInfo(), 167 | SRGBColorSpace, 168 | ); 169 | this.bindTexture( 170 | ['normalMap'], 171 | this.normalTexture, 172 | () => def.getNormalTexture(), 173 | () => def.getNormalTextureInfo(), 174 | NoColorSpace, 175 | ); 176 | this.bindTexture( 177 | ['aoMap'], 178 | this.occlusionTexture, 179 | () => def.getOcclusionTexture(), 180 | () => def.getOcclusionTextureInfo(), 181 | NoColorSpace, 182 | ); 183 | this.bindTexture( 184 | ['roughnessMap', 'metalnessMap'], 185 | this.metallicRoughnessTexture, 186 | () => def.getMetallicRoughnessTexture(), 187 | () => def.getMetallicRoughnessTextureInfo(), 188 | NoColorSpace, 189 | ); 190 | 191 | // KHR_materials_anisotropy 192 | const anisotropyExt = (): Anisotropy | null => def.getExtension('KHR_materials_anisotropy'); 193 | this.bindTexture( 194 | ['anisotropyMap'], 195 | this.anisotropyTexture, 196 | () => anisotropyExt()?.getAnisotropyTexture() || null, 197 | () => anisotropyExt()?.getAnisotropyTextureInfo() || null, 198 | NoColorSpace, 199 | ); 200 | 201 | // KHR_materials_clearcoat 202 | const clearcoatExt = (): Clearcoat | null => def.getExtension('KHR_materials_clearcoat'); 203 | this.bindTexture( 204 | ['clearcoatMap'], 205 | this.clearcoatTexture, 206 | () => clearcoatExt()?.getClearcoatTexture() || null, 207 | () => clearcoatExt()?.getClearcoatTextureInfo() || null, 208 | NoColorSpace, 209 | ); 210 | this.bindTexture( 211 | ['clearcoatRoughnessMap'], 212 | this.clearcoatRoughnessTexture, 213 | () => clearcoatExt()?.getClearcoatRoughnessTexture() || null, 214 | () => clearcoatExt()?.getClearcoatRoughnessTextureInfo() || null, 215 | NoColorSpace, 216 | ); 217 | this.bindTexture( 218 | ['clearcoatNormalMap'], 219 | this.clearcoatNormalTexture, 220 | () => clearcoatExt()?.getClearcoatNormalTexture() || null, 221 | () => clearcoatExt()?.getClearcoatNormalTextureInfo() || null, 222 | NoColorSpace, 223 | ); 224 | 225 | // KHR_materials_iridescence 226 | const iridescenceExt = (): Iridescence | null => def.getExtension('KHR_materials_iridescence'); 227 | this.bindTexture( 228 | ['iridescenceTexture'], 229 | this.iridescenceTexture, 230 | () => iridescenceExt()?.getIridescenceTexture() || null, 231 | () => iridescenceExt()?.getIridescenceTextureInfo() || null, 232 | NoColorSpace, 233 | ); 234 | this.bindTexture( 235 | ['iridescenceThicknessTexture'], 236 | this.iridescenceThicknessTexture, 237 | () => iridescenceExt()?.getIridescenceThicknessTexture() || null, 238 | () => iridescenceExt()?.getIridescenceThicknessTextureInfo() || null, 239 | NoColorSpace, 240 | ); 241 | 242 | // KHR_materials_sheen 243 | const sheenExt = (): Sheen | null => def.getExtension('KHR_materials_sheen'); 244 | this.bindTexture( 245 | ['sheenColorMap'], 246 | this.sheenColorTexture, 247 | () => sheenExt()?.getSheenColorTexture() || null, 248 | () => sheenExt()?.getSheenColorTextureInfo() || null, 249 | SRGBColorSpace, 250 | ); 251 | this.bindTexture( 252 | ['sheenRoughnessMap'], 253 | this.sheenRoughnessTexture, 254 | () => sheenExt()?.getSheenRoughnessTexture() || null, 255 | () => sheenExt()?.getSheenRoughnessTextureInfo() || null, 256 | NoColorSpace, 257 | ); 258 | 259 | // KHR_materials_specular 260 | const specularExt = (): Specular | null => def.getExtension('KHR_materials_specular'); 261 | this.bindTexture( 262 | ['specularIntensityMap'], 263 | this.specularTexture, 264 | () => specularExt()?.getSpecularTexture() || null, 265 | () => specularExt()?.getSpecularTextureInfo() || null, 266 | NoColorSpace, 267 | ); 268 | this.bindTexture( 269 | ['specularColorMap'], 270 | this.specularColorTexture, 271 | () => specularExt()?.getSpecularColorTexture() || null, 272 | () => specularExt()?.getSpecularColorTextureInfo() || null, 273 | SRGBColorSpace, 274 | ); 275 | 276 | // KHR_materials_transmission 277 | const transmissionExt = (): Transmission | null => def.getExtension('KHR_materials_transmission'); 278 | this.bindTexture( 279 | ['transmissionMap'], 280 | this.transmissionTexture, 281 | () => transmissionExt()?.getTransmissionTexture() || null, 282 | () => transmissionExt()?.getTransmissionTextureInfo() || null, 283 | NoColorSpace, 284 | ); 285 | 286 | // KHR_materials_volume 287 | const volumeExt = (): Volume | null => def.getExtension('KHR_materials_volume'); 288 | this.bindTexture( 289 | ['thicknessMap'], 290 | this.thicknessTexture, 291 | () => volumeExt()?.getThicknessTexture() || null, 292 | () => volumeExt()?.getThicknessTextureInfo() || null, 293 | NoColorSpace, 294 | ); 295 | } 296 | 297 | private bindTexture( 298 | maps: string[], 299 | observer: RefObserver, 300 | textureFn: () => TextureDef | null, 301 | textureInfoFn: () => TextureInfoDef | null, 302 | colorSpace: ColorSpace, 303 | ): Subscription { 304 | observer.setParamsFn(() => TexturePool.createParams(textureInfoFn()!, colorSpace)); 305 | 306 | const applyTextureFn = (texture: Texture | null) => { 307 | const material = this.value as any; 308 | for (const map of maps) { 309 | if (!(map in material)) continue; // Unlit ⊂ Standard ⊂ Physical (& Points, Lines) 310 | if (!!material[map] !== !!texture) material.needsUpdate = true; // Recompile on add/remove. 311 | material[map] = texture; 312 | } 313 | }; 314 | 315 | this._textureObservers.push(observer); 316 | this._textureUpdateFns.push(() => observer.update(textureFn())); 317 | this._textureApplyFns.push(() => applyTextureFn(observer.value)); 318 | 319 | return observer.subscribe((texture) => { 320 | applyTextureFn(texture); 321 | this.publishAll(); 322 | }); 323 | } 324 | 325 | private static createValue(def: MaterialDef, pool: ValuePool): Material { 326 | const shadingModel = getShadingModel(def); 327 | switch (shadingModel) { 328 | case ShadingModel.UNLIT: 329 | return pool.requestBase(new MeshBasicMaterial()); 330 | case ShadingModel.STANDARD: 331 | return pool.requestBase(new MeshStandardMaterial()); 332 | case ShadingModel.PHYSICAL: 333 | return pool.requestBase(new MeshPhysicalMaterial()); 334 | default: 335 | throw new Error('Unsupported shading model.'); 336 | } 337 | } 338 | 339 | update() { 340 | const def = this.def; 341 | let value = this.value; 342 | 343 | this.extensions.update(def.listExtensions()); 344 | 345 | const shadingModel = getShadingModel(def); 346 | if ( 347 | (shadingModel === ShadingModel.UNLIT && value.type !== 'MeshBasicMaterial') || 348 | (shadingModel === ShadingModel.STANDARD && value.type !== 'MeshStandardMaterial') || 349 | (shadingModel === ShadingModel.PHYSICAL && value.type !== 'MeshPhysicalMaterial') 350 | ) { 351 | this.pool.releaseBase(this.value); 352 | this.value = MaterialSubject.createValue(def, this.pool); 353 | value = this.value; 354 | for (const fn of this._textureApplyFns) fn(); 355 | } 356 | 357 | switch (shadingModel) { 358 | case ShadingModel.PHYSICAL: 359 | this._updatePhysical(value as MeshPhysicalMaterial); // falls through ⬇ 360 | case ShadingModel.STANDARD: 361 | this._updateStandard(value as MeshStandardMaterial); // falls through ⬇ 362 | default: 363 | this._updateBasic(value as MeshBasicMaterial); 364 | } 365 | 366 | for (const fn of this._textureUpdateFns) fn(); 367 | } 368 | 369 | private _updateBasic(target: MeshBasicMaterial) { 370 | const def = this.def; 371 | 372 | if (def.getName() !== target.name) { 373 | target.name = def.getName(); 374 | } 375 | 376 | if (def.getDoubleSided() !== (target.side === DoubleSide)) { 377 | target.side = def.getDoubleSided() ? DoubleSide : FrontSide; 378 | } 379 | 380 | switch (def.getAlphaMode()) { 381 | case 'OPAQUE': 382 | target.transparent = false; 383 | target.depthWrite = true; 384 | target.alphaTest = 0; 385 | break; 386 | case 'BLEND': 387 | target.transparent = true; 388 | target.depthWrite = false; 389 | target.alphaTest = 0; 390 | break; 391 | case 'MASK': 392 | target.transparent = false; 393 | target.depthWrite = true; 394 | target.alphaTest = def.getAlphaCutoff(); 395 | break; 396 | } 397 | 398 | const alpha = def.getAlpha(); 399 | if (alpha !== target.opacity) { 400 | target.opacity = alpha; 401 | } 402 | 403 | const baseColor = def.getBaseColorFactor().slice(0, 3); 404 | if (!eq(baseColor, target.color.toArray(_vec3))) { 405 | target.color.fromArray(baseColor); 406 | } 407 | } 408 | 409 | private _updateStandard(target: MeshStandardMaterial) { 410 | const def = this.def; 411 | 412 | const emissive = def.getEmissiveFactor(); 413 | if (!eq(emissive, target.emissive.toArray(_vec3))) { 414 | target.emissive.fromArray(emissive); 415 | } 416 | 417 | const roughness = def.getRoughnessFactor(); 418 | if (roughness !== target.roughness) { 419 | target.roughness = roughness; 420 | } 421 | 422 | const metalness = def.getMetallicFactor(); 423 | if (metalness !== target.metalness) { 424 | target.metalness = metalness; 425 | } 426 | 427 | const occlusionStrength = def.getOcclusionStrength(); 428 | if (occlusionStrength !== target.aoMapIntensity) { 429 | target.aoMapIntensity = occlusionStrength; 430 | } 431 | 432 | const normalScale = def.getNormalScale(); 433 | if (normalScale !== target.normalScale.x) { 434 | target.normalScale.setScalar(normalScale); 435 | } 436 | } 437 | 438 | private _updatePhysical(target: MeshPhysicalMaterial) { 439 | const def = this.def; 440 | 441 | if (!(target instanceof MeshPhysicalMaterial)) { 442 | return; 443 | } 444 | 445 | // KHR_materials_anisotropy 446 | // TODO(cleanup): Remove 'any' after https://github.com/three-types/three-ts-types/pull/472. 447 | const anisotropy = def.getExtension('KHR_materials_anisotropy'); 448 | if (anisotropy) { 449 | if (anisotropy.getAnisotropyStrength() !== (target as any).anisotropy) { 450 | if ((target as any).anisotropy === 0) target.needsUpdate = true; 451 | (target as any).anisotropy = anisotropy.getAnisotropyStrength(); 452 | } 453 | if (anisotropy.getAnisotropyRotation() !== (target as any).anisotropyRotation) { 454 | (target as any).anisotropyRotation = anisotropy.getAnisotropyRotation(); 455 | } 456 | } else { 457 | (target as any).anisotropy = 0; 458 | } 459 | 460 | // KHR_materials_clearcoat 461 | const clearcoat = def.getExtension('KHR_materials_clearcoat'); 462 | if (clearcoat) { 463 | if (clearcoat.getClearcoatFactor() !== target.clearcoat) { 464 | if (target.clearcoat === 0) target.needsUpdate = true; 465 | target.clearcoat = clearcoat.getClearcoatFactor(); 466 | } 467 | if (clearcoat.getClearcoatRoughnessFactor() !== target.clearcoatRoughness) { 468 | target.clearcoatRoughness = clearcoat.getClearcoatRoughnessFactor(); 469 | } 470 | if (clearcoat.getClearcoatNormalScale() !== target.clearcoatNormalScale.x) { 471 | target.clearcoatNormalScale.x = clearcoat.getClearcoatNormalScale(); 472 | target.clearcoatNormalScale.y = -clearcoat.getClearcoatNormalScale(); 473 | } 474 | } else { 475 | target.clearcoat = 0; 476 | } 477 | 478 | // KHR_materials_emissive_strength 479 | const emissiveStrength = def.getExtension('KHR_materials_emissive_strength'); 480 | if (emissiveStrength) { 481 | if (emissiveStrength.getEmissiveStrength() !== target.emissiveIntensity) { 482 | target.emissiveIntensity = emissiveStrength.getEmissiveStrength(); 483 | } 484 | } else { 485 | target.emissiveIntensity = 1.0; 486 | } 487 | 488 | // KHR_materials_ior 489 | const ior = def.getExtension('KHR_materials_ior'); 490 | if (ior) { 491 | if (ior.getIOR() !== target.ior) { 492 | target.ior = ior.getIOR(); 493 | } 494 | } else { 495 | target.ior = 1.5; 496 | } 497 | 498 | // KHR_materials_iridescence 499 | const iridescence = def.getExtension('KHR_materials_iridescence'); 500 | if (iridescence) { 501 | if (iridescence.getIridescenceFactor() !== target.iridescence) { 502 | target.iridescence = iridescence.getIridescenceFactor(); 503 | } 504 | const range = [iridescence.getIridescenceThicknessMinimum(), iridescence.getIridescenceThicknessMaximum()]; 505 | if (!eq(range, target.iridescenceThicknessRange)) { 506 | target.iridescenceThicknessRange[0] = range[0]; 507 | target.iridescenceThicknessRange[1] = range[1]; 508 | } 509 | if (iridescence.getIridescenceIOR() !== target.iridescenceIOR) { 510 | target.iridescenceIOR = iridescence.getIridescenceIOR(); 511 | } 512 | } else { 513 | target.iridescence = 0; 514 | } 515 | 516 | // KHR_materials_sheen 517 | const sheen = def.getExtension('KHR_materials_sheen'); 518 | if (sheen) { 519 | target.sheen = 1; 520 | const sheenColor = sheen.getSheenColorFactor(); 521 | if (!eq(sheenColor, target.sheenColor!.toArray(_vec3))) { 522 | target.sheenColor!.fromArray(sheenColor); 523 | } 524 | if (sheen.getSheenRoughnessFactor() !== target.sheenRoughness) { 525 | target.sheenRoughness = sheen.getSheenRoughnessFactor(); 526 | } 527 | } else { 528 | target.sheen = 0; 529 | } 530 | 531 | // KHR_materials_specular 532 | const specular = def.getExtension('KHR_materials_specular'); 533 | if (specular) { 534 | if (specular.getSpecularFactor() !== target.specularIntensity) { 535 | target.specularIntensity = specular.getSpecularFactor(); 536 | } 537 | const specularColor = specular.getSpecularColorFactor(); 538 | if (!eq(specularColor, target.specularColor.toArray(_vec3))) { 539 | target.specularColor.fromArray(specularColor); 540 | } 541 | } else { 542 | target.specularIntensity = 1.0; 543 | target.specularColor.setRGB(1, 1, 1); 544 | } 545 | 546 | // KHR_materials_transmission 547 | const transmission = def.getExtension('KHR_materials_transmission'); 548 | if (transmission) { 549 | if (transmission.getTransmissionFactor() !== target.transmission) { 550 | if (target.transmission === 0) target.needsUpdate = true; 551 | target.transmission = transmission.getTransmissionFactor(); 552 | } 553 | } else { 554 | target.transmission = 0; 555 | } 556 | 557 | // KHR_materials_volume 558 | const volume = def.getExtension('KHR_materials_volume'); 559 | if (volume) { 560 | if (volume.getThicknessFactor() !== target.thickness) { 561 | if (target.thickness === 0) target.needsUpdate = true; 562 | target.thickness = volume.getThicknessFactor(); 563 | } 564 | if (volume.getAttenuationDistance() !== target.attenuationDistance) { 565 | target.attenuationDistance = volume.getAttenuationDistance(); 566 | } 567 | const attenuationColor = volume.getAttenuationColor(); 568 | if (!eq(attenuationColor, target.attenuationColor.toArray(_vec3))) { 569 | target.attenuationColor.fromArray(attenuationColor); 570 | } 571 | } else { 572 | target.thickness = 0; 573 | } 574 | } 575 | 576 | dispose() { 577 | this.extensions.dispose(); 578 | for (const observer of this._textureObservers) { 579 | observer.dispose(); 580 | } 581 | super.dispose(); 582 | } 583 | } 584 | 585 | function getShadingModel(def: MaterialDef): ShadingModel { 586 | for (const extension of def.listExtensions()) { 587 | switch (extension.extensionName) { 588 | case 'KHR_materials_unlit': 589 | return ShadingModel.UNLIT; 590 | 591 | case 'KHR_materials_anisotropy': 592 | case 'KHR_materials_clearcoat': 593 | case 'KHR_materials_ior': 594 | case 'KHR_materials_iridescence': 595 | case 'KHR_materials_sheen': 596 | case 'KHR_materials_specular': 597 | case 'KHR_materials_transmission': 598 | case 'KHR_materials_volume': 599 | return ShadingModel.PHYSICAL; 600 | } 601 | } 602 | return ShadingModel.STANDARD; 603 | } 604 | -------------------------------------------------------------------------------- /assets/view_architecture.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "44347919-ab36-4a3b-3c65-1ded8c8b99ef", 4 | "type": "rectangle", 5 | "name": "Rectangle", 6 | "parentId": "page", 7 | "childIndex": 1, 8 | "point": [ 9 | 20.3, 10 | 358.19 11 | ], 12 | "size": [ 13 | 221, 14 | 121 15 | ], 16 | "rotation": 0, 17 | "style": { 18 | "color": "black", 19 | "size": "small", 20 | "isFilled": false, 21 | "dash": "draw", 22 | "scale": 1 23 | }, 24 | "label": "TextureSubject\nin out", 25 | "labelPoint": [ 26 | 0.5, 27 | 0.5 28 | ] 29 | }, 30 | { 31 | "id": "cea2c83a-f995-49d4-3ce7-a28178f40240", 32 | "type": "rectangle", 33 | "name": "Rectangle", 34 | "parentId": "page", 35 | "childIndex": 2, 36 | "point": [ 37 | 584, 38 | 265 39 | ], 40 | "size": [ 41 | 221, 42 | 121 43 | ], 44 | "rotation": 0, 45 | "style": { 46 | "color": "black", 47 | "size": "small", 48 | "isFilled": false, 49 | "dash": "draw", 50 | "scale": 1 51 | }, 52 | "label": "MaterialSubject\nin out", 53 | "labelPoint": [ 54 | 0.5, 55 | 0.5 56 | ] 57 | }, 58 | { 59 | "id": "571b91be-5f93-4f0e-1a7c-772120e96049", 60 | "type": "rectangle", 61 | "name": "Rectangle", 62 | "parentId": "page", 63 | "childIndex": 3, 64 | "point": [ 65 | 584, 66 | 436 67 | ], 68 | "size": [ 69 | 221, 70 | 121 71 | ], 72 | "rotation": 0, 73 | "style": { 74 | "color": "black", 75 | "size": "small", 76 | "isFilled": false, 77 | "dash": "draw", 78 | "scale": 1 79 | }, 80 | "label": "MaterialSubject\nin out", 81 | "labelPoint": [ 82 | 0.5, 83 | 0.5 84 | ] 85 | }, 86 | { 87 | "id": "957b1cde-79f6-4aae-3fb3-bb3b4b539d95", 88 | "type": "rectangle", 89 | "name": "Rectangle", 90 | "parentId": "page", 91 | "childIndex": 3, 92 | "point": [ 93 | 1169.59, 94 | 150.8 95 | ], 96 | "size": [ 97 | 221, 98 | 121 99 | ], 100 | "rotation": 0, 101 | "style": { 102 | "color": "black", 103 | "size": "small", 104 | "isFilled": false, 105 | "dash": "draw", 106 | "scale": 1 107 | }, 108 | "label": "PrimitiveSubject\nin out", 109 | "labelPoint": [ 110 | 0.5, 111 | 0.5 112 | ] 113 | }, 114 | { 115 | "id": "65bcd3a5-b9fb-45bc-11bb-318ec5ed0c2d", 116 | "type": "rectangle", 117 | "name": "Rectangle", 118 | "parentId": "page", 119 | "childIndex": 4, 120 | "point": [ 121 | 1169.59, 122 | 552.8 123 | ], 124 | "size": [ 125 | 221, 126 | 121 127 | ], 128 | "rotation": 0, 129 | "style": { 130 | "color": "black", 131 | "size": "small", 132 | "isFilled": false, 133 | "dash": "draw", 134 | "scale": 1 135 | }, 136 | "label": "PrimitiveSubject\nin out", 137 | "labelPoint": [ 138 | 0.5, 139 | 0.5 140 | ] 141 | }, 142 | { 143 | "id": "a86d43f1-4bf2-46ea-11a5-d9f2fa734783", 144 | "type": "rectangle", 145 | "name": "Rectangle", 146 | "parentId": "page", 147 | "childIndex": 4, 148 | "point": [ 149 | 1726.16, 150 | 330.72 151 | ], 152 | "size": [ 153 | 221, 154 | 121 155 | ], 156 | "rotation": 0, 157 | "style": { 158 | "color": "black", 159 | "size": "small", 160 | "isFilled": false, 161 | "dash": "draw", 162 | "scale": 1 163 | }, 164 | "label": "MeshSubject\nin out", 165 | "labelPoint": [ 166 | 0.5, 167 | 0.5 168 | ] 169 | }, 170 | { 171 | "id": "66e12089-c96d-4dce-2873-d65a6146de67", 172 | "type": "rectangle", 173 | "name": "Rectangle", 174 | "parentId": "page", 175 | "childIndex": 5, 176 | "point": [ 177 | 2269.66, 178 | 408.59 179 | ], 180 | "size": [ 181 | 221, 182 | 121 183 | ], 184 | "rotation": 0, 185 | "style": { 186 | "color": "black", 187 | "size": "small", 188 | "isFilled": false, 189 | "dash": "draw", 190 | "scale": 1 191 | }, 192 | "label": "NodeSubject\nin out", 193 | "labelPoint": [ 194 | 0.5, 195 | 0.5 196 | ] 197 | }, 198 | { 199 | "id": "eb8a2fc5-3d4b-41db-34ed-22aca02ec5d0", 200 | "type": "rectangle", 201 | "name": "Rectangle", 202 | "parentId": "page", 203 | "childIndex": 6, 204 | "point": [ 205 | 2272.66, 206 | 236.59 207 | ], 208 | "size": [ 209 | 221, 210 | 121 211 | ], 212 | "rotation": 0, 213 | "style": { 214 | "color": "black", 215 | "size": "small", 216 | "isFilled": false, 217 | "dash": "draw", 218 | "scale": 1 219 | }, 220 | "label": "NodeSubject\nin out", 221 | "labelPoint": [ 222 | 0.5, 223 | 0.5 224 | ] 225 | }, 226 | { 227 | "id": "a96250f6-6868-467d-0b21-ad1d3fcfa618", 228 | "type": "rectangle", 229 | "name": "Rectangle", 230 | "parentId": "page", 231 | "childIndex": 7, 232 | "point": [ 233 | 321.06, 234 | 355.79 235 | ], 236 | "size": [ 237 | 166.32, 238 | 33.69 239 | ], 240 | "rotation": 0, 241 | "style": { 242 | "color": "gray", 243 | "size": "small", 244 | "isFilled": false, 245 | "dash": "dotted", 246 | "scale": 1 247 | }, 248 | "label": "RefObserver", 249 | "labelPoint": [ 250 | 0.5, 251 | 0.5 252 | ] 253 | }, 254 | { 255 | "id": "ea3de8f0-9ad8-4837-0b2e-3ef7642bba4a", 256 | "type": "rectangle", 257 | "name": "Rectangle", 258 | "parentId": "page", 259 | "childIndex": 8, 260 | "point": [ 261 | 1468.08, 262 | 390.36 263 | ], 264 | "size": [ 265 | 166.32, 266 | 33.69 267 | ], 268 | "rotation": 0, 269 | "style": { 270 | "color": "gray", 271 | "size": "small", 272 | "isFilled": false, 273 | "dash": "dotted", 274 | "scale": 1 275 | }, 276 | "label": "RefListObserver", 277 | "labelPoint": [ 278 | 0.5, 279 | 0.5 280 | ] 281 | }, 282 | { 283 | "id": "9053976e-0335-48d0-3016-a2fdd405a6ee", 284 | "type": "rectangle", 285 | "name": "Rectangle", 286 | "parentId": "page", 287 | "childIndex": 8, 288 | "point": [ 289 | 322.93, 290 | 443.6 291 | ], 292 | "size": [ 293 | 166.32, 294 | 33.69 295 | ], 296 | "rotation": 0, 297 | "style": { 298 | "color": "gray", 299 | "size": "small", 300 | "isFilled": false, 301 | "dash": "dotted", 302 | "scale": 1 303 | }, 304 | "label": "RefObserver", 305 | "labelPoint": [ 306 | 0.5, 307 | 0.5 308 | ] 309 | }, 310 | { 311 | "id": "522a411e-695a-495b-0630-20152a9f37e1", 312 | "type": "arrow", 313 | "name": "Arrow", 314 | "parentId": "page", 315 | "childIndex": 9, 316 | "point": [ 317 | 241.3, 318 | 373.96 319 | ], 320 | "rotation": 0, 321 | "bend": 0, 322 | "handles": { 323 | "start": { 324 | "id": "start", 325 | "index": 0, 326 | "point": [ 327 | 0, 328 | 16.37 329 | ], 330 | "canBind": true, 331 | "bindingId": "25d0ceec-8199-495f-0c90-cc633ca80f38" 332 | }, 333 | "end": { 334 | "id": "end", 335 | "index": 1, 336 | "point": [ 337 | 63.76, 338 | 0 339 | ], 340 | "canBind": true, 341 | "bindingId": "a4fe2501-cc4a-4c27-341b-16c033b7cfd2" 342 | }, 343 | "bend": { 344 | "id": "bend", 345 | "index": 2, 346 | "point": [ 347 | 31.88, 348 | 8.19 349 | ] 350 | } 351 | }, 352 | "decorations": { 353 | "end": "arrow" 354 | }, 355 | "style": { 356 | "color": "cyan", 357 | "size": "small", 358 | "isFilled": false, 359 | "dash": "solid", 360 | "scale": 1 361 | }, 362 | "label": "", 363 | "labelPoint": [ 364 | 0.5, 365 | 0.5 366 | ] 367 | }, 368 | { 369 | "id": "5c7a1a0d-ac01-498b-1396-9706dfe9bc64", 370 | "type": "arrow", 371 | "name": "Arrow", 372 | "parentId": "page", 373 | "childIndex": 10, 374 | "point": [ 375 | 241.3, 376 | 444.37 377 | ], 378 | "rotation": 0, 379 | "bend": 0.000034537541896031646, 380 | "handles": { 381 | "start": { 382 | "id": "start", 383 | "index": 0, 384 | "point": [ 385 | 0, 386 | 0 387 | ], 388 | "canBind": true, 389 | "bindingId": "fb9931ae-9bdf-4a54-1105-81a138c328bb" 390 | }, 391 | "end": { 392 | "id": "end", 393 | "index": 1, 394 | "point": [ 395 | 65.63, 396 | 15.25 397 | ], 398 | "canBind": true, 399 | "bindingId": "e1db9702-5644-4262-3e5d-ab399cf88ab8" 400 | }, 401 | "bend": { 402 | "id": "bend", 403 | "index": 2, 404 | "point": [ 405 | 32.82, 406 | 7.63 407 | ] 408 | } 409 | }, 410 | "decorations": { 411 | "end": "arrow" 412 | }, 413 | "style": { 414 | "color": "cyan", 415 | "size": "small", 416 | "isFilled": false, 417 | "dash": "solid", 418 | "scale": 1 419 | }, 420 | "label": "", 421 | "labelPoint": [ 422 | 0.5, 423 | 0.5 424 | ] 425 | }, 426 | { 427 | "id": "a0526c14-c266-4092-1f60-c7cc8e40f5f7", 428 | "type": "arrow", 429 | "name": "Arrow", 430 | "parentId": "page", 431 | "childIndex": 11, 432 | "point": [ 433 | 487.38, 434 | 370.33 435 | ], 436 | "rotation": 0, 437 | "bend": -0.00008845569928148244, 438 | "handles": { 439 | "start": { 440 | "id": "start", 441 | "index": 0, 442 | "point": [ 443 | 0, 444 | 1.14 445 | ], 446 | "canBind": true, 447 | "bindingId": "a1c78a4e-97ba-4e2b-1a75-4a0ddb0c24c1" 448 | }, 449 | "end": { 450 | "id": "end", 451 | "index": 1, 452 | "point": [ 453 | 80.62, 454 | 0 455 | ], 456 | "canBind": true, 457 | "bindingId": "ebf10c6a-5cea-4929-3cbe-85787a3dac3a" 458 | }, 459 | "bend": { 460 | "id": "bend", 461 | "index": 2, 462 | "point": [ 463 | 40.31, 464 | 0.57 465 | ] 466 | } 467 | }, 468 | "decorations": { 469 | "end": "arrow" 470 | }, 471 | "style": { 472 | "color": "cyan", 473 | "size": "small", 474 | "isFilled": false, 475 | "dash": "solid", 476 | "scale": 1 477 | }, 478 | "label": "", 479 | "labelPoint": [ 480 | 0.5, 481 | 0.5 482 | ] 483 | }, 484 | { 485 | "id": "5f7d1ae6-edd9-454e-23b9-105526534c9e", 486 | "type": "arrow", 487 | "name": "Arrow", 488 | "parentId": "page", 489 | "childIndex": 12, 490 | "point": [ 491 | 490.13458110516933, 492 | 462.3029590017825, 493 | 0.5 494 | ], 495 | "rotation": 0, 496 | "bend": 0, 497 | "handles": { 498 | "start": { 499 | "id": "start", 500 | "index": 0, 501 | "point": [ 502 | 0, 503 | 0 504 | ], 505 | "canBind": true 506 | }, 507 | "end": { 508 | "id": "end", 509 | "index": 1, 510 | "point": [ 511 | 77.87, 512 | 0.2 513 | ], 514 | "canBind": true, 515 | "bindingId": "53e6a09c-e1ad-4262-1b6c-18e33c897ae8" 516 | }, 517 | "bend": { 518 | "id": "bend", 519 | "index": 2, 520 | "point": [ 521 | 38.94, 522 | 0.1 523 | ] 524 | } 525 | }, 526 | "decorations": { 527 | "end": "arrow" 528 | }, 529 | "style": { 530 | "color": "cyan", 531 | "size": "small", 532 | "isFilled": false, 533 | "dash": "solid", 534 | "scale": 1 535 | }, 536 | "label": "", 537 | "labelPoint": [ 538 | 0.5, 539 | 0.5 540 | ] 541 | }, 542 | { 543 | "id": "b1a5f7a9-17f1-4aa1-3269-613648ea301c", 544 | "type": "rectangle", 545 | "name": "Rectangle", 546 | "parentId": "page", 547 | "childIndex": 4, 548 | "point": [ 549 | 1169.59, 550 | 350.69 551 | ], 552 | "size": [ 553 | 221, 554 | 121 555 | ], 556 | "rotation": 0, 557 | "style": { 558 | "color": "black", 559 | "size": "small", 560 | "isFilled": false, 561 | "dash": "draw", 562 | "scale": 1 563 | }, 564 | "label": "PrimitiveSubject\nin out", 565 | "labelPoint": [ 566 | 0.5, 567 | 0.5 568 | ] 569 | }, 570 | { 571 | "id": "483cc920-863e-4c1b-3d0f-1dcc76dd8af2", 572 | "type": "rectangle", 573 | "name": "Rectangle", 574 | "parentId": "page", 575 | "childIndex": 9, 576 | "point": [ 577 | 892.7, 578 | 253.05 579 | ], 580 | "size": [ 581 | 166.32, 582 | 33.69 583 | ], 584 | "rotation": 0, 585 | "style": { 586 | "color": "gray", 587 | "size": "small", 588 | "isFilled": false, 589 | "dash": "dotted", 590 | "scale": 1 591 | }, 592 | "label": "RefObserver", 593 | "labelPoint": [ 594 | 0.5, 595 | 0.5 596 | ] 597 | }, 598 | { 599 | "id": "159bb303-5506-4e49-10ea-2384fb2f716c", 600 | "type": "rectangle", 601 | "name": "Rectangle", 602 | "parentId": "page", 603 | "childIndex": 10, 604 | "point": [ 605 | 892.7, 606 | 378.21 607 | ], 608 | "size": [ 609 | 166.32, 610 | 33.69 611 | ], 612 | "rotation": 0, 613 | "style": { 614 | "color": "gray", 615 | "size": "small", 616 | "isFilled": false, 617 | "dash": "dotted", 618 | "scale": 1 619 | }, 620 | "label": "RefObserver", 621 | "labelPoint": [ 622 | 0.5, 623 | 0.5 624 | ] 625 | }, 626 | { 627 | "id": "a3828784-de12-4bb6-0f07-ff599ef1b283", 628 | "type": "rectangle", 629 | "name": "Rectangle", 630 | "parentId": "page", 631 | "childIndex": 11, 632 | "point": [ 633 | 892.7, 634 | 539.8 635 | ], 636 | "size": [ 637 | 166.32, 638 | 33.69 639 | ], 640 | "rotation": 0, 641 | "style": { 642 | "color": "gray", 643 | "size": "small", 644 | "isFilled": false, 645 | "dash": "dotted", 646 | "scale": 1 647 | }, 648 | "label": "RefObserver", 649 | "labelPoint": [ 650 | 0.5, 651 | 0.5 652 | ] 653 | }, 654 | { 655 | "id": "5ea5dbb6-9664-499a-2905-46617505ab78", 656 | "type": "arrow", 657 | "name": "Arrow", 658 | "parentId": "page", 659 | "childIndex": 13, 660 | "point": [ 661 | 806.75, 662 | 269.84 663 | ], 664 | "rotation": 0, 665 | "bend": -0.00011688396688326063, 666 | "handles": { 667 | "start": { 668 | "id": "start", 669 | "index": 0, 670 | "point": [ 671 | 0, 672 | 9.41 673 | ], 674 | "canBind": true 675 | }, 676 | "end": { 677 | "id": "end", 678 | "index": 1, 679 | "point": [ 680 | 69.95, 681 | 0 682 | ], 683 | "canBind": true, 684 | "bindingId": "064bba3f-79ba-42d2-0fb7-adbd8c8ccf25" 685 | }, 686 | "bend": { 687 | "id": "bend", 688 | "index": 2, 689 | "point": [ 690 | 34.98, 691 | 4.71 692 | ] 693 | } 694 | }, 695 | "decorations": { 696 | "end": "arrow" 697 | }, 698 | "style": { 699 | "color": "cyan", 700 | "size": "small", 701 | "isFilled": false, 702 | "dash": "solid", 703 | "scale": 1 704 | }, 705 | "label": "", 706 | "labelPoint": [ 707 | 0.5, 708 | 0.5 709 | ] 710 | }, 711 | { 712 | "id": "8a98f412-80d5-469c-2167-bdebc0171a9f", 713 | "type": "arrow", 714 | "name": "Arrow", 715 | "parentId": "page", 716 | "childIndex": 14, 717 | "point": [ 718 | 1060.81, 719 | 266.81 720 | ], 721 | "rotation": 0, 722 | "bend": 0, 723 | "handles": { 724 | "start": { 725 | "id": "start", 726 | "index": 0, 727 | "point": [ 728 | 0, 729 | 0.3 730 | ], 731 | "canBind": true 732 | }, 733 | "end": { 734 | "id": "end", 735 | "index": 1, 736 | "point": [ 737 | 92.78, 738 | 0 739 | ], 740 | "canBind": true, 741 | "bindingId": "e4e92311-03da-4e65-03c9-eb5e05ee76e8" 742 | }, 743 | "bend": { 744 | "id": "bend", 745 | "index": 2, 746 | "point": [ 747 | 46.39, 748 | 0.15 749 | ] 750 | } 751 | }, 752 | "decorations": { 753 | "end": "arrow" 754 | }, 755 | "style": { 756 | "color": "cyan", 757 | "size": "small", 758 | "isFilled": false, 759 | "dash": "solid", 760 | "scale": 1 761 | }, 762 | "label": "", 763 | "labelPoint": [ 764 | 0.5, 765 | 0.5 766 | ] 767 | }, 768 | { 769 | "id": "af0c956a-3067-4d90-175c-11f3c8415e4b", 770 | "type": "arrow", 771 | "name": "Arrow", 772 | "parentId": "page", 773 | "childIndex": 15, 774 | "point": [ 775 | 811.4211586452761, 776 | 363.31352941176465, 777 | 0.5 778 | ], 779 | "rotation": 0, 780 | "bend": 0.00003712407852437962, 781 | "handles": { 782 | "start": { 783 | "id": "start", 784 | "index": 0, 785 | "point": [ 786 | 0, 787 | 0 788 | ], 789 | "canBind": true 790 | }, 791 | "end": { 792 | "id": "end", 793 | "index": 1, 794 | "point": [ 795 | 65.28, 796 | 23.69 797 | ], 798 | "canBind": true, 799 | "bindingId": "39cc08de-3052-410e-3818-b446d754c73c" 800 | }, 801 | "bend": { 802 | "id": "bend", 803 | "index": 2, 804 | "point": [ 805 | 32.64, 806 | 11.85 807 | ] 808 | } 809 | }, 810 | "decorations": { 811 | "end": "arrow" 812 | }, 813 | "style": { 814 | "color": "cyan", 815 | "size": "small", 816 | "isFilled": false, 817 | "dash": "solid", 818 | "scale": 1 819 | }, 820 | "label": "", 821 | "labelPoint": [ 822 | 0.5, 823 | 0.5 824 | ] 825 | }, 826 | { 827 | "id": "67da953b-4a5d-4d84-3a7d-bc8807d6d297", 828 | "type": "arrow", 829 | "name": "Arrow", 830 | "parentId": "page", 831 | "childIndex": 16, 832 | "point": [ 833 | 1063.61, 834 | 391.32 835 | ], 836 | "rotation": 0, 837 | "bend": 0, 838 | "handles": { 839 | "start": { 840 | "id": "start", 841 | "index": 0, 842 | "point": [ 843 | 0, 844 | 0.01 845 | ], 846 | "canBind": true 847 | }, 848 | "end": { 849 | "id": "end", 850 | "index": 1, 851 | "point": [ 852 | 89.98, 853 | 0 854 | ], 855 | "canBind": true, 856 | "bindingId": "14028a89-786b-4660-0d8c-237bf3156dae" 857 | }, 858 | "bend": { 859 | "id": "bend", 860 | "index": 2, 861 | "point": [ 862 | 44.99, 863 | 0.01 864 | ] 865 | } 866 | }, 867 | "decorations": { 868 | "end": "arrow" 869 | }, 870 | "style": { 871 | "color": "cyan", 872 | "size": "small", 873 | "isFilled": false, 874 | "dash": "solid", 875 | "scale": 1 876 | }, 877 | "label": "", 878 | "labelPoint": [ 879 | 0.5, 880 | 0.5 881 | ] 882 | }, 883 | { 884 | "id": "97d56bdd-403d-4842-10f1-2ccd49d093ec", 885 | "type": "arrow", 886 | "name": "Arrow", 887 | "parentId": "page", 888 | "childIndex": 17, 889 | "point": [ 890 | 809.5530659536541, 891 | 521.1673618538324, 892 | 0.5 893 | ], 894 | "rotation": 0, 895 | "bend": -0.00005684314238507206, 896 | "handles": { 897 | "start": { 898 | "id": "start", 899 | "index": 0, 900 | "point": [ 901 | 0, 902 | 0 903 | ], 904 | "canBind": true 905 | }, 906 | "end": { 907 | "id": "end", 908 | "index": 1, 909 | "point": [ 910 | 67.15, 911 | 28.31 912 | ], 913 | "canBind": true, 914 | "bindingId": "56f55741-707a-4f9c-026b-bf349c0874ac" 915 | }, 916 | "bend": { 917 | "id": "bend", 918 | "index": 2, 919 | "point": [ 920 | 33.58, 921 | 14.16 922 | ] 923 | } 924 | }, 925 | "decorations": { 926 | "end": "arrow" 927 | }, 928 | "style": { 929 | "color": "cyan", 930 | "size": "small", 931 | "isFilled": false, 932 | "dash": "solid", 933 | "scale": 1 934 | }, 935 | "label": "", 936 | "labelPoint": [ 937 | 0.5, 938 | 0.5 939 | ] 940 | }, 941 | { 942 | "id": "d5b1c77e-145d-4833-3a48-10f1829d25cb", 943 | "type": "arrow", 944 | "name": "Arrow", 945 | "parentId": "page", 946 | "childIndex": 18, 947 | "point": [ 948 | 1062.679625668449, 949 | 557.5951693404634, 950 | 0.5 951 | ], 952 | "rotation": 0, 953 | "bend": -0.00007486430844108881, 954 | "handles": { 955 | "start": { 956 | "id": "start", 957 | "index": 0, 958 | "point": [ 959 | 0, 960 | 0 961 | ], 962 | "canBind": true 963 | }, 964 | "end": { 965 | "id": "end", 966 | "index": 1, 967 | "point": [ 968 | 90.91, 969 | 18.39 970 | ], 971 | "canBind": true, 972 | "bindingId": "ecab6f9b-c0a0-44ba-1971-1cf57ff6a7f0" 973 | }, 974 | "bend": { 975 | "id": "bend", 976 | "index": 2, 977 | "point": [ 978 | 45.46, 979 | 9.2 980 | ] 981 | } 982 | }, 983 | "decorations": { 984 | "end": "arrow" 985 | }, 986 | "style": { 987 | "color": "cyan", 988 | "size": "small", 989 | "isFilled": false, 990 | "dash": "solid", 991 | "scale": 1 992 | }, 993 | "label": "", 994 | "labelPoint": [ 995 | 0.5, 996 | 0.5 997 | ] 998 | }, 999 | { 1000 | "id": "d5d2e2f6-89d4-4614-307f-d991b8f220cb", 1001 | "type": "arrow", 1002 | "name": "Arrow", 1003 | "parentId": "page", 1004 | "childIndex": 19, 1005 | "point": [ 1006 | 1392.400303030303, 1007 | 252.16796791443846, 1008 | 0.5 1009 | ], 1010 | "rotation": 0, 1011 | "bend": 0, 1012 | "handles": { 1013 | "start": { 1014 | "id": "start", 1015 | "index": 0, 1016 | "point": [ 1017 | 0, 1018 | 0 1019 | ], 1020 | "canBind": true 1021 | }, 1022 | "end": { 1023 | "id": "end", 1024 | "index": 1, 1025 | "point": [ 1026 | 64.54, 1027 | 122.19 1028 | ], 1029 | "canBind": true, 1030 | "bindingId": "efb98d69-5ed2-4991-0b63-fd13955368d7" 1031 | }, 1032 | "bend": { 1033 | "id": "bend", 1034 | "index": 2, 1035 | "point": [ 1036 | 32.27, 1037 | 61.1 1038 | ] 1039 | } 1040 | }, 1041 | "decorations": { 1042 | "end": "arrow" 1043 | }, 1044 | "style": { 1045 | "color": "cyan", 1046 | "size": "small", 1047 | "isFilled": false, 1048 | "dash": "solid", 1049 | "scale": 1 1050 | }, 1051 | "label": "", 1052 | "labelPoint": [ 1053 | 0.5, 1054 | 0.5 1055 | ] 1056 | }, 1057 | { 1058 | "id": "c58d40c1-672e-4e63-1797-a343e4f7d148", 1059 | "type": "arrow", 1060 | "name": "Arrow", 1061 | "parentId": "page", 1062 | "childIndex": 20, 1063 | "point": [ 1064 | 1392.4, 1065 | 409.55 1066 | ], 1067 | "rotation": 0, 1068 | "bend": 0, 1069 | "handles": { 1070 | "start": { 1071 | "id": "start", 1072 | "index": 0, 1073 | "point": [ 1074 | 0, 1075 | 1.41 1076 | ], 1077 | "canBind": true 1078 | }, 1079 | "end": { 1080 | "id": "end", 1081 | "index": 1, 1082 | "point": [ 1083 | 59.68, 1084 | 0 1085 | ], 1086 | "canBind": true, 1087 | "bindingId": "2e2c0fcb-934d-4548-225e-66ae49f638d1" 1088 | }, 1089 | "bend": { 1090 | "id": "bend", 1091 | "index": 2, 1092 | "point": [ 1093 | 29.84, 1094 | 0.71 1095 | ] 1096 | } 1097 | }, 1098 | "decorations": { 1099 | "end": "arrow" 1100 | }, 1101 | "style": { 1102 | "color": "cyan", 1103 | "size": "small", 1104 | "isFilled": false, 1105 | "dash": "solid", 1106 | "scale": 1 1107 | }, 1108 | "label": "", 1109 | "labelPoint": [ 1110 | 0.5, 1111 | 0.5 1112 | ] 1113 | }, 1114 | { 1115 | "id": "ee1e8ed7-c71c-4e48-3a66-0c24e2c2a6ea", 1116 | "type": "arrow", 1117 | "name": "Arrow", 1118 | "parentId": "page", 1119 | "childIndex": 21, 1120 | "point": [ 1121 | 1392.4, 1122 | 440.05 1123 | ], 1124 | "rotation": 0, 1125 | "bend": -0.00005585221371167086, 1126 | "handles": { 1127 | "start": { 1128 | "id": "start", 1129 | "index": 0, 1130 | "point": [ 1131 | 0, 1132 | 130.63 1133 | ], 1134 | "canBind": true 1135 | }, 1136 | "end": { 1137 | "id": "end", 1138 | "index": 1, 1139 | "point": [ 1140 | 75.31, 1141 | 0 1142 | ], 1143 | "canBind": true, 1144 | "bindingId": "f574b266-4a5a-45fc-0336-c8541e551003" 1145 | }, 1146 | "bend": { 1147 | "id": "bend", 1148 | "index": 2, 1149 | "point": [ 1150 | 37.66, 1151 | 65.32 1152 | ] 1153 | } 1154 | }, 1155 | "decorations": { 1156 | "end": "arrow" 1157 | }, 1158 | "style": { 1159 | "color": "cyan", 1160 | "size": "small", 1161 | "isFilled": false, 1162 | "dash": "solid", 1163 | "scale": 1 1164 | }, 1165 | "label": "", 1166 | "labelPoint": [ 1167 | 0.5, 1168 | 0.5 1169 | ] 1170 | }, 1171 | { 1172 | "id": "c87709e8-8601-447d-1576-8007dabf295b", 1173 | "type": "arrow", 1174 | "name": "Arrow", 1175 | "parentId": "page", 1176 | "childIndex": 22, 1177 | "point": [ 1178 | 1636.19, 1179 | 409.09 1180 | ], 1181 | "rotation": 0, 1182 | "bend": -0.000024082723511482717, 1183 | "handles": { 1184 | "start": { 1185 | "id": "start", 1186 | "index": 0, 1187 | "point": [ 1188 | 0, 1189 | 0 1190 | ], 1191 | "canBind": true 1192 | }, 1193 | "end": { 1194 | "id": "end", 1195 | "index": 1, 1196 | "point": [ 1197 | 73.97, 1198 | 0.72 1199 | ], 1200 | "canBind": true, 1201 | "bindingId": "e0a5dcb9-d2cd-4784-1c48-eb7b02d7580f" 1202 | }, 1203 | "bend": { 1204 | "id": "bend", 1205 | "index": 2, 1206 | "point": [ 1207 | 36.99, 1208 | 0.36 1209 | ] 1210 | } 1211 | }, 1212 | "decorations": { 1213 | "end": "arrow" 1214 | }, 1215 | "style": { 1216 | "color": "cyan", 1217 | "size": "small", 1218 | "isFilled": false, 1219 | "dash": "solid", 1220 | "scale": 1 1221 | }, 1222 | "label": "", 1223 | "labelPoint": [ 1224 | 0.5, 1225 | 0.5 1226 | ] 1227 | }, 1228 | { 1229 | "id": "15a86115-06f4-41db-0238-199c5c15635b", 1230 | "type": "rectangle", 1231 | "name": "Rectangle", 1232 | "parentId": "page", 1233 | "childIndex": 11, 1234 | "point": [ 1235 | 2015.42, 1236 | 312.44 1237 | ], 1238 | "size": [ 1239 | 166.32, 1240 | 33.69 1241 | ], 1242 | "rotation": 0, 1243 | "style": { 1244 | "color": "gray", 1245 | "size": "small", 1246 | "isFilled": false, 1247 | "dash": "dotted", 1248 | "scale": 1 1249 | }, 1250 | "label": "RefObserver", 1251 | "labelPoint": [ 1252 | 0.5, 1253 | 0.5 1254 | ] 1255 | }, 1256 | { 1257 | "id": "a817d794-a331-4250-0186-8668957c71df", 1258 | "type": "rectangle", 1259 | "name": "Rectangle", 1260 | "parentId": "page", 1261 | "childIndex": 12, 1262 | "point": [ 1263 | 2017.29, 1264 | 431.45 1265 | ], 1266 | "size": [ 1267 | 166.32, 1268 | 33.69 1269 | ], 1270 | "rotation": 0, 1271 | "style": { 1272 | "color": "gray", 1273 | "size": "small", 1274 | "isFilled": false, 1275 | "dash": "dotted", 1276 | "scale": 1 1277 | }, 1278 | "label": "RefObserver", 1279 | "labelPoint": [ 1280 | 0.5, 1281 | 0.5 1282 | ] 1283 | }, 1284 | { 1285 | "id": "49b5c21e-cc36-4a1e-02ab-c031a7dcf3b5", 1286 | "type": "arrow", 1287 | "name": "Arrow", 1288 | "parentId": "page", 1289 | "childIndex": 23, 1290 | "point": [ 1291 | 1947.16, 1292 | 330.92 1293 | ], 1294 | "rotation": 0, 1295 | "bend": -0.000046694943315935995, 1296 | "handles": { 1297 | "start": { 1298 | "id": "start", 1299 | "index": 0, 1300 | "point": [ 1301 | 0, 1302 | 19.36 1303 | ], 1304 | "canBind": true, 1305 | "bindingId": "16d915c1-4579-4897-04ce-556096bd03a2" 1306 | }, 1307 | "end": { 1308 | "id": "end", 1309 | "index": 1, 1310 | "point": [ 1311 | 52.26, 1312 | 0 1313 | ], 1314 | "canBind": true, 1315 | "bindingId": "922eae17-ef95-4293-3667-3ea0ed15b59a" 1316 | }, 1317 | "bend": { 1318 | "id": "bend", 1319 | "index": 2, 1320 | "point": [ 1321 | 26.13, 1322 | 9.68 1323 | ] 1324 | } 1325 | }, 1326 | "decorations": { 1327 | "end": "arrow" 1328 | }, 1329 | "style": { 1330 | "color": "cyan", 1331 | "size": "small", 1332 | "isFilled": false, 1333 | "dash": "solid", 1334 | "scale": 1 1335 | }, 1336 | "label": "", 1337 | "labelPoint": [ 1338 | 0.5, 1339 | 0.5 1340 | ] 1341 | }, 1342 | { 1343 | "id": "b0c065db-5139-426b-286e-2d56563e5b7f", 1344 | "type": "arrow", 1345 | "name": "Arrow", 1346 | "parentId": "page", 1347 | "childIndex": 24, 1348 | "point": [ 1349 | 1952.8098752228163, 1350 | 419.35180035650615, 1351 | 0.5 1352 | ], 1353 | "rotation": 0, 1354 | "bend": 0.00006267300360398583, 1355 | "handles": { 1356 | "start": { 1357 | "id": "start", 1358 | "index": 0, 1359 | "point": [ 1360 | 0, 1361 | 0 1362 | ], 1363 | "canBind": true 1364 | }, 1365 | "end": { 1366 | "id": "end", 1367 | "index": 1, 1368 | "point": [ 1369 | 48.48, 1370 | 22.1 1371 | ], 1372 | "canBind": true, 1373 | "bindingId": "a93bbcab-3168-4439-0ec4-fe849846aafe" 1374 | }, 1375 | "bend": { 1376 | "id": "bend", 1377 | "index": 2, 1378 | "point": [ 1379 | 24.24, 1380 | 11.05 1381 | ] 1382 | } 1383 | }, 1384 | "decorations": { 1385 | "end": "arrow" 1386 | }, 1387 | "style": { 1388 | "color": "cyan", 1389 | "size": "small", 1390 | "isFilled": false, 1391 | "dash": "solid", 1392 | "scale": 1 1393 | }, 1394 | "label": "", 1395 | "labelPoint": [ 1396 | 0.5, 1397 | 0.5 1398 | ] 1399 | }, 1400 | { 1401 | "id": "d37c0aac-df51-4e62-0e04-8cac5668419c", 1402 | "type": "arrow", 1403 | "name": "Arrow", 1404 | "parentId": "page", 1405 | "childIndex": 25, 1406 | "point": [ 1407 | 2182.59, 1408 | 333.42 1409 | ], 1410 | "rotation": 0, 1411 | "bend": 0, 1412 | "handles": { 1413 | "start": { 1414 | "id": "start", 1415 | "index": 0, 1416 | "point": [ 1417 | 0, 1418 | 0 1419 | ], 1420 | "canBind": true 1421 | }, 1422 | "end": { 1423 | "id": "end", 1424 | "index": 1, 1425 | "point": [ 1426 | 74.07, 1427 | 0.14 1428 | ], 1429 | "canBind": true, 1430 | "bindingId": "c2cc6356-0f1b-4034-364c-2543fc2b1339" 1431 | }, 1432 | "bend": { 1433 | "id": "bend", 1434 | "index": 2, 1435 | "point": [ 1436 | 37.03, 1437 | 0.07 1438 | ] 1439 | } 1440 | }, 1441 | "decorations": { 1442 | "end": "arrow" 1443 | }, 1444 | "style": { 1445 | "color": "cyan", 1446 | "size": "small", 1447 | "isFilled": false, 1448 | "dash": "solid", 1449 | "scale": 1 1450 | }, 1451 | "label": "", 1452 | "labelPoint": [ 1453 | 0.5, 1454 | 0.5 1455 | ] 1456 | }, 1457 | { 1458 | "id": "236ee863-356f-4bf0-3172-8121fe969348", 1459 | "type": "arrow", 1460 | "name": "Arrow", 1461 | "parentId": "page", 1462 | "childIndex": 26, 1463 | "point": [ 1464 | 2183.61, 1465 | 446.28 1466 | ], 1467 | "rotation": 0, 1468 | "bend": 0, 1469 | "handles": { 1470 | "start": { 1471 | "id": "start", 1472 | "index": 0, 1473 | "point": [ 1474 | 0, 1475 | 0.92 1476 | ], 1477 | "canBind": true, 1478 | "bindingId": "2957a3af-7afb-4056-2ce0-de767d58a859" 1479 | }, 1480 | "end": { 1481 | "id": "end", 1482 | "index": 1, 1483 | "point": [ 1484 | 70.05, 1485 | 0 1486 | ], 1487 | "canBind": true, 1488 | "bindingId": "4f10f982-033d-4125-3334-e65fd8783069" 1489 | }, 1490 | "bend": { 1491 | "id": "bend", 1492 | "index": 2, 1493 | "point": [ 1494 | 35.03, 1495 | 0.46 1496 | ] 1497 | } 1498 | }, 1499 | "decorations": { 1500 | "end": "arrow" 1501 | }, 1502 | "style": { 1503 | "color": "cyan", 1504 | "size": "small", 1505 | "isFilled": false, 1506 | "dash": "solid", 1507 | "scale": 1 1508 | }, 1509 | "label": "", 1510 | "labelPoint": [ 1511 | 0.5, 1512 | 0.5 1513 | ] 1514 | }, 1515 | { 1516 | "id": "83d9249d-1a6f-4cde-2481-05beee3a56a9", 1517 | "type": "rectangle", 1518 | "name": "Rectangle", 1519 | "parentId": "page", 1520 | "childIndex": 2, 1521 | "point": [ 1522 | 20.3, 1523 | 187.26 1524 | ], 1525 | "size": [ 1526 | 221, 1527 | 121 1528 | ], 1529 | "rotation": 0, 1530 | "style": { 1531 | "color": "black", 1532 | "size": "small", 1533 | "isFilled": false, 1534 | "dash": "draw", 1535 | "scale": 1 1536 | }, 1537 | "label": "TextureSubject\nin out", 1538 | "labelPoint": [ 1539 | 0.5, 1540 | 0.5 1541 | ] 1542 | }, 1543 | { 1544 | "id": "d39ac77a-b237-4000-0424-371ff0f3a572", 1545 | "type": "rectangle", 1546 | "name": "Rectangle", 1547 | "parentId": "page", 1548 | "childIndex": 8, 1549 | "point": [ 1550 | 321.06, 1551 | 280.13 1552 | ], 1553 | "size": [ 1554 | 166.32, 1555 | 33.69 1556 | ], 1557 | "rotation": 0, 1558 | "style": { 1559 | "color": "gray", 1560 | "size": "small", 1561 | "isFilled": false, 1562 | "dash": "dotted", 1563 | "scale": 1 1564 | }, 1565 | "label": "RefObserver", 1566 | "labelPoint": [ 1567 | 0.5, 1568 | 0.5 1569 | ] 1570 | }, 1571 | { 1572 | "id": "31067b96-124f-4424-312b-8c513607a22a", 1573 | "type": "arrow", 1574 | "name": "Arrow", 1575 | "parentId": "page", 1576 | "childIndex": 27, 1577 | "point": [ 1578 | 245.37579322638146, 1579 | 293.18101604278075, 1580 | 0.5 1581 | ], 1582 | "rotation": 0, 1583 | "bend": 0, 1584 | "handles": { 1585 | "start": { 1586 | "id": "start", 1587 | "index": 0, 1588 | "point": [ 1589 | 0, 1590 | 0 1591 | ], 1592 | "canBind": true 1593 | }, 1594 | "end": { 1595 | "id": "end", 1596 | "index": 1, 1597 | "point": [ 1598 | 59.68, 1599 | 1.43 1600 | ], 1601 | "canBind": true, 1602 | "bindingId": "762a7ff7-7128-4484-3aa3-baa23ae536b7" 1603 | }, 1604 | "bend": { 1605 | "id": "bend", 1606 | "index": 2, 1607 | "point": [ 1608 | 29.84, 1609 | 0.72 1610 | ] 1611 | } 1612 | }, 1613 | "decorations": { 1614 | "end": "arrow" 1615 | }, 1616 | "style": { 1617 | "color": "cyan", 1618 | "size": "small", 1619 | "isFilled": false, 1620 | "dash": "solid", 1621 | "scale": 1 1622 | }, 1623 | "label": "", 1624 | "labelPoint": [ 1625 | 0.5, 1626 | 0.5 1627 | ] 1628 | }, 1629 | { 1630 | "id": "6ef31c94-d4dc-4f3a-31af-34255fb7288a", 1631 | "type": "arrow", 1632 | "name": "Arrow", 1633 | "parentId": "page", 1634 | "childIndex": 28, 1635 | "point": [ 1636 | 489.16, 1637 | 294.99 1638 | ], 1639 | "rotation": 0, 1640 | "bend": 0, 1641 | "handles": { 1642 | "start": { 1643 | "id": "start", 1644 | "index": 0, 1645 | "point": [ 1646 | 0, 1647 | 0.06 1648 | ], 1649 | "canBind": true 1650 | }, 1651 | "end": { 1652 | "id": "end", 1653 | "index": 1, 1654 | "point": [ 1655 | 78.84, 1656 | 0 1657 | ], 1658 | "canBind": true, 1659 | "bindingId": "75686b28-a217-441b-32fc-d64c54e7ec86" 1660 | }, 1661 | "bend": { 1662 | "id": "bend", 1663 | "index": 2, 1664 | "point": [ 1665 | 39.42, 1666 | 0.03 1667 | ] 1668 | } 1669 | }, 1670 | "decorations": { 1671 | "end": "arrow" 1672 | }, 1673 | "style": { 1674 | "color": "cyan", 1675 | "size": "small", 1676 | "isFilled": false, 1677 | "dash": "solid", 1678 | "scale": 1 1679 | }, 1680 | "label": "", 1681 | "labelPoint": [ 1682 | 0.5, 1683 | 0.5 1684 | ] 1685 | }, 1686 | { 1687 | "id": "e2dd4fbe-151f-42f2-12ed-ffdb8453d688", 1688 | "type": "arrow", 1689 | "name": "Arrow", 1690 | "parentId": "page", 1691 | "childIndex": 29, 1692 | "point": [ 1693 | 410.59, 1694 | 172.26 1695 | ], 1696 | "rotation": 0, 1697 | "bend": -0.9793557974694691, 1698 | "handles": { 1699 | "start": { 1700 | "id": "start", 1701 | "index": 0, 1702 | "point": [ 1703 | 180.32, 1704 | 91.99 1705 | ], 1706 | "canBind": true 1707 | }, 1708 | "end": { 1709 | "id": "end", 1710 | "index": 1, 1711 | "point": [ 1712 | -1.82, 1713 | 86.39 1714 | ], 1715 | "canBind": true 1716 | }, 1717 | "bend": { 1718 | "id": "bend", 1719 | "index": 2, 1720 | "point": [ 1721 | 91.99, 1722 | 0 1723 | ] 1724 | } 1725 | }, 1726 | "decorations": { 1727 | "end": "arrow" 1728 | }, 1729 | "style": { 1730 | "color": "violet", 1731 | "size": "small", 1732 | "isFilled": false, 1733 | "dash": "dashed", 1734 | "scale": 1 1735 | }, 1736 | "label": "", 1737 | "labelPoint": [ 1738 | 0.5, 1739 | 0.5 1740 | ] 1741 | }, 1742 | { 1743 | "id": "ebacb773-1d7e-43b2-3e9c-d6ce28556557", 1744 | "type": "text", 1745 | "name": "Text", 1746 | "parentId": "page", 1747 | "childIndex": 30, 1748 | "point": [ 1749 | 430.54, 1750 | 130.1 1751 | ], 1752 | "rotation": 0, 1753 | "text": "TextureParams", 1754 | "style": { 1755 | "color": "violet", 1756 | "size": "small", 1757 | "isFilled": false, 1758 | "dash": "dashed", 1759 | "scale": 1, 1760 | "font": "script", 1761 | "textAlign": "middle" 1762 | } 1763 | }, 1764 | { 1765 | "id": "604450f1-494f-4539-224e-21237e29aa86", 1766 | "type": "arrow", 1767 | "name": "Arrow", 1768 | "parentId": "page", 1769 | "childIndex": 30, 1770 | "point": [ 1771 | 990.61, 1772 | 86.19 1773 | ], 1774 | "rotation": 0, 1775 | "bend": -0.9793277841422385, 1776 | "handles": { 1777 | "start": { 1778 | "id": "start", 1779 | "index": 0, 1780 | "point": [ 1781 | 198.09, 1782 | 55.7 1783 | ], 1784 | "canBind": true 1785 | }, 1786 | "end": { 1787 | "id": "end", 1788 | "index": 1, 1789 | "point": [ 1790 | 10.38, 1791 | 150.86 1792 | ], 1793 | "canBind": true, 1794 | "bindingId": "67c8208b-cce9-4deb-1b6f-81a5524a7f68" 1795 | }, 1796 | "bend": { 1797 | "id": "bend", 1798 | "index": 2, 1799 | "point": [ 1800 | 57.64, 1801 | 11.37 1802 | ] 1803 | } 1804 | }, 1805 | "decorations": { 1806 | "end": "arrow" 1807 | }, 1808 | "style": { 1809 | "color": "violet", 1810 | "size": "small", 1811 | "isFilled": false, 1812 | "dash": "dashed", 1813 | "scale": 1 1814 | }, 1815 | "label": "", 1816 | "labelPoint": [ 1817 | 0.5, 1818 | 0.5 1819 | ] 1820 | }, 1821 | { 1822 | "id": "8df2580d-cdc2-4176-3d3f-a7990d4223e0", 1823 | "type": "text", 1824 | "name": "Text", 1825 | "parentId": "page", 1826 | "childIndex": 31, 1827 | "point": [ 1828 | 1020.59, 1829 | 44.23 1830 | ], 1831 | "rotation": 0, 1832 | "text": "MaterialParams", 1833 | "style": { 1834 | "color": "violet", 1835 | "size": "small", 1836 | "isFilled": false, 1837 | "dash": "dashed", 1838 | "scale": 1, 1839 | "font": "script", 1840 | "textAlign": "middle" 1841 | } 1842 | } 1843 | ] --------------------------------------------------------------------------------