@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.
Files changed (54) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/NOTICE.md +2 -2
  3. package/README.md +1 -1
  4. package/dist/assets/falcon-9-block-5-apart.painted.svg +1 -1
  5. package/dist/assets/falcon-9-block-5-apart.svg +1 -1
  6. package/dist/assets/falcon-9-block-5-apart.thick.svg +1 -1
  7. package/dist/assets/falcon-9-block-5-dragon-2-apart.painted.svg +1 -1
  8. package/dist/assets/falcon-9-block-5-dragon-2-apart.svg +1 -1
  9. package/dist/assets/falcon-9-block-5-dragon-2-apart.thick.svg +1 -1
  10. package/dist/assets/falcon-9-block-5-dragon-2-second-stage.glb +0 -0
  11. package/dist/assets/falcon-9-block-5-dragon-2-second-stage.painted.svg +1 -1
  12. package/dist/assets/falcon-9-block-5-dragon-2-second-stage.svg +1 -1
  13. package/dist/assets/falcon-9-block-5-dragon-2-second-stage.thick.svg +1 -1
  14. package/dist/assets/falcon-9-block-5-dragon-2.glb +0 -0
  15. package/dist/assets/falcon-9-block-5-second-stage.glb +0 -0
  16. package/dist/assets/falcon-9-block-5-second-stage.painted.svg +1 -1
  17. package/dist/assets/falcon-9-block-5-second-stage.svg +1 -1
  18. package/dist/assets/falcon-9-block-5-second-stage.thick.svg +1 -1
  19. package/dist/assets/falcon-9-block-5.glb +0 -0
  20. package/dist/assets/falcon-9-ft-apart.painted.svg +1 -1
  21. package/dist/assets/falcon-9-ft-apart.svg +1 -1
  22. package/dist/assets/falcon-9-ft-apart.thick.svg +1 -1
  23. package/dist/assets/falcon-9-ft-second-stage.glb +0 -0
  24. package/dist/assets/falcon-9-ft-second-stage.painted.svg +1 -1
  25. package/dist/assets/falcon-9-ft-second-stage.svg +1 -1
  26. package/dist/assets/falcon-9-ft-second-stage.thick.svg +1 -1
  27. package/dist/assets/falcon-9-ft.glb +0 -0
  28. package/dist/assets/falcon-9-v1.1-apart.painted.svg +1 -1
  29. package/dist/assets/falcon-9-v1.1-apart.svg +1 -1
  30. package/dist/assets/falcon-9-v1.1-apart.thick.svg +1 -1
  31. package/dist/assets/falcon-9-v1.1-second-stage.glb +0 -0
  32. package/dist/assets/falcon-9-v1.1-second-stage.painted.svg +1 -1
  33. package/dist/assets/falcon-9-v1.1-second-stage.svg +1 -1
  34. package/dist/assets/falcon-9-v1.1-second-stage.thick.svg +1 -1
  35. package/dist/assets/falcon-9-v1.1.glb +0 -0
  36. package/dist/data/falcon.d.ts +3 -1
  37. package/dist/data/falcon.js +2 -0
  38. package/dist/data/sources.d.ts +8 -0
  39. package/dist/data/sources.js +1 -0
  40. package/dist/element/scene.d.ts +17 -2
  41. package/dist/element/scene.js +142 -19
  42. package/dist/element.d.ts +15 -3
  43. package/dist/element.js +112 -20
  44. package/dist/framing.d.ts +10 -0
  45. package/dist/framing.js +4 -0
  46. package/dist/index.d.ts +3 -2
  47. package/dist/index.js +2 -1
  48. package/dist/model/falcon.d.ts +1 -0
  49. package/dist/model/falcon.js +5 -3
  50. package/dist/model/registry.js +1 -1
  51. package/dist/parts.d.ts +2 -1
  52. package/dist/pixels.d.ts +8 -0
  53. package/dist/pixels.js +14 -0
  54. package/package.json +2 -1
@@ -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", () => this.setAngle(this.homeAngle, true));
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
- for (const part of this.parts)
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.code.visible = !painted;
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.disposeTree(this.root);
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
- disposeTree(tree) {
602
- tree.traverse((node) => {
603
- const geometry = node.geometry;
604
- geometry?.dispose();
605
- if (node instanceof LineSegments2)
606
- node.material.dispose();
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.orbit(dA, dP);
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
- if ((ambient && this.controls.autoRotate) || changed || this.pose || this.needsDraw || Math.abs(dA) > 1e-5)
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.camera.updateProjectionMatrix();
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
- for (const clean of this.cleanups.splice(0))
177
- clean();
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 svg = this.builtNow.svg({ look, show, view: from });
254
- const template = document.createElement("template");
255
- template.innerHTML = svg;
256
- const node = template.content.firstElementChild;
257
- if (!node)
258
- return;
259
- node.setAttribute("aria-label", this.getAttribute("label") ?? this.builtNow.report.label);
260
- this.drawing.replaceChildren(node);
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
- this.setState("picture");
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
- if (generation === this.generation)
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.cleanups.push(() => window.removeEventListener("deviceorientation", onTilt));
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.cleanups.push(() => button.remove());
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));