@johnmorrisdotca/wakusei-planets 0.3.1 → 0.5.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.0
4
+
5
+ The bodies for someone else's three.js scene.
6
+
7
+ - **`@johnmorrisdotca/wakusei-planets/painter`**: `painter.make(three, "Jupiter", { look, invalidate, ratio })` makes the Sun, a planet, Pluto or a moon as an object of radius 1 for a perspective-camera scene of your own, in the realistic or the painted look, lit from any direction in the scene's own axes (equatorial J2000, y up), turned about its real pole at the moment given, with its rings and the Sun's glow. Nothing is drawn by it and it holds no renderer, loop or clock: the caller places, scales and calls `update({ toSun, ms, radiusPx })` at each draw. A painted body shows the shaded look until its map arrives, fetches a sharper map only when it is big on the screen, and lets the big one go a few seconds after it shrinks. `painter.moons("Jupiter", ms)` places a planet's moons in planet radii. Its shape is chikyu-earth's `BodyPainter`, so `setBodyPainter(painter)` is the whole wiring (chikyu-earth 0.11).
8
+ - New shaders for it (`VERTEX_WORLD` and friends): the lighting, the ring's shadow and the limb are worked out from the direction to the camera, not from an orthographic viewer's frame.
9
+ - `orientation(id, ms)` and `moonOffset(id, ms)`: a body's pole and prime meridian, and a moon's place from its planet, in the same axes.
10
+ - A guide, "In your own three.js scene", in English and Japanese.
11
+
12
+ ## 0.4.0
13
+
14
+ "Put it in your page" is a configurator.
15
+
16
+ - **Set up a body, copy the code.** The demo's last section follows what is set up above it (body, look, sky and date, moons, turning) and a size (160, 240, 320 or 480), and writes working code for plain HTML from jsDelivr, an ES module, React, Vue, Angular, Svelte and an iframe of the embed page, on tabs that answer the arrow keys, beside a picture made from the same choices, with a Copy button and the credits that go with the picture. The choices are kept in the address (`?embed=react&size=240`).
17
+ - **`embedCode(options, kind, settings)` and `embedCredit(options)`** make the same text for a page of your own (`EMBED_KINDS`: `html`, `module`, `react`, `vue`, `angular`, `svelte`, `iframe`). Pattern: senshu-jerseys' `embedCode` and its tabbed "Put it in your page". Every snippet is tested (`test/embed.test.ts`): the React component is built and rendered, the Vue and Svelte components compiled, the Angular template parsed, the module checked as TypeScript, the iframe's address read the way the embed page reads it, and every body's entry point exists in the package.
18
+ - A guide, "Put it in your page", in English and Japanese.
19
+ - **kazoku-ui 0.11.0**: the view controls' Centre button (it appears once the body is dragged away), a pan that keeps a quarter of the body in sight (told the body's size with `object-size`), and a drag that keeps the touched point under the pointer (`grab-radius`). The 0.3.1 stopgap that made Fit re-centre is gone.
20
+
3
21
  ## 0.3.1
4
22
 
5
23
  The Earth is a body like the others, and "To scale" is a comparison.
package/README.md CHANGED
@@ -11,9 +11,9 @@ A clean line drawing, a shaded realistic drawing, or a painted surface map, with
11
11
 
12
12
  <p align="center"><a href="https://johnmorrisdotca.github.io/wakusei-planets/"><strong>Open the demo →</strong></a> · <a href="https://johnmorrisdotca.github.io/wakusei-planets/embed.html">Embed page</a> · <a href="https://johnmorrisdotca.github.io/wakusei-planets/docs/">Docs</a></p>
13
13
 
14
- <p align="center"><img alt="Jupiter and Saturn drawn in the realistic look, and Saturn as a line drawing" src="https://raw.githubusercontent.com/johnmorrisdotca/wakusei-planets/v0.3.1/docs/images/hero-desk.png" width="640"></p>
14
+ <p align="center"><img alt="Jupiter and Saturn drawn in the realistic look, and Saturn as a line drawing" src="https://raw.githubusercontent.com/johnmorrisdotca/wakusei-planets/v0.5.0/docs/images/hero-desk.png" width="640"></p>
15
15
 
16
- <p align="center"><img alt="Every body at one scale, the Earth among them: each small body has a magnified picture joined by a line, and the Sun shows only its edge" src="https://raw.githubusercontent.com/johnmorrisdotca/wakusei-planets/v0.3.1/docs/images/lineup-desk.png" width="640"></p>
16
+ <p align="center"><img alt="Every body at one scale, the Earth among them: each small body has a magnified picture joined by a line, and the Sun shows only its edge" src="https://raw.githubusercontent.com/johnmorrisdotca/wakusei-planets/v0.5.0/docs/images/lineup-desk.png" width="640"></p>
17
17
 
18
18
  ## One minute
19
19
 
@@ -94,6 +94,7 @@ Give a body a `time` and it is drawn as the sky shows it then: its pole and rota
94
94
  | An ES module in Vite or webpack, only the bodies you need | [bundlers guide](docs/guides/bundlers.md) |
95
95
  | React, Vue, Svelte, Angular; Next.js, Nuxt, SvelteKit | [frameworks guide](docs/guides/frameworks.md) |
96
96
  | An `<iframe>` of the embed page, no script of your own | [iframe guide](docs/guides/embed-in-an-iframe.md) |
97
+ | Set up a body in the demo and copy the code for HTML, React, Vue, Angular, Svelte or an iframe | [Put it in your page](docs/guides/put-it-in-your-page.md), or `embedCode()` |
97
98
  | TypeScript types, an SVG string on a server, Node | [TypeScript and Node guide](docs/guides/typescript-and-node.md) |
98
99
  | The looks, the entry points, one WebGL context | [looks and bodies](docs/guides/looks-and-bodies.md) |
99
100
  | Moons where they are on a date, time that runs fast | [moons and time](docs/guides/moons-and-time.md) |
package/dist/element.d.ts CHANGED
@@ -25,7 +25,7 @@ export type WakuseiState = "line" | "loading" | "still" | "live" | "lost" | "una
25
25
  * console.log(TEXTURE_BASE);
26
26
  * ```
27
27
  */
28
- export declare const TEXTURE_BASE = "https://cdn.jsdelivr.net/npm/@johnmorrisdotca/wakusei-planets@0.3.1/dist/textures/";
28
+ export declare const TEXTURE_BASE = "https://cdn.jsdelivr.net/npm/@johnmorrisdotca/wakusei-planets@0.5.0/dist/textures/";
29
29
  /**
30
30
  * Where the sharpest surface pictures are: the files of this exact version in the repository, on jsDelivr's GitHub address. The npm package holds only the two smaller widths, so a page that
31
31
  * never zooms in never fetches them. Set `texture-far-base` on an element to serve them yourself.
@@ -37,7 +37,7 @@ export declare const TEXTURE_BASE = "https://cdn.jsdelivr.net/npm/@johnmorrisdot
37
37
  * console.log(TEXTURE_FAR_BASE);
38
38
  * ```
39
39
  */
