@chocodrop/mcp 0.1.0-alpha.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.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +19 -0
  3. package/bin/chocodrop-mcp.js +13 -0
  4. package/package.json +42 -0
  5. package/runtime/.chocodrop-mcp-runtime +1 -0
  6. package/runtime/asset-bridge.js +460 -0
  7. package/runtime/mcp-server.js +107 -0
  8. package/runtime/site/examples/basic/README.md +38 -0
  9. package/runtime/site/examples/basic/index.html +1730 -0
  10. package/runtime/site/examples/bookmarklet-v2.html +141 -0
  11. package/runtime/site/examples/bookmarklet.html +126 -0
  12. package/runtime/site/examples/lofi-room/index.html +2570 -0
  13. package/runtime/site/examples/lofi-room/sandbox-lite.html +124 -0
  14. package/runtime/site/examples/music-garden/index.html +2150 -0
  15. package/runtime/site/examples/pixel-ocean/index.html +1780 -0
  16. package/runtime/site/examples/react-three-fiber/AdvancedExample.jsx +338 -0
  17. package/runtime/site/examples/react-three-fiber/App.jsx +97 -0
  18. package/runtime/site/examples/react-three-fiber/README.md +294 -0
  19. package/runtime/site/examples/react-three-fiber/package.json +25 -0
  20. package/runtime/site/examples/sdk-test/README.md +70 -0
  21. package/runtime/site/examples/space/index.html +1975 -0
  22. package/runtime/site/examples/toy-city/index.html +2288 -0
  23. package/runtime/site/examples/wabi-sabi/index.html +2515 -0
  24. package/runtime/site/getting-started.html +84 -0
  25. package/runtime/site/index.html +1370 -0
  26. package/runtime/site/public/CommandUI.js +6341 -0
  27. package/runtime/site/public/LiveCommandClient.js +432 -0
  28. package/runtime/site/public/SceneManager.js +5493 -0
  29. package/runtime/site/public/bookmarklet.js +206 -0
  30. package/runtime/site/public/bootstrap.js +50 -0
  31. package/runtime/site/public/chocodrop-demo.umd.js +24020 -0
  32. package/runtime/site/public/chocodrop-demo.umd.min.js +24020 -0
  33. package/runtime/site/public/chocodrop.esm.js +2 -0
  34. package/runtime/site/public/html-sandbox/frame.js +865 -0
  35. package/runtime/site/public/icons/icon-192.png +0 -0
  36. package/runtime/site/public/icons/icon-512.png +0 -0
  37. package/runtime/site/public/icons/icon-maskable-512.png +0 -0
  38. package/runtime/site/public/immersive-launcher.js +175 -0
  39. package/runtime/site/public/immersive.html +165 -0
  40. package/runtime/site/public/index.html +1011 -0
  41. package/runtime/site/public/index.js +9 -0
  42. package/runtime/site/public/load-chocodrop.js +25 -0
  43. package/runtime/site/public/load-three.js +26 -0
  44. package/runtime/site/public/manifest.webmanifest +59 -0
  45. package/runtime/site/public/pwa-bootstrap.js +128 -0
  46. package/runtime/site/public/sample-card.svg +15 -0
  47. package/runtime/site/public/service-worker.js +117 -0
  48. package/runtime/site/public/translation-dictionary.js +257 -0
  49. package/runtime/site/public/xr/xr-ui.css +338 -0
  50. package/runtime/site/public/xr-viewer.html +263 -0
  51. package/runtime/site/src/client/local-bridge.js +83 -0
