@johnmorrisdotca/uchusen-rockets 0.2.0 → 0.2.2

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 CHANGED
@@ -2,6 +2,23 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.2.2]
6
+
7
+ - Demo: kazoku-ui 0.7 (a dev dependency, used only by the demo). The demo's strict Content-Security-Policy is now plain `style-src 'self'`: the controls adopt their styles as a constructable stylesheet since kazoku-ui 0.3.1, so the build no longer writes the controls' style text's hash into the page (the `%KAZOKU_STYLE%` step of `scripts/site.ts` is gone). The introduction's own pause (a one-minute timer and a scroll observer in `demo/demo.ts`) is replaced by the controls' `autoplay-for="60"` and their idle-out-of-sight, from kazoku-ui 0.7.1; `docs/element.md` says so.
8
+ - Repository: `release.yml` calls kazoku-ui's shared `reusable-release.yml` (pinned to v0.7.3): the same checks, tag, GitHub release with the tarball and npm publish by trusted publishing; the trusted publisher entry stays `release.yml`. The GitHub release's notes are generated by GitHub until the shared workflow reads `scripts/release-notes.ts`. `AGENTS.md` is written from kazoku-ui's template.
9
+
10
+ ## [0.2.1]
11
+
12
+ - Fixed: the demo could hang the screen while turning. When the 3D view turned by itself, or glided to a stop after a drag, the controls' change event asked for the next frame while the view's own loop did too, so each frame started two and the number of draws doubled every frame: thousands of draws a second, enough to starve the graphics card (measured in a real browser on a real graphics card: 12,308 draws in 3 seconds, against 181 now at 60 frames a second). One frame is asked for, once.
13
+ - Fixed: every change of variant, look or parts left the old shapes in the graphics card (about 60 buffers each), and a painted model's pictures with them. They are given back now, and the glow's too.
14
+ - Fixed: the canvas had no size limit but a ratio of 2. It is held to 4096 pixels a side and 6 million pixels in all, so a view as wide as a 5K screen is no longer drawn at 15 million.
15
+ - The drawing, with no WebGL, is zoomed and moved by its `viewBox` instead of a CSS scale, so it never grows past its box; it is made again at most 20 times a second, never while out of sight (a hidden tab, scrolled away), and is not rebuilt when only the zoom or the move changes.
16
+ - The "Tilt to turn" listener is let go when the 3D view is, and does nothing out of sight; it was added again after each return to the screen.
17
+ - `drive()` with the same choice again does nothing.
18
+ - The demo's auto-turn stops after a minute and when the view leaves the screen; Play starts it again.
19
+ - `pixelRatioFor(width, height, deviceRatio)`, with `MAX_PIXELS`, `MAX_SIDE` and `MAX_RATIO`.
20
+ - New browser tests: draws per frame, nothing drawing when still or out of sight, no growth of contexts, buffers, vertex arrays or textures over changes, a lost context, and the canvas within its budget from 1x to 5x on a 5K screen.
21
+
5
22
  ## [0.2.0]
6
23
 
7
24
  - `element.drive({ yaw, tilt, zoom, panX, panY })`: a page can hand the turn, tip, zoom and move to the family's `<kazoku-view-controls>` ([`@johnmorrisdotca/kazoku-ui`](https://www.npmjs.com/package/@johnmorrisdotca/kazoku-ui)). The element's own drag, wheel, keys and spin stand down, and the 3D view and the drawing both follow, so it works with no WebGL as well. `drive(null)` takes them back. kazoku-ui is not imported by the package: the demo uses it, as a dev dependency.
