@gjsify/example-dom-three-geometry-teapot 0.27.1 → 0.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/gjs.js +18 -18
- package/dist/gjs.node.mjs +20 -20
- package/package.json +9 -8
- package/src/assets/pisa/nx.png +0 -0
- package/src/assets/pisa/ny.png +0 -0
- package/src/assets/pisa/nz.png +0 -0
- package/src/assets/pisa/px.png +0 -0
- package/src/assets/pisa/py.png +0 -0
- package/src/assets/pisa/pz.png +0 -0
- package/src/assets/uv_grid_opengl.jpg +0 -0
- package/src/browser/browser-main.ts +4 -0
- package/src/browser/browser.ts +284 -0
- package/src/browser/index.html +11 -0
- package/src/browser/webgl.css +53 -0
- package/src/gjs/gjs.ts +27 -0
- package/src/gjs/teapot-window.blp +111 -0
- package/src/gjs/teapot-window.ts +145 -0
- package/src/three-demo.ts +242 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// Adwaita window for the three.js teapot example.
|
|
2
|
+
// Uses a Blueprint template for the UI layout and WebGLBridge for WebGL.
|
|
3
|
+
|
|
4
|
+
import GObject from 'gi://GObject?version=2.0';
|
|
5
|
+
import Gtk from 'gi://Gtk?version=4.0';
|
|
6
|
+
import Adw from 'gi://Adw?version=1';
|
|
7
|
+
import { WebGLBridge } from '@gjsify/webgl';
|
|
8
|
+
import {
|
|
9
|
+
start,
|
|
10
|
+
TESS_VALUES,
|
|
11
|
+
SHADING_VALUES,
|
|
12
|
+
DEFAULT_TESS_INDEX,
|
|
13
|
+
DEFAULT_SHADING_INDEX,
|
|
14
|
+
type TeapotDemo,
|
|
15
|
+
} from '../three-demo.js';
|
|
16
|
+
import Template from './teapot-window.blp';
|
|
17
|
+
|
|
18
|
+
export class TeapotWindow extends Adw.ApplicationWindow {
|
|
19
|
+
declare private _glAreaContainer: Gtk.Box;
|
|
20
|
+
declare private _tessRow: Adw.ComboRow;
|
|
21
|
+
declare private _lidRow: Adw.SwitchRow;
|
|
22
|
+
declare private _bodyRow: Adw.SwitchRow;
|
|
23
|
+
declare private _bottomRow: Adw.SwitchRow;
|
|
24
|
+
declare private _fitLidRow: Adw.SwitchRow;
|
|
25
|
+
declare private _nonblinnRow: Adw.SwitchRow;
|
|
26
|
+
declare private _shadingRow: Adw.ComboRow;
|
|
27
|
+
declare private _splitView: Adw.OverlaySplitView;
|
|
28
|
+
declare private _sidebarToggleButton: Gtk.ToggleButton;
|
|
29
|
+
declare private _pauseButton: Gtk.Button;
|
|
30
|
+
|
|
31
|
+
/** Live demo reference; set once the WebGLBridge is ready. */
|
|
32
|
+
private _demo: TeapotDemo | null = null;
|
|
33
|
+
|
|
34
|
+
static {
|
|
35
|
+
GObject.registerClass(
|
|
36
|
+
{
|
|
37
|
+
GTypeName: 'TeapotWindow',
|
|
38
|
+
Template,
|
|
39
|
+
InternalChildren: [
|
|
40
|
+
'glAreaContainer',
|
|
41
|
+
'tessRow',
|
|
42
|
+
'lidRow',
|
|
43
|
+
'bodyRow',
|
|
44
|
+
'bottomRow',
|
|
45
|
+
'fitLidRow',
|
|
46
|
+
'nonblinnRow',
|
|
47
|
+
'shadingRow',
|
|
48
|
+
'splitView',
|
|
49
|
+
'sidebarToggleButton',
|
|
50
|
+
'pauseButton',
|
|
51
|
+
],
|
|
52
|
+
},
|
|
53
|
+
this,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
constructor(application: Adw.Application) {
|
|
58
|
+
super({ application });
|
|
59
|
+
|
|
60
|
+
// Set up ComboRow models
|
|
61
|
+
this._tessRow.set_model(Gtk.StringList.new(TESS_VALUES.map(String)));
|
|
62
|
+
this._tessRow.set_selected(DEFAULT_TESS_INDEX);
|
|
63
|
+
|
|
64
|
+
this._shadingRow.set_model(Gtk.StringList.new([...SHADING_VALUES]));
|
|
65
|
+
this._shadingRow.set_selected(DEFAULT_SHADING_INDEX);
|
|
66
|
+
|
|
67
|
+
// Create and insert WebGL widget
|
|
68
|
+
const glArea = new WebGLBridge();
|
|
69
|
+
glArea.set_hexpand(true);
|
|
70
|
+
glArea.set_vexpand(true);
|
|
71
|
+
glArea.installGlobals();
|
|
72
|
+
this._glAreaContainer.append(glArea);
|
|
73
|
+
|
|
74
|
+
// Expose GL area dimensions as innerWidth/innerHeight for three.js
|
|
75
|
+
Object.defineProperty(globalThis, 'innerWidth', {
|
|
76
|
+
get: () => glArea.get_allocated_width(),
|
|
77
|
+
configurable: true,
|
|
78
|
+
});
|
|
79
|
+
Object.defineProperty(globalThis, 'innerHeight', {
|
|
80
|
+
get: () => glArea.get_allocated_height(),
|
|
81
|
+
configurable: true,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// Initialize three.js when GL context is ready
|
|
85
|
+
glArea.onReady((canvas) => {
|
|
86
|
+
glArea.grab_focus();
|
|
87
|
+
const ctx = glArea.get_context()!;
|
|
88
|
+
print(`Context version: OpenGL${ctx.get_use_es() ? ' ES' : ''} ${ctx.get_version().join('.')}`);
|
|
89
|
+
|
|
90
|
+
this._demo = start(canvas);
|
|
91
|
+
this.connectControls(this._demo);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// Pause/Resume button — toggles demo state and swaps the icon.
|
|
95
|
+
this._pauseButton.connect('clicked', () => {
|
|
96
|
+
if (!this._demo) return;
|
|
97
|
+
if (this._demo.isPaused) {
|
|
98
|
+
this._demo.resume();
|
|
99
|
+
this._pauseButton.set_icon_name('media-playback-pause-symbolic');
|
|
100
|
+
this._pauseButton.set_tooltip_text('Pause Rendering');
|
|
101
|
+
} else {
|
|
102
|
+
this._demo.pause();
|
|
103
|
+
this._pauseButton.set_icon_name('media-playback-start-symbolic');
|
|
104
|
+
this._pauseButton.set_tooltip_text('Resume Rendering');
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private connectControls(demo: TeapotDemo) {
|
|
110
|
+
this._tessRow.connect('notify::selected', () => {
|
|
111
|
+
demo.effectController.newTess = TESS_VALUES[this._tessRow.selected];
|
|
112
|
+
demo.render();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
this._shadingRow.connect('notify::selected', () => {
|
|
116
|
+
demo.effectController.newShading = SHADING_VALUES[this._shadingRow.selected];
|
|
117
|
+
demo.render();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
this._lidRow.connect('notify::active', () => {
|
|
121
|
+
demo.effectController.lid = this._lidRow.active;
|
|
122
|
+
demo.render();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
this._bodyRow.connect('notify::active', () => {
|
|
126
|
+
demo.effectController.body = this._bodyRow.active;
|
|
127
|
+
demo.render();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
this._bottomRow.connect('notify::active', () => {
|
|
131
|
+
demo.effectController.bottom = this._bottomRow.active;
|
|
132
|
+
demo.render();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
this._fitLidRow.connect('notify::active', () => {
|
|
136
|
+
demo.effectController.fitLid = this._fitLidRow.active;
|
|
137
|
+
demo.render();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
this._nonblinnRow.connect('notify::active', () => {
|
|
141
|
+
demo.effectController.nonblinn = this._nonblinnRow.active;
|
|
142
|
+
demo.render();
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// Ported from refs/three/examples/webgl_geometry_teapot.html
|
|
2
|
+
// Original: MIT license, three.js contributors
|
|
3
|
+
// Reimplemented as platform-agnostic module for GJS and browser targets.
|
|
4
|
+
|
|
5
|
+
import * as THREE from 'three';
|
|
6
|
+
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
|
7
|
+
import { TeapotGeometry } from 'three/addons/geometries/TeapotGeometry.js';
|
|
8
|
+
|
|
9
|
+
export type ShadingMode = 'wireframe' | 'flat' | 'smooth' | 'glossy' | 'textured' | 'reflective';
|
|
10
|
+
|
|
11
|
+
export const TESS_VALUES = [2, 3, 4, 5, 6, 8, 10, 15, 20, 30, 40, 50] as const;
|
|
12
|
+
export const SHADING_VALUES: readonly ShadingMode[] = [
|
|
13
|
+
'wireframe',
|
|
14
|
+
'flat',
|
|
15
|
+
'smooth',
|
|
16
|
+
'glossy',
|
|
17
|
+
'textured',
|
|
18
|
+
'reflective',
|
|
19
|
+
];
|
|
20
|
+
export const DEFAULT_TESS_INDEX = 7; // "15"
|
|
21
|
+
export const DEFAULT_SHADING_INDEX = 3; // "glossy"
|
|
22
|
+
|
|
23
|
+
export interface TeapotEffectController {
|
|
24
|
+
newTess: number;
|
|
25
|
+
lid: boolean;
|
|
26
|
+
body: boolean;
|
|
27
|
+
bottom: boolean;
|
|
28
|
+
fitLid: boolean;
|
|
29
|
+
nonblinn: boolean;
|
|
30
|
+
newShading: ShadingMode;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface TeapotDemo {
|
|
34
|
+
readonly effectController: TeapotEffectController;
|
|
35
|
+
/** Call after changing effectController properties to schedule a re-render. */
|
|
36
|
+
render(): void;
|
|
37
|
+
/** Suppress future render() calls until `resume()` is called. */
|
|
38
|
+
pause(): void;
|
|
39
|
+
/** Flush any pending render queued during pause. */
|
|
40
|
+
resume(): void;
|
|
41
|
+
/** Current paused state. */
|
|
42
|
+
readonly isPaused: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface StartOptions {
|
|
46
|
+
/** Base path for loading texture assets (default: './'). */
|
|
47
|
+
assetBase?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const TEAPOT_SIZE = 300;
|
|
51
|
+
|
|
52
|
+
export function start(canvas: HTMLCanvasElement, options?: StartOptions): TeapotDemo {
|
|
53
|
+
const assetBase = options?.assetBase ?? './';
|
|
54
|
+
|
|
55
|
+
// Renderer
|
|
56
|
+
// oxlint-disable-next-line typescript/no-explicit-any -- THREE.WebGLRenderer canvas option expects OffscreenCanvas; GJS canvas type is incompatible
|
|
57
|
+
const renderer = new THREE.WebGLRenderer({ canvas: canvas as any, antialias: true });
|
|
58
|
+
// Pass updateStyle=false: the canvas CSS width/height are managed by the
|
|
59
|
+
// host container (flex layout). We only want to update the drawing buffer.
|
|
60
|
+
renderer.setSize(canvas.width, canvas.height, false);
|
|
61
|
+
// Track the size we last handed the renderer. `renderer.domElement` IS this
|
|
62
|
+
// canvas, so the obvious `renderer.domElement.width !== w` compares a value
|
|
63
|
+
// with itself and is never true — the resize branch below would be dead and
|
|
64
|
+
// the camera aspect would stay frozen at the first allocation. (The pixel
|
|
65
|
+
// post-processing showcase already carries this shape; keep the two aligned.)
|
|
66
|
+
let prevW = canvas.width;
|
|
67
|
+
let prevH = canvas.height;
|
|
68
|
+
|
|
69
|
+
// Camera
|
|
70
|
+
const camera = new THREE.PerspectiveCamera(45, canvas.width / canvas.height, 1, 80000);
|
|
71
|
+
camera.position.set(-600, 550, 1300);
|
|
72
|
+
|
|
73
|
+
// Scene
|
|
74
|
+
const scene = new THREE.Scene();
|
|
75
|
+
scene.background = new THREE.Color(0xaaaaaa);
|
|
76
|
+
|
|
77
|
+
// Lights
|
|
78
|
+
const ambientLight = new THREE.AmbientLight(0x7c7c7c, 2.0);
|
|
79
|
+
scene.add(ambientLight);
|
|
80
|
+
|
|
81
|
+
const light = new THREE.DirectionalLight(0xffffff, 2.0);
|
|
82
|
+
light.position.set(0.32, 0.39, 0.7);
|
|
83
|
+
scene.add(light);
|
|
84
|
+
|
|
85
|
+
// Texture map
|
|
86
|
+
const textureMap = new THREE.TextureLoader().load(`${assetBase}assets/uv_grid_opengl.jpg`);
|
|
87
|
+
textureMap.wrapS = textureMap.wrapT = THREE.RepeatWrapping;
|
|
88
|
+
textureMap.anisotropy = 16;
|
|
89
|
+
textureMap.colorSpace = THREE.SRGBColorSpace;
|
|
90
|
+
|
|
91
|
+
// Reflection map (cubemap)
|
|
92
|
+
const path = `${assetBase}assets/pisa/`;
|
|
93
|
+
const urls = ['px.png', 'nx.png', 'py.png', 'ny.png', 'pz.png', 'nz.png'];
|
|
94
|
+
const textureCube = new THREE.CubeTextureLoader().setPath(path).load(urls);
|
|
95
|
+
|
|
96
|
+
// Materials
|
|
97
|
+
const materials: Record<ShadingMode, THREE.Material> = {
|
|
98
|
+
wireframe: new THREE.MeshBasicMaterial({ wireframe: true }),
|
|
99
|
+
flat: new THREE.MeshPhongMaterial({ specular: 0x000000, flatShading: true, side: THREE.DoubleSide }),
|
|
100
|
+
smooth: new THREE.MeshLambertMaterial({ side: THREE.DoubleSide }),
|
|
101
|
+
glossy: new THREE.MeshPhongMaterial({
|
|
102
|
+
color: 0xc0c0c0,
|
|
103
|
+
specular: 0x404040,
|
|
104
|
+
shininess: 300,
|
|
105
|
+
side: THREE.DoubleSide,
|
|
106
|
+
}),
|
|
107
|
+
textured: new THREE.MeshPhongMaterial({ map: textureMap, side: THREE.DoubleSide }),
|
|
108
|
+
reflective: new THREE.MeshPhongMaterial({ envMap: textureCube, side: THREE.DoubleSide }),
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
// OrbitControls
|
|
112
|
+
// oxlint-disable-next-line typescript/no-explicit-any -- OrbitControls domElement type is HTMLElement; GJS canvas type is incompatible
|
|
113
|
+
const cameraControls = new OrbitControls(camera, canvas as any);
|
|
114
|
+
cameraControls.addEventListener('change', render);
|
|
115
|
+
|
|
116
|
+
// Effect controller — external GUI mutates this, then calls render()
|
|
117
|
+
const effectController: TeapotEffectController = {
|
|
118
|
+
newTess: 15,
|
|
119
|
+
lid: true,
|
|
120
|
+
body: true,
|
|
121
|
+
bottom: true,
|
|
122
|
+
fitLid: false,
|
|
123
|
+
nonblinn: false,
|
|
124
|
+
newShading: 'glossy',
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
// Track last-rendered state for change detection
|
|
128
|
+
let tess = -1; // force initial creation
|
|
129
|
+
let bBottom: boolean;
|
|
130
|
+
let bLid: boolean;
|
|
131
|
+
let bBody: boolean;
|
|
132
|
+
let bFitLid: boolean;
|
|
133
|
+
let bNonBlinn: boolean;
|
|
134
|
+
let shading: ShadingMode;
|
|
135
|
+
let teapot: THREE.Mesh | undefined;
|
|
136
|
+
|
|
137
|
+
function createNewTeapot() {
|
|
138
|
+
if (teapot !== undefined) {
|
|
139
|
+
teapot.geometry.dispose();
|
|
140
|
+
scene.remove(teapot);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const geometry = new TeapotGeometry(
|
|
144
|
+
TEAPOT_SIZE,
|
|
145
|
+
tess,
|
|
146
|
+
effectController.bottom,
|
|
147
|
+
effectController.lid,
|
|
148
|
+
effectController.body,
|
|
149
|
+
effectController.fitLid,
|
|
150
|
+
!effectController.nonblinn,
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
teapot = new THREE.Mesh(geometry, materials[shading]);
|
|
154
|
+
scene.add(teapot);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function doRender() {
|
|
158
|
+
// Check if parameters changed
|
|
159
|
+
if (
|
|
160
|
+
effectController.newTess !== tess ||
|
|
161
|
+
effectController.bottom !== bBottom ||
|
|
162
|
+
effectController.lid !== bLid ||
|
|
163
|
+
effectController.body !== bBody ||
|
|
164
|
+
effectController.fitLid !== bFitLid ||
|
|
165
|
+
effectController.nonblinn !== bNonBlinn ||
|
|
166
|
+
effectController.newShading !== shading
|
|
167
|
+
) {
|
|
168
|
+
tess = effectController.newTess;
|
|
169
|
+
bBottom = effectController.bottom;
|
|
170
|
+
bLid = effectController.lid;
|
|
171
|
+
bBody = effectController.body;
|
|
172
|
+
bFitLid = effectController.fitLid;
|
|
173
|
+
bNonBlinn = effectController.nonblinn;
|
|
174
|
+
shading = effectController.newShading;
|
|
175
|
+
|
|
176
|
+
createNewTeapot();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Reflective mode uses cubemap as background
|
|
180
|
+
if (shading === 'reflective') {
|
|
181
|
+
scene.background = textureCube;
|
|
182
|
+
} else {
|
|
183
|
+
scene.background = null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Handle resize — canvas.width/height are the GTK-owned drawing buffer
|
|
187
|
+
const w = canvas.width;
|
|
188
|
+
const h = canvas.height;
|
|
189
|
+
if (prevW !== w || prevH !== h) {
|
|
190
|
+
prevW = w;
|
|
191
|
+
prevH = h;
|
|
192
|
+
renderer.setSize(w, h, false);
|
|
193
|
+
camera.aspect = w / h;
|
|
194
|
+
camera.updateProjectionMatrix();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
renderer.render(scene, camera);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Render scheduling: coalesce multiple render requests into one frame.
|
|
201
|
+
// In GTK, the GL context is only current inside the frame pipeline (rAF),
|
|
202
|
+
// so direct calls from signal handlers would fail.
|
|
203
|
+
let renderPending = false;
|
|
204
|
+
let paused = false;
|
|
205
|
+
// Set when render() is called while paused, so resume() can flush it.
|
|
206
|
+
let deferredRender = false;
|
|
207
|
+
function render() {
|
|
208
|
+
if (paused) {
|
|
209
|
+
deferredRender = true;
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if (renderPending) return;
|
|
213
|
+
renderPending = true;
|
|
214
|
+
requestAnimationFrame(() => {
|
|
215
|
+
renderPending = false;
|
|
216
|
+
doRender();
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Initial render
|
|
221
|
+
render();
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
effectController,
|
|
225
|
+
render,
|
|
226
|
+
get isPaused() {
|
|
227
|
+
return paused;
|
|
228
|
+
},
|
|
229
|
+
pause() {
|
|
230
|
+
if (paused) return;
|
|
231
|
+
paused = true;
|
|
232
|
+
},
|
|
233
|
+
resume() {
|
|
234
|
+
if (!paused) return;
|
|
235
|
+
paused = false;
|
|
236
|
+
if (deferredRender) {
|
|
237
|
+
deferredRender = false;
|
|
238
|
+
render();
|
|
239
|
+
}
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
}
|