@@ -0,0 +1,865 @@
1
+ function sandboxNow() {
2
+ return typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now();
3
+ }
4
+
5
+ function startChocoDropSandbox() {
6
+ 'use strict';
7
+
8
+ const CHANNEL = 'chocodrop-html-sandbox';
9
+ const start = sandboxNow();
10
+ const config = window.__CHOCODROP_HTML_SANDBOX_CONFIG || {};
11
+ delete window.__CHOCODROP_HTML_SANDBOX_CONFIG;
12
+
13
+ const state = {
14
+ exported: false,
15
+ lastMutation: sandboxNow(),
16
+ idleTimer: null,
17
+ watchdog: null,
18
+ renderers: new Set(),
19
+ firstRenderReported: false,
20
+ thumbnailSent: false
21
+ };
22
+
23
+ const post = (type, payload, transfer) => {
24
+ const message = { source: CHANNEL, type, payload };
25
+ try {
26
+ parent.postMessage(message, '*', transfer || []);
27
+ } catch (error) {
28
+ parent.postMessage(message, '*');
29
+ }
30
+ };
31
+
32
+ const log = (level, message) => {
33
+ post('log', { level, message, elapsedMs: Math.round(sandboxNow() - start) });
34
+ };
35
+
36
+ const fail = (code, detail = {}) => {
37
+ if (state.exported) return;
38
+ state.exported = true;
39
+ if (state.watchdog) {
40
+ clearTimeout(state.watchdog);
41
+ state.watchdog = null;
42
+ }
43
+ clearTimeout(state.idleTimer);
44
+ disposeTrackedRenderers(state, log);
45
+ post('error', {
46
+ code,
47
+ message: detail.message || 'HTML サンドボックスでエラーが発生しました',
48
+ detail
49
+ });
50
+ };
51
+
52
+ const virtualProject = setupVirtualProject();
53
+
54
+ post('boot', { fileName: config.fileName || 'inline.html' });
55
+ post('log', { level: 'info', message: 'three-ready 受信、サンドボックス初期化開始', elapsedMs: 0 });
56
+ setupConsoleMirroring(log);
57
+ setupErrorBridge(fail, log);
58
+
59
+ if (virtualProject) {
60
+ installVirtualFileSystem(virtualProject, log);
61
+ }
62
+
63
+ const networkGuard = createNetworkGuard(config, fail, log);
64
+ networkGuard.install();
65
+
66
+ const THREE = window.THREE;
67
+ if (!THREE || !THREE.GLTFExporter) {
68
+ fail('three-missing', { message: 'Three.js または GLTFExporter を初期化できません。' });
69
+ return;
70
+ }
71
+
72
+ const exporter = new THREE.GLTFExporter();
73
+ const trackedScenes = new Set();
74
+ const requestFirstRenderExport = createFirstRenderExporter(state);
75
+ wrapThreeSceneTracking(THREE, trackedScenes, state, log);
76
+ wrapRendererHooks(THREE, trackedScenes, state, requestFirstRenderExport, log, post, fail);
77
+ wrapTextureLoader(THREE, log);
78
+
79
+ window.ChocoDropSandbox = createSandboxApi({ THREE, exporter, trackedScenes, state, fail, log, networkGuard, post, start });
80
+ window.dispatchEvent(new CustomEvent('chocodrop:sandbox-ready', { detail: { sandbox: window.ChocoDropSandbox } }));
81
+
82
+ const autoExportDelay = Number(config.autoExportIdleMs) || 1500;
83
+ const maxExecutionMs = Number(config.maxExecutionMs) || 12000;
84
+
85
+ const scheduleIdleCheck = () => {
86
+ clearTimeout(state.idleTimer);
87
+ state.idleTimer = setTimeout(() => {
88
+ if (state.exported) return;
89
+ const idleFor = sandboxNow() - state.lastMutation;
90
+ if (idleFor >= autoExportDelay) {
91
+ window.ChocoDropSandbox.exportScene(null, { reason: 'idle', idleFor });
92
+ } else {
93
+ scheduleIdleCheck();
94
+ }
95
+ }, autoExportDelay);
96
+ };
97
+
98
+ scheduleIdleCheck();
99
+ state.watchdog = setTimeout(() => {
100
+ window.ChocoDropSandbox.exportScene(null, { reason: 'timeout', maxExecutionMs });
101
+ }, maxExecutionMs);
102
+
103
+ setTimeout(() => {
104
+ if (!state.exported && !trackedScenes.size) {
105
+ log('warn', 'Scene が検出されていません。render() が呼ばれているか、scene.add が行われているかを確認してください。');
106
+ }
107
+ }, Math.min(2000, Math.max(500, autoExportDelay)));
108
+
109
+ log('info', 'HTML サンドボックスを初期化しました');
110
+ }
111
+
112
+ if (window.__waitThree && typeof window.__waitThree.then === 'function') {
113
+ window.__waitThree.then(startChocoDropSandbox).catch(error => {
114
+ const message = error?.message || 'THREE の初期化に失敗しました';
115
+ const payload = { code: 'three-bootstrap-failed', message };
116
+ try {
117
+ parent.postMessage({ source: CHANNEL, type: 'error', payload }, '*');
118
+ } catch (_) {
119
+ /* noop */
120
+ }
121
+ });
122
+ } else if (window.THREE && window.THREE.GLTFExporter) {
123
+ startChocoDropSandbox();
124
+ } else {
125
+ window.addEventListener('three-ready', () => startChocoDropSandbox(), { once: true });
126
+ }
127
+
128
+ function setupConsoleMirroring(log) {
129
+ ['log', 'info', 'warn', 'error'].forEach(level => {
130
+ const original = console[level] || console.log;
131
+ console[level] = (...args) => {
132
+ try {
133
+ original.apply(console, args);
134
+ } catch (_) {
135
+ /* noop */
136
+ }
137
+ try {
138
+ const message = args.map(arg => serializeValue(arg)).join(' ');
139
+ log(level === 'log' ? 'info' : level, message);
140
+ } catch (_) {
141
+ /* noop */
142
+ }
143
+ };
144
+ });
145
+ }
146
+
147
+ function setupErrorBridge(fail, log) {
148
+ window.addEventListener('error', event => {
149
+ const target = event.target;
150
+ if (target && target !== window) {
151
+ const tag = target.tagName?.toLowerCase?.() || 'resource';
152
+ const url = target.src || target.href || '';
153
+ log('error', `${tag} の読み込みに失敗: ${url || 'unknown resource'}`);
154
+ return;
155
+ }
156
+ fail('runtime-error', {
157
+ message: event.message,
158
+ source: event.filename,
159
+ line: event.lineno,
160
+ column: event.colno
161
+ });
162
+ });
163
+ window.addEventListener('unhandledrejection', event => {
164
+ const reason = event.reason || {};
165
+ fail('unhandled-rejection', {
166
+ message: reason?.message || serializeValue(reason),
167
+ stack: reason?.stack
168
+ });
169
+ });
170
+ }
171
+
172
+ function createNetworkGuard(config, fail, log) {
173
+ const allowed = Array.isArray(config.allowedOrigins) ? config.allowedOrigins : [];
174
+ const maxRequests = Number(config.maxNetworkRequests) || 12;
175
+ let requestCount = 0;
176
+
177
+ const isAllowed = url => {
178
+ if (!url) return true;
179
+ if (url.startsWith('data:') || url.startsWith('blob:') || url.startsWith('about:')) return true;
180
+ try {
181
+ const parsed = new URL(url, window.location.href);
182
+ const origin = parsed.origin;
183
+ if (allowed.includes('self') && (origin === window.location.origin || origin === 'null')) {
184
+ return true;
185
+ }
186
+ if (allowed.includes(origin)) return true;
187
+ if (allowed.includes(parsed.hostname)) return true;
188
+ } catch (_) {
189
+ return false;
190
+ }
191
+ return false;
192
+ };
193
+
194
+ const enforce = (url, channel) => {
195
+ requestCount += 1;
196
+ if (requestCount > maxRequests) {
197
+ fail('network-limit', { message: `通信回数が上限(${maxRequests})を超えました`, url, channel });
198
+ throw new Error('network-limit');
199
+ }
200
+ if (!isAllowed(url)) {
201
+ fail('network-blocked', { message: '許可リストにないホストへの通信を遮断しました', url, channel });
202
+ throw new Error('network-blocked');
203
+ }
204
+ };
205
+
206
+ const installFetch = () => {
207
+ if (!window.fetch) return;
208
+ const original = window.fetch.bind(window);
209
+ window.fetch = (...args) => {
210
+ try {
211
+ const url = extractUrlFromArgs(args[0]);
212
+ enforce(url, 'fetch');
213
+ } catch (error) {
214
+ return Promise.reject(error);
215
+ }
216
+ return original(...args);
217
+ };
218
+ };
219
+
220
+ const installXHR = () => {
221
+ if (!window.XMLHttpRequest) return;
222
+ const originalOpen = window.XMLHttpRequest.prototype.open;
223
+ const originalSend = window.XMLHttpRequest.prototype.send;
224
+ window.XMLHttpRequest.prototype.open = function (method, url, ...rest) {
225
+ this.__chocodropUrl = url;
226
+ return originalOpen.call(this, method, url, ...rest);
227
+ };
228
+ window.XMLHttpRequest.prototype.send = function (...args) {
229
+ enforce(this.__chocodropUrl, 'xhr');
230
+ return originalSend.apply(this, args);
231
+ };
232
+ };
233
+
234
+ const installWebSocket = () => {
235
+ if (!window.WebSocket) return;
236
+ const OriginalWebSocket = window.WebSocket;
237
+ function GuardedWebSocket(url, protocols) {
238
+ enforce(url, 'websocket');
239
+ return new OriginalWebSocket(url, protocols);
240
+ }
241
+ GuardedWebSocket.prototype = OriginalWebSocket.prototype;
242
+ window.WebSocket = GuardedWebSocket;
243
+ };
244
+
245
+ return {
246
+ install() {
247
+ installFetch();
248
+ installXHR();
249
+ installWebSocket();
250
+ log('info', `通信制限: ホスト ${allowed.join(', ') || 'なし'}, 最大${maxRequests}回`);
251
+ },
252
+ get count() {
253
+ return requestCount;
254
+ }
255
+ };
256
+ }
257
+
258
+ function setupVirtualProject() {
259
+ const frame = window.frameElement;
260
+ if (!frame) return null;
261
+ const payload = frame.__chocodropVirtualProject;
262
+ if (!payload || !Array.isArray(payload.files) || payload.files.length === 0) {
263
+ return null;
264
+ }
265
+ frame.__chocodropVirtualProject = null;
266
+ const files = new Map();
267
+ payload.files.forEach(entry => {
268
+ if (Array.isArray(entry) && typeof entry[0] === 'string' && entry[1]?.url) {
269
+ files.set(entry[0], entry[1]);
270
+ }
271
+ });
272
+ if (!files.size) {
273
+ return null;
274
+ }
275
+ return {
276
+ baseDir: (payload.baseDir || '').replace(/\\/g, '/'),
277
+ files
278
+ };
279
+ }
280
+
281
+ function installVirtualFileSystem(project, log) {
282
+ const resolver = createVirtualResolver(project);
283
+ if (!resolver) return;
284
+
285
+ const rewriteValue = value => resolver(value);
286
+
287
+ patchElementAttributeHook(rewriteValue);
288
+ [
289
+ [window.HTMLScriptElement, 'src'],
290
+ [window.HTMLLinkElement, 'href'],
291
+ [window.HTMLImageElement, 'src'],
292
+ [window.HTMLVideoElement, 'src'],
293
+ [window.HTMLAudioElement, 'src'],
294
+ [window.HTMLSourceElement, 'src'],
295
+ [window.HTMLTrackElement, 'src'],
296
+ [window.HTMLIFrameElement, 'src'],
297
+ [window.HTMLEmbedElement, 'src'],
298
+ [window.HTMLObjectElement, 'data']
299
+ ].forEach(entry => patchUrlProperty(entry[0], entry[1], rewriteValue));
300
+
301
+ patchFetchHooks(rewriteValue);
302
+ patchWorkerHooks(rewriteValue);
303
+ log('info', `ローカルZIP資産 ${project.files.size} 件をマウントしました`);
304
+ }
305
+
306
+ function createVirtualResolver(project) {
307
+ const map = project.files;
308
+ if (!map || !map.size) return null;
309
+ const baseDir = normaliseBaseDir(project.baseDir || '');
310
+ return value => {
311
+ const normalized = normalizeVirtualPath(value, baseDir);
312
+ if (!normalized) return null;
313
+ const entry = map.get(normalized);
314
+ return entry?.url || null;
315
+ };
316
+ }
317
+
318
+ function normaliseBaseDir(dir) {
319
+ if (!dir) return '';
320
+ const normalized = dir.replace(/\\/g, '/');
321
+ return normalized.endsWith('/') ? normalized : `${normalized}/`;
322
+ }
323
+
324
+ function normalizeVirtualPath(value, baseDir) {
325
+ if (value == null) return null;
326
+ const raw = typeof value === 'string' ? value : String(value);
327
+ const trimmed = raw.trim();
328
+ if (!trimmed) return null;
329
+ if (/^[a-zA-Z]+:/.test(trimmed) || trimmed.startsWith('//') || trimmed.startsWith('#')) {
330
+ return null;
331
+ }
332
+ const withoutQuery = trimmed.split(/[?#]/)[0];
333
+ const relative = withoutQuery.startsWith('/')
334
+ ? withoutQuery.slice(1)
335
+ : `${baseDir || ''}${withoutQuery}`;
336
+ const collapsed = collapsePath(relative);
337
+ return collapsed;
338
+ }
339
+
340
+ function collapsePath(path) {
341
+ return path
342
+ .split('/')
343
+ .reduce((stack, segment) => {
344
+ if (!segment || segment === '.') return stack;
345
+ if (segment === '..') {
346
+ stack.pop();
347
+ } else {
348
+ stack.push(segment);
349
+ }
350
+ return stack;
351
+ }, [])
352
+ .join('/');
353
+ }
354
+
355
+ function patchElementAttributeHook(rewriteValue) {
356
+ const originalSetAttribute = Element.prototype.setAttribute;
357
+ Element.prototype.setAttribute = function (name, value) {
358
+ if (typeof name === 'string' && typeof value === 'string') {
359
+ if (isResourceAttribute(name)) {
360
+ const mapped = rewriteValue(value);
361
+ if (mapped) {
362
+ return originalSetAttribute.call(this, name, mapped);
363
+ }
364
+ }
365
+ }
366
+ return originalSetAttribute.call(this, name, value);
367
+ };
368
+ }
369
+
370
+ function isResourceAttribute(name) {
371
+ return ['src', 'href', 'data'].includes(name.toLowerCase());
372
+ }
373
+
374
+ function patchUrlProperty(ctor, property, rewriteValue) {
375
+ if (!ctor || !ctor.prototype) return;
376
+ const descriptor = Object.getOwnPropertyDescriptor(ctor.prototype, property);
377
+ if (!descriptor || typeof descriptor.set !== 'function') return;
378
+ Object.defineProperty(ctor.prototype, property, {
379
+ configurable: true,
380
+ enumerable: descriptor.enumerable,
381
+ get: descriptor.get
382
+ ? function () {
383
+ return descriptor.get.call(this);
384
+ }
385
+ : function () {
386
+ return this.getAttribute(property);
387
+ },
388
+ set(value) {
389
+ const mapped = typeof value === 'string' ? rewriteValue(value) : null;
390
+ return descriptor.set.call(this, mapped || value);
391
+ }
392
+ });
393
+ }
394
+
395
+ function patchFetchHooks(rewriteValue) {
396
+ const originalFetch = window.fetch;
397
+ window.fetch = function (input, init) {
398
+ const mapped = resolveFetchResource(input, rewriteValue, init);
399
+ if (mapped) {
400
+ return originalFetch(mapped.resource, mapped.init);
401
+ }
402
+ return originalFetch(input, init);
403
+ };
404
+
405
+ const originalOpen = XMLHttpRequest.prototype.open;
406
+ XMLHttpRequest.prototype.open = function (method, url, ...rest) {
407
+ const mapped = typeof url === 'string' ? rewriteValue(url) : null;
408
+ return originalOpen.call(this, method, mapped || url, ...rest);
409
+ };
410
+ }
411
+
412
+ function resolveFetchResource(resource, rewriteValue, init) {
413
+ if (resource instanceof Request) {
414
+ const mappedUrl = rewriteValue(resource.url);
415
+ if (!mappedUrl) return null;
416
+ const cloned = resource.clone();
417
+ const nextInit = buildRequestInit(cloned, init);
418
+ return { resource: mappedUrl, init: nextInit };
419
+ }
420
+ if (typeof resource === 'string' || resource instanceof URL) {
421
+ const mappedUrl = rewriteValue(String(resource));
422
+ if (!mappedUrl) return null;
423
+ return { resource: mappedUrl, init };
424
+ }
425
+ return null;
426
+ }
427
+
428
+ function buildRequestInit(request, override = {}) {
429
+ const init = { ...override };
430
+ if (!('method' in init)) init.method = request.method;
431
+ if (!('headers' in init)) init.headers = request.headers;
432
+ if (!('body' in init) && request.method !== 'GET' && request.method !== 'HEAD') {
433
+ init.body = request.body;
434
+ }
435
+ if (!('mode' in init)) init.mode = request.mode;
436
+ if (!('credentials' in init)) init.credentials = request.credentials;
437
+ if (!('cache' in init)) init.cache = request.cache;
438
+ if (!('redirect' in init)) init.redirect = request.redirect;
439
+ if (!('referrer' in init)) init.referrer = request.referrer;
440
+ if (!('referrerPolicy' in init)) init.referrerPolicy = request.referrerPolicy;
441
+ if (!('integrity' in init)) init.integrity = request.integrity;
442
+ if (!('keepalive' in init)) init.keepalive = request.keepalive;
443
+ if (!('signal' in init)) init.signal = request.signal;
444
+ return init;
445
+ }
446
+
447
+ function patchWorkerHooks(rewriteValue) {
448
+ if (typeof window.Worker === 'function') {
449
+ const OriginalWorker = window.Worker;
450
+ window.Worker = function (url, options) {
451
+ const mapped = typeof url === 'string' ? rewriteValue(url) : null;
452
+ return new OriginalWorker(mapped || url, options);
453
+ };
454
+ window.Worker.prototype = OriginalWorker.prototype;
455
+ }
456
+ if (typeof window.SharedWorker === 'function') {
457
+ const OriginalSharedWorker = window.SharedWorker;
458
+ window.SharedWorker = function (url, options) {
459
+ const mapped = typeof url === 'string' ? rewriteValue(url) : null;
460
+ return new OriginalSharedWorker(mapped || url, options);
461
+ };
462
+ window.SharedWorker.prototype = OriginalSharedWorker.prototype;
463
+ }
464
+ }
465
+
466
+ function wrapThreeSceneTracking(THREE, trackedScenes, state, log) {
467
+ const BaseScene = THREE.Scene;
468
+ class SandboxScene extends BaseScene {
469
+ constructor(...args) {
470
+ super(...args);
471
+ trackedScenes.add(this);
472
+ state.lastMutation = sandboxNow();
473
+ }
474
+ }
475
+ SandboxScene.prototype = BaseScene.prototype;
476
+ Object.setPrototypeOf(SandboxScene, BaseScene);
477
+ THREE.Scene = SandboxScene;
478
+
479
+ const originalAdd = THREE.Object3D.prototype.add;
480
+ THREE.Object3D.prototype.add = function (...objects) {
481
+ state.lastMutation = sandboxNow();
482
+ return originalAdd.apply(this, objects);
483
+ };
484
+
485
+ log('info', 'Scene/Object3D フックを適用しました');
486
+ }
487
+
488
+ function wrapRendererHooks(THREE, trackedScenes, state, requestFirstRenderExport, log, post, fail) {
489
+ const Renderer = THREE.WebGLRenderer;
490
+ if (!Renderer || !Renderer.prototype) {
491
+ log('warn', 'WebGLRenderer フックを適用できません (未定義)');
492
+ return;
493
+ }
494
+
495
+ const originalRender = Renderer.prototype.render;
496
+ if (typeof originalRender === 'function') {
497
+ Renderer.prototype.render = function patchedRender(scene, camera, ...rest) {
498
+ if (scene && typeof scene.traverse === 'function') {
499
+ trackedScenes.add(scene);
500
+ }
501
+ if (state.renderers && this && typeof this.dispose === 'function') {
502
+ state.renderers.add(this);
503
+ }
504
+ // fallback 用に renderer から scene を逆参照できるようメモ
505
+ if (scene && typeof scene === 'object') {
506
+ scene.__rendererHint = this;
507
+ }
508
+ this.__chocodropState = state;
509
+ maybeAttachContextLossListener(this, fail, log);
510
+ state.lastMutation = sandboxNow();
511
+ try {
512
+ requestFirstRenderExport(scene, camera);
513
+ } catch (_) {
514
+ /* noop */
515
+ }
516
+ const output = originalRender.call(this, scene, camera, ...rest);
517
+ try {
518
+ captureThumbnail(this, state, post, log);
519
+ } catch (_) {
520
+ /* noop */
521
+ }
522
+ return output;
523
+ };
524
+ }
525
+
526
+ const originalDispose = Renderer.prototype.dispose;
527
+ Renderer.prototype.dispose = function patchedDispose(...args) {
528
+ if (state.renderers) {
529
+ state.renderers.delete(this);
530
+ }
531
+ if (typeof originalDispose === 'function') {
532
+ return originalDispose.apply(this, args);
533
+ }
534
+ return undefined;
535
+ };
536
+
537
+ log('info', 'WebGLRenderer フックを適用しました');
538
+ }
539
+
540
+ function createFirstRenderExporter(state) {
541
+ const enqueue = typeof queueMicrotask === 'function' ? queueMicrotask : cb => Promise.resolve().then(cb);
542
+ return (scene, camera) => {
543
+ if (state.exported || state.firstRenderReported) return;
544
+ if (!scene || typeof scene.traverse !== 'function') return;
545
+ state.firstRenderReported = true;
546
+ enqueue(() => {
547
+ try {
548
+ if (!state.exported) {
549
+ window.ChocoDropSandbox?.exportScene(scene, {
550
+ reason: 'first-render',
551
+ cameraUuid: camera?.uuid || null
552
+ });
553
+ }
554
+ } catch (error) {
555
+ state.firstRenderReported = false;
556
+ console.warn('Initial render export failed, retry allowed.', error);
557
+ }
558
+ });
559
+ };
560
+ }
561
+
562
+ function disposeTrackedRenderers(state, log) {
563
+ if (!state.renderers || !state.renderers.size) {
564
+ return;
565
+ }
566
+ state.renderers.forEach(renderer => {
567
+ try {
568
+ renderer.dispose?.();
569
+ } catch (error) {
570
+ log('warn', `renderer.dispose() に失敗: ${error?.message || error}`);
571
+ }
572
+ });
573
+ state.renderers.clear();
574
+ }
575
+
576
+ function createSandboxApi({ THREE, exporter, trackedScenes, state, fail, log, networkGuard, post, start }) {
577
+ const exportScene = (target, meta = {}) => {
578
+ if (state.exported) return;
579
+ let scene = target;
580
+ if (!scene) {
581
+ scene = pickScene(trackedScenes);
582
+ }
583
+ if (!scene) {
584
+ fail('scene-missing', { message: 'エクスポート対象の Scene が検出できませんでした。scene.add/render が呼ばれているか確認してください。' });
585
+ return;
586
+ }
587
+ try {
588
+ log('info', 'Scene export を開始します');
589
+ const prepared = prepareScene(scene, THREE, log);
590
+ const sceneJson = prepared.toJSON();
591
+ const objectCount = countObjects(prepared);
592
+ state.exported = true;
593
+ clearTimeout(state.watchdog);
594
+ clearTimeout(state.idleTimer);
595
+ post('result', {
596
+ sceneJson,
597
+ summary: {
598
+ objectCount,
599
+ durationMs: Math.round(sandboxNow() - start),
600
+ reason: meta.reason || 'manual',
601
+ networkRequests: networkGuard.count
602
+ }
603
+ });
604
+ post('log', { level: 'info', message: 'Scene JSON 送信完了、GLB エクスポート開始', elapsedMs: Math.round(sandboxNow() - start) });
605
+ disposeTrackedRenderers(state, log);
606
+ exportGlb(prepared, exporter, log, post, start);
607
+ } catch (error) {
608
+ fail('export-failed', { message: error?.message || 'Scene JSON 変換に失敗しました' });
609
+ }
610
+ };
611
+
612
+ return {
613
+ exportScene,
614
+ halt: reason => fail('halted', { message: reason || '手動停止' }),
615
+ reportScene: scene => {
616
+ if (scene) {
617
+ trackedScenes.add(scene);
618
+ }
619
+ }
620
+ };
621
+ }
622
+
623
+ function exportGlb(scene, exporter, log, post, startTime) {
624
+ try {
625
+ const origin = Number.isFinite(startTime) ? startTime : sandboxNow();
626
+ post('log', { level: 'info', message: 'GLB エクスポート中…', elapsedMs: Math.round(sandboxNow() - origin) });
627
+ // 追加フォールバック: サムネイル未取得ならここで再試行
628
+ tryFallbackThumbnailCapture(scene, log, post);
629
+ const animations = Array.isArray(scene.animations) ? scene.animations : [];
630
+ exporter.parse(
631
+ scene,
632
+ result => {
633
+ if (result instanceof ArrayBuffer) {
634
+ post('glb', { byteLength: result.byteLength, buffer: result }, [result]);
635
+ } else {
636
+ log('warn', 'GLB バッファを取得できませんでした');
637
+ }
638
+ },
639
+ error => log('warn', `GLB エクスポートに失敗: ${error?.message || error}`),
640
+ {
641
+ binary: true,
642
+ onlyVisible: true,
643
+ forceIndices: true,
644
+ truncateDrawRange: true,
645
+ maxTextureSize: 2048,
646
+ animations,
647
+ includeCustomExtensions: true
648
+ }
649
+ );
650
+ } catch (error) {
651
+ log('warn', `GLB エクスポートに失敗: ${error?.message || error}`);
652
+ }
653
+ }
654
+
655
+ function tryFallbackThumbnailCapture(scene, log, post) {
656
+ if (!scene || !scene.__rendererHint || scene.__rendererHint.__chocodropThumbnailSent) return;
657
+ const renderer = scene.__rendererHint;
658
+ // 一度失敗していても安全に再試行するが、成功時のみフラグを立てる
659
+ try {
660
+ captureThumbnail(renderer, renderer.__chocodropState || {}, post, log);
661
+ } catch (error) {
662
+ log('warn', `サムネイル再取得に失敗: ${error?.message || error}`);
663
+ }
664
+ }
665
+
666
+ function pickScene(trackedScenes) {
667
+ if (trackedScenes.size === 1) {
668
+ return trackedScenes.values().next().value;
669
+ }
670
+ if (trackedScenes.size === 0 && window.scene && typeof window.scene.toJSON === 'function') {
671
+ return window.scene;
672
+ }
673
+ let best = null;
674
+ let bestScore = -1;
675
+ trackedScenes.forEach(scene => {
676
+ const score = countObjects(scene);
677
+ if (score > bestScore) {
678
+ best = scene;
679
+ bestScore = score;
680
+ }
681
+ });
682
+ return best;
683
+ }
684
+
685
+ function countObjects(root) {
686
+ let count = 0;
687
+ root.traverse(() => {
688
+ count += 1;
689
+ });
690
+ return count;
691
+ }
692
+
693
+ function prepareScene(scene, THREE, log) {
694
+ // クローンしてオリジナルを汚染しない
695
+ const cloned = scene.clone(true);
696
+ const removable = [];
697
+ cloned.traverse(obj => {
698
+ const scaleMagnitude = obj.scale ? Math.abs(obj.scale.x * obj.scale.y * obj.scale.z) : 1;
699
+ if (obj.visible === false || scaleMagnitude < 1e-6) {
700
+ removable.push(obj);
701
+ }
702
+ });
703
+ removable.forEach(node => node.parent?.remove(node));
704
+
705
+ dedupeResources(cloned, THREE, log);
706
+ return cloned;
707
+ }
708
+
709
+ function dedupeResources(root, THREE, log) {
710
+ if (!THREE || !root) return;
711
+ const geometryMap = new Map();
712
+ const materialMap = new Map();
713
+ root.traverse(obj => {
714
+ if (obj.isMesh || obj.isPoints || obj.isLine) {
715
+ if (obj.geometry && obj.geometry.isBufferGeometry) {
716
+ const signature = geometrySignature(obj.geometry);
717
+ const existing = geometryMap.get(signature);
718
+ if (existing) {
719
+ obj.geometry = existing;
720
+ } else {
721
+ geometryMap.set(signature, optimizeGeometry(obj.geometry, THREE, log));
722
+ }
723
+ }
724
+ const materials = Array.isArray(obj.material) ? obj.material : [obj.material].filter(Boolean);
725
+ if (materials.length) {
726
+ obj.material = materials.map(mat => {
727
+ const key = materialSignature(mat);
728
+ const found = materialMap.get(key);
729
+ if (found) return found;
730
+ materialMap.set(key, mat);
731
+ return mat;
732
+ });
733
+ if (obj.material.length === 1) {
734
+ obj.material = obj.material[0];
735
+ }
736
+ }
737
+ }
738
+ });
739
+ }
740
+
741
+ function geometrySignature(geometry) {
742
+ const attrs = geometry.attributes || {};
743
+ const keys = Object.keys(attrs)
744
+ .sort()
745
+ .map(name => {
746
+ const attr = attrs[name];
747
+ return `${name}:${attr?.itemSize || 0}:${attr?.count || 0}:${attr?.normalized ? 1 : 0}`;
748
+ })
749
+ .join('|');
750
+ const index = geometry.index ? `i:${geometry.index.count}` : 'i:0';
751
+ return `${geometry.type || geometry.constructor?.name || 'BufferGeometry'};${index};${keys}`;
752
+ }
753
+
754
+ function optimizeGeometry(geometry, THREE, log) {
755
+ if (THREE.BufferGeometryUtils && typeof THREE.BufferGeometryUtils.mergeVertices === 'function') {
756
+ try {
757
+ const merged = THREE.BufferGeometryUtils.mergeVertices(geometry, 1e-4);
758
+ merged.computeBoundingSphere?.();
759
+ merged.computeBoundingBox?.();
760
+ return merged;
761
+ } catch (error) {
762
+ log('warn', `ジオメトリの最適化に失敗: ${error?.message || error}`);
763
+ }
764
+ }
765
+ return geometry;
766
+ }
767
+
768
+ function materialSignature(material = {}) {
769
+ const props = ['type', 'color', 'roughness', 'metalness', 'opacity', 'transparent', 'map', 'normalMap'];
770
+ return props
771
+ .map(key => {
772
+ const value = material[key];
773
+ if (value && value.uuid) return `${key}:${value.uuid}`;
774
+ if (value && value.isColor) return `${key}:${value.getHexString?.() || ''}`;
775
+ return `${key}:${value ?? 'null'}`;
776
+ })
777
+ .join(';');
778
+ }
779
+
780
+ function maybeAttachContextLossListener(renderer, fail, log) {
781
+ if (!renderer || renderer.__chocodropContextGuard) return;
782
+ const canvas = renderer.domElement;
783
+ if (!canvas || typeof canvas.addEventListener !== 'function') return;
784
+ renderer.__chocodropContextGuard = true;
785
+ const handleLost = event => {
786
+ try {
787
+ event?.preventDefault?.();
788
+ } catch (_) {
789
+ /* noop */
790
+ }
791
+ log('error', 'WebGL コンテキストが失われました。テクスチャサイズやGPU負荷を見直してください。');
792
+ fail?.('webgl-context-lost', {
793
+ message: 'WebGL コンテキストが失われました。ブラウザをリロードするか、描画負荷を下げて再実行してください。'
794
+ });
795
+ };
796
+ const handleRestore = () => log('info', 'WebGL コンテキストが復旧しました');
797
+ canvas.addEventListener('webglcontextlost', handleLost, { passive: false });
798
+ canvas.addEventListener('webglcontextrestored', handleRestore, { passive: true });
799
+ }
800
+
801
+ function wrapTextureLoader(THREE, log) {
802
+ if (!THREE?.TextureLoader || !THREE.TextureLoader.prototype) return;
803
+ const originalLoad = THREE.TextureLoader.prototype.load;
804
+ THREE.TextureLoader.prototype.load = function patchedTextureLoad(url, onLoad, onError, ...rest) {
805
+ const handleError = error => {
806
+ const reason = error?.message || error || 'テクスチャの読み込みに失敗しました';
807
+ log('warn', `Texture 読み込み失敗: ${url || 'unknown'} (${reason}). CORS 設定やパスをご確認ください。`);
808
+ if (typeof onError === 'function') {
809
+ try {
810
+ onError(error);
811
+ } catch (_) {
812
+ /* noop */
813
+ }
814
+ }
815
+ };
816
+ return originalLoad.call(this, url, onLoad, handleError, ...rest);
817
+ };
818
+ }
819
+
820
+ function captureThumbnail(renderer, state, post, log) {
821
+ if (state.thumbnailSent) return;
822
+ const canvas = renderer?.domElement;
823
+ if (!canvas || !canvas.width || !canvas.height) return;
824
+
825
+ const maxSize = 512;
826
+ const scale = Math.min(1, maxSize / Math.max(canvas.width, canvas.height));
827
+ const targetWidth = Math.max(1, Math.round(canvas.width * scale));
828
+ const targetHeight = Math.max(1, Math.round(canvas.height * scale));
829
+
830
+ try {
831
+ const offscreen = document.createElement('canvas');
832
+ offscreen.width = targetWidth;
833
+ offscreen.height = targetHeight;
834
+ const ctx = offscreen.getContext('2d');
835
+ if (!ctx) return;
836
+ ctx.drawImage(canvas, 0, 0, targetWidth, targetHeight);
837
+ const dataUrl = offscreen.toDataURL('image/png');
838
+ state.thumbnailSent = true;
839
+ if (renderer) renderer.__chocodropThumbnailSent = true;
840
+ post('thumbnail', { dataUrl, width: targetWidth, height: targetHeight });
841
+ } catch (error) {
842
+ log('warn', `サムネイル生成に失敗: ${error?.message || error}`);
843
+ }
844
+ }
845
+
846
+ function extractUrlFromArgs(input) {
847
+ if (!input) return '';
848
+ if (typeof input === 'string') return input;
849
+ if (typeof Request !== 'undefined' && input instanceof Request) {
850
+ return input.url;
851
+ }
852
+ if (input.url) return input.url;
853
+ return '';
854
+ }
855
+
856
+ function serializeValue(value) {
857
+ if (typeof value === 'string') return value;
858
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value);
859
+ if (value instanceof Error) return value.message || value.stack || value.toString();
860
+ try {
861
+ return JSON.stringify(value);
862
+ } catch (_) {
863
+ return Object.prototype.toString.call(value);
864
+ }
865
+ }