@@ -102,7 +102,8 @@ export declare class RocketViewer {
102
102
  private lens;
103
103
  setTilt(azimuthDeg: number, polarDeg: number): void;
104
104
  dispose(): void;
105
- private disposeTree;
105
+ /** Lets the graphics card have back what the parts took: geometries, the thick lines' materials and a licensed model's own materials and pictures. */
106
+ private disposeParts;
106
107
  private addGlow;
107
108
  /** The pool of light is as wide as what stands in it. */
108
109
  private fitGlow;
@@ -9,6 +9,7 @@ import { LineSegmentsGeometry } from "three/addons/lines/LineSegmentsGeometry.js
9
9
  import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
10
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;
@@ -72,6 +73,8 @@ void main() {
72
73
  /** The materials of an engine's bell, in this package's own meshes. */
73
74
  const NOZZLE_MATERIALS = new Set(["bell", "bell-inside", "engine-metal"]);
74
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"];
75
78
  export class RocketViewer {
76
79
  canvas;
77
80
  frames = 0;
@@ -110,7 +113,6 @@ export class RocketViewer {
110
113
  this.holder = holder;
111
114
  this.options = options;
112
115
  this.renderer = new WebGLRenderer({ antialias: true, alpha: true, powerPreference: "low-power" });
113
- this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
114
116
  this.renderer.outputColorSpace = SRGBColorSpace;
115
117
  this.renderer.toneMapping = ACESFilmicToneMapping;
116
118
  this.renderer.toneMappingExposure = 1;
@@ -188,9 +190,7 @@ export class RocketViewer {
188
190
  const gltf = await new GLTFLoader().parseAsync(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), "");
189
191
  if (this.disposed || generation !== this.generation)
190
192
  return;
191
- for (const part of this.parts)
192
- part.holder.removeFromParent();
193
- this.disposeTree(this.root);
193
+ this.disposeParts();
194
194
  this.root.clear();
195
195
  this.parts = [];
196
196
  this.built = built;
@@ -609,6 +609,9 @@ export class RocketViewer {
609
609
  }
610
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
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;
612
615
  this.driven = choice;
613
616
  this.controls.enabled = choice === null;
614
617
  if (choice)
@@ -670,7 +673,9 @@ export class RocketViewer {
670
673
  document.removeEventListener("visibilitychange", this.onVisibility);
671
674
  this.resize.disconnect();
672
675
  this.controls.dispose();
673
- this.disposeTree(this.root);
676
+ this.disposeParts();
677
+ this.glow?.geometry.dispose();
678
+ this.glow?.material?.dispose();
674
679
  for (const material of this.materialCache.values())
675
680
  material.dispose();
676
681
  this.outlineMaterial.dispose();
@@ -681,13 +686,25 @@ export class RocketViewer {
681
686
  this.renderer.forceContextLoss();
682
687
  this.canvas.remove();
683
688
  }
684
- disposeTree(tree) {
685
- tree.traverse((node) => {
686
- const geometry = node.geometry;
687
- geometry?.dispose();
688
- if (node instanceof LineSegments2)
689
- node.material.dispose();
690
- });
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
+ }
691
708
  }
692
709
  addGlow() {
693
710
  if (!this.built || this.glow)
@@ -805,7 +822,8 @@ export class RocketViewer {
805
822
  if (this.glow)
806
823
  this.glow.visible = this.look !== "painted" && this.camera.position.y > 0;
807
824
  }
808
- 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))
809
827
  this.frame = requestAnimationFrame(this.tick);
810
828
  };
811
829
  syncSizes() {
@@ -815,7 +833,9 @@ export class RocketViewer {
815
833
  fit() {
816
834
  const w = Math.max(1, this.holder.clientWidth);
817
835
  const h = Math.max(1, this.holder.clientHeight);
836
+ this.renderer.setPixelRatio(pixelRatioFor(w, h, window.devicePixelRatio));
818
837
  this.renderer.setSize(w, h, false);
838
+ this.outlineMaterial.uniforms.width.value = LOOKS[this.look].outlinePx * this.renderer.getPixelRatio();
819
839
  this.camera.aspect = w / h;
820
840
  this.lens();
821
841
  this.syncSizes();
package/dist/element.d.ts CHANGED
@@ -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;
@@ -25,6 +24,11 @@ export declare class UchusenRocket extends HTMLBase {
25
24
  private key;
26
25
  private driven;
27
26
  private placeFrame;
27
+ private placedAt;
28
+ private stale;
29
+ private onScreen;
30
+ private drawn;
31
+ private tiltCleanups;
28
32
  /** What is showing now. */
29
33
  get state(): RocketState;
30
34
  /** What the fit check said about what is showing: its label, parts and credits. */
@@ -36,8 +40,9 @@ export declare class UchusenRocket extends HTMLBase {
36
40
  get angle(): Angle | null;
37
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. */
38
42
  drive(choice: Partial<ViewChoice> | null): void;
39
- /** 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. */
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. */
40
44
  private place;
45
+ private seen;
41
46
  connectedCallback(): void;
42
47
  disconnectedCallback(): void;
43
48
  attributeChangedCallback(name: string, before: string | null, after: string | null): void;
@@ -51,13 +56,14 @@ export declare class UchusenRocket extends HTMLBase {
51
56
  private settings;
52
57
  private update;
53
58
  private renderShell;
54
- /** 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. */
55
60
  private redraw;
56
61
  private mark;
57
62
  private applyRocket;
58
63
  private lastView;
59
64
  private applySettings;
60
65
  private startWatching;
66
+ private stopTilt;
61
67
  private fallBack;
62
68
  private start;
63
69
  private startTilt;
package/dist/element.js CHANGED
@@ -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) {
@@ -88,7 +90,6 @@ export class UchusenRocket extends HTMLBase {
88
90
  drawing = null;
89
91
  viewer = null;
90
92
  watch = null;
91
- cleanups = [];
92
93
  releaseTimer = 0;
93
94
  revealTimer = 0;
94
95
  generation = 0;
@@ -100,6 +101,11 @@ export class UchusenRocket extends HTMLBase {
100
101
  key = "";
101
102
  driven = null;
102
103
  placeFrame = 0;
104
+ placedAt = 0;
105
+ stale = false;
106
+ onScreen = true;
107
+ drawn = { key: "", box: [0, 0, 1, 1] };
108
+ tiltCleanups = [];
103
109
  /** What is showing now. */
104
110
  get state() {
105
111
  return this.stateNow;
@@ -122,6 +128,7 @@ export class UchusenRocket extends HTMLBase {
122
128
  }
123
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. */
124
130
  drive(choice) {
131
+ const before = this.driven;
125
132
  if (choice === null) {
126
133
  this.driven = null;
127
134
  delete this.dataset.driven;
@@ -137,22 +144,44 @@ export class UchusenRocket extends HTMLBase {
137
144
  };
138
145
  this.dataset.driven = "true";
139
146
  }
147
+ const now = this.driven;
148
+ if (before && now && Object.keys(now).every((key) => before[key] === now[key]))
149
+ return;
140
150
  this.viewer?.drive(this.driven);
141
151
  this.place();
142
152
  }
143
- /** 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. */
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. */
144
154
  place() {
145
155
  if (this.stateNow === "3d" && this.viewer)
146
156
  return;
157
+ if (!this.onScreen || document.hidden) {
158
+ this.stale = true;
159
+ return;
160
+ }
147
161
  window.cancelAnimationFrame(this.placeFrame);
148
- this.placeFrame = window.requestAnimationFrame(() => this.redraw());
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);
149
172
  }
173
+ seen = () => {
174
+ if (!document.hidden && this.stale)
175
+ this.place();
176
+ };
150
177
  connectedCallback() {
151
178
  if (!this.shadowRoot)
152
179
  adopt(this.attachShadow({ mode: "open" }), CSS, rocketSheet);
180
+ document.addEventListener("visibilitychange", this.seen);
153
181
  this.queue();
154
182
  }
155
183
  disconnectedCallback() {
184
+ document.removeEventListener("visibilitychange", this.seen);
156
185
  this.teardown();
157
186
  this.key = "";
158
187
  }
@@ -204,8 +233,7 @@ export class UchusenRocket extends HTMLBase {
204
233
  window.clearTimeout(this.releaseTimer);
205
234
  window.clearTimeout(this.revealTimer);
206
235
  window.cancelAnimationFrame(this.placeFrame);
207
- for (const clean of this.cleanups.splice(0))
208
- clean();
236
+ this.stopTilt();
209
237
  this.viewer?.dispose();
210
238
  this.viewer = null;
211
239
  }
@@ -274,23 +302,35 @@ export class UchusenRocket extends HTMLBase {
274
302
  this.holder = holder;
275
303
  this.startWatching();
276
304
  }
277
- /** 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. */
278
306
  redraw() {
279
307
  if (!this.builtNow || !this.drawing)
280
308
  return;
281
309
  const { look, show, angle } = this.settings(this.builtNow);
282
310
  // when a 3D view is showing, a drawing of the same angle is ready behind it
283
311
  const from = this.driven ? turnedBy(angle, this.driven.yaw, this.driven.tilt) : (this.viewer?.currentAngle() ?? angle);
284
- const svg = this.builtNow.svg({ look, show, view: from });
285
- const template = document.createElement("template");
286
- template.innerHTML = svg;
287
- const node = template.content.firstElementChild;
288
- if (!node)
289
- return;
290
- node.setAttribute("aria-label", this.getAttribute("label") ?? this.builtNow.report.label);
291
- this.drawing.replaceChildren(node);
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
+ }
292
326
  const { zoom = 1, panX = 0, panY = 0 } = this.driven ?? {};
293
- this.drawing.style.transform = zoom !== 1 || panX !== 0 || panY !== 0 ? `translate(${panX}px, ${panY}px) scale(${zoom})` : "";
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)}`;
294
334
  }
295
335
  mark() {
296
336
  if (!this.builtNow)
@@ -340,10 +380,15 @@ export class UchusenRocket extends HTMLBase {
340
380
  this.setState(reason === "off" ? "picture" : "fallback", reason);
341
381
  if (reason !== "off")
342
382
  this.emit("uchusen-fallback", { reason });
343
- return;
344
383
  }
345
- this.setState("picture");
384
+ else
385
+ this.setState("picture");
346
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;
347
392
  if (seen.isIntersecting) {
348
393
  this.shownAt ||= performance.now();
349
394
  window.clearTimeout(this.releaseTimer);
@@ -358,6 +403,7 @@ export class UchusenRocket extends HTMLBase {
358
403
  this.releaseTimer = window.setTimeout(() => {
359
404
  this.viewer?.dispose();
360
405
  this.viewer = null;
406
+ this.stopTilt();
361
407
  if (generation === this.generation) {
362
408
  this.setState("picture");
363
409
  if (this.driven)
@@ -368,7 +414,12 @@ export class UchusenRocket extends HTMLBase {
368
414
  }, { rootMargin: "200px 0px" });
369
415
  this.watch.observe(this);
370
416
  }
417
+ stopTilt() {
418
+ for (const clean of this.tiltCleanups.splice(0))
419
+ clean();
420
+ }
371
421
  fallBack(why) {
422
+ this.stopTilt();
372
423
  this.viewer?.dispose();
373
424
  this.viewer = null;
374
425
  this.setState("fallback", why);
@@ -428,6 +479,7 @@ export class UchusenRocket extends HTMLBase {
428
479
  }
429
480
  }
430
481
  startTilt() {
482
+ this.stopTilt();
431
483
  const Orientation = (typeof DeviceOrientationEvent === "undefined" ? undefined : DeviceOrientationEvent);
432
484
  const mode = tiltMode({
433
485
  reducedMotion: media("(prefers-reduced-motion: reduce)"),
@@ -438,6 +490,8 @@ export class UchusenRocket extends HTMLBase {
438
490
  const listen = () => {
439
491
  let rest = null;
440
492
  const onTilt = (event) => {
493
+ if (!this.onScreen || document.hidden)
494
+ return;
441
495
  const reading = { beta: event.beta, gamma: event.gamma };
442
496
  rest ??= reading;
443
497
  const turn = tiltTurn(reading, rest, window.screen.orientation?.angle ?? 0);
@@ -445,7 +499,7 @@ export class UchusenRocket extends HTMLBase {
445
499
  this.viewer?.setTilt(turn.azimuth, turn.polar);
446
500
  };
447
501
  window.addEventListener("deviceorientation", onTilt);
448
- this.cleanups.push(() => window.removeEventListener("deviceorientation", onTilt));
502
+ this.tiltCleanups.push(() => window.removeEventListener("deviceorientation", onTilt));
449
503
  };
450
504
  if (mode === "on")
451
505
  listen();
@@ -466,7 +520,7 @@ export class UchusenRocket extends HTMLBase {
466
520
  }
467
521
  });
468
522
  this.picture.appendChild(button);
469
- this.cleanups.push(() => button.remove());
523
+ this.tiltCleanups.push(() => button.remove());
470
524
  }
471
525
  }
472
526
  }
package/dist/index.d.ts CHANGED
@@ -17,6 +17,7 @@ export type { Look, LookName } from "./looks.ts";
17
17
  export { cleanShow, placesFor, showsOf } from "./layout.ts";
18
18
  export type { Place, ShowName } from "./layout.ts";
19
19
  export { CROSSFADE_MS, DRAWING_HOLD_MS, REDUCED_FADE_MS, revealDelay } from "./crossfade.ts";
20
+ export { MAX_PIXELS, MAX_RATIO, MAX_SIDE, pixelRatioFor } from "./pixels.ts";
20
21
  export { tiltMode, tiltTurn } from "./tilt.ts";
21
22
  export type { Reading, TiltMode } from "./tilt.ts";
22
23
  export { MATERIALS } from "./model/materials.ts";
package/dist/index.js CHANGED
@@ -9,5 +9,6 @@ export { axesOf, cleanView, DEFAULT_VIEW, directionOf, FOV_DEG, frameFor, PICTUR
9
9
  export { cleanLook, DEFAULT_LOOK, LOOK_NAMES, LOOKS } from "./looks.js";
10
10
  export { cleanShow, placesFor, showsOf } from "./layout.js";
11
11
  export { CROSSFADE_MS, DRAWING_HOLD_MS, REDUCED_FADE_MS, revealDelay } from "./crossfade.js";
12
+ export { MAX_PIXELS, MAX_RATIO, MAX_SIDE, pixelRatioFor } from "./pixels.js";
12
13
  export { tiltMode, tiltTurn } from "./tilt.js";
13
14
  export { MATERIALS } from "./model/materials.js";
@@ -0,0 +1,8 @@
1
+ /** The most the backing store may hold, in pixels (a 620 x 827 view at a ratio of 2 is 2 million). */
2
+ export declare const MAX_PIXELS = 6000000;
3
+ /** The longest side the backing store may have. */
4
+ export declare const MAX_SIDE = 4096;
5
+ /** The sharpest ratio ever used, however dense the screen. */
6
+ export declare const MAX_RATIO = 2;
7
+ /** The device pixel ratio to draw a `width` x `height` view at: the screen's own, held to 2, to 4096 pixels a side and to 6 million in all. At least 0.25, and 1 for a nonsense value. */
8
+ export declare function pixelRatioFor(width: number, height: number, deviceRatio: number): number;
package/dist/pixels.js ADDED
@@ -0,0 +1,14 @@
1
+ // How sharp the 3D canvas may be. The backing store is width x height x ratio squared, and a view as big as a 5K screen at a ratio of 2 would ask the graphics card for 15 million pixels, every frame. Pure.
2
+ /** The most the backing store may hold, in pixels (a 620 x 827 view at a ratio of 2 is 2 million). */
3
+ export const MAX_PIXELS = 6_000_000;
4
+ /** The longest side the backing store may have. */
5
+ export const MAX_SIDE = 4096;
6
+ /** The sharpest ratio ever used, however dense the screen. */
7
+ export const MAX_RATIO = 2;
8
+ /** The device pixel ratio to draw a `width` x `height` view at: the screen's own, held to 2, to 4096 pixels a side and to 6 million in all. At least 0.25, and 1 for a nonsense value. */
9
+ export function pixelRatioFor(width, height, deviceRatio) {
10
+ const w = Math.max(1, width);
11
+ const h = Math.max(1, height);
12
+ const wanted = Number.isFinite(deviceRatio) && deviceRatio > 0 ? Math.min(deviceRatio, MAX_RATIO) : 1;
13
+ return Math.max(0.25, Math.min(wanted, MAX_SIDE / Math.max(w, h), Math.sqrt(MAX_PIXELS / (w * h))));
14
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@johnmorrisdotca/uchusen-rockets",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Uchusen Rockets 宇宙船: line drawings and 3D views of rockets, as a web component. Every variant with its own sourced sizes, a typed way to assemble a rocket from parts, stack and separate. No logos. three.js is a peer dependency.",
5
5
  "keywords": ["rocket", "rockets", "spaceship", "starship", "super-heavy", "falcon-9", "dragon", "spaceflight", "line-drawing", "svg", "3d", "3d-models", "glb", "gltf", "three", "threejs", "webgl", "web-component", "custom-element", "react", "typescript", "data"],
6
6
  "homepage": "https://johnmorrisdotca.github.io/uchusen-rockets/",
@@ -47,7 +47,7 @@
47
47
  "@gltf-transform/core": "4.5.1",
48
48
  "@gltf-transform/extensions": "4.5.1",
49
49
  "@gltf-transform/functions": "4.5.1",
50
- "@johnmorrisdotca/kazoku-ui": "0.2.1",
50
+ "@johnmorrisdotca/kazoku-ui": "^0.7.3",
51
51
  "@playwright/test": "1.64.0",
52
52
  "@types/node": "24.10.1",
53
53
  "@types/react": "19.2.7",