@johnmorrisdotca/uchusen-rockets 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/NOTICE.md +2 -0
  3. package/README.md +80 -5
  4. package/dist/assets/droneship-asog-landed.glb +0 -0
  5. package/dist/assets/droneship-asog.glb +0 -0
  6. package/dist/assets/droneship-jrti-landed.glb +0 -0
  7. package/dist/assets/droneship-jrti.glb +0 -0
  8. package/dist/assets/droneship-ocisly-landed.glb +0 -0
  9. package/dist/assets/droneship-ocisly.glb +0 -0
  10. package/dist/assets/falcon-9-block-5-dragon-2-first-stage.glb +0 -0
  11. package/dist/assets/falcon-9-block-5-dragon-2.glb +0 -0
  12. package/dist/assets/falcon-9-block-5-first-stage.glb +0 -0
  13. package/dist/assets/falcon-9-block-5.glb +0 -0
  14. package/dist/assets/falcon-9-ft-first-stage.glb +0 -0
  15. package/dist/assets/falcon-9-ft.glb +0 -0
  16. package/dist/assets/falcon-9-v1.0-dragon-apart.painted.svg +1 -0
  17. package/dist/assets/falcon-9-v1.0-dragon-apart.svg +1 -0
  18. package/dist/assets/falcon-9-v1.0-dragon-apart.thick.svg +1 -0
  19. package/dist/assets/falcon-9-v1.0-dragon-capsule.glb +0 -0
  20. package/dist/assets/falcon-9-v1.0-dragon-capsule.painted.svg +1 -0
  21. package/dist/assets/falcon-9-v1.0-dragon-capsule.svg +1 -0
  22. package/dist/assets/falcon-9-v1.0-dragon-capsule.thick.svg +1 -0
  23. package/dist/assets/falcon-9-v1.0-dragon-first-stage.glb +0 -0
  24. package/dist/assets/falcon-9-v1.0-dragon-first-stage.painted.svg +1 -0
  25. package/dist/assets/falcon-9-v1.0-dragon-first-stage.svg +1 -0
  26. package/dist/assets/falcon-9-v1.0-dragon-first-stage.thick.svg +1 -0
  27. package/dist/assets/falcon-9-v1.0-dragon-second-stage.glb +0 -0
  28. package/dist/assets/falcon-9-v1.0-dragon-second-stage.painted.svg +1 -0
  29. package/dist/assets/falcon-9-v1.0-dragon-second-stage.svg +1 -0
  30. package/dist/assets/falcon-9-v1.0-dragon-second-stage.thick.svg +1 -0
  31. package/dist/assets/falcon-9-v1.0-dragon-trunk.glb +0 -0
  32. package/dist/assets/falcon-9-v1.0-dragon-trunk.painted.svg +1 -0
  33. package/dist/assets/falcon-9-v1.0-dragon-trunk.svg +1 -0
  34. package/dist/assets/falcon-9-v1.0-dragon-trunk.thick.svg +1 -0
  35. package/dist/assets/falcon-9-v1.0-dragon.glb +0 -0
  36. package/dist/assets/falcon-9-v1.0-dragon.painted.svg +1 -0
  37. package/dist/assets/falcon-9-v1.0-dragon.svg +1 -0
  38. package/dist/assets/falcon-9-v1.0-dragon.thick.svg +1 -0
  39. package/dist/assets/falcon-9-v1.1-first-stage.glb +0 -0
  40. package/dist/assets/falcon-9-v1.1.glb +0 -0
  41. package/dist/assets/falcon-heavy-falcon-heavy-centre.glb +0 -0
  42. package/dist/assets/falcon-heavy-falcon-heavy-side-left.glb +0 -0
  43. package/dist/assets/falcon-heavy-falcon-heavy-side-right.glb +0 -0
  44. package/dist/assets/falcon-heavy-falcon-heavy.glb +0 -0
  45. package/dist/assets/landing-zone-lz-1-landed.glb +0 -0
  46. package/dist/assets/landing-zone-lz-1.glb +0 -0
  47. package/dist/assets/landing-zone-lz-4-landed.glb +0 -0
  48. package/dist/assets/landing-zone-lz-4.glb +0 -0
  49. package/dist/assets/launch-tower-catch-booster.glb +0 -0
  50. package/dist/assets/launch-tower-catch.glb +0 -0
  51. package/dist/assets/launch-tower-stack-booster.glb +0 -0
  52. package/dist/assets/launch-tower-stack-ship.glb +0 -0
  53. package/dist/assets/launch-tower-stack.glb +0 -0
  54. package/dist/assets/starship-2019.glb +0 -0
  55. package/dist/assets/starship-v1-booster.glb +0 -0
  56. package/dist/assets/starship-v1-flight1-booster.glb +0 -0
  57. package/dist/assets/starship-v1-flight1-ship.glb +0 -0
  58. package/dist/assets/starship-v1-flight1.glb +0 -0
  59. package/dist/assets/starship-v1-ship.glb +0 -0
  60. package/dist/assets/starship-v1.glb +0 -0
  61. package/dist/assets/starship-v2-booster.glb +0 -0
  62. package/dist/assets/starship-v2-ship.glb +0 -0
  63. package/dist/assets/starship-v2.glb +0 -0
  64. package/dist/assets/starship-v3-booster.glb +0 -0
  65. package/dist/assets/starship-v3-ship.glb +0 -0
  66. package/dist/assets/starship-v3.glb +0 -0
  67. package/dist/assets/starship-v4-booster.glb +0 -0
  68. package/dist/assets/starship-v4-ship.glb +0 -0
  69. package/dist/assets/starship-v4.glb +0 -0
  70. package/dist/build/glb.js +15 -7
  71. package/dist/build/index.d.ts +5 -0
  72. package/dist/build/index.js +16 -3
  73. package/dist/data/falcon.js +18 -0
  74. package/dist/data/flights.d.ts +7 -0
  75. package/dist/data/flights.js +204 -13
  76. package/dist/data/propellants.d.ts +59 -0
  77. package/dist/data/propellants.js +43 -0
  78. package/dist/data/sources.d.ts +92 -0
  79. package/dist/data/sources.js +12 -0
  80. package/dist/draw/plume.d.ts +10 -1
  81. package/dist/draw/plume.js +15 -5
  82. package/dist/element/plumes.d.ts +7 -8
  83. package/dist/element/plumes.js +36 -21
  84. package/dist/element/scene.d.ts +6 -1
  85. package/dist/element/scene.js +71 -7
  86. package/dist/element.d.ts +1 -0
  87. package/dist/element.js +18 -6
  88. package/dist/flight.d.ts +2 -0
  89. package/dist/flight.js +8 -1
  90. package/dist/flightbar.d.ts +30 -0
  91. package/dist/flightbar.js +46 -9
  92. package/dist/index.d.ts +3 -0
  93. package/dist/index.js +2 -0
  94. package/dist/model/falcon.js +2 -2
  95. package/dist/model/moves.d.ts +36 -0
  96. package/dist/model/moves.js +70 -0
  97. package/dist/model/pieces.d.ts +31 -0
  98. package/dist/model/pieces.js +51 -9
  99. package/dist/model/registry.js +2 -2
  100. package/dist/model/starship.d.ts +6 -3
  101. package/dist/model/starship.js +12 -7
  102. package/dist/react.d.ts +3 -3
  103. package/package.json +18 -3
