PageSourceSearch

https://lagrange-566.pages.dev/js/routemap3d.js?b=1786340351

js lagrange-566.pages.dev collected 2026-10-03 05:22:31 UTC 22,112 bytes, 407 lines download raw bytes

1// ============================================================
2// LAGRANGE routemap3d v2 — ブリーフィング/デブリーフィングの3Dルート飛行演出
3//   星図ハイブリッド: 黄道グリッド+発光航路+実惑星マテリアル(world.jsの生成テクスチャを共有)
4//   出発=双胴円筒コロニー(実機シルエット準拠)→ 航路追従 → 目標天体で旋回アプローチ。
5//   mountRoute3D(container, {origin, dest, wpName, mode:'depart'|'return', destColor, bodyId, shipType})
6//   unmountRoute3D() — レンダラ破棄(2つ目のWebGLコンテキストを残さない)
7// ============================================================
8import * as THREE from 'three';
9import { getBody } from './world.js?b=1786340351';
10import { ship as gameShip, buildShipPreview } from './ship.js?b=1786340351';
11
12let R = null;
13const gsapOk = () => typeof window.gsap !== 'undefined';
14
15function starfield() {
16  const g = new THREE.BufferGeometry(), N = 1400, p = new Float32Array(N * 3), c = new Float32Array(N * 3);
17  let seed = 91;
18  const rnd = () => { seed = (seed * 16807) % 2147483647; return seed / 2147483647; };
19  for (let i = 0; i < N; i++) {
20    const r = 420 + rnd() * 980, th = rnd() * Math.PI * 2, ph = Math.acos(rnd() * 2 - 1);
21    p[i * 3] = r * Math.sin(ph) * Math.cos(th); p[i * 3 + 1] = r * Math.cos(ph); p[i * 3 + 2] = r * Math.sin(ph) * Math.sin(th);
22    const t = rnd(); const cr = t < .12 ? [1, .85, .7] : t < .24 ? [.75, .85, 1] : [.85, .95, 1];
23    c[i * 3] = cr[0]; c[i * 3 + 1] = cr[1]; c[i * 3 + 2] = cr[2];
24  }
25  g.setAttribute('position', new THREE.BufferAttribute(p, 3));
26  g.setAttribute('color', new THREE.BufferAttribute(c, 3));
27  return new THREE.Points(g, new THREE.PointsMaterial({ vertexColors: true, size: 2.1, sizeAttenuation: false, transparent: true, opacity: .9 }));
28}
29
30// 星図風の黄道グリッド(同心円3本+放射目盛)
31function chartGrid() {
32  const grp = new THREE.Group();
33  const mkRing = (r, op) => {
34    const pts = [];
35    for (let i = 0; i <= 128; i++) { const a = i / 128 * Math.PI * 2; pts.push(new THREE.Vector3(Math.cos(a) * r, 0, Math.sin(a) * r)); }
36    const l = new THREE.Line(new THREE.BufferGeometry().setFromPoints(pts),
37      new THREE.LineBasicMaterial({ color: 0x79e7dd, transparent: true, opacity: op }));
38    grp.add(l);
39  };
40  mkRing(46, .14); mkRing(74, .1); mkRing(102, .07);
41  const seg = [];
42  for (let i = 0; i < 24; i++) {
43    const a = i / 24 * Math.PI * 2, c = Math.cos(a), s = Math.sin(a);
44    seg.push(new THREE.Vector3(c * 44, 0, s * 44), new THREE.Vector3(c * (i % 6 === 0 ? 40 : 42.5), 0, s * (i % 6 === 0 ? 40 : 42.5)));
45  }
46  const ticks = new THREE.LineSegments(new THREE.BufferGeometry().setFromPoints(seg),
47    new THREE.LineBasicMaterial({ color: 0x79e7dd, transparent: true, opacity: .18 }));
48  grp.add(ticks);
49  grp.position.y = -14;
50  return grp;
51}
52
53// カプセル型コロニー(アマテラス実機準拠: 白カプセル+ティール窓帯3列+上部ラジエータ+端部ドックリング)
54function colony() {
55  const grp = new THREE.Group();
56  const hullMat = new THREE.MeshStandardMaterial({ color: 0xe6e2d8, roughness: .55, metalness: .25 });
57  const bandMat = new THREE.MeshBasicMaterial({ color: 0x59d8c8, transparent: true, opacity: .85, side: THREE.DoubleSide });
58  const grayMat = new THREE.MeshStandardMaterial({ color: 0x3a4650, roughness: .6, metalness: .6 });
59  const hull = new THREE.Mesh(new THREE.CylinderGeometry(3.6, 3.6, 13, 40), hullMat);
60  hull.rotation.z = Math.PI / 2; grp.add(hull);
61  for (const sd of [-1, 1]) {
62    const cap = new THREE.Mesh(new THREE.SphereGeometry(3.6, 40, 24, 0, Math.PI * 2, 0, Math.PI / 2), hullMat);
63    cap.rotation.z = -sd * Math.PI / 2; cap.position.x = sd * 6.5; grp.add(cap);
64  }
65  for (const x of [-4.2, 0, 4.2]) {
66    const band = new THREE.Mesh(new THREE.CylinderGeometry(3.66, 3.66, 2.1, 40, 1, true), bandMat);
67    band.rotation.z = Math.PI / 2; band.position.x = x; grp.add(band);
68  }
69  const panel = new THREE.Mesh(new THREE.BoxGeometry(8.6, .18, 2.8), grayMat);
70  panel.position.y = 4.5; grp.add(panel);
71  for (const x of [-3, 3]) {
72    const strut = new THREE.Mesh(new THREE.BoxGeometry(.16, .9, .16), grayMat);
73    strut.position.set(x, 4.0, 0); grp.add(strut);
74  }
75  const dockRing = new THREE.Mesh(new THREE.TorusGeometry(2.3, .3, 10, 36), grayMat);
76  dockRing.rotation.y = Math.PI / 2; dockRing.position.x = 8.2; grp.add(dockRing);
77  const glow = new THREE.Mesh(new THREE.CircleGeometry(1.5, 24),
78    new THREE.MeshBasicMaterial({ color: 0x8fe6ff, transparent: true, op
78acity: .9, side: THREE.DoubleSide }));
79  glow.rotation.y = Math.PI / 2; glow.position.x = 8.05; grp.add(glow);
80  return grp;
81}
82
83// 実惑星マテリアル — world.jsのライブ天体からテクスチャを共有(未構築時は色フォールバック)
84function planetMat(bodyId, fallbackColor) {
85  try {
86    const b = bodyId && getBody(bodyId);
87    const src = b && b.mesh && b.mesh.material;
88    const map = src && (src.map || (src.uniforms && ((src.uniforms.map && src.uniforms.map.value) || (src.uniforms.tDay && src.uniforms.tDay.value))));
89    const cloudsMap = b && b.clouds && b.clouds.material && (b.clouds.material.map || (b.clouds.material.uniforms && b.clouds.material.uniforms.map && b.clouds.material.uniforms.map.value));
90    if (map) return { mat: new THREE.MeshStandardMaterial({ map, roughness: .92, metalness: 0 }), cloudsMap: cloudsMap || null };
91    if (src && src.color) return { mat: new THREE.MeshStandardMaterial({ color: src.color.getHex(), roughness: .92 }), cloudsMap: null };
92  } catch (e) { }
93  return { mat: new THREE.MeshStandardMaterial({ color: fallbackColor, roughness: .95, metalness: 0 }), cloudsMap: null };
94}
95
96function planet(bodyId, fallbackColor) {
97  const grp = new THREE.Group();
98  const { mat, cloudsMap } = planetMat(bodyId, fallbackColor);
99  const p = new THREE.Mesh(new THREE.SphereGeometry(14, 64, 48), mat);
100  grp.add(p); grp.userData.body = p;
101  if (cloudsMap) {
102    const cl = new THREE.Mesh(new THREE.SphereGeometry(14.35, 48, 36),
103      new THREE.MeshStandardMaterial({ map: cloudsMap, transparent: true, opacity: .85, depthWrite: false, roughness: 1 }));
104    grp.add(cl); grp.userData.clouds = cl;
105  }
106  const atmoColor = bodyId === 'MARS' ? 0xd8926a : bodyId === 'EARTH' ? 0x7fc4ff : 0x9fc4d8;
107  const atmo = new THREE.Mesh(new THREE.SphereGeometry(14.9, 48, 36),
108    new THREE.MeshBasicMaterial({ color: atmoColor, transparent: true, opacity: .12, side: THREE.BackSide, blending: THREE.AdditiveBlending }));
109  grp.add(atmo);
110  if (bodyId === 'SATURN') {
111    const ring = new THREE.Mesh(new THREE.RingGeometry(17.5, 26, 72),
112      new THREE.MeshBasicMaterial({ color: 0xd9c9a8, transparent: true, opacity: .5, side: THREE.DoubleSide }));
113    ring.rotation.x = Math.PI / 2.25; grp.add(ring);
114  }
115  return grp;
116}
117
118// 目標マーカー(琥珀ダイヤ+パルスリング)
119function destMarker() {
120  const grp = new THREE.Group();
121  const dia = new THREE.Mesh(new THREE.OctahedronGeometry(2.4),
122    new THREE.MeshBasicMaterial({ color: 0xe8b94d, transparent: true, opacity: .9, wireframe: true }));
123  grp.add(dia); grp.userData.dia = dia;
124  const ring = new THREE.Mesh(new THREE.TorusGeometry(19, .22, 8, 64),
125    new THREE.MeshBasicMaterial({ color: 0xe8b94d, transparent: true, opacity: .55 }));
126  ring.rotation.x = Math.PI / 2.3; grp.add(ring); grp.userData.ring = ring;
127  return grp;
128}
129
130// 長翼機(デルフィナス03系シルエット: 細身胴+前後に伸びる長翼+エンジン光)
131// 実機モデル(ドックUIと同じ buildShipPreview)を全長約6ユニットへ正規化して使う。
132//   ゲーム機体は-Zが機首→routemap3dは+Z前進(lookAt前提)なので内側を180°反転。
133//   モデルは非同期(GLB)ロードのため、初回2秒は毎フレーム renorm() でbbox追従。
134function realShip(type) {
135  const holder = new THREE.Group();
136  const inner = new THREE.Group();
137  let m; try { m = buildShipPreview(type); } catch (e) { m = null; }
138  if (m) { m.rotation.y = Math.PI; inner.add(m); }
139  holder.add(inner);
140  // ★機体正規化。旧実装はBox3を「ワールド空間」で測り、その中心をローカルのinner.positionから
141  //   引いていたため、航路上でholder(機体)が移動・回転した状態でrenormが走ると機体のワールド座標分
142  //   モデルが画面外へ吹き飛んだ(=軌道を飛ばずに消える主因)。holderローカル空間で計測して根治する。
143  const _invHolder = new THREE.Matrix4(), _mm = new THREE.Matrix4(), _tmp = new THREE.Box3();
144  const measureLocal = () => {
145    holder.updateWorldMatrix(true, false);
146    inner.updateWorldMatrix(false, true);
147    _invHolder.copy(holder.matrixWorld).invert();
148    const box = new THREE.Box3();
149    inner.traverse(o => {
150      if (!o.isMesh || !o.geometry) return;
151      if (!o.geometry.boundingBox) o.geometry.computeBoundingBox();
152      _mm.multiplyMatrices(_invHolder, o.matrixWorld);
153      _tmp.copy(o.geometry.boundingBox).applyMatrix4(_mm);
154      box.union(_tmp);
155    });
156    return box;
157  };
158  holder.userData.renorm = () => {
159    inner.scale.set(1, 1, 1); inner.position.set(0, 0, 0);
160    const box = measureLocal();
161    if (box.isEmpty()) return false;
162    const size = new THREE.Vector3(); box.getSize(size);
163    const len = Math.max(size.x, size.y, size.z);
164    if (!(len > 0)) return false;
165    inner.scale.setScalar(6 / len);
166    const box2 = measureLocal();
167    const c = new THREE.Vector3(); box2.getCenter(c); inner.position.sub(c);
168    return true;
169  };
170  holder.userData.renorm();
171  return holder;
172}
173
174function label(container, cls, text) {
175  const el = document.createElement('div');
176  el.className = 'sbR3Lbl ' + cls; el.textContent = text;
177  container.appendChild(el);
178  return el;
179}
180
181export function unmountRoute3D() {
182  if (!R) return;
183  cancelAnimationFrame(R.raf);
184  try { R.tl && R.tl.kill(); } catch { }
185  try { R.tl2 && R.tl2.kill(); } catch { }
186  try { window.removeEventListener('resize', R.onResize); } catch { }
187  try { R.renderer.dispose(); R.renderer.forceContextLoss?.(); } catch { }
188  try { R.canvas.remove(); } catch { }
189  R.labels.forEach(l => { try { l.remove(); } catch { } });
190  R = null;
191}
192
193let cssInjected = false;
194function injectCss() {
195  if (cssInjected) return; cssInjected = true;
196  const s = document.createElement('style');
197  s.textContent = `
198.sbRoute3D{position:absolute;inset:0;width:100%;height:100%;z-index:2;display:block}
199.sbR3Lbl{position:absolute;z-index:3;font-family:var(--mono,monospace);font-size:11px;letter-spacing:.14em;
200  padding:5px 11px;background:rgba(4,12,14,.72);border-left:2px solid #79e7dd;color:#cfeeea;opacity:0;pointer-events:none;white-space:nowrap}
201.sbR3Lbl.orig{top:44px;left:14px;border-left-color:#79e7dd;color:#bfeeea}
202.sbR3Lbl.dst{bottom:14px;right:14px;border-left:0;border-right:2px solid #e8b94d;color:#ffe9a8;text-align:right}
203.sbR3Lbl.prog{bottom:12px;left:50%;transform:translateX(-50%);border-left:0;border-bottom:1px solid rgba(121,231,221,.5);
204  background:rgba(4,12,14,.55);font-size:10px;letter-spacing:.26em;color:#9fdede;padding:4px 14px}
205`;
206  document.head.appendChild(s);
207}
208
209// destColor: フォールバック色 / bodyId: 実惑星のテクスチャ共有に使用
210export function mountRoute3D(container, opts = {}) {
211  unmountRoute3D();
212  injectCss();
213  const w = container.clientWidth || 640, h = container.clientHeight || 300;
214  const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, powerPreference: 'low-power' });
215  renderer.setPixelRatio(Math.min(2, window.devicePixelRatio || 1));
216  renderer.setSize(w, h);
217  const canvas = renderer.domElement; canvas.className = 'sbRoute3D';
218  container.appendChild(canvas);
219
220  const scene = new THREE.Scene();
221  scene.fog = new THREE.FogExp2(0x02060a, 0.0012);
222  const cam = new THREE.PerspectiveCamera(46, w / h, 0.1, 5000);
223  scene.add(new THREE.AmbientLight(0x46586a, 1.15));
224  const key = new THREE.DirectionalLight(0xfff2dd, 1.9); key.position.set(-30, 22, 40); scene.add(key);
225  const rim = new THREE.DirectionalLight(0x79e7dd, .35); rim.position.set(6, -4, -8); scene.add(rim);
226  scene.add(starfield());
227  scene.add(chartGrid());
228  // 太陽(遠景の加算スプライト)
229  const sunTex = (() => {
230    const cv = document.createElement('canvas'); cv.width = cv.height = 128;
231    const ctx = cv.getContext('2d');
232    const g = ctx.createRadialGradient(64, 64, 4, 64, 64, 64);
233    g.addColorStop(0, 'rgba(255,246,224,1)'); g.addColorStop(.25, 'rgba(255,224,160,.85)'); g.addColorStop(1, 'rgba(255,200,120,0)');
234    ctx.fillStyle = g; ctx.fillRect(0, 0, 128, 128);
235    return new THREE.CanvasTexture(cv);
236  })();
237  const sun = new THREE.Sprite(new THREE.SpriteMaterial({ map: sunTex, transparent: true, opacity: .95, depthWrite: false }));
238  sun.scale.set(60, 60, 1); sun.position.set(-260, 90, -160); scene.add(sun);
239
240  const isReturn = opts.mode === 'return';
241  // depart: コロニー(左)→天体(右) / return: 天体(左)→コロニー(右)
242  const originPos = new THREE.Vector3(-46, 0, 0);
243  const destPos = new THREE.Vector3(48, 8, -14);
244  const colonyGrp = colony();
245  const planetGrp = planet(opts.bodyId, opts.destColor != null ? opts.destColor : 0x4a7fbf);
246  const org = isReturn ? planetGrp : colonyGrp;
247  const dst = isReturn ? colonyGrp : planetGrp;
248  org.position.copy(originPos); scene.add(org);
249  dst.position.copy(destPos); scene.add(dst);
250  const marker = destMarker(); marker.position.copy(destPos); scene.add(marker);   // リングは天体周回軌道のまま
251  const shipType = opts.shipType || (gameShip && gameShip.type) || 'hayabusa';
252  const shipModel = realShip(shipType); scene.add(shipModel);
253
254  // 航路: ベース線 + 発光ダッシュ(オフセットアニメ) + 中間ダイヤ
255  const mid = new THREE.Vector3(2, 30, 26);
256  const curve = new THREE.QuadraticBezierCurve3(originPos.clone(), mid, destPos.clone());
257  // スタンドオフ: 惑星(半径14+大気)の手前・コロニー全長の外で航路を打ち切る(天体に刺さらない)
258  const clearOrg = isReturn ? 21 : 13.5, clearDst = isReturn ? 13.5 : 21;
259  let tStart = 0, tEnd = 1;
260  for (let t = 0; t <= 0.5; t += 0.004) { if (curve.getPoint(t).distanceTo(originPos) >= clearOrg) { tStart = t; break; } }
261  for (let t = 1; t >= 0.5; t -= 0.004) { if (curve.getPoint(t).distanceTo(destPos) >= clearDst) { tEnd = t; break; } }
262  const RT = (u) => curve.getPoint(tStart + (tEnd - tStart) * u);
263  const pts = Array.from({ length: 97 }, (_, i) => RT(i / 96));
264  scene.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints(pts),
265    new THREE.LineBasicMaterial({ color: 0x79e7dd, transparent: true, opacity: .3 })));
266  const dashGeo = new THREE.BufferGeometry().setFromPoints(pts);
267  const dashMat = new THREE.LineDashedMaterial({ color: 0xbdfff5, transparent: true, opacity: .95, dashSize: 2.4, gapSize: 3.2 });
268  const dash = new THREE.Line(dashGeo, dashMat);
269  dash.computeLineDistances(); scene.add(dash);
270  for (const t of [.25, .5, .75]) {
271    const d = new THREE.Mesh(new THREE.OctahedronGeometry(.8),
272      new THREE.MeshBasicMaterial({ color: 0x79e7dd, transparent: true, opacity: .8, wireframe: true }));
273    d.position.copy(RT(t)); scene.add(d);
274  }
275  // エンジントレイル(リボン)
276  const TRAIL_N = 26;
277  const trailPts = Array.from({ length: TRAIL_N }, () => RT(0).clone());
278  const trailGeo = new THREE.BufferGeometry().setFromPoints(trailPts);
279  const trail = new THREE.Line(trailGeo, new THREE.LineBasicMaterial({ color: 0x8fe6ff, transparent: true, opacity: .75, blending: THREE.AdditiveBlending }));
280  scene.add(trail);
281
282  const labels = [
283    label(container, 'orig', `出発 / ${opts.origin || '母港'}`),
284    label(container, 'dst', `到着 / ${opts.dest || '目標'}${opts.wpName ? ' 「' + opts.wpName + '」' : ''}`),
285    label(container, 'prog', 'ROUTE ▸ 0%'),
286  ];
287
288  const camTarget = new THREE.Vector3().copy(originPos);
289  const st = { shipT: 0, following: false, t: 0 };
290  shipModel.position.copy(RT(0));
291  marker.userData.dia.position.copy(RT(1).clone().sub(destPos));   // ダイヤは接近点(表面手前)に浮かせる
292  cam.position.set(originPos.x - 6, 7, 24);
293  cam.lookAt(camTarget);
294
295  R = { renderer, canvas, scene, cam, raf: 0, tl: null, labels, onResize: null };
296  R.onResize = () => {
297    const nw = container.clientWidth || w, nh = container.clientHeight || h;
298    renderer.setSize(nw, nh); cam.aspect = nw / nh; cam.updateProjectionMatrix();
299  };
300  window.addEventListener('resize', R.onResize);
301
302  const setLbl = (el, on) => { if (gsapOk()) gsap.to(el, { opacity: on ? 1 : 0, duration: .5 }); else el.style.opacity = on ? 1 : 0; };
303  if (gsapOk()) {
304    // ★上手く飛んでいた原版のカメラ演出(クローズアップ→プルバック→航路追従→到着旋回)。機体だけ実機モデルに換装。
305    const tl = gsap.timeline({ onComplete: () => { R && (st.following = false); } });
306    R.tl = tl;
307    // 1) 出発点のクローズアップ
308    tl.call(() => { setLbl(labels[0], true); setLbl(labels[2], true); }, null, .2);
309    tl.to(cam.position, { x: originPos.x + 10, y: 5, z: 17, duration: 1.25, ease: 'power1.inOut' }, 0);
310    // 2) 急速プルバック(星図の高度へ)
311    tl.to(cam.position, { x: 0, y: 58, z: 88, duration: 1.0, ease: 'power3.in' }, 1.3);
312    tl.to(camTarget, { x: 4, y: 4, z: 4, duration: 1.0, ease: 'power2.out' }, 1.3);
313    tl.call(() => setLbl(labels[0], false), null, 2.1);
314    // 3) 航路追従(機体を斜め後方から=背景が流れて飛んでいるのが分かる)
315    tl.call(() => { st.following = true; }, null, 2.3);
316    tl.to(st, { shipT: 1, duration: 4.4, ease: 'power1.inOut' }, 2.3);
317    // 4) 目標旋回アプローチ
318    tl.call(() => { st.following = false; }, null, 6.7);
319    tl.call(() => setLbl(labels[1], true), null, 6.8);
320    tl.to(cam.position, { x: destPos.x + 20, y: destPos.y + 9, z: destPos.z + 30, duration: 1.8, ease: 'power2.out' }, 6.7);
321    tl.to(camTarget, { x: destPos.x, y: destPos.y, z: destPos.z, duration: 1.8, ease: 'power2.out' }, 6.7);
322  } else {
323    st.shipT = 1; shipModel.position.copy(RT(1)); labels[1].style.opacity = 1;
324    cam.position.set(destPos.x + 20, destPos.y + 10, destPos.z + 40); cam.lookAt(destPos);
325  }
326
327  const tmpAhead = new THREE.Vector3(), tmpBack = new THREE.Vector3(), tmpFwd = new THREE.Vector3(), tmpDesired = new THREE.Vector3();
328  const render = () => {
329    if (!R) return;
330    R.raf = requestAnimationFrame(render);
331    st.t += 1 / 60;
332    colonyGrp.rotation.x += 0.004;                      // 双胴の回転(人工重力)
333    planetGrp.rotation.y += 0.0014;
334    if (planetGrp.userData.clouds) planetGrp.userData.clouds.rotation.y += 0.0022;
335    marker.userData.dia.rotation.y += 0.02;
336    const pulse = 1 + Math.sin(st.t * 2.6) * .04;
337    marker.userData.ring.scale.set(pulse, pulse, 1);
338    dashMat.dashSize = 2.4; dashMat.gapSize = 3.2;
339    dash.material.opacity = .75 + Math.sin(st.t * 3.1) * .2;
340    dashGeo.attributes.position.needsUpdate = false;
341    const p = RT(st.shipT); shipModel.position.copy(p);
342    if (st.t < 2.0) shipModel.userData.renorm();   // 非同期GLB到着に追従
343    tmpAhead.copy(RT(Math.min(1, st.shipT + 0.015)));
344    shipModel.lookAt(tmpAhead);
345    // トレイル更新
346    trailPts.pop(); trailPts.unshift(p.clone());
347    trailGeo.setFromPoints(trailPts);
348    // 進捗表示
349    if (labels[2]) labels[2].textContent = `ROUTE ▸ ${Math.round(st.shipT * 100)}%`;
350    if (st.following) {
351      tmpBack.copy(RT(Math.max(0, st.shipT - 0.05)));
352      tmpFwd.subVectors(p, tmpBack).normalize();
353      tmpDesired.set(p.x - tmpFwd.x * 18 + 5, p.y + 9, p.z - tmpFwd.z * 18 + 11);
354      cam.position.lerp(tmpDesired, 0.12);
355      cam.lookAt(tmpAhead);
356    } else {
357      cam.lookAt(camTarget);
358    }
359    renderer.render(scene, cam);
360  };
361  render();
362  return R;
363}
364
365
366// ============================================================
367// 発進前点検カード用: 実機モデルを小窓で回転表示(画像スプライト廃止)
368// ============================================================
369let SR = null;
370export function unmountShip3D() {
371  if (!SR) return;
372  cancelAnimationFrame(SR.raf);
373  try { SR.renderer.dispose(); SR.renderer.forceContextLoss?.(); } catch { }
374  try { SR.canvas.remove(); } catch { }
375  SR = null;
376}
377export function mountShip3D(container, type) {
378  unmountShip3D();
379  const w = container.clientWidth || 360, h = container.clientHeight || 200;
380  const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, powerPreference: 'low-power' });
381  renderer.setPixelRatio(Math.min(2, window.devicePixelRatio || 1));
382  renderer.setSize(w, h);
383  const canvas = renderer.domElement; canvas.style.cssText = 'position:absolute;inset:0;width:100%;height:100%';
384  container.appendChild(canvas);
385  const scene = new THREE.Scene();
386  const cam = new THREE.PerspectiveCamera(40, w / h, 0.1, 1000);
387  scene.add(new THREE.AmbientLight(0x64788c, 1.2));
388  const key = new THREE.DirectionalLight(0xfff2dd, 2.3); key.position.set(-4, 5, 6); scene.add(key);
389  const rim = new THREE.DirectionalLight(0x79e7dd, .85); rim.position.set(6, -2, -4); scene.add(rim);
390  const holder = realShip(type); scene.add(holder);   // 実機モデル(全長6ユニット正規化)
391  cam.position.set(5, 2.6, 9.6); cam.lookAt(0, 0, 0);   // 寄せて機体を大きく
392  SR = { renderer, canvas, scene, cam, holder, raf: 0, t: 0 };
393  const onResize = () => {
394    const nw = container.clientWidth || w, nh = container.clientHeight || h;
395    renderer.setSize(nw, nh); cam.aspect = nw / nh; cam.updateProjectionMatrix();
396  };
397  SR.onResize = onResize; window.addEventListener('resize', onResize);
398  const render = () => {
399    if (!SR) return;
400    SR.raf = requestAnimationFrame(render);
401    SR.t += 1 / 60;
402    holder.rotation.y = SR.t * .5;               // ゆっくり回転
403    if (SR.t < 2.2) holder.userData.renorm();    // 非同期GLB到着に追従
404    renderer.render(scene, cam);
405  };
406  render();
407}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.