40
- export declare const TEXTURE_FAR_BASE = "https://cdn.jsdelivr.net/gh/johnmorrisdotca/wakusei-planets@v0.3.1/textures/";
40
+ export declare const TEXTURE_FAR_BASE = "https://cdn.jsdelivr.net/gh/johnmorrisdotca/wakusei-planets@v0.5.0/textures/";
41
41
  /**
42
42
  * The `<wakusei-body>` custom element: a body of the solar system, drawn in a look.
43
43
  *
@@ -0,0 +1,96 @@
1
+ import { type BodyId, type Look } from "./facts.ts";
2
+ /**
3
+ * The ways `embedCode` writes a body into a page.
4
+ *
5
+ * @example
6
+ * ```ts
7
+ * import { EMBED_KINDS } from "@johnmorrisdotca/wakusei-planets";
8
+ *
9
+ * console.log(EMBED_KINDS.join(", "));
10
+ * ```
11
+ */
12
+ export declare const EMBED_KINDS: readonly ["html", "module", "react", "vue", "angular", "svelte", "iframe"];
13
+ /**
14
+ * One of the ways in `EMBED_KINDS`.
15
+ *
16
+ * @example
17
+ * ```ts
18
+ * import { EMBED_KINDS, type EmbedKind } from "@johnmorrisdotca/wakusei-planets";
19
+ *
20
+ * const kind: EmbedKind = EMBED_KINDS[2];
21
+ * console.log(kind);
22
+ * ```
23
+ */
24
+ export type EmbedKind = (typeof EMBED_KINDS)[number];
25
+ /**
26
+ * What the reader chose: the body, how it is drawn, and whether it is a portrait or the sky at a date.
27
+ *
28
+ * @example
29
+ * ```ts
30
+ * import { embedCode, type EmbedOptions } from "@johnmorrisdotca/wakusei-planets";
31
+ *
32
+ * const options: EmbedOptions = { body: "saturn", look: "painted", size: 320, turn: 12 };
33
+ * console.log(embedCode(options, "html"));
34
+ * ```
35
+ */
36
+ export interface EmbedOptions {
37
+ body: BodyId;
38
+ look?: Look;
39
+ /** The date and time as an ISO string, or `now`: the body as the Earth sees it then. Left out, a portrait. */
40
+ time?: string;
41
+ /** A planet with its moons on their orbits (needs `time`). */
42
+ moons?: boolean;
43
+ /** Degrees a second it turns by itself; left out or 0, it is still. */
44
+ turn?: number;
45
+ /** Pixels across. */
46
+ size?: number;
47
+ }
48
+ /**
49
+ * Where the embed code points: the package's version on jsDelivr, and the published demo, where `embed.html` is.
50
+ *
51
+ * @example
52
+ * ```ts
53
+ * import { embedCode } from "@johnmorrisdotca/wakusei-planets";
54
+ *
55
+ * console.log(embedCode({ body: "mars", look: "line" }, "iframe", { site: "https://example.com/wakusei/" }));
56
+ * ```
57
+ */
58
+ export interface EmbedSettings {
59
+ /** The published demo's address, ending in a slash. */
60
+ site?: string;
61
+ /** The package's address on the CDN, with its version. */
62
+ cdn?: string;
63
+ }
64
+ /** The entry point that holds a body's shaded and painted look. */
65
+ export declare function entryOf(id: BodyId): string;
66
+ /**
67
+ * The text that puts a body in a page, for one way of making the page. Each way is built, parsed or run by the package's tests: the plain HTML one with a script tag from jsDelivr, an ES module, a React component,
68
+ * a Vue single-file component, an Angular standalone component, a Svelte component, and an iframe of the demo's `embed.html`.
69
+ *
70
+ * @param options - The body, its look, the date, the moons, the turn and the size.
71
+ * @param kind - The way the page is made; `html` by default.
72
+ * @param settings - The CDN and the demo's address, when they are not the package's own.
73
+ * @returns The text to paste into the page's source.
74
+ *
75
+ * @example
76
+ * ```ts
77
+ * import { embedCode } from "@johnmorrisdotca/wakusei-planets";
78
+ *
79
+ * console.log(embedCode({ body: "jupiter", look: "realistic", time: "now", moons: true }, "react"));
80
+ * ```
81
+ */
82
+ export declare function embedCode(options: EmbedOptions, kind?: EmbedKind, settings?: EmbedSettings): string;
83
+ /**
84
+ * The credits that travel with a picture of a body: the line and shaded looks are drawn from numbers, the painted look uses public-domain pictures.
85
+ *
86
+ * @param options - The choices, so only what is used is named.
87
+ * @returns A short line of credit to put near the picture.
88
+ *
89
+ * @example
90
+ * ```ts
91
+ * import { embedCredit } from "@johnmorrisdotca/wakusei-planets";
92
+ *
93
+ * console.log(embedCredit({ body: "mars", look: "painted" }));
94
+ * ```
95
+ */
96
+ export declare function embedCredit(options: EmbedOptions): string;
package/dist/embed.js ADDED
@@ -0,0 +1,112 @@
1
+ // The text to put a body in a page, for each way a page is made: a script tag from a CDN, an ES module, React, Vue, Angular, Svelte, or an iframe of `embed.html`. The demo's "Put it in your page"
2
+ // section shows these as the reader changes a choice, and the tests build or parse every one. Pure: no DOM.
3
+ import { BODIES } from "./facts.js";
4
+ import { VERSION } from "./version.js";
5
+ /**
6
+ * The ways `embedCode` writes a body into a page.
7
+ *
8
+ * @example
9
+ * ```ts
10
+ * import { EMBED_KINDS } from "@johnmorrisdotca/wakusei-planets";
11
+ *
12
+ * console.log(EMBED_KINDS.join(", "));
13
+ * ```
14
+ */
15
+ export const EMBED_KINDS = ["html", "module", "react", "vue", "angular", "svelte", "iframe"];
16
+ const SITE = "https://johnmorrisdotca.github.io/wakusei-planets/";
17
+ const CDN = `https://cdn.jsdelivr.net/npm/@johnmorrisdotca/wakusei-planets@${VERSION}`;
18
+ const PACKAGE = "@johnmorrisdotca/wakusei-planets";
19
+ /** The entry point that holds a body's shaded and painted look. */
20
+ export function entryOf(id) {
21
+ const parent = BODIES[id].parent;
22
+ return parent === undefined ? id : `moons-${parent}`;
23
+ }
24
+ /** The attributes of the element for these choices, in the order they are written. */
25
+ function attributesOf(o) {
26
+ const out = [["body", o.body], ["look", o.look ?? "realistic"]];
27
+ if (o.size !== undefined && o.size !== 320)
28
+ out.push(["size", String(o.size)]);
29
+ if (o.time !== undefined)
30
+ out.push(["time", o.time]);
31
+ if (o.moons === true && o.time !== undefined)
32
+ out.push(["moons", ""]);
33
+ if (o.turn !== undefined && o.turn > 0)
34
+ out.push(["turn", String(o.turn)]);
35
+ return out;
36
+ }
37
+ const quote = (v) => `"${v.replace(/&/g, "&amp;").replace(/"/g, "&quot;")}"`;
38
+ const tagAttrs = (o) => attributesOf(o).map(([k, v]) => (v === "" ? k : `${k}=${quote(v)}`)).join(" ");
39
+ const needsShading = (o) => (o.look ?? "realistic") !== "line";
40
+ /**
41
+ * The text that puts a body in a page, for one way of making the page. Each way is built, parsed or run by the package's tests: the plain HTML one with a script tag from jsDelivr, an ES module, a React component,
42
+ * a Vue single-file component, an Angular standalone component, a Svelte component, and an iframe of the demo's `embed.html`.
43
+ *
44
+ * @param options - The body, its look, the date, the moons, the turn and the size.
45
+ * @param kind - The way the page is made; `html` by default.
46
+ * @param settings - The CDN and the demo's address, when they are not the package's own.
47
+ * @returns The text to paste into the page's source.
48
+ *
49
+ * @example
50
+ * ```ts
51
+ * import { embedCode } from "@johnmorrisdotca/wakusei-planets";
52
+ *
53
+ * console.log(embedCode({ body: "jupiter", look: "realistic", time: "now", moons: true }, "react"));
54
+ * ```
55
+ */
56
+ export function embedCode(options, kind = "html", settings = {}) {
57
+ const cdn = settings.cdn ?? CDN;
58
+ const site = settings.site ?? SITE;
59
+ const shaded = needsShading(options);
60
+ const entry = entryOf(options.body);
61
+ const size = options.size ?? 320;
62
+ if (kind === "iframe") {
63
+ const query = new URLSearchParams({ body: options.body, look: options.look ?? "realistic" });
64
+ if (options.time !== undefined)
65
+ query.set("time", options.time);
66
+ if (options.moons === true && options.time !== undefined)
67
+ query.set("moons", "1");
68
+ if (options.turn !== undefined && options.turn > 0)
69
+ query.set("turn", String(options.turn));
70
+ return `<iframe\n src=${quote(`${site}embed.html?${query.toString()}`)}\n title=${quote(BODIES[options.body].name.en)}\n width="${size}"\n height="${size}"\n loading="lazy"\n style="border: 0"\n></iframe>`;
71
+ }
72
+ if (kind === "html") {
73
+ const scripts = [`<script type="module" src="${cdn}/element/+esm"></script>`, ...(shaded ? [`<script type="module" src="${cdn}/${entry}/+esm"></script>`] : [])];
74
+ return `${scripts.join("\n")}\n\n<wakusei-body ${tagAttrs(options)}></wakusei-body>`;
75
+ }
76
+ const imports = [`import "${PACKAGE}/element";`, ...(shaded ? [`import "${PACKAGE}/${entry}";`] : [])];
77
+ if (kind === "module") {
78
+ const sets = attributesOf(options).map(([k, v]) => `planet.setAttribute(${JSON.stringify(k)}, ${JSON.stringify(v)});`);
79
+ return `${imports.join("\n")}\n\nconst planet = document.createElement("wakusei-body");\n${sets.join("\n")}\ndocument.querySelector("#planet").append(planet);`;
80
+ }
81
+ const dynamic = [`void import("${PACKAGE}/element");`, ...(shaded ? [`void import("${PACKAGE}/${entry}");`] : [])];
82
+ if (kind === "react") {
83
+ const props = attributesOf(options).map(([k, v]) => (v === "" ? k : `${k}=${quote(v)}`)).join(" ");
84
+ return `import { useEffect } from "react";\n\nexport function Planet() {\n useEffect(() => {\n // The element needs a browser, so it is imported in an effect, never at the top of the file.\n${dynamic.map((l) => ` ${l}`).join("\n")}\n }, []);\n return <wakusei-body ${props} />;\n}`;
85
+ }
86
+ if (kind === "vue") {
87
+ return `<script setup lang="ts">\nimport { onMounted } from "vue";\n\nonMounted(() => {\n${dynamic.map((l) => ` ${l}`).join("\n")}\n});\n</script>\n\n<template>\n <wakusei-body ${tagAttrs(options)}></wakusei-body>\n</template>`;
88
+ }
89
+ if (kind === "angular") {
90
+ return `import { Component, CUSTOM_ELEMENTS_SCHEMA, OnInit } from "@angular/core";\n\n@Component({\n selector: "app-planet",\n standalone: true,\n schemas: [CUSTOM_ELEMENTS_SCHEMA],\n template: \`<wakusei-body ${tagAttrs(options)}></wakusei-body>\`,\n})\nexport class PlanetComponent implements OnInit {\n ngOnInit(): void {\n // The element needs a browser: ngOnInit also runs when a server renders.\n if (typeof window !== "undefined") {\n${dynamic.map((l) => ` ${l}`).join("\n")}\n }\n }\n}`;
91
+ }
92
+ return `<script lang="ts">\n import { onMount } from "svelte";\n\n onMount(() => {\n${dynamic.map((l) => ` ${l}`).join("\n")}\n });\n</script>\n\n<wakusei-body ${tagAttrs(options)}></wakusei-body>`;
93
+ }
94
+ /**
95
+ * The credits that travel with a picture of a body: the line and shaded looks are drawn from numbers, the painted look uses public-domain pictures.
96
+ *
97
+ * @param options - The choices, so only what is used is named.
98
+ * @returns A short line of credit to put near the picture.
99
+ *
100
+ * @example
101
+ * ```ts
102
+ * import { embedCredit } from "@johnmorrisdotca/wakusei-planets";
103
+ *
104
+ * console.log(embedCredit({ body: "mars", look: "painted" }));
105
+ * ```
106
+ */
107
+ export function embedCredit(options) {
108
+ const base = "Wakusei Planets by John Morris (MIT). Sizes, tilt and rotation: IAU and NASA fact sheets.";
109
+ const painted = (options.look ?? "realistic") === "painted" ? " Surface pictures: NASA, USGS and JPL imagery in the public domain (details in the package's NOTICE.md)." : "";
110
+ const earth = options.body === "earth" ? " Earth: NASA Earth Observatory, Blue Marble." : "";
111
+ return `${base}${painted}${earth}`;
112
+ }
package/dist/index.d.ts CHANGED
@@ -3,3 +3,4 @@ export { apparent, moonGeocentric, heliocentric, orbitPath, system, type Apparen
3
3
  export { drawnTilt, portrait, type PortraitOptions } from "./pose.ts";
4
4
  export { lineSvg, mix, type LineOptions } from "./line.ts";
5
5
  export { ringStrip, type RingStrip } from "./scene/rings.ts";
6
+ export { embedCode, embedCredit, EMBED_KINDS, type EmbedKind, type EmbedOptions, type EmbedSettings } from "./embed.ts";
package/dist/index.js CHANGED
@@ -3,3 +3,4 @@ export { apparent, moonGeocentric, heliocentric, orbitPath, system } from "./eph
3
3
  export { drawnTilt, portrait } from "./pose.js";
4
4
  export { lineSvg, mix } from "./line.js";
5
5
  export { ringStrip } from "./scene/rings.js";
6
+ export { embedCode, embedCredit, EMBED_KINDS } from "./embed.js";
@@ -2,11 +2,20 @@ export declare const VERTEX = "\nvarying vec3 vObj;\nvarying vec3 vN;\nvarying v
2
2
  /** Noise, craters and the band palette, for the surface code of a body to call. */
3
3
  export declare const PRELUDE = "\nfloat hash13(vec3 p) { p = fract(p * 0.1031); p += dot(p, p.zyx + 31.32); return fract((p.x + p.y) * p.z); }\nvec3 hash33(vec3 p) { p = fract(p * vec3(0.1031, 0.1030, 0.0973)); p += dot(p, p.yxz + 33.33); return fract((p.xxy + p.yxx) * p.zyx); }\nfloat vnoise(vec3 p) {\n vec3 i = floor(p); vec3 f = fract(p); f = f * f * (3.0 - 2.0 * f);\n return mix(mix(mix(hash13(i), hash13(i + vec3(1,0,0)), f.x), mix(hash13(i + vec3(0,1,0)), hash13(i + vec3(1,1,0)), f.x), f.y),\n mix(mix(hash13(i + vec3(0,0,1)), hash13(i + vec3(1,0,1)), f.x), mix(hash13(i + vec3(0,1,1)), hash13(i + vec3(1,1,1)), f.x), f.y), f.z);\n}\nfloat fbm(vec3 p) { float a = 0.5; float s = 0.0; for (int i = 0; i < 5; i++) { s += a * vnoise(p); p = p * 2.03 + vec3(1.7, 9.2, 3.1); a *= 0.5; } return s; }\n// Craters: a bowl and a raised rim in each cell that has one. Returns a height, negative in a bowl.\nfloat craters(vec3 p, float freq, float amp, float seed) {\n vec3 q = p * freq + seed; vec3 ip = floor(q); float h = 0.0;\n for (int x = -1; x <= 1; x++) for (int y = -1; y <= 1; y++) for (int z = -1; z <= 1; z++) {\n vec3 c = ip + vec3(float(x), float(y), float(z));\n float on = step(0.52, hash13(c + 3.7));\n vec3 ctr = c + hash33(c);\n float rad = mix(0.12, 0.36, hash13(c + 17.3));\n float d = length(q - ctr) / rad;\n float bowl = d < 1.0 ? (d * d - 1.0) : 0.0;\n float rim = d >= 1.0 ? exp(-pow((d - 1.0) * 5.0, 2.0)) * 0.3 : 0.0;\n h += on * amp * rad * (bowl + rim);\n }\n return h;\n}\nfloat lon(vec3 p) { return atan(p.y, p.x); }\nfloat lat(vec3 p) { return asin(clamp(p.z, -1.0, 1.0)); }\nfloat wrapPi(float a) { return a - 6.2831853 * floor((a + 3.1415927) / 6.2831853); }\nvec3 dirOf(float lonDeg, float latDeg) { float lo = radians(lonDeg); float la = radians(latDeg); return vec3(cos(la) * cos(lo), cos(la) * sin(lo), sin(la)); }\n// One big round bowl with a rim, by longitude, latitude and angular radius in degrees. Negative inside.\nfloat bowl(vec3 p, float lonDeg, float latDeg, float radDeg, float depth) {\n float d = acos(clamp(dot(p, dirOf(lonDeg, latDeg)), -1.0, 1.0)) / radians(radDeg);\n return d < 1.0 ? depth * (d * d - 1.0) : depth * exp(-pow((d - 1.0) * 5.0, 2.0)) * 0.3;\n}\n// A soft oval on the body, 1 inside and 0 outside, by longitude and latitude in degrees.\nfloat oval(vec3 p, float lon0, float lat0, float rx, float ry, float soft) {\n float dx = wrapPi(lon(p) - radians(lon0)) * cos(radians(lat0)) / radians(rx);\n float dy = (lat(p) - radians(lat0)) / radians(ry);\n return 1.0 - smoothstep(1.0 - soft, 1.0, sqrt(dx * dx + dy * dy));\n}";
4
4
  /** The fragment shader of a body: the surface code goes between the prelude and `main`. */
5
- export declare function bodyFragment(surface: string, painted: boolean): string;
5
+ export declare function bodyFragment(surface: string, painted: boolean, world?: boolean): string;
6
6
  /** A painted body's surface code: the map by longitude and latitude, with the seam at 180 degrees left alone. */
7
7
  export declare const PAINTED_SURFACE = "\nvec4 surface(vec3 p) {\n float u1 = atan(p.y, p.x) / 6.2831853 + 0.5;\n float u2 = fract(u1 + 0.5) - 0.5;\n float v = asin(clamp(p.z, -1.0, 1.0)) / 3.1415927 + 0.5;\n vec2 a = vec2(u1, v); vec2 b = vec2(u2, v);\n vec2 ax = dFdx(a); vec2 ay = dFdy(a); vec2 bx = dFdx(b); vec2 by = dFdy(b);\n bool useA = dot(ax, ax) + dot(ay, ay) <= dot(bx, bx) + dot(by, by);\n vec3 c = textureGrad(uMap, useA ? a : b, useA ? ax : bx, useA ? ay : by).rgb;\n return vec4(c, dot(c, vec3(0.3, 0.59, 0.11)));\n}";
8
8
  export declare const RING_FRAGMENT = "\nprecision highp float;\nuniform sampler2D uRingTex;\nuniform vec2 uRingR;\nuniform vec3 uLight;\nuniform vec3 uRingN;\nvarying vec3 vL;\nvarying vec3 vW;\nvoid main() {\n float u = (length(vL.xy) - uRingR.x) / (uRingR.y - uRingR.x);\n if (u < 0.0 || u > 1.0) discard;\n vec4 t = texture2D(uRingTex, vec2(u, 0.5));\n float nl = dot(uRingN, uLight);\n float side = nl * uRingN.z;\n float bright = 0.16 + 0.84 * clamp(abs(nl) * 2.0, 0.0, 1.0);\n if (side < 0.0) bright *= 0.38;\n float b = dot(vW, uLight);\n float closest = sqrt(max(dot(vW, vW) - b * b, 0.0));\n float shadow = b < 0.0 ? smoothstep(1.02, 0.97, closest) : 0.0;\n vec3 c = pow(t.rgb, vec3(2.2)) * bright * (1.0 - 0.93 * shadow);\n gl_FragColor = vec4(pow(c, vec3(1.0 / 2.2)), t.a);\n}";
9
9
  export declare const RING_VERTEX = "\nvarying vec3 vL;\nvarying vec3 vW;\nvoid main() {\n vL = position;\n vec4 w = modelMatrix * vec4(position, 1.0);\n vW = w.xyz;\n gl_Position = projectionMatrix * viewMatrix * w;\n}";
10
+ /**
11
+ * The body's vertex shader for a perspective camera and a body anywhere in the scene: the normal and the position are in the scene's axes, the position in body radii from the body's centre
12
+ * (`uScale` is the body's size in scene units), and `vV` points at the camera.
13
+ */
14
+ export declare const VERTEX_WORLD = "\nuniform float uScale;\nvarying vec3 vObj;\nvarying vec3 vN;\nvarying vec3 vW;\nvarying vec3 vV;\nvoid main() {\n vObj = vec3(position.z, position.x, position.y);\n vec4 w = modelMatrix * vec4(position, 1.0);\n vN = normalize(transpose(inverse(mat3(modelMatrix))) * normal);\n vW = mat3(modelMatrix) * position / uScale;\n vV = cameraPosition - w.xyz;\n gl_Position = projectionMatrix * viewMatrix * w;\n}";
15
+ /** The ring's vertex shader for a perspective camera, with the same frame as `VERTEX_WORLD`. */
16
+ export declare const RING_VERTEX_WORLD = "\nuniform float uScale;\nvarying vec3 vL;\nvarying vec3 vW;\nvarying vec3 vV;\nvoid main() {\n vL = position;\n vec4 w = modelMatrix * vec4(position, 1.0);\n vW = mat3(modelMatrix) * position / uScale;\n vV = cameraPosition - w.xyz;\n gl_Position = projectionMatrix * viewMatrix * w;\n}";
17
+ /** The ring's fragment shader for a perspective camera: which side is seen comes from the direction to the camera. */
18
+ export declare const RING_FRAGMENT_WORLD: string;
10
19
  export declare const GLOW_VERTEX = "\nvarying vec2 vP;\nvoid main() { vP = position.xy; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }";
11
20
  export declare const GLOW_FRAGMENT = "\nprecision highp float;\nvarying vec2 vP;\nuniform vec3 uColour;\nvoid main() {\n float r = length(vP);\n if (r < 0.98) discard;\n float a = exp(-(r - 1.0) * 2.4) * 0.62;\n float streak = 0.82 + 0.18 * sin(atan(vP.y, vP.x) * 9.0 + r * 3.0);\n a *= streak * smoothstep(3.0, 1.6, r);\n gl_FragColor = vec4(uColour * a, a);\n}";
12
21
  /** A cheap lit ball for a moon seen small beside its planet. */
@@ -51,7 +51,7 @@ float oval(vec3 p, float lon0, float lat0, float rx, float ry, float soft) {
51
51
  return 1.0 - smoothstep(1.0 - soft, 1.0, sqrt(dx * dx + dy * dy));
52
52
  }`;
53
53
  /** The fragment shader of a body: the surface code goes between the prelude and `main`. */
54
- export function bodyFragment(surface, painted) {
54
+ export function bodyFragment(surface, painted, world = false) {
55
55
  return /* glsl */ `
56
56
  precision highp float;
57
57
  uniform vec3 uLight;
@@ -70,13 +70,14 @@ ${painted ? "uniform sampler2D uMap;" : ""}
70
70
  varying vec3 vObj;
71
71
  varying vec3 vN;
72
72
  varying vec3 vW;
73
+ ${world ? "varying vec3 vV;" : ""}
73
74
  ${PRELUDE}
74
75
  ${surface}
75
76
  void main() {
76
77
  vec3 p = normalize(vObj);
77
78
  vec4 s = surface(p);
78
79
  vec3 n = normalize(vN);
79
- float mu = clamp(n.z, 0.0, 1.0);
80
+ float mu = ${world ? "clamp(dot(n, normalize(vV)), 0.0, 1.0)" : "clamp(n.z, 0.0, 1.0)"};
80
81
  if (uEmissive > 0.5) {
81
82
  float edge = pow(mu, uLimb);
82
83
  vec3 c = s.rgb * mix(vec3(1.0, 0.55, 0.25), vec3(1.0), edge) * (0.55 + 0.45 * edge);
@@ -153,6 +154,39 @@ void main() {
153
154
  vW = w.xyz;
154
155
  gl_Position = projectionMatrix * viewMatrix * w;
155
156
  }`;
157
+ /**
158
+ * The body's vertex shader for a perspective camera and a body anywhere in the scene: the normal and the position are in the scene's axes, the position in body radii from the body's centre
159
+ * (`uScale` is the body's size in scene units), and `vV` points at the camera.
160
+ */
161
+ export const VERTEX_WORLD = /* glsl */ `
162
+ uniform float uScale;
163
+ varying vec3 vObj;
164
+ varying vec3 vN;
165
+ varying vec3 vW;
166
+ varying vec3 vV;
167
+ void main() {
168
+ vObj = vec3(position.z, position.x, position.y);
169
+ vec4 w = modelMatrix * vec4(position, 1.0);
170
+ vN = normalize(transpose(inverse(mat3(modelMatrix))) * normal);
171
+ vW = mat3(modelMatrix) * position / uScale;
172
+ vV = cameraPosition - w.xyz;
173
+ gl_Position = projectionMatrix * viewMatrix * w;
174
+ }`;
175
+ /** The ring's vertex shader for a perspective camera, with the same frame as `VERTEX_WORLD`. */
176
+ export const RING_VERTEX_WORLD = /* glsl */ `
177
+ uniform float uScale;
178
+ varying vec3 vL;
179
+ varying vec3 vW;
180
+ varying vec3 vV;
181
+ void main() {
182
+ vL = position;
183
+ vec4 w = modelMatrix * vec4(position, 1.0);
184
+ vW = mat3(modelMatrix) * position / uScale;
185
+ vV = cameraPosition - w.xyz;
186
+ gl_Position = projectionMatrix * viewMatrix * w;
187
+ }`;
188
+ /** The ring's fragment shader for a perspective camera: which side is seen comes from the direction to the camera. */
189
+ export const RING_FRAGMENT_WORLD = RING_FRAGMENT.replace("varying vec3 vW;", "varying vec3 vW;\nvarying vec3 vV;").replace("float side = nl * uRingN.z;", "float side = nl * dot(uRingN, normalize(vV));");
156
190
  export const GLOW_VERTEX = /* glsl */ `
157
191
  varying vec2 vP;
158
192
  void main() { vP = position.xy; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`;
@@ -0,0 +1,38 @@
1
+ import { type BodyId, type MoonId } from "../facts.ts";
2
+ import { type Vec3 } from "../vec.ts";
3
+ /**
4
+ * Where a moon is from its planet's centre, in planet radii, in the scene's axes (x to the vernal equinox, y north).
5
+ *
6
+ * @param id - The moon's name.
7
+ * @param ms - The instant, in milliseconds since 1970.
8
+ * @returns The offset, in radii of the planet the moon goes round.
9
+ *
10
+ * @example
11
+ * ```ts
12
+ * import { moonOffset } from "@johnmorrisdotca/wakusei-planets/painter";
13
+ *
14
+ * const [x, y, z] = moonOffset("io", Date.UTC(2026, 10, 2));
15
+ * console.log(Math.hypot(x, y, z).toFixed(1));
16
+ * ```
17
+ */
18
+ export declare function moonOffset(id: MoonId, ms: number): Vec3;
19
+ /**
20
+ * The north pole and the prime meridian of a body at a time, as unit vectors in the scene's axes (x to the vernal equinox, y north). A moon that keeps one face to its planet is
21
+ * given the direction to the planet as its meridian, so its near side faces the planet.
22
+ *
23
+ * @param id - The body's name.
24
+ * @param ms - The instant, in milliseconds since 1970.
25
+ * @returns The pole and the direction of longitude 0, both unit vectors.
26
+ *
27
+ * @example
28
+ * ```ts
29
+ * import { orientation } from "@johnmorrisdotca/wakusei-planets/painter";
30
+ *
31
+ * const { pole } = orientation("mars", Date.UTC(2026, 10, 2));
32
+ * console.log(pole.map((n) => n.toFixed(2)).join(", "));
33
+ * ```
34
+ */
35
+ export declare function orientation(id: BodyId, ms: number): {
36
+ pole: Vec3;
37
+ meridian: Vec3;
38
+ };
@@ -0,0 +1,72 @@
1
+ // How a body is turned in space at a time, in the axes of an equatorial J2000 scene with y up (x to the vernal equinox, y to the celestial north pole, z toward -y of the equator).
2
+ // Pure: no three.js. The painter uses it to turn each body about its real pole, and to find where a moon is round its planet.
3
+ import { moonVector } from "../ephemeris.js";
4
+ import { BODIES } from "../facts.js";
5
+ import { cross, dot, fromRaDec, OBLIQUITY, RAD, unit, wrap360 } from "../vec.js";
6
+ const J2000 = Date.UTC(2000, 0, 1, 12);
7
+ const DAY = 86_400_000;
8
+ /** An equatorial vector (x to the vernal equinox, z to the north pole) in the scene's axes. */
9
+ const toScene = (v) => [v[0], v[2], -v[1]];
10
+ /** An ecliptic J2000 vector in equatorial J2000 axes. */
11
+ const fromEcliptic = (v) => {
12
+ const c = Math.cos(OBLIQUITY * RAD);
13
+ const s = Math.sin(OBLIQUITY * RAD);
14
+ return [v[0], v[1] * c - v[2] * s, v[1] * s + v[2] * c];
15
+ };
16
+ /**
17
+ * Where a moon is from its planet's centre, in planet radii, in the scene's axes (x to the vernal equinox, y north).
18
+ *
19
+ * @param id - The moon's name.
20
+ * @param ms - The instant, in milliseconds since 1970.
21
+ * @returns The offset, in radii of the planet the moon goes round.
22
+ *
23
+ * @example
24
+ * ```ts
25
+ * import { moonOffset } from "@johnmorrisdotca/wakusei-planets/painter";
26
+ *
27
+ * const [x, y, z] = moonOffset("io", Date.UTC(2026, 10, 2));
28
+ * console.log(Math.hypot(x, y, z).toFixed(1));
29
+ * ```
30
+ */
31
+ export function moonOffset(id, ms) {
32
+ const facts = BODIES[id];
33
+ const radius = BODIES[facts.parent ?? "earth"].radiusKm;
34
+ const v = toScene(fromEcliptic(moonVector(id, ms)));
35
+ return [v[0] / radius, v[1] / radius, v[2] / radius];
36
+ }
37
+ /**
38
+ * The north pole and the prime meridian of a body at a time, as unit vectors in the scene's axes (x to the vernal equinox, y north). A moon that keeps one face to its planet is
39
+ * given the direction to the planet as its meridian, so its near side faces the planet.
40
+ *
41
+ * @param id - The body's name.
42
+ * @param ms - The instant, in milliseconds since 1970.
43
+ * @returns The pole and the direction of longitude 0, both unit vectors.
44
+ *
45
+ * @example
46
+ * ```ts
47
+ * import { orientation } from "@johnmorrisdotca/wakusei-planets/painter";
48
+ *
49
+ * const { pole } = orientation("mars", Date.UTC(2026, 10, 2));
50
+ * console.log(pole.map((n) => n.toFixed(2)).join(", "));
51
+ * ```
52
+ */
53
+ export function orientation(id, ms) {
54
+ const facts = BODIES[id];
55
+ const { ra, dec } = facts.pole;
56
+ const pole = fromRaDec(ra, dec);
57
+ let meridian;
58
+ if (facts.synchronous === true && facts.parent !== undefined) {
59
+ const planetward = unit(fromEcliptic(moonVector(id, ms)).map((n) => -n));
60
+ const d = dot(planetward, pole);
61
+ meridian = unit([planetward[0] - pole[0] * d, planetward[1] - pole[1] * d, planetward[2] - pole[2] * d]);
62
+ }
63
+ else {
64
+ const w = wrap360(facts.prime.w0 + facts.prime.rate * ((ms - J2000) / DAY));
65
+ const node = fromRaDec(ra + 90, 0);
66
+ const third = cross(pole, node);
67
+ const c = Math.cos(w * RAD);
68
+ const s = Math.sin(w * RAD);
69
+ meridian = [node[0] * c + third[0] * s, node[1] * c + third[1] * s, node[2] * c + third[2] * s];
70
+ }
71
+ return { pole: toScene(pole), meridian: toScene(meridian) };
72
+ }
@@ -0,0 +1,164 @@
1
+ import type * as THREE from "three";
2
+ import "../bodies/all.ts";
3
+ import { type Vec3 } from "../vec.ts";
4
+ import { moonOffset, orientation } from "./orient.ts";
5
+ export { moonOffset, orientation };
6
+ /**
7
+ * The looks the painter draws: `realistic` (shaded from the body's real colours and features) and `painted` (a public-domain surface map, with the shaded look until it arrives).
8
+ *
9
+ * @example
10
+ * ```ts no-run
11
+ * import { LOOKS, type PainterLook } from "@johnmorrisdotca/wakusei-planets/painter";
12
+ *
13
+ * const first: PainterLook = LOOKS[0];
14
+ * console.log(first);
15
+ * ```
16
+ */
17
+ export declare const LOOKS: readonly ["realistic", "painted"];
18
+ /**
19
+ * One of the looks in `LOOKS`: `realistic` or `painted`.
20
+ *
21
+ * @example
22
+ * ```ts no-run
23
+ * import type { PainterLook } from "@johnmorrisdotca/wakusei-planets/painter";
24
+ *
25
+ * const look: PainterLook = "painted";
26
+ * console.log(look);
27
+ * ```
28
+ */
29
+ export type PainterLook = (typeof LOOKS)[number];
30
+ /**
31
+ * What the caller gives the painter when it makes a body.
32
+ *
33
+ * @example
34
+ * ```ts no-run
35
+ * import type { PaintContext } from "@johnmorrisdotca/wakusei-planets/painter";
36
+ *
37
+ * const context: PaintContext = { look: "painted", invalidate: () => undefined, ratio: 2 };
38
+ * console.log(context.look);
39
+ * ```
40
+ */
41
+ export interface PaintContext {
42
+ /** `realistic` (the default) or `painted`. */
43
+ look?: string;
44
+ /** Ask for a new draw: a sharper picture has arrived. */
45
+ invalidate?(): void;
46
+ /** Device pixels to a CSS pixel; default 1. */
47
+ ratio?: number;
48
+ /** The widest picture the GPU should be given, in pixels; default 2048. */
49
+ maxTexture?: number;
50
+ }
51
+ /**
52
+ * What the caller tells a painted body at each draw: the direction to the Sun (a unit vector in the scene's axes), the time, and how many CSS pixels across its radius the body is on the screen.
53
+ *
54
+ * @example
55
+ * ```ts no-run
56
+ * import type { PaintInfo } from "@johnmorrisdotca/wakusei-planets/painter";
57
+ *
58
+ * const info: PaintInfo = { toSun: [1, 0, 0], ms: Date.UTC(2026, 10, 2), radiusPx: 80 };
59
+ * console.log(info.radiusPx);
60
+ * ```
61
+ */
62
+ export interface PaintInfo {
63
+ toSun: Vec3;
64
+ ms: number;
65
+ radiusPx: number;
66
+ }
67
+ /**
68
+ * One body made by the painter: an object of radius 1 at the origin for the caller to move and scale.
69
+ *
70
+ * @example
71
+ * ```ts no-run
72
+ * import type { Painting } from "@johnmorrisdotca/wakusei-planets/painter";
73
+ *
74
+ * const stop = (painting: Painting): void => painting.dispose();
75
+ * console.log(typeof stop);
76
+ * ```
77
+ */
78
+ export interface Painting {
79
+ object: THREE.Object3D;
80
+ /** True to have the caller turn the object to face the camera (the Sun, which carries a glow). */
81
+ billboard?: boolean;
82
+ /** Call at each draw, after the object has been placed and scaled. */
83
+ update(info: PaintInfo): void;
84
+ dispose(): void;
85
+ }
86
+ /**
87
+ * One moon of a planet at a time: where it is from the planet's centre and how big it is, both in radii of the planet, in the scene's axes.
88
+ *
89
+ * @example
90
+ * ```ts no-run
91
+ * import { painter } from "@johnmorrisdotca/wakusei-planets/painter";
92
+ *
93
+ * const io = painter.moons("Jupiter", Date.UTC(2026, 10, 2))[0];
94
+ * console.log(io?.name, io?.radius.toFixed(3));
95
+ * ```
96
+ */
97
+ export interface MoonSite {
98
+ /** The moon's name as `make` takes it, for example `Io`. */
99
+ name: string;
100
+ offset: Vec3;
101
+ radius: number;
102
+ }
103
+ /**
104
+ * Where the pictures are, if not on jsDelivr: a folder address that ends in a slash. `farBase` holds the widest pictures (2048), which the npm package leaves out.
105
+ *
106
+ * @example
107
+ * ```ts no-run
108
+ * import { createPainter, type PainterOptions } from "@johnmorrisdotca/wakusei-planets/painter";
109
+ *
110
+ * const options: PainterOptions = { textureBase: "/textures/", farBase: "/textures/" };
111
+ * console.log(createPainter(options).looks);
112
+ * ```
113
+ */
114
+ export interface PainterOptions {
115
+ textureBase?: string;
116
+ farBase?: string;
117
+ }
118
+ /**
119
+ * A painter: makes the bodies as three.js objects (`make`) and tells where a planet's moons are (`moons`). Its shape is chikyu-earth's `BodyPainter`, so `setBodyPainter(painter)` is all the wiring.
120
+ *
121
+ * @example
122
+ * ```ts no-run
123
+ * import { createPainter, type WakuseiPainter } from "@johnmorrisdotca/wakusei-planets/painter";
124
+ *
125
+ * const mine: WakuseiPainter = createPainter({ textureBase: "/pictures/", farBase: "/pictures/" });
126
+ * console.log(mine.looks.join(", "));
127
+ * ```
128
+ */
129
+ export interface WakuseiPainter {
130
+ /** The looks `make` takes in `context.look`. */
131
+ readonly looks: readonly PainterLook[];
132
+ /** Make a body: `Sun`, a planet, `Pluto` or a moon, in any case. Throws for a name it does not know. */
133
+ /** `three` is the three.js module (or a subset of it holding the parts `PainterThree` names); it is typed loosely so that another package can hand over its own subset. */
134
+ make(three: object, name: string, context?: PaintContext): Painting;
135
+ /** The moons the painter draws for a planet (none for the Earth: its Moon is the viewer's own), where they are at a time, nearest first. */
136
+ moons(planet: string, ms: number): MoonSite[];
137
+ }
138
+ /**
139
+ * Make a painter that takes its pictures from the given folders (jsDelivr's copy of this version by default).
140
+ *
141
+ * @param options - Where the pictures are.
142
+ * @returns The painter.
143
+ *
144
+ * @example
145
+ * ```ts no-run
146
+ * import { createPainter } from "@johnmorrisdotca/wakusei-planets/painter";
147
+ *
148
+ * const painter = createPainter();
149
+ * console.log(painter.moons("Mars", Date.UTC(2026, 10, 2)).map((m) => m.name));
150
+ * ```
151
+ */
152
+ export declare function createPainter(options?: PainterOptions): WakuseiPainter;
153
+ /**
154
+ * The painter with its default pictures: pass it to chikyu-earth's `setBodyPainter`.
155
+ *
156
+ * @example
157
+ * ```ts no-run
158
+ * import { painter } from "@johnmorrisdotca/wakusei-planets/painter";
159
+ * import { setBodyPainter } from "@johnmorrisdotca/chikyu-earth/space/element";
160
+ *
161
+ * setBodyPainter(painter);
162
+ * ```
163
+ */
164
+ export declare const painter: WakuseiPainter;
@@ -0,0 +1,355 @@
1
+ import "../bodies/all.js";
2
+ import { BODIES, MOONS_OF } from "../facts.js";
3
+ import { VERSION } from "../version.js";
4
+ import { cross } from "../vec.js";
5
+ import { bodyFragment, GLOW_FRAGMENT, GLOW_VERTEX, PAINTED_SURFACE, RING_FRAGMENT_WORLD, RING_VERTEX_WORLD, VERTEX_WORLD } from "./glsl.js";
6
+ import { lookOf } from "./look.js";
7
+ import { moonOffset, orientation } from "./orient.js";
8
+ import { ringStrip } from "./rings.js";
9
+ export { moonOffset, orientation };
10
+ /**
11
+ * The looks the painter draws: `realistic` (shaded from the body's real colours and features) and `painted` (a public-domain surface map, with the shaded look until it arrives).
12
+ *
13
+ * @example
14
+ * ```ts no-run
15
+ * import { LOOKS, type PainterLook } from "@johnmorrisdotca/wakusei-planets/painter";
16
+ *
17
+ * const first: PainterLook = LOOKS[0];
18
+ * console.log(first);
19
+ * ```
20
+ */
21
+ export const LOOKS = ["realistic", "painted"];
22
+ const TEXTURE_BASE = `https://cdn.jsdelivr.net/npm/@johnmorrisdotca/wakusei-planets@${VERSION}/dist/textures/`;
23
+ const TEXTURE_FAR_BASE = `https://cdn.jsdelivr.net/gh/johnmorrisdotca/wakusei-planets@v${VERSION}/textures/`;
24
+ const slash = (base) => (base.endsWith("/") ? base : `${base}/`);
25
+ const titled = (id) => id.charAt(0).toUpperCase() + id.slice(1);
26
+ const pools = new WeakMap();
27
+ function acquire(three, key, make) {
28
+ let pool = pools.get(three);
29
+ if (pool === undefined)
30
+ pools.set(three, (pool = new Map()));
31
+ let held = pool.get(key);
32
+ if (held === undefined)
33
+ pool.set(key, (held = { value: make(), users: 0 }));
34
+ held.users += 1;
35
+ const mine = held;
36
+ const owner = pool;
37
+ let released = false;
38
+ return {
39
+ value: mine.value,
40
+ release() {
41
+ if (released)
42
+ return;
43
+ released = true;
44
+ mine.users -= 1;
45
+ if (mine.users <= 0) {
46
+ mine.value.dispose();
47
+ owner.delete(key);
48
+ }
49
+ },
50
+ };
51
+ }
52
+ const fallbackLook = (id) => {
53
+ const c = [1, 3, 5].map((i) => (parseInt(BODIES[id].colour.slice(i, i + 2), 16) / 255).toFixed(3)).join(", ");
54
+ return { id, surface: `vec4 surface(vec3 p) { float n = fbm(p * 5.0); return vec4(vec3(${c}) * (0.85 + 0.3 * n), 0.0); }` };
55
+ };
56
+ /** The name of a body as the painter takes it: any case, `Sun`, a planet or Pluto, or a moon such as `Io`. */
57
+ function idOf(name) {
58
+ const id = name.toLowerCase();
59
+ if (!(id in BODIES))
60
+ throw new Error(`wakusei-planets: no body called ${name}`);
61
+ return id;
62
+ }
63
+ const moonsOf = (planet) => {
64
+ const id = planet.toLowerCase();
65
+ return id === "earth" ? [] : id in MOONS_OF ? MOONS_OF[id] : [];
66
+ };
67
+ function paint(T, id, context, bases) {
68
+ const facts = BODIES[id];
69
+ const look = lookOf(id) ?? fallbackLook(id);
70
+ const wantPainted = (context.look === "painted" || look.mapped === true) && look.painted !== undefined;
71
+ const ratio = context.ratio ?? 1;
72
+ const maxWidth = context.maxTexture ?? 2048;
73
+ const redraw = () => context.invalidate?.();
74
+ const releases = [];
75
+ const disposables = [];
76
+ const sphere = acquire(T, "sphere", () => new T.SphereGeometry(1, 96, 48));
77
+ releases.push(sphere.release);
78
+ const group = new T.Group();
79
+ group.name = facts.name.en;
80
+ const uniforms = (painted) => {
81
+ const a = look.atmosphere;
82
+ return {
83
+ uLight: { value: new T.Vector3(1, 0, 0) },
84
+ uRelief: { value: painted ? (look.painted?.relief ?? 0) : (look.relief ?? 0.6) },
85
+ uAmbient: { value: look.ambient ?? 0.04 },
86
+ uLimb: { value: look.limb ?? 0 },
87
+ uWrap: { value: look.wrap ?? (a === undefined ? 0.04 : 0.3) },
88
+ uAtmos: { value: new T.Vector3(...(a?.colour ?? [0, 0, 0])) },
89
+ uAtmosPow: { value: a?.power ?? 3 },
90
+ uEmissive: { value: look.emissive === true ? 1 : 0 },
91
+ uRingN: { value: new T.Vector3(0, 1, 0) },
92
+ uRingR: { value: new T.Vector2(1, 2) },
93
+ uRingOn: { value: 0 },
94
+ uRingTex: { value: null },
95
+ uMap: { value: null },
96
+ uScale: { value: 1 },
97
+ };
98
+ };
99
+ const material = (painted) => {
100
+ const m = new T.ShaderMaterial({ vertexShader: VERTEX_WORLD, fragmentShader: bodyFragment(painted ? PAINTED_SURFACE : look.surface, painted, true), uniforms: uniforms(painted) });
101
+ disposables.push(m);
102
+ return m;
103
+ };
104
+ const shader = material(false);
105
+ const paintedMaterial = wantPainted ? material(true) : undefined;
106
+ const materials = [shader, ...(paintedMaterial ? [paintedMaterial] : [])];
107
+ const mesh = new T.Mesh(sphere.value, shader);
108
+ mesh.matrixAutoUpdate = false;
109
+ group.add(mesh);
110
+ // rings: the table's strip at once, the picture of them (painted) when it arrives
111
+ let ringMesh;
112
+ let ringMaterial;
113
+ let ringTexture;
114
+ let ringInner = 1;
115
+ let ringOuter = 2;
116
+ const table = look.emissive === true ? null : ringStrip(facts, 1024);
117
+ const loadImage = (url) => new Promise((resolve, reject) => {
118
+ const loader = new T.TextureLoader();
119
+ loader.setCrossOrigin("anonymous");
120
+ loader.load(url, resolve, undefined, (e) => reject(e instanceof Error ? e : new Error("texture failed")));
121
+ });
122
+ let gone = false;
123
+ if (table !== null) {
124
+ ringInner = table.inner;
125
+ ringOuter = table.outer;
126
+ const strip = new T.DataTexture(table.data, table.width, 1, T.RGBAFormat);
127
+ strip.magFilter = T.LinearFilter;
128
+ strip.minFilter = T.LinearFilter;
129
+ strip.wrapS = T.ClampToEdgeWrapping;
130
+ strip.needsUpdate = true;
131
+ ringTexture = strip;
132
+ const ringGeometry = new T.RingGeometry(ringInner, ringOuter, 256, 1);
133
+ disposables.push(ringGeometry);
134
+ ringMaterial = new T.ShaderMaterial({
135
+ vertexShader: RING_VERTEX_WORLD,
136
+ fragmentShader: RING_FRAGMENT_WORLD,
137
+ side: T.DoubleSide,
138
+ transparent: true,
139
+ depthWrite: false,
140
+ uniforms: { uRingTex: { value: strip }, uRingR: { value: new T.Vector2(ringInner, ringOuter) }, uLight: { value: new T.Vector3(1, 0, 0) }, uRingN: { value: new T.Vector3(0, 1, 0) }, uScale: { value: 1 } },
141
+ });
142
+ disposables.push(ringMaterial);
143
+ ringMesh = new T.Mesh(ringGeometry, ringMaterial);
144
+ ringMesh.matrixAutoUpdate = false;
145
+ ringMesh.renderOrder = 1;
146
+ group.add(ringMesh);
147
+ const photo = wantPainted ? look.painted?.rings : undefined;
148
+ if (photo !== undefined) {
149
+ void loadImage(`${bases.near}${photo.map}-${photo.width}.webp`).then((t) => {
150
+ if (gone) {
151
+ t.dispose();
152
+ return;
153
+ }
154
+ t.magFilter = T.LinearFilter;
155
+ t.minFilter = T.LinearFilter;
156
+ t.generateMipmaps = false;
157
+ t.wrapS = T.ClampToEdgeWrapping;
158
+ t.colorSpace = T.NoColorSpace;
159
+ ringTexture?.dispose();
160
+ ringTexture = t;
161
+ ringInner = photo.from / facts.radiusKm;
162
+ ringOuter = photo.to / facts.radiusKm;
163
+ ringMesh.geometry.dispose();
164
+ ringMesh.geometry = new T.RingGeometry(ringInner, ringOuter, 256, 1);
165
+ disposables.push(ringMesh.geometry);
166
+ ringMaterial.uniforms.uRingTex.value = t;
167
+ ringMaterial.uniforms.uRingR.value.set(ringInner, ringOuter);
168
+ for (const m of materials) {
169
+ m.uniforms.uRingTex.value = t;
170
+ m.uniforms.uRingR.value.set(ringInner, ringOuter);
171
+ }
172
+ redraw();
173
+ }, () => undefined);
174
+ }
175
+ for (const m of materials) {
176
+ m.uniforms.uRingOn.value = 1;
177
+ m.uniforms.uRingTex.value = strip;
178
+ m.uniforms.uRingR.value.set(ringInner, ringOuter);
179
+ }
180
+ }
181
+ // the glow of a star: a plane through its centre that the caller turns to the camera
182
+ if (look.emissive === true) {
183
+ const glowGeometry = acquire(T, "glow", () => new T.PlaneGeometry(6, 6));
184
+ releases.push(glowGeometry.release);
185
+ const glowMaterial = new T.ShaderMaterial({ vertexShader: GLOW_VERTEX, fragmentShader: GLOW_FRAGMENT, transparent: true, depthWrite: false, blending: T.AdditiveBlending, uniforms: { uColour: { value: new T.Vector3(1, 0.7, 0.3) } } });
186
+ disposables.push(glowMaterial);
187
+ const glow = new T.Mesh(glowGeometry.value, glowMaterial);
188
+ glow.renderOrder = 4;
189
+ glow.frustumCulled = false;
190
+ group.add(glow);
191
+ }
192
+ // the painted map: the smallest width at once, a sharper one when the body is big on the screen, back to the small one when it is not
193
+ const widths = (look.painted?.widths ?? []).filter((w, i) => i === 0 || w <= maxWidth);
194
+ let small;
195
+ let sharp;
196
+ let sharpWidth = 0;
197
+ let loading = false;
198
+ let failed = false;
199
+ let tooBigSince = 0;
200
+ const tried = new Set();
201
+ const urlOf = (w) => `${look.painted.far !== undefined && w >= look.painted.far ? bases.far : bases.near}${look.painted.map}-${w}.webp`;
202
+ const prepare = (t) => {
203
+ t.anisotropy = 4;
204
+ t.wrapS = T.RepeatWrapping;
205
+ t.colorSpace = T.NoColorSpace;
206
+ return t;
207
+ };
208
+ const show = (t) => {
209
+ paintedMaterial.uniforms.uMap.value = t;
210
+ mesh.material = paintedMaterial;
211
+ redraw();
212
+ };
213
+ if (wantPainted && paintedMaterial !== undefined && widths[0] !== undefined) {
214
+ void loadImage(urlOf(widths[0])).then((t) => {
215
+ if (gone) {
216
+ t.dispose();
217
+ return;
218
+ }
219
+ small = prepare(t);
220
+ if (sharp === undefined)
221
+ show(small);
222
+ }, () => {
223
+ failed = true;
224
+ });
225
+ }
226
+ const keepSized = (radiusPx) => {
227
+ if (!wantPainted || failed || small === undefined || loading)
228
+ return;
229
+ const wanted = radiusPx * 2 * ratio * 1.6;
230
+ const next = widths.find((w) => w >= wanted) ?? widths[widths.length - 1];
231
+ if (next > sharpWidth && next > widths[0] && !tried.has(next)) {
232
+ tried.add(next);
233
+ loading = true;
234
+ void loadImage(urlOf(next)).then((t) => {
235
+ loading = false;
236
+ if (gone) {
237
+ t.dispose();
238
+ return;
239
+ }
240
+ const old = sharp;
241
+ sharp = prepare(t);
242
+ sharpWidth = next;
243
+ show(sharp);
244
+ old?.dispose();
245
+ }, () => {
246
+ loading = false;
247
+ });
248
+ }
249
+ else if (sharp !== undefined && wanted * 2 < sharpWidth) {
250
+ // much smaller on the screen than the picture it holds: after a few seconds, let the big picture go
251
+ const now = Date.now();
252
+ if (tooBigSince === 0)
253
+ tooBigSince = now;
254
+ else if (now - tooBigSince > 4000) {
255
+ show(small);
256
+ sharp.dispose();
257
+ sharp = undefined;
258
+ sharpWidth = 0;
259
+ tried.clear();
260
+ tooBigSince = 0;
261
+ }
262
+ }
263
+ else
264
+ tooBigSince = 0;
265
+ };
266
+ const axes = facts.axesKm;
267
+ const scaleX = axes ? axes[1] / facts.radiusKm : 1;
268
+ const scaleY = axes ? axes[2] / facts.radiusKm : 1 - facts.flattening;
269
+ const scaleZ = axes ? axes[0] / facts.radiusKm : 1;
270
+ const basis = (object, x, y, z, sx = 1, sy = 1, sz = 1) => {
271
+ object.matrix.makeBasis(new T.Vector3(x[0] * sx, x[1] * sx, x[2] * sx), new T.Vector3(y[0] * sy, y[1] * sy, y[2] * sy), new T.Vector3(z[0] * sz, z[1] * sz, z[2] * sz));
272
+ };
273
+ const light = new T.Vector3();
274
+ const poleVec = new T.Vector3();
275
+ return {
276
+ object: group,
277
+ ...(look.emissive === true ? { billboard: true } : {}),
278
+ update(info) {
279
+ light.set(info.toSun[0], info.toSun[1], info.toSun[2]);
280
+ const scale = group.scale.x || 1;
281
+ if (look.emissive !== true) {
282
+ const { pole, meridian } = orientation(id, info.ms);
283
+ const east = cross(pole, meridian);
284
+ basis(mesh, east, pole, meridian, scaleX, scaleY, scaleZ);
285
+ if (ringMesh !== undefined)
286
+ basis(ringMesh, meridian, east, pole);
287
+ poleVec.set(pole[0], pole[1], pole[2]);
288
+ }
289
+ for (const m of materials) {
290
+ m.uniforms.uLight.value.copy(light);
291
+ m.uniforms.uScale.value = scale;
292
+ m.uniforms.uRingN.value.copy(poleVec);
293
+ }
294
+ if (ringMaterial !== undefined) {
295
+ ringMaterial.uniforms.uLight.value.copy(light);
296
+ ringMaterial.uniforms.uRingN.value.copy(poleVec);
297
+ ringMaterial.uniforms.uScale.value = scale;
298
+ }
299
+ keepSized(info.radiusPx);
300
+ },
301
+ dispose() {
302
+ gone = true;
303
+ small?.dispose();
304
+ sharp?.dispose();
305
+ ringTexture?.dispose();
306
+ for (const d of disposables)
307
+ d.dispose();
308
+ for (const r of releases)
309
+ r();
310
+ group.clear();
311
+ },
312
+ };
313
+ }
314
+ /**
315
+ * Make a painter that takes its pictures from the given folders (jsDelivr's copy of this version by default).
316
+ *
317
+ * @param options - Where the pictures are.
318
+ * @returns The painter.
319
+ *
320
+ * @example
321
+ * ```ts no-run
322
+ * import { createPainter } from "@johnmorrisdotca/wakusei-planets/painter";
323
+ *
324
+ * const painter = createPainter();
325
+ * console.log(painter.moons("Mars", Date.UTC(2026, 10, 2)).map((m) => m.name));
326
+ * ```
327
+ */
328
+ export function createPainter(options = {}) {
329
+ const near = slash(options.textureBase ?? TEXTURE_BASE);
330
+ const far = slash(options.farBase ?? (options.textureBase !== undefined ? options.textureBase : TEXTURE_FAR_BASE));
331
+ return {
332
+ looks: LOOKS,
333
+ make: (three, name, context = {}) => paint(three, idOf(name), context, { near, far }),
334
+ moons(planet, ms) {
335
+ const radius = BODIES[idOf(planet)]?.radiusKm;
336
+ if (radius === undefined)
337
+ return [];
338
+ return moonsOf(planet)
339
+ .map((moon) => ({ name: titled(moon), offset: moonOffset(moon, ms), radius: BODIES[moon].radiusKm / radius }))
340
+ .sort((a, b) => Math.hypot(...a.offset) - Math.hypot(...b.offset));
341
+ },
342
+ };
343
+ }
344
+ /**
345
+ * The painter with its default pictures: pass it to chikyu-earth's `setBodyPainter`.
346
+ *
347
+ * @example
348
+ * ```ts no-run
349
+ * import { painter } from "@johnmorrisdotca/wakusei-planets/painter";
350
+ * import { setBodyPainter } from "@johnmorrisdotca/chikyu-earth/space/element";
351
+ *
352
+ * setBodyPainter(painter);
353
+ * ```
354
+ */
355
+ export const painter = createPainter();
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const VERSION = "0.3.1";
1
+ export declare const VERSION = "0.5.0";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.3.1";
1
+ export const VERSION = "0.5.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@johnmorrisdotca/wakusei-planets",
3
- "version": "0.3.1",
3
+ "version": "0.5.0",
4
4
  "description": "Wakusei Planets 惑星 — The Sun, planets and moons for the web: each as a clean line drawing, a shaded realistic drawing or a painted surface map, with real sizes, axial tilt and rotation as data, and positions of the moons for any date and time. SVG for the lines, three.js (optional) for the rest.",
5
5
  "keywords": [
6
6
  "planets",
@@ -57,6 +57,11 @@
57
57
  "import": "./dist/scene/look.js",
58
58
  "default": "./dist/scene/look.js"
59
59
  },
60
+ "./painter": {
61
+ "types": "./dist/scene/painter.d.ts",
62
+ "import": "./dist/scene/painter.js",
63
+ "default": "./dist/scene/painter.js"
64
+ },
60
65
  "./all": {
61
66
  "types": "./dist/bodies/all.d.ts",
62
67
  "import": "./dist/bodies/all.js",
@@ -217,11 +222,11 @@
217
222
  },
218
223
  "packageManager": "pnpm@10.28.0",
219
224
  "dependencies": {
220
- "@johnmorrisdotca/kazoku-ui": "^0.9.1"
225
+ "@johnmorrisdotca/kazoku-ui": "^0.11.0"
221
226
  },
222
227
  "peerDependencies": {
223
- "three": ">=0.160.0",
224
- "@johnmorrisdotca/chikyu-earth": ">=0.10.0"
228
+ "@johnmorrisdotca/chikyu-earth": ">=0.10.0",
229
+ "three": ">=0.160.0"
225
230
  },
226
231
  "peerDependenciesMeta": {
227
232
  "three": {