@@ -1,7 +1,6 @@
1
+ import type { FlameKind } from "../data/propellants.ts";
1
2
  import { BufferGeometry, Group, PointLight, ShaderMaterial } from "three";
2
3
  import { type Bell } from "../engines.ts";
3
- /** What burns in each engine's flame, as a picture: a fuel's flame is drawn in its own colours. */
4
- export type Fuel = "methane" | "kerosene";
5
4
  /** One part's flames: at most as many as it has bells. Its buffers are made once and written when the engines that burn change. */
6
5
  export declare class PartPlumes {
7
6
  readonly group: Group<import("three").Object3DEventMap>;
@@ -9,23 +8,23 @@ export declare class PartPlumes {
9
8
  private flames;
10
9
  private glows;
11
10
  private key;
12
- constructor(kit: PlumeKit, bells: readonly Bell[]);
11
+ constructor(kit: PlumeKit, bells: readonly Bell[], kind: FlameKind);
13
12
  /** The bells that burn now. Nothing is written when they are the ones that burned last. */
14
13
  light(burning: readonly Bell[]): void;
15
14
  dispose(): void;
16
15
  }
17
- /** One set of everything a flame needs, for one viewer. */
16
+ /** One set of everything a flame needs, for one viewer. The materials of each kind of flame share one program and one set of clocks; only the two colours differ. */
18
17
  export declare class PlumeKit {
19
18
  readonly shape: BufferGeometry<import("three").NormalBufferAttributes, import("three").BufferGeometryEventMap>;
20
19
  readonly quad: BufferGeometry<import("three").NormalBufferAttributes, import("three").BufferGeometryEventMap>;
21
- readonly flame: ShaderMaterial;
22
- readonly glow: ShaderMaterial;
23
20
  /** The hull's own glow in the painted look. Always in the scene, at no strength when no engine burns, so lighting engines never makes the materials build again. */
24
21
  readonly light: PointLight;
25
22
  private uniforms;
26
- constructor();
23
+ private kinds;
24
+ private kind;
25
+ flameFor(kind: FlameKind): ShaderMaterial;
26
+ glowFor(kind: FlameKind): ShaderMaterial;
27
27
  setLook(painted: boolean): void;
28
- setFuel(fuel: Fuel): void;
29
28
  /** 0 near the ground, 1 high up where a flame fans out. */
30
29
  setAir(air: number): void;
31
30
  /** Where the ground stands, for a flame to stop at. */
@@ -1,4 +1,3 @@
1
- // Flames for the 3D view, made here and loaded only with it. One shape, one material and one instanced draw for every engine of a part, however many burn, so lighting and dousing engines changes numbers, never what the graphics card holds. The look of a flame (its shock diamonds, its length, its glow) is a picture, not a measurement.
2
1
  import { AdditiveBlending, BufferGeometry, Color, DoubleSide, DynamicDrawUsage, Float32BufferAttribute, Group, InstancedMesh, Matrix4, PointLight, Quaternion, ShaderMaterial, Vector3 } from "three";
3
2
  import { PLUME_DIAMONDS, plumeLength } from "../engines.js";
4
3
  const STEPS = 28;
@@ -100,9 +99,14 @@ function glowShape() {
100
99
  geometry.setIndex([0, 1, 2, 0, 2, 3]);
101
100
  return geometry;
102
101
  }
103
- const FUEL_COLOURS = {
104
- methane: { hot: "#fff1e6", cool: "#6f7dff" },
102
+ /** What a flame is drawn in: the kind of flame its engine's propellant gives (`src/data/propellants.ts`), and a neutral grey-white where no page read says. A picture, not a measurement. */
103
+ const FLAME_TONES = {
105
104
  kerosene: { hot: "#fff4d2", cool: "#ff7a1a" },
105
+ methane: { hot: "#fff1e6", cool: "#6f7dff" },
106
+ hydrogen: { hot: "#ffffff", cool: "#6fa8ff" },
107
+ solid: { hot: "#fff1c4", cool: "#ff4a12" },
108
+ hypergolic: { hot: "#ffffff", cool: "#b8c4ee" },
109
+ neutral: { hot: "#ffffff", cool: "#9aa3b2" },
106
110
  };
107
111
  const place = new Matrix4();
108
112
  const still = new Quaternion();
@@ -115,11 +119,11 @@ export class PartPlumes {
115
119
  flames;
116
120
  glows;
117
121
  key = "";
118
- constructor(kit, bells) {
122
+ constructor(kit, bells, kind) {
119
123
  this.bells = bells;
120
124
  this.group.name = "plumes";
121
- this.flames = new InstancedMesh(kit.shape, kit.flame, bells.length);
122
- this.glows = new InstancedMesh(kit.quad, kit.glow, bells.length);
125
+ this.flames = new InstancedMesh(kit.shape, kit.flameFor(kind), bells.length);
126
+ this.glows = new InstancedMesh(kit.quad, kit.glowFor(kind), bells.length);
123
127
  for (const mesh of [this.flames, this.glows]) {
124
128
  mesh.instanceMatrix.setUsage(DynamicDrawUsage);
125
129
  mesh.frustumCulled = false;
@@ -149,12 +153,10 @@ export class PartPlumes {
149
153
  this.glows.dispose();
150
154
  }
151
155
  }
152
- /** One set of everything a flame needs, for one viewer. */
156
+ /** One set of everything a flame needs, for one viewer. The materials of each kind of flame share one program and one set of clocks; only the two colours differ. */
153
157
  export class PlumeKit {
154
158
  shape = flameShape();
155
159
  quad = glowShape();
156
- flame;
157
- glow;
158
160
  /** The hull's own glow in the painted look. Always in the scene, at no strength when no engine burns, so lighting engines never makes the materials build again. */
159
161
  light = new PointLight("#ffb56b", 0, 0, 1.6);
160
162
  uniforms = {
@@ -164,21 +166,31 @@ export class PlumeKit {
164
166
  uLine: { value: 1 },
165
167
  uFloor: { value: -1e6 },
166
168
  uGlow: { value: 0.75 },
167
- uHot: { value: new Color(FUEL_COLOURS.kerosene.hot) },
168
- uCool: { value: new Color(FUEL_COLOURS.kerosene.cool) },
169
169
  };
170
- constructor() {
171
- const common = { transparent: true, depthWrite: false, blending: AdditiveBlending, toneMapped: false };
172
- this.flame = new ShaderMaterial({ ...common, vertexShader: FLAME_VERTEX, fragmentShader: FLAME_FRAGMENT, uniforms: this.uniforms, side: DoubleSide });
173
- this.glow = new ShaderMaterial({ ...common, vertexShader: GLOW_VERTEX, fragmentShader: GLOW_FRAGMENT, uniforms: this.uniforms });
170
+ kinds = new Map();
171
+ kind(kind) {
172
+ const known = this.kinds.get(kind);
173
+ if (known)
174
+ return known;
175
+ const tone = FLAME_TONES[kind];
176
+ const uniforms = { ...this.uniforms, uHot: { value: new Color(tone.hot) }, uCool: { value: new Color(tone.cool) } };
177
+ const common = { transparent: true, depthWrite: false, blending: AdditiveBlending, toneMapped: false, uniforms };
178
+ const made = {
179
+ flame: new ShaderMaterial({ ...common, vertexShader: FLAME_VERTEX, fragmentShader: FLAME_FRAGMENT, side: DoubleSide }),
180
+ glow: new ShaderMaterial({ ...common, vertexShader: GLOW_VERTEX, fragmentShader: GLOW_FRAGMENT }),
181
+ };
182
+ this.kinds.set(kind, made);
183
+ return made;
184
+ }
185
+ flameFor(kind) {
186
+ return this.kind(kind).flame;
187
+ }
188
+ glowFor(kind) {
189
+ return this.kind(kind).glow;
174
190
  }
175
191
  setLook(painted) {
176
192
  this.uniforms.uLine.value = painted ? 0 : 1;
177
193
  }
178
- setFuel(fuel) {
179
- this.uniforms.uHot.value.set(FUEL_COLOURS[fuel].hot);
180
- this.uniforms.uCool.value.set(FUEL_COLOURS[fuel].cool);
181
- }
182
194
  /** 0 near the ground, 1 high up where a flame fans out. */
183
195
  setAir(air) {
184
196
  this.uniforms.uAir.value = air;
@@ -200,8 +212,11 @@ export class PlumeKit {
200
212
  dispose() {
201
213
  this.shape.dispose();
202
214
  this.quad.dispose();
203
- this.flame.dispose();
204
- this.glow.dispose();
215
+ for (const { flame, glow } of this.kinds.values()) {
216
+ flame.dispose();
217
+ glow.dispose();
218
+ }
219
+ this.kinds.clear();
205
220
  this.light.dispose();
206
221
  this.light.removeFromParent();
207
222
  }
@@ -9,7 +9,7 @@ export interface ViewerOptions {
9
9
  onLost: () => void;
10
10
  /** Where the licensed models are. */
11
11
  assets: string;
12
- /** Tells the page which painted parts have come in. */
12
+ /** Tells the page which painted parts have come in, or "" when the look is not painted. */
13
13
  onPainted: (state: string) => void;
14
14
  /** Tells the page a timeline moved. */
15
15
  onFlight?: () => void;
@@ -62,6 +62,9 @@ export declare class RocketViewer {
62
62
  private lastDistance;
63
63
  private resize;
64
64
  private disposed;
65
+ private compiling;
66
+ private compiled;
67
+ private settled;
65
68
  constructor(holder: HTMLElement, options: ViewerOptions);
66
69
  private homeAngle;
67
70
  setLabel(label: string): void;
@@ -80,6 +83,8 @@ export declare class RocketViewer {
80
83
  private repeating;
81
84
  /** One of the three looks. The camera does not move. */
82
85
  setLook(look: LookName): void;
86
+ /** Builds the shaders a look needs beside the page's own work, where the browser can build them in parallel (KHR_parallel_shader_compile), and holds the next draw until they are in, so the page keeps answering while a look is first built. The last picture stays up meanwhile. A model that comes in later builds its own. */
87
+ private prepare;
83
88
  /** Which engines burn: at full thrust every one that shows, a count for each part, or none. Nothing is made or freed by this after a part's first flame: only which of its flames are drawn changes. */
84
89
  setEngines(lit: {
85
90
  readonly [slot: string]: number;
@@ -18,6 +18,8 @@ import { GroundKit } from "./ground.js";
18
18
  import { PartPlumes, PlumeKit } from "./plumes.js";
19
19
  const IDLE_STOP_MS = 90_000;
20
20
  const MOVE_MS = 1400;
21
+ /** Longest a draw waits for shaders to build: a browser that never says they are ready still draws. */
22
+ const COMPILE_MS = 4000;
21
23
  const VIEW_MS = 700;
22
24
  const autoRotateOf = (spin) => (spin.seconds <= 0 ? 0 : (60 / spin.seconds) * (spin.way === "cw" ? -1 : 1));
23
25
  const LINE_VERTEX = /* glsl */ `
@@ -77,8 +79,6 @@ void main() {
77
79
  /** The materials of an engine's bell, in this package's own meshes. */
78
80
  const NOZZLE_MATERIALS = new Set(["bell", "bell-inside", "engine-metal"]);
79
81
  const asVec = (v) => [v.x, v.y, v.z];
80
- /** A flame's colours, a picture: Raptor burns methane, Merlin kerosene. */
81
- const fuelOf = (built) => (built.report.parts.some(({ part }) => part.rocket === "starship") ? "methane" : "kerosene");
82
82
  /** The pictures a licensed model's material may hold (the shared sky it reflects is not one of them). */
83
83
  const MODEL_PICTURES = ["map", "normalMap", "roughnessMap", "metalnessMap", "aoMap", "emissiveMap"];
84
84
  export class RocketViewer {
@@ -129,6 +129,9 @@ export class RocketViewer {
129
129
  lastDistance = 0;
130
130
  resize;
131
131
  disposed = false;
132
+ compiling = 0;
133
+ compiled = new Set();
134
+ settled = Promise.resolve();
132
135
  constructor(holder, options) {
133
136
  this.holder = holder;
134
137
  this.options = options;
@@ -242,8 +245,20 @@ export class RocketViewer {
242
245
  to: [0, 0, 0],
243
246
  hideAtEnd: false,
244
247
  plumes: null,
248
+ moves: [],
245
249
  };
246
250
  this.takeMeshes(source, code, view);
251
+ for (const node of source.children.slice(1)) {
252
+ const axis = node.userData.axis;
253
+ if (!axis)
254
+ continue;
255
+ const group = new Group();
256
+ group.name = node.name;
257
+ group.position.copy(node.position);
258
+ code.add(group);
259
+ this.takeMeshes(node, group, view);
260
+ view.moves.push({ name: String(node.userData.group), group, axis: new Vector3(axis[0], axis[1], axis[2]).normalize(), angle: 0 });
261
+ }
247
262
  const place = places.find((p) => p.slot === part.slot);
248
263
  holder.position.set(...(place?.at ?? [0, 0, 0]));
249
264
  holder.visible = place?.shown ?? false;
@@ -252,7 +267,6 @@ export class RocketViewer {
252
267
  }
253
268
  this.addGlow();
254
269
  this.setLook(look);
255
- this.plumes.setFuel(fuelOf(built));
256
270
  this.frameTo(angle ?? this.currentAngle(), false);
257
271
  if (this.clockNow) {
258
272
  this.flightKey = "";
@@ -263,6 +277,7 @@ export class RocketViewer {
263
277
  this.canvas.dataset.parts = this.parts.map((part) => part.slot).join(" ");
264
278
  this.drivenDirty = this.driven !== null;
265
279
  this.wake();
280
+ await this.settled;
266
281
  }
267
282
  /** Moves the parts from the packed nodes into `into`, keeping each mesh's own placement. */
268
283
  takeMeshes(source, into, view) {
@@ -415,9 +430,38 @@ export class RocketViewer {
415
430
  this.canvas.dataset.look = look;
416
431
  this.plumes.setLook(painted);
417
432
  this.relight();
418
- this.reportPainted();
433
+ // "painted" is said when its shaders are built, so a reader who waits for it finds the next frame ready to draw
434
+ if (look === "painted")
435
+ void this.prepare(false).then(() => this.reportPainted());
436
+ else {
437
+ this.reportPainted();
438
+ void this.prepare(false);
439
+ }
419
440
  this.wake();
420
441
  }
442
+ /** Builds the shaders a look needs beside the page's own work, where the browser can build them in parallel (KHR_parallel_shader_compile), and holds the next draw until they are in, so the page keeps answering while a look is first built. The last picture stays up meanwhile. A model that comes in later builds its own. */
443
+ prepare(force) {
444
+ // without the browser's parallel compile (software drawing has none) compileAsync is no different from the first draw's own build, so nothing waits
445
+ if (this.disposed || !this.renderer.extensions.has("KHR_parallel_shader_compile") || (!force && this.compiled.has(this.look)))
446
+ return Promise.resolve();
447
+ this.compiled.add(this.look);
448
+ this.compiling++;
449
+ let timer = 0;
450
+ const late = new Promise((resolve) => {
451
+ timer = window.setTimeout(resolve, COMPILE_MS);
452
+ });
453
+ const built = this.renderer.compileAsync(this.scene, this.camera).then(() => undefined);
454
+ const wait = Promise.race([built, late])
455
+ .catch(() => undefined)
456
+ .finally(() => {
457
+ window.clearTimeout(timer);
458
+ this.compiling--;
459
+ if (!this.disposed)
460
+ this.wake();
461
+ });
462
+ this.settled = Promise.all([this.settled, wait]).then(() => undefined);
463
+ return wait;
464
+ }
421
465
  /** Which engines burn: at full thrust every one that shows, a count for each part, or none. Nothing is made or freed by this after a part's first flame: only which of its flames are drawn changes. */
422
466
  setEngines(lit) {
423
467
  // a flight keeps its own, and what was asked for comes back after it
@@ -455,10 +499,11 @@ export class RocketViewer {
455
499
  for (const view of this.parts) {
456
500
  const count = counts.get(view.slot) ?? 0;
457
501
  if (count > 0 && !view.plumes) {
458
- const bells = bellsOf(this.built.parts.find((part) => part.slot === view.slot)?.pieces ?? []);
502
+ const built = this.built.parts.find((part) => part.slot === view.slot);
503
+ const bells = bellsOf(built?.pieces ?? []);
459
504
  if (bells.length === 0)
460
505
  continue;
461
- view.plumes = new PartPlumes(this.plumes, bells);
506
+ view.plumes = new PartPlumes(this.plumes, bells, built?.flame ?? "neutral");
462
507
  view.holder.add(view.plumes.group);
463
508
  }
464
509
  if (!view.plumes)
@@ -507,7 +552,12 @@ export class RocketViewer {
507
552
  part.holder.position.set(...(place?.at ?? [0, 0, 0]));
508
553
  part.holder.rotation.set(0, 0, 0);
509
554
  part.holder.visible = place?.shown ?? false;
555
+ for (const move of part.moves) {
556
+ move.angle = 0;
557
+ move.group.quaternion.identity();
558
+ }
510
559
  }
560
+ this.canvas.dataset.turned = "";
511
561
  this.plumes.setAir(0);
512
562
  this.plumes.setFloor(-1e6);
513
563
  this.reframe();
@@ -583,10 +633,20 @@ export class RocketViewer {
583
633
  continue;
584
634
  const turn = MathUtils.degToRad(pose.pitch);
585
635
  const middle = (parts.find((one) => one.slot === part.slot)?.height ?? 0) / 2;
636
+ for (const move of part.moves) {
637
+ const angle = pose.moves[move.name] ?? 0;
638
+ if (angle === move.angle)
639
+ continue;
640
+ move.angle = angle;
641
+ move.group.quaternion.setFromAxisAngle(move.axis, MathUtils.degToRad(angle));
642
+ }
586
643
  part.holder.visible = !pose.gone;
587
644
  part.holder.rotation.set(0, 0, turn);
588
645
  part.holder.position.set(place.at[0] + pose.dx + Math.sin(turn) * middle, place.at[1] + pose.dy + middle - Math.cos(turn) * middle, place.at[2]);
589
646
  }
647
+ const turned = this.parts.flatMap((part) => part.moves.filter((move) => move.angle !== 0).map((move) => `${part.slot}:${move.group.name.split("~").slice(1).join("~")}=${Math.round(move.angle)}`)).join(" ");
648
+ if (turned !== this.canvas.dataset.turned)
649
+ this.canvas.dataset.turned = turned;
590
650
  this.plumes.setAir(frame.air);
591
651
  this.plumes.setFloor(frame.groundY);
592
652
  this.ground?.set(frame.groundY, this.options.reducedMotion ? 0 : frame.smoke, performance.now() / 1000);
@@ -611,6 +671,7 @@ export class RocketViewer {
611
671
  reportPainted() {
612
672
  if (this.look !== "painted") {
613
673
  this.canvas.dataset.painted = "";
674
+ this.options.onPainted("");
614
675
  return;
615
676
  }
616
677
  const states = this.parts.map((part) => (part.modelFile ? part.modelState : "code"));
@@ -652,7 +713,7 @@ export class RocketViewer {
652
713
  part.holder.add(gltf.scene);
653
714
  part.modelState = "ready";
654
715
  this.useModel(part, this.look === "painted");
655
- this.reportPainted();
716
+ void this.prepare(true).then(() => this.reportPainted());
656
717
  this.wake();
657
718
  })
658
719
  .catch(() => {
@@ -976,6 +1037,9 @@ export class RocketViewer {
976
1037
  this.frame = 0;
977
1038
  if (!this.visible || document.hidden || this.disposed)
978
1039
  return;
1040
+ // shaders are being built: the next draw waits for them (prepare asks for a frame when they are in)
1041
+ if (this.compiling > 0)
1042
+ return;
979
1043
  this.inTick = true;
980
1044
  this.clock.update(now);
981
1045
  const dt = Math.min(this.clock.getDelta(), 0.1);
package/dist/element.d.ts CHANGED
@@ -185,6 +185,7 @@ export declare class UchusenLineup extends HTMLBase {
185
185
  */
186
186
  export declare function defineUchusenRocket(): void;
187
187
  export { ROCKETS, UchusenFlight };
188
+ export type { FlightWords } from "./flightbar.ts";
188
189
  declare global {
189
190
  interface HTMLElementTagNameMap {
190
191
  "uchusen-rocket": UchusenRocket;
package/dist/element.js CHANGED
@@ -503,11 +503,12 @@ export class UchusenRocket extends HTMLBase {
503
503
  const from = this.driven ? turnedBy(angle, this.driven.yaw, this.driven.tilt) : (this.viewer?.currentAngle() ?? angle);
504
504
  const label = this.getAttribute("label") ?? this.builtNow.report.label;
505
505
  const lit = flying ? (media("(prefers-reduced-motion: reduce)") ? false : flying.burns) : this.engineLights();
506
- const key = `${this.key}|${look}|${show}|${from.az.toFixed(2)}|${from.el.toFixed(2)}|${label}|${flying ? flying.index : ""}|${JSON.stringify(lit)}`;
506
+ const moves = flying ? Object.fromEntries(Object.entries(flying.phase.pose).flatMap(([slot, pose]) => (pose.moves ? [[slot, pose.moves]] : []))) : undefined;
507
+ const key = `${this.key}|${look}|${show}|${from.az.toFixed(2)}|${from.el.toFixed(2)}|${label}|${flying ? flying.index : ""}|${JSON.stringify(lit)}|${JSON.stringify(moves ?? {})}`;
507
508
  let node = this.drawing.firstElementChild;
508
509
  if (key !== this.drawn.key || !node) {
509
510
  const template = document.createElement("template");
510
- template.innerHTML = this.builtNow.svg({ look, show, view: from, engines: lit });
511
+ template.innerHTML = this.builtNow.svg({ look, show, view: from, engines: lit, ...(moves ? { moves } : {}) });
511
512
  node = template.content.firstElementChild;
512
513
  if (!node)
513
514
  return;
@@ -548,9 +549,17 @@ export class UchusenRocket extends HTMLBase {
548
549
  // a new rocket keeps the angle, unless the same change named another view
549
550
  const from = viewName !== this.lastView || this.driven ? angle : this.viewer.currentAngle();
550
551
  this.lastView = viewName;
551
- await this.viewer.load(this.builtNow, show, look, from);
552
- if (this.viewer)
553
- this.lightViewer(this.viewer);
552
+ const viewer = this.viewer;
553
+ await viewer.load(this.builtNow, show, look, from);
554
+ if (this.viewer === viewer && this.builtNow) {
555
+ // a look or part chosen while the rocket loaded
556
+ const now = this.settings(this.builtNow);
557
+ if (now.look !== look)
558
+ viewer.setLook(now.look);
559
+ if (now.show !== show)
560
+ viewer.setShow(now.show);
561
+ this.lightViewer(viewer);
562
+ }
554
563
  this.emit("uchusen-change", { report: this.builtNow.report });
555
564
  }
556
565
  }
@@ -648,7 +657,8 @@ export class UchusenRocket extends HTMLBase {
648
657
  assets: assets ? `${assets.replace(/\/$/, "")}/` : defaultBase(),
649
658
  onPainted: (state) => {
650
659
  this.dataset.painted = state;
651
- this.emit("uchusen-painted", { state });
660
+ if (state)
661
+ this.emit("uchusen-painted", { state });
652
662
  },
653
663
  onFlight: () => this.noteFlight(!this.clock?.playing),
654
664
  });
@@ -666,6 +676,8 @@ export class UchusenRocket extends HTMLBase {
666
676
  if (generation !== this.generation || this.viewer !== made)
667
677
  return;
668
678
  const now = this.settings(this.builtNow ?? built);
679
+ if (now.look !== look)
680
+ made.setLook(now.look);
669
681
  if (now.show !== show)
670
682
  made.setShow(now.show);
671
683
  this.lightViewer(made);
package/dist/flight.d.ts CHANGED
@@ -93,6 +93,8 @@ export interface PoseNow {
93
93
  dy: number;
94
94
  pitch: number;
95
95
  gone: boolean;
96
+ /** Degrees each moving group is turned, by name. */
97
+ moves: Readonly<Record<string, number>>;
96
98
  }
97
99
  /**
98
100
  * Everything the picture needs at one place on the timeline: the phase, how each part stands, which engines burn, how far the ground has fallen, and how much smoke stands on the pad.
package/dist/flight.js CHANGED
@@ -103,7 +103,14 @@ const poseIn = (pose, part, height) => ({
103
103
  dy: pose?.rest ? -part.baseY : (pose?.dy ?? 0) * height,
104
104
  pitch: pose?.pitch ?? 0,
105
105
  gone: pose?.gone ?? false,
106
+ moves: pose?.moves ?? {},
106
107
  });
108
+ const mixMoves = (from, to, t) => {
109
+ const out = {};
110
+ for (const name of new Set([...Object.keys(from), ...Object.keys(to)]))
111
+ out[name] = mix(from[name] ?? 0, to[name] ?? 0, t);
112
+ return out;
113
+ };
107
114
  /**
108
115
  * Everything the picture needs at a place on the timeline, for a vehicle `height` metres tall.
109
116
  *
@@ -129,7 +136,7 @@ export function frameAt(flight, position, parts, height) {
129
136
  for (const part of parts) {
130
137
  const from = poseIn(phase.pose[part.slot], part, height);
131
138
  const to = poseIn(next.pose[part.slot], part, height);
132
- const now = { dx: mix(from.dx, to.dx, t), dy: mix(from.dy, to.dy, t), pitch: mix(from.pitch, to.pitch, t), gone: from.gone };
139
+ const now = { dx: mix(from.dx, to.dx, t), dy: mix(from.dy, to.dy, t), pitch: mix(from.pitch, to.pitch, t), gone: from.gone, moves: mixMoves(from.moves, to.moves, t) };
133
140
  poses[part.slot] = now;
134
141
  if (now.dx !== 0 || now.dy !== 0)
135
142
  separated = true;
@@ -1,4 +1,28 @@
1
1
  declare const HTMLBase: typeof HTMLElement;
2
+ /**
3
+ * The words the flight bar says itself, each of which a page can replace (for a language) with `flight.words = { play: "…" }`. A phase's name, what happens in it and a flight's note are the data's and stay as they are.
4
+ *
5
+ * @example
6
+ * ```ts
7
+ * import type { FlightWords } from "@johnmorrisdotca/uchusen-rockets/element";
8
+ *
9
+ * const words: Partial<FlightWords> = { play: "Start", pause: "Stop" };
10
+ * console.log(Object.keys(words));
11
+ * ```
12
+ */
13
+ export interface FlightWords {
14
+ play: string;
15
+ pause: string;
16
+ again: string;
17
+ back: string;
18
+ next: string;
19
+ leave: string;
20
+ range: string;
21
+ none: string;
22
+ sourced: string;
23
+ reduced: string;
24
+ from: string;
25
+ }
2
26
  /**
3
27
  * The `<uchusen-flight>` element: a play button, a slider and the phase's name for a rocket's flight. Its `for` attribute is the id of the `<uchusen-rocket>` it drives. A variant with no flight says so.
4
28
  *
@@ -16,7 +40,13 @@ export declare class UchusenFlight extends HTMLBase {
16
40
  private rocket;
17
41
  private drawnFor;
18
42
  private chartKey;
43
+ private said;
19
44
  private parts;
45
+ /** The words this bar says, in the page's language. Anything left out stays English. */
46
+ get words(): Partial<FlightWords>;
47
+ set words(words: Partial<FlightWords>);
48
+ private word;
49
+ private relabel;
20
50
  connectedCallback(): void;
21
51
  disconnectedCallback(): void;
22
52
  attributeChangedCallback(): void;
package/dist/flightbar.js CHANGED
@@ -22,6 +22,19 @@ figcaption { font-size: 0.74rem; line-height: 1.25; margin-top: 0.2rem; }
22
22
  a { color: inherit; }
23
23
  `;
24
24
  const sheet = { made: null };
25
+ const WORDS = {
26
+ play: "Play",
27
+ pause: "Pause",
28
+ again: "Play again",
29
+ back: "Back",
30
+ next: "Next",
31
+ leave: "Back to the rocket",
32
+ range: "Where along the flight",
33
+ none: "No flight timeline for this version yet: no page read here gives one.",
34
+ sourced: " The times and engine counts are sourced; how the picture moves between them is illustrative, not to scale. ",
35
+ reduced: "This device asks for reduced motion, so nothing plays and no flame shows: move the slider or step through, and the charts say which engines burn. ",
36
+ from: "From ",
37
+ };
25
38
  /**
26
39
  * The `<uchusen-flight>` element: a play button, a slider and the phase's name for a rocket's flight. Its `for` attribute is the id of the `<uchusen-rocket>` it drives. A variant with no flight says so.
27
40
  *
@@ -39,7 +52,31 @@ export class UchusenFlight extends HTMLBase {
39
52
  rocket = null;
40
53
  drawnFor = "";
41
54
  chartKey = "";
55
+ said = {};
42
56
  parts = null;
57
+ /** The words this bar says, in the page's language. Anything left out stays English. */
58
+ get words() {
59
+ return this.said;
60
+ }
61
+ set words(words) {
62
+ this.said = { ...words };
63
+ this.relabel();
64
+ }
65
+ word(key) {
66
+ return this.said[key] ?? WORDS[key];
67
+ }
68
+ relabel() {
69
+ const parts = this.parts;
70
+ if (!parts)
71
+ return;
72
+ parts.back.textContent = this.word("back");
73
+ parts.next.textContent = this.word("next");
74
+ parts.leave.textContent = this.word("leave");
75
+ parts.range.setAttribute("aria-label", this.word("range"));
76
+ parts.none.textContent = this.word("none");
77
+ this.drawnFor = "";
78
+ this.update();
79
+ }
43
80
  connectedCallback() {
44
81
  if (!this.shadowRoot)
45
82
  this.build();
@@ -87,22 +124,22 @@ export class UchusenFlight extends HTMLBase {
87
124
  node.addEventListener("click", on);
88
125
  return node;
89
126
  };
90
- const play = button("flight-play", "Play", () => {
127
+ const play = button("flight-play", this.word("play"), () => {
91
128
  if (this.rocket?.flightState.playing)
92
129
  this.rocket.pause();
93
130
  else
94
131
  this.rocket?.play();
95
132
  });
96
133
  const position = () => this.rocket?.flightState.position ?? 0;
97
- const back = button("flight-back", "Back", () => this.rocket?.seek(Math.ceil(position()) - 1));
98
- const next = button("flight-next", "Next", () => this.rocket?.seek(Math.floor(position()) + 1));
99
- const leave = button("flight-leave", "Back to the rocket", () => this.rocket?.endFlight());
134
+ const back = button("flight-back", this.word("back"), () => this.rocket?.seek(Math.ceil(position()) - 1));
135
+ const next = button("flight-next", this.word("next"), () => this.rocket?.seek(Math.floor(position()) + 1));
136
+ const leave = button("flight-leave", this.word("leave"), () => this.rocket?.endFlight());
100
137
  const range = make("input", "flight-range");
101
138
  range.type = "range";
102
139
  range.min = "0";
103
140
  range.step = "0.01";
104
141
  range.value = "0";
105
- range.setAttribute("aria-label", "Where along the flight");
142
+ range.setAttribute("aria-label", this.word("range"));
106
143
  range.addEventListener("input", () => this.rocket?.seek(Number(range.value)));
107
144
  const row = document.createElement("div");
108
145
  row.className = "row";
@@ -120,7 +157,7 @@ export class UchusenFlight extends HTMLBase {
120
157
  const body = document.createElement("div");
121
158
  body.setAttribute("part", "flight-body");
122
159
  body.append(row, now, what, charts, about);
123
- const none = make("p", "flight-none", "No flight timeline for this version yet: no page read here gives one.");
160
+ const none = make("p", "flight-none", this.word("none"));
124
161
  none.className = "fine";
125
162
  root.append(body, none);
126
163
  this.parts = { play, back, next, leave, range, now, what, charts, about, none, body };
@@ -133,7 +170,7 @@ export class UchusenFlight extends HTMLBase {
133
170
  link.href = source.url;
134
171
  link.rel = "noopener";
135
172
  link.textContent = `${source.publisher}: ${source.title}`;
136
- this.parts.about.replaceChildren(`${flight.basis} The times and engine counts are sourced; how the picture moves between them is illustrative, not to scale. ${flight.note ?? ""} ${reduced ? "This device asks for reduced motion, so nothing plays and no flame shows: move the slider or step through, and the charts say which engines burn. " : ""}From `, link, ".");
173
+ this.parts.about.replaceChildren(`${flight.basis}${this.word("sourced")}${flight.note ?? ""} ${reduced ? this.word("reduced") : ""}${this.word("from")}`, link, ".");
137
174
  }
138
175
  update = () => {
139
176
  const parts = this.parts;
@@ -160,14 +197,14 @@ export class UchusenFlight extends HTMLBase {
160
197
  this.dataset.flight = flight.id;
161
198
  this.dataset.phase = state.active ? phase.id : "";
162
199
  this.dataset.playing = String(state.playing);
163
- parts.play.textContent = state.playing ? "Pause" : state.ended ? "Play again" : "Play";
200
+ parts.play.textContent = this.word(state.playing ? "pause" : state.ended ? "again" : "play");
164
201
  parts.play.setAttribute("aria-pressed", String(state.playing));
165
202
  parts.back.disabled = state.position <= 0;
166
203
  parts.next.disabled = state.position >= lastPosition(flight);
167
204
  parts.leave.disabled = !state.active;
168
205
  parts.range.value = String(state.position);
169
206
  parts.range.setAttribute("aria-valuetext", `${clockWords(phase.at)}, ${phase.name}`);
170
- parts.now.textContent = `${clockWords(phase.at)}, ${phase.name}${phase.altitude?.value ? `, ${phase.altitude.value}` : ""}`;
207
+ parts.now.textContent = `${clockWords(phase.at)}, ${phase.name}${phase.altitude?.value ? `, ${phase.altitude.value}` : ""}${phase.speed?.value ? `, ${phase.speed.value}` : ""}`;
171
208
  parts.what.textContent = phase.what;
172
209
  const chartKey = `${flight.id}/${index}/${state.active}`;
173
210
  if (chartKey !== this.chartKey) {
package/dist/index.d.ts CHANGED
@@ -6,6 +6,9 @@ export { MAKERS } from "./data/makers.ts";
6
6
  export type { MakerId } from "./data/makers.ts";
7
7
  export { FLIGHT_LOG, flightLogOf } from "./data/flight-log.ts";
8
8
  export type { LoggedFlight } from "./data/flight-log.ts";
9
+ export { moved, movingGroupsOf } from "./model/moves.ts";
10
+ export { PROPELLANTS, propellantOf } from "./data/propellants.ts";
11
+ export type { EnginePropellant, FlameKind } from "./data/propellants.ts";
9
12
  export { SOURCES } from "./data/sources.ts";
10
13
  export type { Source, SourceId } from "./data/sources.ts";
11
14
  export { creditLine, MODEL_CREDITS } from "./data/credits.ts";
package/dist/index.js CHANGED
@@ -3,6 +3,8 @@ export { PLANNED_ROCKETS, ROCKETS, partIdOf, SLOT_NAMES, variantOf } from "./roc
3
3
  export { blank, figureWords } from "./data/types.js";
4
4
  export { MAKERS } from "./data/makers.js";
5
5
  export { FLIGHT_LOG, flightLogOf } from "./data/flight-log.js";
6
+ export { moved, movingGroupsOf } from "./model/moves.js";
7
+ export { PROPELLANTS, propellantOf } from "./data/propellants.js";
6
8
  export { SOURCES } from "./data/sources.js";
7
9
  export { creditLine, MODEL_CREDITS } from "./data/credits.js";
8
10
  export { PART_ENTRIES, partEntry } from "./model/registry.js";
@@ -56,11 +56,11 @@ function firstStage(d, withTop) {
56
56
  if (d.legs)
57
57
  for (const az of [45, 135, 225, 315]) {
58
58
  const frame = radial(deg(az));
59
- pieces.push({ kind: "plate", material: "black", frame, outline: [[r - 0.05, 0.8], [r + 0.5, 0.8], [r + 0.55, 3], [r + 0.15, 10.5], [r - 0.05, 11]], thickness: 1.2 });
59
+ pieces.push({ kind: "plate", material: "black", frame, outline: [[r - 0.05, 0.8], [r + 0.5, 0.8], [r + 0.55, 3], [r + 0.15, 10.5], [r - 0.05, 11]], thickness: 1.2, move: { name: "legs", pivot: pointOf(frame, r + 0.2, 0.8), axis: frame.w } });
60
60
  }
61
61
  if (d.fins)
62
62
  for (const az of [45, 135, 225, 315])
63
- pieces.push(...gridFin(az, tank + 0.1, r, r + 1.4, 1.5, 0.3, false));
63
+ pieces.push(...gridFin(az, tank + 0.1, r, r + 1.4, 1.5, 0.3, false, { name: "grid-fins", about: "tangent" }));
64
64
  return pieces;
65
65
  }
66
66
  function secondStage(d) {