@gjsify/example-dom-three-geometry-teapot 0.28.0 → 0.30.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/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@gjsify/example-dom-three-geometry-teapot",
3
- "version": "0.28.0",
3
+ "version": "0.30.0",
4
4
  "description": "Three.js geometry teapot example ported from refs/three/examples/webgl_geometry_teapot.html with Adwaita controls",
5
5
  "main": "dist/gjs.js",
6
6
  "type": "module",
7
7
  "files": [
8
- "dist"
8
+ "dist",
9
+ "src"
9
10
  ],
10
11
  "exports": {
11
12
  "./browser": "./src/browser/browser.ts",
@@ -26,7 +27,7 @@
26
27
  "build:assets": "gjsify copy src/browser/index.html src/browser/webgl.css src/assets dist/"
27
28
  },
28
29
  "dependencies": {
29
- "@gjsify/webgl": "^0.28.0"
30
+ "@gjsify/webgl": "^0.30.0"
30
31
  },
31
32
  "devDependencies": {
32
33
  "@girs/adw-1": "^4.1.0",
@@ -34,12 +35,12 @@
34
35
  "@girs/gjs": "^4.1.0",
35
36
  "@girs/gobject-2.0": "^4.1.0",
36
37
  "@girs/gtk-4.0": "^4.1.0",
37
- "@gjsify/adwaita-icons": "^0.28.0",
38
- "@gjsify/adwaita-web": "^0.28.0",
39
- "@gjsify/cli": "^0.28.0",
40
- "@gjsify/devtools": "^0.28.0",
38
+ "@gjsify/adwaita-icons": "^0.30.0",
39
+ "@gjsify/adwaita-web": "^0.30.0",
40
+ "@gjsify/cli": "^0.30.0",
41
+ "@gjsify/devtools": "^0.30.0",
41
42
  "@gjsify/node-gi": "file:../../../packages/node-gi/node-gi",
42
- "@gjsify/vite-plugin-blueprint": "^0.28.0",
43
+ "@gjsify/vite-plugin-blueprint": "^0.30.0",
43
44
  "@types/node": "^25.9.2",
44
45
  "@types/three": "^0.185.0",
45
46
  "http-server": "^14.1.1",
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
@@ -0,0 +1,4 @@
1
+ // Standalone browser entry point — runs the teapot demo fullscreen.
2
+ import { mount } from './browser.js';
3
+
4
+ mount(document.body);
@@ -0,0 +1,284 @@
1
+ // Browser UI for three-geometry-teapot example.
2
+ // Mirrors the GJS/Adwaita UI from gjs/teapot-window.ts using @gjsify/adwaita-web.
3
+
4
+ import '@gjsify/adwaita-web'; // registers the custom elements + self-injects the stylesheet
5
+ import type { AdwOverlaySplitView, AdwHeaderBar } from '@gjsify/adwaita-web';
6
+ import { mediaPlaybackPauseSymbolic, mediaPlaybackStartSymbolic } from '@gjsify/adwaita-icons/actions';
7
+ import {
8
+ start,
9
+ TESS_VALUES,
10
+ SHADING_VALUES,
11
+ DEFAULT_TESS_INDEX,
12
+ DEFAULT_SHADING_INDEX,
13
+ type TeapotDemo,
14
+ } from '../three-demo.js';
15
+
16
+ export interface MountOptions {
17
+ /** Base path for loading texture assets (forwarded to three-demo). */
18
+ assetBase?: string;
19
+ }
20
+
21
+ /** Handle returned by `mount()` so hosts (e.g. the website slideshow) can pause and resume rendering. */
22
+ export interface ShowcaseHandle {
23
+ pause(): void;
24
+ resume(): void;
25
+ readonly isPaused: boolean;
26
+ }
27
+
28
+ /** Parse a trusted literal SVG string into an SVGElement. */
29
+ function parseSvg(svgSource: string): SVGElement {
30
+ const doc = new DOMParser().parseFromString(svgSource, 'image/svg+xml');
31
+ return doc.documentElement as unknown as SVGElement;
32
+ }
33
+
34
+ /** Replace a button's icon with a freshly-parsed copy of the given SVG source. */
35
+ function setButtonIcon(btn: HTMLButtonElement, svgSource: string): void {
36
+ btn.replaceChildren(parseSvg(svgSource));
37
+ }
38
+
39
+ /**
40
+ * Create the Adwaita teapot UI and mount it into the given container.
41
+ * The container receives an adw-window with sidebar controls and a WebGL canvas.
42
+ */
43
+ export function mount(container: HTMLElement, options?: MountOptions): ShowcaseHandle {
44
+ // Build UI — mirrors GJS Blueprint structure
45
+ const win = document.createElement('adw-window');
46
+ win.setAttribute('width', '1100');
47
+ win.setAttribute('height', '700');
48
+
49
+ // Header bar (toggle button added after DOM connection below)
50
+ const headerBar = document.createElement('adw-header-bar') as AdwHeaderBar;
51
+ headerBar.setAttribute('title', 'Three.js Teapot');
52
+
53
+ // Sidebar toggle button
54
+ const toggleBtn = document.createElement('button');
55
+ toggleBtn.className = 'adw-header-btn adw-sidebar-toggle-icon active';
56
+ toggleBtn.title = 'Toggle Sidebar';
57
+
58
+ // Pause/Resume rendering button — placed in header bar end section.
59
+ const pauseBtn = document.createElement('button');
60
+ pauseBtn.className = 'adw-header-btn';
61
+ pauseBtn.title = 'Pause Rendering';
62
+ setButtonIcon(pauseBtn, mediaPlaybackPauseSymbolic);
63
+
64
+ // OverlaySplitView — sidebar + content
65
+ const splitView = document.createElement('adw-overlay-split-view') as AdwOverlaySplitView;
66
+ splitView.setAttribute('min-sidebar-width', '280');
67
+ splitView.setAttribute('max-sidebar-width', '400');
68
+ splitView.setAttribute('sidebar-width-fraction', '0.30');
69
+ splitView.setAttribute('show-sidebar', '');
70
+
71
+ // Sidebar content
72
+ const sidebarContent = document.createElement('div');
73
+ sidebarContent.setAttribute('slot', 'sidebar');
74
+ sidebarContent.className = 'adw-sidebar-content';
75
+
76
+ // Geometry group
77
+ const geoGroup = document.createElement('adw-preferences-group');
78
+ geoGroup.setAttribute('title', 'Geometry');
79
+
80
+ const tessRow = document.createElement('adw-combo-row');
81
+ tessRow.setAttribute('title', 'Tessellation Level');
82
+ tessRow.setAttribute('items', JSON.stringify(TESS_VALUES.map(String)));
83
+ tessRow.setAttribute('selected', String(DEFAULT_TESS_INDEX));
84
+
85
+ const lidRow = document.createElement('adw-switch-row');
86
+ lidRow.setAttribute('title', 'Display Lid');
87
+ lidRow.setAttribute('active', '');
88
+
89
+ const bodyRow = document.createElement('adw-switch-row');
90
+ bodyRow.setAttribute('title', 'Display Body');
91
+ bodyRow.setAttribute('active', '');
92
+
93
+ const bottomRow = document.createElement('adw-switch-row');
94
+ bottomRow.setAttribute('title', 'Display Bottom');
95
+ bottomRow.setAttribute('active', '');
96
+
97
+ const fitLidRow = document.createElement('adw-switch-row');
98
+ fitLidRow.setAttribute('title', 'Snug Lid');
99
+
100
+ const nonblinnRow = document.createElement('adw-switch-row');
101
+ nonblinnRow.setAttribute('title', 'Original Scale');
102
+
103
+ geoGroup.append(tessRow, lidRow, bodyRow, bottomRow, fitLidRow, nonblinnRow);
104
+
105
+ // Material group
106
+ const matGroup = document.createElement('adw-preferences-group');
107
+ matGroup.setAttribute('title', 'Material');
108
+
109
+ const shadingRow = document.createElement('adw-combo-row');
110
+ shadingRow.setAttribute('title', 'Shading');
111
+ shadingRow.setAttribute('items', JSON.stringify([...SHADING_VALUES]));
112
+ shadingRow.setAttribute('selected', String(DEFAULT_SHADING_INDEX));
113
+
114
+ matGroup.append(shadingRow);
115
+
116
+ sidebarContent.append(geoGroup, matGroup);
117
+
118
+ // GL container (content slot) — inline styles so the showcase
119
+ // is self-contained and works regardless of host CSS.
120
+ const glContainer = document.createElement('div');
121
+ glContainer.setAttribute('slot', 'content');
122
+ glContainer.id = 'gl-area-container';
123
+ glContainer.style.cssText = 'flex:1;position:relative;min-width:0;min-height:0';
124
+
125
+ const canvas = document.createElement('canvas');
126
+ canvas.id = 'webgl-canvas';
127
+ canvas.style.cssText = 'display:block;width:100%;height:100%;position:absolute;inset:0';
128
+ glContainer.append(canvas);
129
+
130
+ splitView.append(sidebarContent, glContainer);
131
+ win.append(headerBar, splitView);
132
+ container.append(win);
133
+
134
+ // Append toggle button to header bar start section AFTER DOM connection
135
+ const startSection = headerBar.startSection ?? headerBar.querySelector('.adw-header-bar-start');
136
+ if (startSection) {
137
+ startSection.appendChild(toggleBtn);
138
+ } else {
139
+ headerBar.prepend(toggleBtn);
140
+ }
141
+
142
+ const endSection = headerBar.endSection ?? headerBar.querySelector('.adw-header-bar-end');
143
+ if (endSection) {
144
+ endSection.appendChild(pauseBtn);
145
+ } else {
146
+ headerBar.append(pauseBtn);
147
+ }
148
+
149
+ // Sidebar toggle wiring
150
+ toggleBtn.addEventListener('click', () => {
151
+ splitView.toggleSidebar();
152
+ toggleBtn.classList.toggle('active', splitView.showSidebar);
153
+ });
154
+
155
+ splitView.addEventListener('sidebar-toggled', () => {
156
+ toggleBtn.classList.toggle('active', splitView.showSidebar);
157
+ });
158
+
159
+ // Responsive breakpoints — mirror GJS Adw.Breakpoint behavior
160
+ let lastCollapsed: boolean | null = null;
161
+ new ResizeObserver(([entry]) => {
162
+ const width = entry.contentRect.width;
163
+ const shouldCollapse = width < 800;
164
+ if (shouldCollapse === lastCollapsed) return;
165
+ lastCollapsed = shouldCollapse;
166
+ splitView.collapsed = shouldCollapse;
167
+ splitView.showSidebar = !shouldCollapse;
168
+ toggleBtn.classList.toggle('active', !shouldCollapse);
169
+ }).observe(win);
170
+
171
+ // Sync canvas size with container and re-render on resize
172
+ // (needed when slide becomes visible after being display:none)
173
+ canvas.width = glContainer.clientWidth;
174
+ canvas.height = glContainer.clientHeight;
175
+
176
+ let demo: TeapotDemo | null = null;
177
+ // Buffers pause() calls that arrive before the demo exists.
178
+ let pendingPause = false;
179
+ new ResizeObserver(() => {
180
+ const w = glContainer.clientWidth;
181
+ const h = glContainer.clientHeight;
182
+ if (w > 0 && h > 0) {
183
+ canvas.width = w;
184
+ canvas.height = h;
185
+ if (!demo) {
186
+ demo = start(canvas, { assetBase: options?.assetBase });
187
+ connectControls(
188
+ demo,
189
+ tessRow as AdwRow,
190
+ shadingRow as AdwRow,
191
+ lidRow as AdwRow,
192
+ bodyRow as AdwRow,
193
+ bottomRow as AdwRow,
194
+ fitLidRow as AdwRow,
195
+ nonblinnRow as AdwRow,
196
+ );
197
+ if (pendingPause) {
198
+ demo.pause();
199
+ pendingPause = false;
200
+ }
201
+ } else {
202
+ demo.render();
203
+ }
204
+ }
205
+ }).observe(glContainer);
206
+
207
+ // Pause button wiring — toggles demo state and swaps the icon.
208
+ function updatePauseButton(paused: boolean): void {
209
+ setButtonIcon(pauseBtn, paused ? mediaPlaybackStartSymbolic : mediaPlaybackPauseSymbolic);
210
+ pauseBtn.title = paused ? 'Resume Rendering' : 'Pause Rendering';
211
+ }
212
+ pauseBtn.addEventListener('click', () => {
213
+ if (demo) {
214
+ if (demo.isPaused) demo.resume();
215
+ else demo.pause();
216
+ updatePauseButton(demo.isPaused);
217
+ } else {
218
+ pendingPause = !pendingPause;
219
+ updatePauseButton(pendingPause);
220
+ }
221
+ });
222
+
223
+ return {
224
+ get isPaused() {
225
+ return demo ? demo.isPaused : pendingPause;
226
+ },
227
+ pause() {
228
+ if (demo) {
229
+ demo.pause();
230
+ updatePauseButton(true);
231
+ } else {
232
+ pendingPause = true;
233
+ updatePauseButton(true);
234
+ }
235
+ },
236
+ resume() {
237
+ if (demo) {
238
+ demo.resume();
239
+ updatePauseButton(false);
240
+ } else {
241
+ pendingPause = false;
242
+ updatePauseButton(false);
243
+ }
244
+ },
245
+ };
246
+ }
247
+
248
+ // Adwaita web components expose custom properties (.selected, .active) not in HTMLElement types.
249
+ type AdwRow = HTMLElement & Record<string, unknown>;
250
+
251
+ function connectControls(
252
+ demo: TeapotDemo,
253
+ tessRow: AdwRow,
254
+ shadingRow: AdwRow,
255
+ lidRow: AdwRow,
256
+ bodyRow: AdwRow,
257
+ bottomRow: AdwRow,
258
+ fitLidRow: AdwRow,
259
+ nonblinnRow: AdwRow,
260
+ ) {
261
+ tessRow.addEventListener('notify::selected', () => {
262
+ demo.effectController.newTess = TESS_VALUES[tessRow.selected as number];
263
+ demo.render();
264
+ });
265
+
266
+ shadingRow.addEventListener('notify::selected', () => {
267
+ demo.effectController.newShading = SHADING_VALUES[shadingRow.selected as number];
268
+ demo.render();
269
+ });
270
+
271
+ const toggleRows: Array<[AdwRow, 'lid' | 'body' | 'bottom' | 'fitLid' | 'nonblinn']> = [
272
+ [lidRow, 'lid'],
273
+ [bodyRow, 'body'],
274
+ [bottomRow, 'bottom'],
275
+ [fitLidRow, 'fitLid'],
276
+ [nonblinnRow, 'nonblinn'],
277
+ ];
278
+ for (const [row, key] of toggleRows) {
279
+ row.addEventListener('notify::active', () => {
280
+ demo.effectController[key] = Boolean(row.active);
281
+ demo.render();
282
+ });
283
+ }
284
+ }
@@ -0,0 +1,11 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <title>Three.js Teapot — gjsify</title>
6
+ <link rel="stylesheet" href="./webgl.css" type="text/css" />
7
+ <link rel="stylesheet" href="./browser.css" type="text/css" />
8
+ <script src="./browser.js" type="module" defer></script>
9
+ </head>
10
+ <body></body>
11
+ </html>
@@ -0,0 +1,53 @@
1
+ * {
2
+ margin: 0;
3
+ padding: 0;
4
+ box-sizing: border-box;
5
+ }
6
+
7
+ html, body {
8
+ width: 100%;
9
+ height: 100%;
10
+ overflow: hidden;
11
+ background: #2d2d2d;
12
+ display: flex;
13
+ align-items: center;
14
+ justify-content: center;
15
+ }
16
+
17
+ adw-window {
18
+ max-width: 100%;
19
+ max-height: 100%;
20
+ }
21
+
22
+ .adw-window-body {
23
+ display: flex;
24
+ flex: 1;
25
+ min-height: 0;
26
+ }
27
+
28
+ .adw-sidebar {
29
+ width: 320px;
30
+ flex-shrink: 0;
31
+ overflow-y: auto;
32
+ }
33
+
34
+ .adw-sidebar-content {
35
+ padding: 12px;
36
+ display: flex;
37
+ flex-direction: column;
38
+ gap: 12px;
39
+ }
40
+
41
+ #gl-area-container {
42
+ flex: 1;
43
+ position: relative;
44
+ min-width: 0;
45
+ }
46
+
47
+ #gl-area-container canvas {
48
+ display: block;
49
+ width: 100%;
50
+ height: 100%;
51
+ position: absolute;
52
+ inset: 0;
53
+ }
package/src/gjs/gjs.ts ADDED
@@ -0,0 +1,27 @@
1
+ // GJS/Adwaita entry point for three-geometry-teapot example.
2
+ // Ported from refs/three/examples/webgl_geometry_teapot.html
3
+
4
+ import Adw from 'gi://Adw?version=1';
5
+ import Gio from 'gi://Gio?version=2.0';
6
+
7
+ import { installDevtools } from '@gjsify/devtools';
8
+ import { TeapotWindow } from './teapot-window.js';
9
+
10
+ const app = new Adw.Application({
11
+ application_id: 'gjsify.examples.three-geometry-teapot',
12
+ flags: Gio.ApplicationFlags.FLAGS_NONE,
13
+ });
14
+
15
+ app.connect('startup', () => installDevtools(app));
16
+
17
+ app.connect('activate', () => {
18
+ let win = app.get_active_window();
19
+ if (!win) win = new TeapotWindow(app);
20
+ win.present();
21
+ });
22
+
23
+ // runAsync (NOT the sync run()): defers the blocking main loop to a macrotask so
24
+ // the synchronous view load doesn't hang on its spinner, and it is the required
25
+ // lifecycle on the `--app node` reverse bridge (a sync run() deadlocks there) as
26
+ // well as the GJS-recommended one (Gio.Application.runAsync). Runs on all runtimes.
27
+ await app.runAsync([]);
@@ -0,0 +1,111 @@
1
+ using Gtk 4.0;
2
+ using Adw 1;
3
+
4
+ template $TeapotWindow: Adw.ApplicationWindow {
5
+ default-width: 1100;
6
+ default-height: 700;
7
+ title: "Three.js Teapot";
8
+
9
+ [breakpoint]
10
+ Adw.Breakpoint {
11
+ condition ("max-width: 799sp")
12
+ setters {
13
+ splitView.collapsed: true;
14
+ splitView.show-sidebar: false;
15
+ }
16
+ }
17
+
18
+ [breakpoint]
19
+ Adw.Breakpoint {
20
+ condition ("min-width: 800sp")
21
+ setters {
22
+ splitView.collapsed: false;
23
+ splitView.show-sidebar: true;
24
+ }
25
+ }
26
+
27
+ content: Gtk.Box {
28
+ orientation: vertical;
29
+
30
+ Adw.HeaderBar {
31
+ [start]
32
+ ToggleButton sidebarToggleButton {
33
+ icon-name: "sidebar-show-symbolic";
34
+ tooltip-text: "Toggle Sidebar";
35
+ active: bind splitView.show-sidebar;
36
+ }
37
+
38
+ [end]
39
+ Gtk.Button pauseButton {
40
+ icon-name: "media-playback-pause-symbolic";
41
+ tooltip-text: "Pause Rendering";
42
+ }
43
+ }
44
+
45
+ Adw.OverlaySplitView splitView {
46
+ sidebar-width-fraction: 0.30;
47
+ min-sidebar-width: 280;
48
+ max-sidebar-width: 400;
49
+ show-sidebar: bind sidebarToggleButton.active;
50
+
51
+ sidebar: Gtk.ScrolledWindow {
52
+ hscrollbar-policy: never;
53
+
54
+ Gtk.Box {
55
+ orientation: vertical;
56
+ spacing: 12;
57
+ margin-top: 12;
58
+ margin-bottom: 12;
59
+ margin-start: 12;
60
+ margin-end: 12;
61
+
62
+ Adw.PreferencesGroup {
63
+ title: "Geometry";
64
+
65
+ Adw.ComboRow tessRow {
66
+ title: "Tessellation Level";
67
+ }
68
+
69
+ Adw.SwitchRow lidRow {
70
+ title: "Display Lid";
71
+ active: true;
72
+ }
73
+
74
+ Adw.SwitchRow bodyRow {
75
+ title: "Display Body";
76
+ active: true;
77
+ }
78
+
79
+ Adw.SwitchRow bottomRow {
80
+ title: "Display Bottom";
81
+ active: true;
82
+ }
83
+
84
+ Adw.SwitchRow fitLidRow {
85
+ title: "Snug Lid";
86
+ active: false;
87
+ }
88
+
89
+ Adw.SwitchRow nonblinnRow {
90
+ title: "Original Scale";
91
+ active: false;
92
+ }
93
+ }
94
+
95
+ Adw.PreferencesGroup {
96
+ title: "Material";
97
+
98
+ Adw.ComboRow shadingRow {
99
+ title: "Shading";
100
+ }
101
+ }
102
+ }
103
+ };
104
+
105
+ content: Gtk.Box glAreaContainer {
106
+ hexpand: true;
107
+ vexpand: true;
108
+ };
109
+ }
110
+ };
111
+ }
@@ -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
+ }