@johnmorrisdotca/uchusen-rockets 0.1.0 → 0.2.1
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/CHANGELOG.md +23 -0
- package/NOTICE.md +2 -2
- package/README.md +1 -1
- package/dist/assets/falcon-9-block-5-apart.painted.svg +1 -1
- package/dist/assets/falcon-9-block-5-apart.svg +1 -1
- package/dist/assets/falcon-9-block-5-apart.thick.svg +1 -1
- package/dist/assets/falcon-9-block-5-dragon-2-apart.painted.svg +1 -1
- package/dist/assets/falcon-9-block-5-dragon-2-apart.svg +1 -1
- package/dist/assets/falcon-9-block-5-dragon-2-apart.thick.svg +1 -1
- package/dist/assets/falcon-9-block-5-dragon-2-second-stage.glb +0 -0
- package/dist/assets/falcon-9-block-5-dragon-2-second-stage.painted.svg +1 -1
- package/dist/assets/falcon-9-block-5-dragon-2-second-stage.svg +1 -1
- package/dist/assets/falcon-9-block-5-dragon-2-second-stage.thick.svg +1 -1
- package/dist/assets/falcon-9-block-5-dragon-2.glb +0 -0
- package/dist/assets/falcon-9-block-5-second-stage.glb +0 -0
- package/dist/assets/falcon-9-block-5-second-stage.painted.svg +1 -1
- package/dist/assets/falcon-9-block-5-second-stage.svg +1 -1
- package/dist/assets/falcon-9-block-5-second-stage.thick.svg +1 -1
- package/dist/assets/falcon-9-block-5.glb +0 -0
- package/dist/assets/falcon-9-ft-apart.painted.svg +1 -1
- package/dist/assets/falcon-9-ft-apart.svg +1 -1
- package/dist/assets/falcon-9-ft-apart.thick.svg +1 -1
- package/dist/assets/falcon-9-ft-second-stage.glb +0 -0
- package/dist/assets/falcon-9-ft-second-stage.painted.svg +1 -1
- package/dist/assets/falcon-9-ft-second-stage.svg +1 -1
- package/dist/assets/falcon-9-ft-second-stage.thick.svg +1 -1
- package/dist/assets/falcon-9-ft.glb +0 -0
- package/dist/assets/falcon-9-v1.1-apart.painted.svg +1 -1
- package/dist/assets/falcon-9-v1.1-apart.svg +1 -1
- package/dist/assets/falcon-9-v1.1-apart.thick.svg +1 -1
- package/dist/assets/falcon-9-v1.1-second-stage.glb +0 -0
- package/dist/assets/falcon-9-v1.1-second-stage.painted.svg +1 -1
- package/dist/assets/falcon-9-v1.1-second-stage.svg +1 -1
- package/dist/assets/falcon-9-v1.1-second-stage.thick.svg +1 -1
- package/dist/assets/falcon-9-v1.1.glb +0 -0
- package/dist/data/falcon.d.ts +3 -1
- package/dist/data/falcon.js +2 -0
- package/dist/data/sources.d.ts +8 -0
- package/dist/data/sources.js +1 -0
- package/dist/element/scene.d.ts +17 -2
- package/dist/element/scene.js +142 -19
- package/dist/element.d.ts +15 -3
- package/dist/element.js +112 -20
- package/dist/framing.d.ts +10 -0
- package/dist/framing.js +4 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +2 -1
- package/dist/model/falcon.d.ts +1 -0
- package/dist/model/falcon.js +5 -3
- package/dist/model/registry.js +1 -1
- package/dist/parts.d.ts +2 -1
- package/dist/pixels.d.ts +8 -0
- package/dist/pixels.js +14 -0
- package/package.json +2 -1
package/dist/element/scene.js
CHANGED
|
@@ -7,8 +7,9 @@ import { LineMaterial } from "three/addons/lines/LineMaterial.js";
|
|
|
7
7
|
import { LineSegments2 } from "three/addons/lines/LineSegments2.js";
|
|
8
8
|
import { LineSegmentsGeometry } from "three/addons/lines/LineSegmentsGeometry.js";
|
|
9
9
|
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
|
|
10
|
-
import { FOV_DEG, frameFor, PICTURE_H } from "../framing.js";
|
|
10
|
+
import { directionOf, FOV_DEG, frameFor, PICTURE_H, turnedBy } from "../framing.js";
|
|
11
11
|
import { LOOKS } from "../looks.js";
|
|
12
|
+
import { pixelRatioFor } from "../pixels.js";
|
|
12
13
|
import { MATERIALS } from "../model/materials.js";
|
|
13
14
|
import { paintGlow, paintSky, paintSteel, paintTiles } from "./paint.js";
|
|
14
15
|
const IDLE_STOP_MS = 90_000;
|
|
@@ -69,7 +70,11 @@ void main() {
|
|
|
69
70
|
gl_FragColor = vec4(color, 1.0);
|
|
70
71
|
#include <colorspace_fragment>
|
|
71
72
|
}`;
|
|
73
|
+
/** The materials of an engine's bell, in this package's own meshes. */
|
|
74
|
+
const NOZZLE_MATERIALS = new Set(["bell", "bell-inside", "engine-metal"]);
|
|
72
75
|
const asVec = (v) => [v.x, v.y, v.z];
|
|
76
|
+
/** The pictures a licensed model's material may hold (the shared sky it reflects is not one of them). */
|
|
77
|
+
const MODEL_PICTURES = ["map", "normalMap", "roughnessMap", "metalnessMap", "aoMap", "emissiveMap"];
|
|
73
78
|
export class RocketViewer {
|
|
74
79
|
canvas;
|
|
75
80
|
frames = 0;
|
|
@@ -99,13 +104,15 @@ export class RocketViewer {
|
|
|
99
104
|
needsDraw = true;
|
|
100
105
|
pose = null;
|
|
101
106
|
tilt = { azimuth: 0, polar: 0, applied: { azimuth: 0, polar: 0 } };
|
|
107
|
+
driven = null;
|
|
108
|
+
drivenDirty = false;
|
|
109
|
+
lastDistance = 0;
|
|
102
110
|
resize;
|
|
103
111
|
disposed = false;
|
|
104
112
|
constructor(holder, options) {
|
|
105
113
|
this.holder = holder;
|
|
106
114
|
this.options = options;
|
|
107
115
|
this.renderer = new WebGLRenderer({ antialias: true, alpha: true, powerPreference: "low-power" });
|
|
108
|
-
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
|
109
116
|
this.renderer.outputColorSpace = SRGBColorSpace;
|
|
110
117
|
this.renderer.toneMapping = ACESFilmicToneMapping;
|
|
111
118
|
this.renderer.toneMappingExposure = 1;
|
|
@@ -162,7 +169,10 @@ export class RocketViewer {
|
|
|
162
169
|
this.canvas.addEventListener("focus", () => (this.controls.enableZoom = true));
|
|
163
170
|
this.canvas.addEventListener("blur", () => (this.controls.enableZoom = false));
|
|
164
171
|
this.canvas.addEventListener("pointerdown", () => this.canvas.focus({ preventScroll: true }));
|
|
165
|
-
this.canvas.addEventListener("dblclick", () =>
|
|
172
|
+
this.canvas.addEventListener("dblclick", () => {
|
|
173
|
+
if (!this.driven)
|
|
174
|
+
this.setAngle(this.homeAngle, true);
|
|
175
|
+
});
|
|
166
176
|
this.canvas.addEventListener("keydown", (event) => this.key(event));
|
|
167
177
|
document.addEventListener("visibilitychange", this.onVisibility);
|
|
168
178
|
this.resize = new ResizeObserver(() => this.fit());
|
|
@@ -180,9 +190,7 @@ export class RocketViewer {
|
|
|
180
190
|
const gltf = await new GLTFLoader().parseAsync(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), "");
|
|
181
191
|
if (this.disposed || generation !== this.generation)
|
|
182
192
|
return;
|
|
183
|
-
|
|
184
|
-
part.holder.removeFromParent();
|
|
185
|
-
this.disposeTree(this.root);
|
|
193
|
+
this.disposeParts();
|
|
186
194
|
this.root.clear();
|
|
187
195
|
this.parts = [];
|
|
188
196
|
this.built = built;
|
|
@@ -202,7 +210,9 @@ export class RocketViewer {
|
|
|
202
210
|
code,
|
|
203
211
|
model: null,
|
|
204
212
|
modelFile: part.record.painted?.file ?? null,
|
|
213
|
+
ownNozzle: Boolean(part.record.painted?.ownNozzle),
|
|
205
214
|
modelState: "none",
|
|
215
|
+
nozzleShown: false,
|
|
206
216
|
faces: [],
|
|
207
217
|
outlines: [],
|
|
208
218
|
feature: [],
|
|
@@ -223,6 +233,7 @@ export class RocketViewer {
|
|
|
223
233
|
this.frameTo(angle ?? this.currentAngle(), false);
|
|
224
234
|
this.canvas.dataset.ready = "true";
|
|
225
235
|
this.canvas.dataset.parts = this.parts.map((part) => part.slot).join(" ");
|
|
236
|
+
this.drivenDirty = this.driven !== null;
|
|
226
237
|
this.wake();
|
|
227
238
|
}
|
|
228
239
|
/** Moves the parts from the packed nodes into `into`, keeping each mesh's own placement. */
|
|
@@ -394,7 +405,7 @@ export class RocketViewer {
|
|
|
394
405
|
return;
|
|
395
406
|
if (part.model) {
|
|
396
407
|
part.model.visible = painted;
|
|
397
|
-
part
|
|
408
|
+
this.showCode(part, painted);
|
|
398
409
|
return;
|
|
399
410
|
}
|
|
400
411
|
part.code.visible = true;
|
|
@@ -430,11 +441,26 @@ export class RocketViewer {
|
|
|
430
441
|
this.reportPainted();
|
|
431
442
|
});
|
|
432
443
|
}
|
|
444
|
+
/** Behind a licensed model this package's code shows nothing, except the nozzle the model lacks, and not while the stage is nested in the one below. */
|
|
445
|
+
showCode(part, painted) {
|
|
446
|
+
const nozzleOnly = painted && part.ownNozzle;
|
|
447
|
+
part.code.visible = !painted || nozzleOnly;
|
|
448
|
+
const nozzle = nozzleOnly && this.show !== "together";
|
|
449
|
+
for (const { mesh, name } of part.faces)
|
|
450
|
+
mesh.visible = !nozzleOnly || (nozzle && NOZZLE_MATERIALS.has(name));
|
|
451
|
+
for (const lines of [...part.feature, ...part.weld])
|
|
452
|
+
lines.visible = !nozzleOnly;
|
|
453
|
+
part.nozzleShown = nozzle;
|
|
454
|
+
this.canvas.dataset.nozzle = this.parts.some((one) => one.nozzleShown) ? "own" : "";
|
|
455
|
+
}
|
|
433
456
|
/** Together, apart, or one part alone: an animated move, or a cut under reduced motion. */
|
|
434
457
|
setShow(show) {
|
|
435
458
|
if (!this.built || show === this.show)
|
|
436
459
|
return;
|
|
437
460
|
this.show = show;
|
|
461
|
+
for (const part of this.parts)
|
|
462
|
+
if (part.model)
|
|
463
|
+
this.showCode(part, this.look === "painted");
|
|
438
464
|
const places = this.built.placesOf(show);
|
|
439
465
|
const fromAngle = this.currentAngle();
|
|
440
466
|
const framing = this.framingFor(fromAngle);
|
|
@@ -541,6 +567,11 @@ export class RocketViewer {
|
|
|
541
567
|
setAngle(angle, animate) {
|
|
542
568
|
if (!this.built)
|
|
543
569
|
return;
|
|
570
|
+
if (this.driven) {
|
|
571
|
+
this.homeAngle = angle;
|
|
572
|
+
this.syncDriven();
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
544
575
|
this.controls.autoRotate = false;
|
|
545
576
|
const framing = this.framingFor(angle);
|
|
546
577
|
if (animate && !this.options.reducedMotion)
|
|
@@ -572,10 +603,65 @@ export class RocketViewer {
|
|
|
572
603
|
}
|
|
573
604
|
setSpin(spin) {
|
|
574
605
|
this.controls.autoRotateSpeed = autoRotateOf(spin);
|
|
575
|
-
this.controls.autoRotate = !this.options.reducedMotion && spin.seconds > 0;
|
|
606
|
+
this.controls.autoRotate = !this.driven && !this.options.reducedMotion && spin.seconds > 0;
|
|
576
607
|
this.canvas.dataset.spin = `${spin.seconds}${spin.way}`;
|
|
577
608
|
this.wake();
|
|
578
609
|
}
|
|
610
|
+
/** Hands the turn, tip, zoom and pan to the page, as the family's view controls report them: this view's own hands (drag, wheel, keys, spin) stand down, and the camera is the chosen view's angle turned and tipped by them. `null` takes them back. */
|
|
611
|
+
drive(choice) {
|
|
612
|
+
const same = choice !== null && this.driven !== null && Object.keys(choice).every((key) => choice[key] === this.driven?.[key]);
|
|
613
|
+
if (same)
|
|
614
|
+
return;
|
|
615
|
+
this.driven = choice;
|
|
616
|
+
this.controls.enabled = choice === null;
|
|
617
|
+
if (choice)
|
|
618
|
+
this.controls.autoRotate = false;
|
|
619
|
+
this.canvas.style.touchAction = choice ? "auto" : "pan-y";
|
|
620
|
+
this.syncDriven();
|
|
621
|
+
}
|
|
622
|
+
/** The camera is put where the choice says at once, seen or not, so a page that asks where it looks from is told the truth; a move between parts moves it by itself. */
|
|
623
|
+
syncDriven() {
|
|
624
|
+
this.drivenDirty = false;
|
|
625
|
+
this.lens();
|
|
626
|
+
if (!this.pose)
|
|
627
|
+
this.applyDriven();
|
|
628
|
+
this.needsDraw = true;
|
|
629
|
+
this.wake();
|
|
630
|
+
}
|
|
631
|
+
drivenAngle() {
|
|
632
|
+
if (!this.driven)
|
|
633
|
+
return null;
|
|
634
|
+
const turned = turnedBy(this.homeAngle, this.driven.yaw, this.driven.tilt);
|
|
635
|
+
const lean = this.tilt.applied;
|
|
636
|
+
return { az: turned.az + MathUtils.radToDeg(lean.azimuth), el: MathUtils.clamp(turned.el - MathUtils.radToDeg(lean.polar), -87, 87) };
|
|
637
|
+
}
|
|
638
|
+
/** The camera stands where the view's angle, turned and tipped, puts it, as far off as the whole rocket needs from the view's own angle, so turning never makes it breathe. */
|
|
639
|
+
applyDriven() {
|
|
640
|
+
const angle = this.drivenAngle();
|
|
641
|
+
if (!angle || !this.built)
|
|
642
|
+
return;
|
|
643
|
+
const framing = this.framingFor(this.homeAngle);
|
|
644
|
+
const direction = directionOf(angle);
|
|
645
|
+
this.camera.position.set(framing.target[0] + direction[0] * framing.distance, framing.target[1] + direction[1] * framing.distance, framing.target[2] + direction[2] * framing.distance);
|
|
646
|
+
this.controls.target.set(...framing.target);
|
|
647
|
+
if (Math.abs(framing.distance - this.lastDistance) > 1e-6) {
|
|
648
|
+
this.lastDistance = framing.distance;
|
|
649
|
+
this.limits(framing.distance);
|
|
650
|
+
}
|
|
651
|
+
this.controls.update();
|
|
652
|
+
}
|
|
653
|
+
/** Zoom is the camera's, and the pan moves the picture by pixels, so the view stays sharp at any zoom. */
|
|
654
|
+
lens() {
|
|
655
|
+
const { zoom = 1, panX = 0, panY = 0 } = this.driven ?? {};
|
|
656
|
+
const w = Math.max(1, this.holder.clientWidth);
|
|
657
|
+
const h = Math.max(1, this.holder.clientHeight);
|
|
658
|
+
this.camera.zoom = zoom;
|
|
659
|
+
if (panX !== 0 || panY !== 0)
|
|
660
|
+
this.camera.setViewOffset(w, h, -panX, -panY, w, h);
|
|
661
|
+
else
|
|
662
|
+
this.camera.clearViewOffset();
|
|
663
|
+
this.camera.updateProjectionMatrix();
|
|
664
|
+
}
|
|
579
665
|
setTilt(azimuthDeg, polarDeg) {
|
|
580
666
|
this.tilt.azimuth = MathUtils.degToRad(MathUtils.clamp(azimuthDeg, -25, 25));
|
|
581
667
|
this.tilt.polar = MathUtils.degToRad(MathUtils.clamp(polarDeg, -12, 12));
|
|
@@ -587,7 +673,9 @@ export class RocketViewer {
|
|
|
587
673
|
document.removeEventListener("visibilitychange", this.onVisibility);
|
|
588
674
|
this.resize.disconnect();
|
|
589
675
|
this.controls.dispose();
|
|
590
|
-
this.
|
|
676
|
+
this.disposeParts();
|
|
677
|
+
this.glow?.geometry.dispose();
|
|
678
|
+
this.glow?.material?.dispose();
|
|
591
679
|
for (const material of this.materialCache.values())
|
|
592
680
|
material.dispose();
|
|
593
681
|
this.outlineMaterial.dispose();
|
|
@@ -598,13 +686,25 @@ export class RocketViewer {
|
|
|
598
686
|
this.renderer.forceContextLoss();
|
|
599
687
|
this.canvas.remove();
|
|
600
688
|
}
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
node
|
|
607
|
-
|
|
689
|
+
/** Lets the graphics card have back what the parts took: geometries, the thick lines' materials and a licensed model's own materials and pictures. */
|
|
690
|
+
disposeParts() {
|
|
691
|
+
for (const part of this.parts) {
|
|
692
|
+
part.holder.traverse((node) => {
|
|
693
|
+
node.geometry?.dispose();
|
|
694
|
+
if (node instanceof LineSegments2)
|
|
695
|
+
node.material.dispose();
|
|
696
|
+
});
|
|
697
|
+
part.model?.traverse((node) => {
|
|
698
|
+
if (!(node instanceof Mesh))
|
|
699
|
+
return;
|
|
700
|
+
for (const material of Array.isArray(node.material) ? node.material : [node.material]) {
|
|
701
|
+
for (const slot of MODEL_PICTURES)
|
|
702
|
+
material[slot]?.dispose();
|
|
703
|
+
material.dispose();
|
|
704
|
+
}
|
|
705
|
+
});
|
|
706
|
+
part.holder.removeFromParent();
|
|
707
|
+
}
|
|
608
708
|
}
|
|
609
709
|
addGlow() {
|
|
610
710
|
if (!this.built || this.glow)
|
|
@@ -654,11 +754,21 @@ export class RocketViewer {
|
|
|
654
754
|
const dA = (t.azimuth - t.applied.azimuth) * 0.12;
|
|
655
755
|
const dP = (t.polar - t.applied.polar) * 0.12;
|
|
656
756
|
if (Math.abs(dA) > 1e-5 || Math.abs(dP) > 1e-5) {
|
|
657
|
-
this.
|
|
757
|
+
if (this.driven)
|
|
758
|
+
this.drivenDirty = true;
|
|
759
|
+
else
|
|
760
|
+
this.orbit(dA, dP);
|
|
658
761
|
t.applied.azimuth += dA;
|
|
659
762
|
t.applied.polar += dP;
|
|
660
763
|
this.needsDraw = true;
|
|
661
764
|
}
|
|
765
|
+
if (this.drivenDirty) {
|
|
766
|
+
this.drivenDirty = false;
|
|
767
|
+
this.lens();
|
|
768
|
+
if (!this.pose)
|
|
769
|
+
this.applyDriven();
|
|
770
|
+
this.needsDraw = true;
|
|
771
|
+
}
|
|
662
772
|
if (this.pose) {
|
|
663
773
|
const { from, to, start, ms, moveParts } = this.pose;
|
|
664
774
|
const k = Math.min(1, (performance.now() - start) / ms);
|
|
@@ -673,6 +783,9 @@ export class RocketViewer {
|
|
|
673
783
|
while (dTheta < -Math.PI)
|
|
674
784
|
dTheta += 2 * Math.PI;
|
|
675
785
|
const s = new Spherical(MathUtils.lerp(a.radius, b.radius, e), MathUtils.lerp(a.phi, b.phi, e), a.theta + dTheta * e);
|
|
786
|
+
const aim = this.drivenAngle();
|
|
787
|
+
if (aim)
|
|
788
|
+
s.setFromVector3(new Vector3(...directionOf(aim)).setLength(s.radius));
|
|
676
789
|
this.camera.position.copy(this.controls.target).add(new Vector3().setFromSpherical(s));
|
|
677
790
|
if (moveParts)
|
|
678
791
|
for (const part of this.parts) {
|
|
@@ -686,6 +799,7 @@ export class RocketViewer {
|
|
|
686
799
|
this.pose = null;
|
|
687
800
|
if (done)
|
|
688
801
|
this.finishParts();
|
|
802
|
+
this.drivenDirty = this.driven !== null;
|
|
689
803
|
}
|
|
690
804
|
}
|
|
691
805
|
if (!ambient)
|
|
@@ -701,10 +815,15 @@ export class RocketViewer {
|
|
|
701
815
|
this.canvas.dataset.azimuth = angle.az.toFixed(2);
|
|
702
816
|
this.canvas.dataset.elevation = angle.el.toFixed(2);
|
|
703
817
|
this.canvas.dataset.moving = this.pose ? "true" : "false";
|
|
818
|
+
if (this.driven) {
|
|
819
|
+
this.canvas.dataset.zoom = this.driven.zoom.toFixed(2);
|
|
820
|
+
this.canvas.dataset.pan = `${Math.round(this.driven.panX)},${Math.round(this.driven.panY)}`;
|
|
821
|
+
}
|
|
704
822
|
if (this.glow)
|
|
705
823
|
this.glow.visible = this.look !== "painted" && this.camera.position.y > 0;
|
|
706
824
|
}
|
|
707
|
-
|
|
825
|
+
// the controls' own change event may already have asked for the next frame (wake): one frame is asked for, never two, or each frame would double the next
|
|
826
|
+
if (!this.frame && ((ambient && this.controls.autoRotate) || changed || this.pose || this.needsDraw || Math.abs(dA) > 1e-5))
|
|
708
827
|
this.frame = requestAnimationFrame(this.tick);
|
|
709
828
|
};
|
|
710
829
|
syncSizes() {
|
|
@@ -714,9 +833,11 @@ export class RocketViewer {
|
|
|
714
833
|
fit() {
|
|
715
834
|
const w = Math.max(1, this.holder.clientWidth);
|
|
716
835
|
const h = Math.max(1, this.holder.clientHeight);
|
|
836
|
+
this.renderer.setPixelRatio(pixelRatioFor(w, h, window.devicePixelRatio));
|
|
717
837
|
this.renderer.setSize(w, h, false);
|
|
838
|
+
this.outlineMaterial.uniforms.width.value = LOOKS[this.look].outlinePx * this.renderer.getPixelRatio();
|
|
718
839
|
this.camera.aspect = w / h;
|
|
719
|
-
this.
|
|
840
|
+
this.lens();
|
|
720
841
|
this.syncSizes();
|
|
721
842
|
if (this.built && !this.pose)
|
|
722
843
|
this.applyFraming(this.framingFor(this.currentAngle()));
|
|
@@ -730,6 +851,8 @@ export class RocketViewer {
|
|
|
730
851
|
this.camera.position.copy(this.controls.target).add(new Vector3().setFromSpherical(s));
|
|
731
852
|
}
|
|
732
853
|
key(event) {
|
|
854
|
+
if (this.driven)
|
|
855
|
+
return;
|
|
733
856
|
const step = MathUtils.degToRad(15);
|
|
734
857
|
const moves = {
|
|
735
858
|
ArrowLeft: () => this.orbit(-step, 0),
|
package/dist/element.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Description, FitReport } from "./fit.ts";
|
|
2
|
-
import { type Angle } from "./framing.ts";
|
|
2
|
+
import { type Angle, type ViewChoice } from "./framing.ts";
|
|
3
3
|
import { ROCKETS } from "./rockets.ts";
|
|
4
4
|
export type RocketState = "picture" | "loading" | "3d" | "fallback" | "error";
|
|
5
5
|
declare const HTMLBase: typeof HTMLElement;
|
|
@@ -13,7 +13,6 @@ export declare class UchusenRocket extends HTMLBase {
|
|
|
13
13
|
private drawing;
|
|
14
14
|
private viewer;
|
|
15
15
|
private watch;
|
|
16
|
-
private cleanups;
|
|
17
16
|
private releaseTimer;
|
|
18
17
|
private revealTimer;
|
|
19
18
|
private generation;
|
|
@@ -23,6 +22,13 @@ export declare class UchusenRocket extends HTMLBase {
|
|
|
23
22
|
private shownAt;
|
|
24
23
|
private starting;
|
|
25
24
|
private key;
|
|
25
|
+
private driven;
|
|
26
|
+
private placeFrame;
|
|
27
|
+
private placedAt;
|
|
28
|
+
private stale;
|
|
29
|
+
private onScreen;
|
|
30
|
+
private drawn;
|
|
31
|
+
private tiltCleanups;
|
|
26
32
|
/** What is showing now. */
|
|
27
33
|
get state(): RocketState;
|
|
28
34
|
/** What the fit check said about what is showing: its label, parts and credits. */
|
|
@@ -32,6 +38,11 @@ export declare class UchusenRocket extends HTMLBase {
|
|
|
32
38
|
set description(value: Description | null);
|
|
33
39
|
/** Where the camera is looking from, while there is a 3D view. */
|
|
34
40
|
get angle(): Angle | null;
|
|
41
|
+
/** Lets a page choose the turn, tip, zoom and pan, as the family's `<kazoku-view-controls>` reports them in `kazoku-viewchange` (`element.drive(event.detail)`): the element's own drag, wheel, keys and spin stand down, and the 3D view and the drawing both follow. `null` takes them back. */
|
|
42
|
+
drive(choice: Partial<ViewChoice> | null): void;
|
|
43
|
+
/** The drawing, turned and moved to the chosen view. The 3D view moves itself, so only the drawing needs it, and only until the 3D view shows. It is drawn at most about 20 times a second and never while out of sight: it is made again when it comes back. */
|
|
44
|
+
private place;
|
|
45
|
+
private seen;
|
|
35
46
|
connectedCallback(): void;
|
|
36
47
|
disconnectedCallback(): void;
|
|
37
48
|
attributeChangedCallback(name: string, before: string | null, after: string | null): void;
|
|
@@ -45,13 +56,14 @@ export declare class UchusenRocket extends HTMLBase {
|
|
|
45
56
|
private settings;
|
|
46
57
|
private update;
|
|
47
58
|
private renderShell;
|
|
48
|
-
/** The drawing for what the attributes say now. It is made from code, so it needs no file. */
|
|
59
|
+
/** The drawing for what the attributes say now. It is made from code, so it needs no file. A zoom or a move only changes which part of it the box shows (its viewBox), so the drawing is as sharp as the box and never grows past it. */
|
|
49
60
|
private redraw;
|
|
50
61
|
private mark;
|
|
51
62
|
private applyRocket;
|
|
52
63
|
private lastView;
|
|
53
64
|
private applySettings;
|
|
54
65
|
private startWatching;
|
|
66
|
+
private stopTilt;
|
|
55
67
|
private fallBack;
|
|
56
68
|
private start;
|
|
57
69
|
private startTilt;
|
package/dist/element.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { build, FitError } from "./build/index.js";
|
|
5
5
|
import { lineupSvg } from "./build/lineup.js";
|
|
6
6
|
import { CROSSFADE_MS, REDUCED_FADE_MS, revealDelay } from "./crossfade.js";
|
|
7
|
-
import { cleanView, VIEWS } from "./framing.js";
|
|
7
|
+
import { cleanView, turnedBy, VIEWS } from "./framing.js";
|
|
8
8
|
import { cleanShow } from "./layout.js";
|
|
9
9
|
import { cleanLook } from "./looks.js";
|
|
10
10
|
import { ROCKETS } from "./rockets.js";
|
|
@@ -14,6 +14,8 @@ const HTMLBase = typeof HTMLElement === "undefined" ? class {
|
|
|
14
14
|
/** A view out of sight keeps its WebGL canvas this long before letting it go (a browser allows only a few at a time). */
|
|
15
15
|
const RELEASE_MS = 30_000;
|
|
16
16
|
const DEFAULT_SPIN = { seconds: 180, way: "cw" };
|
|
17
|
+
/** The drawing, followed along a turn, is made again at most this often. */
|
|
18
|
+
const PLACE_MS = 50;
|
|
17
19
|
let assetBase = null;
|
|
18
20
|
/** Where the licensed models are, for every element with no `assets` attribute of its own. Left unset: beside this module, in ./assets/. */
|
|
19
21
|
export function setAssetBase(url) {
|
|
@@ -27,6 +29,7 @@ const CSS = `
|
|
|
27
29
|
.drawing, .drawing svg { display: block; width: 100%; height: 100%; }
|
|
28
30
|
.drawing, .canvas { transition: opacity var(--uchusen-fade, 1500ms) ease-in-out, filter var(--uchusen-fade, 1500ms) ease-in-out; }
|
|
29
31
|
.stage { position: absolute; inset: 0; }
|
|
32
|
+
:host([data-driven]) .picture { overflow: hidden; }
|
|
30
33
|
.canvas { display: block; width: 100%; height: 100%; opacity: 0; filter: blur(3px); cursor: grab; border-radius: inherit; }
|
|
31
34
|
.canvas:active { cursor: grabbing; }
|
|
32
35
|
.canvas:focus-visible { outline: 2px solid currentColor; outline-offset: -2px; }
|
|
@@ -87,7 +90,6 @@ export class UchusenRocket extends HTMLBase {
|
|
|
87
90
|
drawing = null;
|
|
88
91
|
viewer = null;
|
|
89
92
|
watch = null;
|
|
90
|
-
cleanups = [];
|
|
91
93
|
releaseTimer = 0;
|
|
92
94
|
revealTimer = 0;
|
|
93
95
|
generation = 0;
|
|
@@ -97,6 +99,13 @@ export class UchusenRocket extends HTMLBase {
|
|
|
97
99
|
shownAt = 0;
|
|
98
100
|
starting = false;
|
|
99
101
|
key = "";
|
|
102
|
+
driven = null;
|
|
103
|
+
placeFrame = 0;
|
|
104
|
+
placedAt = 0;
|
|
105
|
+
stale = false;
|
|
106
|
+
onScreen = true;
|
|
107
|
+
drawn = { key: "", box: [0, 0, 1, 1] };
|
|
108
|
+
tiltCleanups = [];
|
|
100
109
|
/** What is showing now. */
|
|
101
110
|
get state() {
|
|
102
111
|
return this.stateNow;
|
|
@@ -117,12 +126,62 @@ export class UchusenRocket extends HTMLBase {
|
|
|
117
126
|
get angle() {
|
|
118
127
|
return this.viewer?.currentAngle() ?? null;
|
|
119
128
|
}
|
|
129
|
+
/** Lets a page choose the turn, tip, zoom and pan, as the family's `<kazoku-view-controls>` reports them in `kazoku-viewchange` (`element.drive(event.detail)`): the element's own drag, wheel, keys and spin stand down, and the 3D view and the drawing both follow. `null` takes them back. */
|
|
130
|
+
drive(choice) {
|
|
131
|
+
const before = this.driven;
|
|
132
|
+
if (choice === null) {
|
|
133
|
+
this.driven = null;
|
|
134
|
+
delete this.dataset.driven;
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
const number = (value, fallback, lo, hi) => (typeof value === "number" && Number.isFinite(value) ? Math.min(hi, Math.max(lo, value)) : fallback);
|
|
138
|
+
this.driven = {
|
|
139
|
+
yaw: number(choice.yaw, this.driven?.yaw ?? 0, -3600, 3600),
|
|
140
|
+
tilt: number(choice.tilt, this.driven?.tilt ?? 0, -90, 90),
|
|
141
|
+
zoom: number(choice.zoom, this.driven?.zoom ?? 1, 0.25, 8),
|
|
142
|
+
panX: number(choice.panX, this.driven?.panX ?? 0, -5000, 5000),
|
|
143
|
+
panY: number(choice.panY, this.driven?.panY ?? 0, -5000, 5000),
|
|
144
|
+
};
|
|
145
|
+
this.dataset.driven = "true";
|
|
146
|
+
}
|
|
147
|
+
const now = this.driven;
|
|
148
|
+
if (before && now && Object.keys(now).every((key) => before[key] === now[key]))
|
|
149
|
+
return;
|
|
150
|
+
this.viewer?.drive(this.driven);
|
|
151
|
+
this.place();
|
|
152
|
+
}
|
|
153
|
+
/** The drawing, turned and moved to the chosen view. The 3D view moves itself, so only the drawing needs it, and only until the 3D view shows. It is drawn at most about 20 times a second and never while out of sight: it is made again when it comes back. */
|
|
154
|
+
place() {
|
|
155
|
+
if (this.stateNow === "3d" && this.viewer)
|
|
156
|
+
return;
|
|
157
|
+
if (!this.onScreen || document.hidden) {
|
|
158
|
+
this.stale = true;
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
window.cancelAnimationFrame(this.placeFrame);
|
|
162
|
+
const frame = (time) => {
|
|
163
|
+
if (time - this.placedAt < PLACE_MS) {
|
|
164
|
+
this.placeFrame = window.requestAnimationFrame(frame);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
this.placedAt = time;
|
|
168
|
+
this.stale = false;
|
|
169
|
+
this.redraw();
|
|
170
|
+
};
|
|
171
|
+
this.placeFrame = window.requestAnimationFrame(frame);
|
|
172
|
+
}
|
|
173
|
+
seen = () => {
|
|
174
|
+
if (!document.hidden && this.stale)
|
|
175
|
+
this.place();
|
|
176
|
+
};
|
|
120
177
|
connectedCallback() {
|
|
121
178
|
if (!this.shadowRoot)
|
|
122
179
|
adopt(this.attachShadow({ mode: "open" }), CSS, rocketSheet);
|
|
180
|
+
document.addEventListener("visibilitychange", this.seen);
|
|
123
181
|
this.queue();
|
|
124
182
|
}
|
|
125
183
|
disconnectedCallback() {
|
|
184
|
+
document.removeEventListener("visibilitychange", this.seen);
|
|
126
185
|
this.teardown();
|
|
127
186
|
this.key = "";
|
|
128
187
|
}
|
|
@@ -173,8 +232,8 @@ export class UchusenRocket extends HTMLBase {
|
|
|
173
232
|
this.watch = null;
|
|
174
233
|
window.clearTimeout(this.releaseTimer);
|
|
175
234
|
window.clearTimeout(this.revealTimer);
|
|
176
|
-
|
|
177
|
-
|
|
235
|
+
window.cancelAnimationFrame(this.placeFrame);
|
|
236
|
+
this.stopTilt();
|
|
178
237
|
this.viewer?.dispose();
|
|
179
238
|
this.viewer = null;
|
|
180
239
|
}
|
|
@@ -243,21 +302,35 @@ export class UchusenRocket extends HTMLBase {
|
|
|
243
302
|
this.holder = holder;
|
|
244
303
|
this.startWatching();
|
|
245
304
|
}
|
|
246
|
-
/** The drawing for what the attributes say now. It is made from code, so it needs no file. */
|
|
305
|
+
/** The drawing for what the attributes say now. It is made from code, so it needs no file. A zoom or a move only changes which part of it the box shows (its viewBox), so the drawing is as sharp as the box and never grows past it. */
|
|
247
306
|
redraw() {
|
|
248
307
|
if (!this.builtNow || !this.drawing)
|
|
249
308
|
return;
|
|
250
309
|
const { look, show, angle } = this.settings(this.builtNow);
|
|
251
310
|
// when a 3D view is showing, a drawing of the same angle is ready behind it
|
|
252
|
-
const from = this.viewer?.currentAngle() ?? angle;
|
|
253
|
-
const
|
|
254
|
-
const
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
311
|
+
const from = this.driven ? turnedBy(angle, this.driven.yaw, this.driven.tilt) : (this.viewer?.currentAngle() ?? angle);
|
|
312
|
+
const label = this.getAttribute("label") ?? this.builtNow.report.label;
|
|
313
|
+
const key = `${this.key}|${look}|${show}|${from.az.toFixed(2)}|${from.el.toFixed(2)}|${label}`;
|
|
314
|
+
let node = this.drawing.firstElementChild;
|
|
315
|
+
if (key !== this.drawn.key || !node) {
|
|
316
|
+
const template = document.createElement("template");
|
|
317
|
+
template.innerHTML = this.builtNow.svg({ look, show, view: from });
|
|
318
|
+
node = template.content.firstElementChild;
|
|
319
|
+
if (!node)
|
|
320
|
+
return;
|
|
321
|
+
node.setAttribute("aria-label", label);
|
|
322
|
+
this.drawing.replaceChildren(node);
|
|
323
|
+
const [x = 0, y = 0, w = 1, h = 1] = (node.getAttribute("viewBox") ?? "").split(/[\s,]+/).map(Number);
|
|
324
|
+
this.drawn = { key, box: [x, y, w, h] };
|
|
325
|
+
}
|
|
326
|
+
const { zoom = 1, panX = 0, panY = 0 } = this.driven ?? {};
|
|
327
|
+
const [x, y, w, h] = this.drawn.box;
|
|
328
|
+
// the picture fits the box (meet), so a user unit is `unit` pixels; the zoom is about the box's middle and the move is in pixels
|
|
329
|
+
const unit = Math.min(this.drawing.clientWidth / w, this.drawing.clientHeight / h) || 1;
|
|
330
|
+
const view = zoom === 1 && panX === 0 && panY === 0 ? [x, y, w, h] : [x + w / 2 - w / (2 * zoom) - panX / (unit * zoom), y + h / 2 - h / (2 * zoom) - panY / (unit * zoom), w / zoom, h / zoom];
|
|
331
|
+
node.setAttribute("viewBox", view.map((n) => +n.toFixed(3)).join(" "));
|
|
332
|
+
this.drawing.dataset.zoom = zoom.toFixed(2);
|
|
333
|
+
this.drawing.dataset.pan = `${Math.round(panX)},${Math.round(panY)}`;
|
|
261
334
|
}
|
|
262
335
|
mark() {
|
|
263
336
|
if (!this.builtNow)
|
|
@@ -278,7 +351,7 @@ export class UchusenRocket extends HTMLBase {
|
|
|
278
351
|
const { look, show, viewName, angle } = this.settings(this.builtNow);
|
|
279
352
|
this.viewer.setLabel(this.getAttribute("label") ?? this.builtNow.report.label);
|
|
280
353
|
// a new rocket keeps the angle, unless the same change named another view
|
|
281
|
-
const from = viewName !== this.lastView ? angle : this.viewer.currentAngle();
|
|
354
|
+
const from = viewName !== this.lastView || this.driven ? angle : this.viewer.currentAngle();
|
|
282
355
|
this.lastView = viewName;
|
|
283
356
|
await this.viewer.load(this.builtNow, show, look, from);
|
|
284
357
|
this.emit("uchusen-change", { report: this.builtNow.report });
|
|
@@ -307,10 +380,15 @@ export class UchusenRocket extends HTMLBase {
|
|
|
307
380
|
this.setState(reason === "off" ? "picture" : "fallback", reason);
|
|
308
381
|
if (reason !== "off")
|
|
309
382
|
this.emit("uchusen-fallback", { reason });
|
|
310
|
-
return;
|
|
311
383
|
}
|
|
312
|
-
|
|
384
|
+
else
|
|
385
|
+
this.setState("picture");
|
|
313
386
|
this.watch = new IntersectionObserver(([seen]) => {
|
|
387
|
+
this.onScreen = seen.isIntersecting;
|
|
388
|
+
if (seen.isIntersecting && this.stale)
|
|
389
|
+
this.place();
|
|
390
|
+
if (reason)
|
|
391
|
+
return;
|
|
314
392
|
if (seen.isIntersecting) {
|
|
315
393
|
this.shownAt ||= performance.now();
|
|
316
394
|
window.clearTimeout(this.releaseTimer);
|
|
@@ -325,14 +403,23 @@ export class UchusenRocket extends HTMLBase {
|
|
|
325
403
|
this.releaseTimer = window.setTimeout(() => {
|
|
326
404
|
this.viewer?.dispose();
|
|
327
405
|
this.viewer = null;
|
|
328
|
-
|
|
406
|
+
this.stopTilt();
|
|
407
|
+
if (generation === this.generation) {
|
|
329
408
|
this.setState("picture");
|
|
409
|
+
if (this.driven)
|
|
410
|
+
this.place();
|
|
411
|
+
}
|
|
330
412
|
}, RELEASE_MS);
|
|
331
413
|
}
|
|
332
414
|
}, { rootMargin: "200px 0px" });
|
|
333
415
|
this.watch.observe(this);
|
|
334
416
|
}
|
|
417
|
+
stopTilt() {
|
|
418
|
+
for (const clean of this.tiltCleanups.splice(0))
|
|
419
|
+
clean();
|
|
420
|
+
}
|
|
335
421
|
fallBack(why) {
|
|
422
|
+
this.stopTilt();
|
|
336
423
|
this.viewer?.dispose();
|
|
337
424
|
this.viewer = null;
|
|
338
425
|
this.setState("fallback", why);
|
|
@@ -363,6 +450,8 @@ export class UchusenRocket extends HTMLBase {
|
|
|
363
450
|
},
|
|
364
451
|
});
|
|
365
452
|
this.viewer = made;
|
|
453
|
+
if (this.driven)
|
|
454
|
+
made.drive(this.driven);
|
|
366
455
|
made.setVisible(true);
|
|
367
456
|
const { look, show, viewName, angle } = this.settings(this.builtNow ?? built);
|
|
368
457
|
this.lastView = viewName;
|
|
@@ -390,6 +479,7 @@ export class UchusenRocket extends HTMLBase {
|
|
|
390
479
|
}
|
|
391
480
|
}
|
|
392
481
|
startTilt() {
|
|
482
|
+
this.stopTilt();
|
|
393
483
|
const Orientation = (typeof DeviceOrientationEvent === "undefined" ? undefined : DeviceOrientationEvent);
|
|
394
484
|
const mode = tiltMode({
|
|
395
485
|
reducedMotion: media("(prefers-reduced-motion: reduce)"),
|
|
@@ -400,6 +490,8 @@ export class UchusenRocket extends HTMLBase {
|
|
|
400
490
|
const listen = () => {
|
|
401
491
|
let rest = null;
|
|
402
492
|
const onTilt = (event) => {
|
|
493
|
+
if (!this.onScreen || document.hidden)
|
|
494
|
+
return;
|
|
403
495
|
const reading = { beta: event.beta, gamma: event.gamma };
|
|
404
496
|
rest ??= reading;
|
|
405
497
|
const turn = tiltTurn(reading, rest, window.screen.orientation?.angle ?? 0);
|
|
@@ -407,7 +499,7 @@ export class UchusenRocket extends HTMLBase {
|
|
|
407
499
|
this.viewer?.setTilt(turn.azimuth, turn.polar);
|
|
408
500
|
};
|
|
409
501
|
window.addEventListener("deviceorientation", onTilt);
|
|
410
|
-
this.
|
|
502
|
+
this.tiltCleanups.push(() => window.removeEventListener("deviceorientation", onTilt));
|
|
411
503
|
};
|
|
412
504
|
if (mode === "on")
|
|
413
505
|
listen();
|
|
@@ -428,7 +520,7 @@ export class UchusenRocket extends HTMLBase {
|
|
|
428
520
|
}
|
|
429
521
|
});
|
|
430
522
|
this.picture.appendChild(button);
|
|
431
|
-
this.
|
|
523
|
+
this.tiltCleanups.push(() => button.remove());
|
|
432
524
|
}
|
|
433
525
|
}
|
|
434
526
|
}
|
package/dist/framing.d.ts
CHANGED
|
@@ -15,6 +15,16 @@ export interface Angle {
|
|
|
15
15
|
export declare const VIEWS: Readonly<Record<ViewName, Angle>>;
|
|
16
16
|
export declare const cleanView: (value: unknown) => ViewName;
|
|
17
17
|
export declare const deg: (d: number) => number;
|
|
18
|
+
/** What the family's view controls report: the turn and tip in degrees, the zoom (1 is the whole rocket in the picture) and the pan in pixels. */
|
|
19
|
+
export interface ViewChoice {
|
|
20
|
+
yaw: number;
|
|
21
|
+
tilt: number;
|
|
22
|
+
zoom: number;
|
|
23
|
+
panX: number;
|
|
24
|
+
panY: number;
|
|
25
|
+
}
|
|
26
|
+
/** What the family's view controls choose: the turn and tip, in degrees, from a base angle. Dragging right makes `yaw` larger, which carries the camera the other way round; `tilt` larger tips the top away, which lowers the camera. */
|
|
27
|
+
export declare function turnedBy(base: Angle, yaw: number, tilt: number): Angle;
|
|
18
28
|
/** The unit vector from what is looked at towards the camera. */
|
|
19
29
|
export declare function directionOf({ az, el }: Angle): Vec3;
|
|
20
30
|
export interface Framing {
|
package/dist/framing.js
CHANGED
|
@@ -15,6 +15,10 @@ export const VIEWS = {
|
|
|
15
15
|
};
|
|
16
16
|
export const cleanView = (value) => (VIEW_NAMES.includes(value) ? value : DEFAULT_VIEW);
|
|
17
17
|
export const deg = (d) => (d * Math.PI) / 180;
|
|
18
|
+
/** What the family's view controls choose: the turn and tip, in degrees, from a base angle. Dragging right makes `yaw` larger, which carries the camera the other way round; `tilt` larger tips the top away, which lowers the camera. */
|
|
19
|
+
export function turnedBy(base, yaw, tilt) {
|
|
20
|
+
return { az: base.az - yaw, el: Math.min(87, Math.max(-87, base.el - tilt)) };
|
|
21
|
+
}
|
|
18
22
|
/** The unit vector from what is looked at towards the camera. */
|
|
19
23
|
export function directionOf({ az, el }) {
|
|
20
24
|
const c = Math.cos(deg(el));
|