@johnmorrisdotca/wakusei-planets 0.5.1 → 0.7.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.
@@ -0,0 +1,172 @@
1
+ // What is drawn over and under the bodies of a portrait, as plain shapes: the sky behind them (a few hundred stars, always the same ones), the rings of the moons' orbits, the names, and, while
2
+ // editing, the chosen body's outline and the guides. The same shapes become an SVG overlay on the page and 2D canvas strokes in an exported picture, so the two always agree. Pure: no DOM.
3
+ import { BODIES } from "../facts.js";
4
+ import { lineSvg } from "../line.js";
5
+ import { toScreen } from "./model.js";
6
+ /** The sky's stars: the same ones every time, so a picture and its export agree. */
7
+ export function starfield(count = 140) {
8
+ let seed = 20261103;
9
+ const next = () => {
10
+ seed = (seed * 1664525 + 1013904223) >>> 0;
11
+ return seed / 4294967296;
12
+ };
13
+ return Array.from({ length: count }, () => {
14
+ const big = next() > 0.9;
15
+ return { x: next(), y: next(), r: big ? 0.0016 : 0.0008 + next() * 0.0006, a: 0.35 + next() * 0.65 };
16
+ });
17
+ }
18
+ const STARS = starfield();
19
+ /** The CSS behind the picture for a background (the stars are separate gradients, so the page draws them without a script). */
20
+ export function backgroundCss(bg) {
21
+ if (bg === "transparent")
22
+ return "transparent";
23
+ if (bg === "gradient")
24
+ return "radial-gradient(120% 140% at 30% 20%, #1c3358 0%, #0d1a33 55%, #050914 100%)";
25
+ if (bg === "black")
26
+ return "#000";
27
+ const dots = STARS.map((s) => `radial-gradient(${(s.r * 1000).toFixed(1)}px ${(s.r * 1000).toFixed(1)}px at ${(s.x * 100).toFixed(2)}% ${(s.y * 100).toFixed(2)}%, rgba(255,255,255,${s.a.toFixed(2)}), transparent)`);
28
+ return `${dots.join(",")},radial-gradient(100% 120% at 50% 40%, #07101f 0%, #000 100%)`;
29
+ }
30
+ /** Paint the background onto a 2D canvas the size of the picture. */
31
+ export function paintBackground(ctx, bg, width, height) {
32
+ if (bg === "transparent")
33
+ return;
34
+ if (bg === "gradient") {
35
+ const g = ctx.createRadialGradient(width * 0.3, height * 0.2, 0, width * 0.3, height * 0.2, Math.max(width, height) * 1.1);
36
+ g.addColorStop(0, "#1c3358");
37
+ g.addColorStop(0.55, "#0d1a33");
38
+ g.addColorStop(1, "#050914");
39
+ ctx.fillStyle = g;
40
+ ctx.fillRect(0, 0, width, height);
41
+ return;
42
+ }
43
+ if (bg === "black") {
44
+ ctx.fillStyle = "#000";
45
+ ctx.fillRect(0, 0, width, height);
46
+ return;
47
+ }
48
+ const g = ctx.createRadialGradient(width / 2, height * 0.4, 0, width / 2, height * 0.4, Math.max(width, height) * 0.8);
49
+ g.addColorStop(0, "#07101f");
50
+ g.addColorStop(1, "#000");
51
+ ctx.fillStyle = g;
52
+ ctx.fillRect(0, 0, width, height);
53
+ for (const s of STARS) {
54
+ ctx.fillStyle = `rgba(255,255,255,${s.a})`;
55
+ ctx.beginPath();
56
+ ctx.arc(s.x * width, s.y * height, Math.max(0.5, s.r * 1000), 0, Math.PI * 2);
57
+ ctx.fill();
58
+ }
59
+ }
60
+ const labelSize = (width) => Math.max(11, Math.min(15, width / 60));
61
+ /** The orbit rings and the names for a picture of this size, and the selected body's outline and the guides when asked. */
62
+ export function shapesOf(p, resolved, width, height, lang = "en", edit) {
63
+ const at = toScreen(resolved.camera, width, height);
64
+ const out = { circles: [], lines: [], texts: [] };
65
+ const byBody = new Map(resolved.placed.map((q) => [q.body, q]));
66
+ if (p.orbits) {
67
+ for (const moon of resolved.placed) {
68
+ const host = BODIES[moon.body].parent === undefined ? undefined : byBody.get(BODIES[moon.body].parent);
69
+ if (host === undefined)
70
+ continue;
71
+ const d = Math.hypot(moon.x - host.x, moon.y - host.y) * at.scale;
72
+ if (d > 2)
73
+ out.circles.push({ cx: at.px(host.x), cy: at.py(host.y), r: d, kind: "orbit" });
74
+ }
75
+ }
76
+ if (p.labels) {
77
+ const size = labelSize(width);
78
+ const boxes = [];
79
+ for (const q of resolved.placed) {
80
+ const text = BODIES[q.body].name[lang];
81
+ const w = [...text].reduce((n, c) => n + (c.charCodeAt(0) > 255 ? size : size * 0.58), 0);
82
+ const x = at.px(q.x);
83
+ const y = at.py(q.y) + Math.max(q.r * at.scale, 2) + size * 1.25;
84
+ const box = { x0: x - w / 2, x1: x + w / 2, y0: y - size, y1: y + size * 0.3 };
85
+ if (box.x0 < 0 || box.x1 > width || box.y1 > height + size)
86
+ continue;
87
+ if (boxes.some((b) => box.x0 < b.x1 && box.x1 > b.x0 && box.y0 < b.y1 && box.y1 > b.y0))
88
+ continue;
89
+ boxes.push(box);
90
+ out.texts.push({ x, y, text, size });
91
+ }
92
+ }
93
+ if (edit?.selected !== undefined) {
94
+ const q = resolved.placed[edit.selected];
95
+ if (q !== undefined)
96
+ out.circles.push({ cx: at.px(q.x), cy: at.py(q.y), r: Math.max(q.r * at.scale, 5) + 5, kind: "select" });
97
+ }
98
+ for (const g of edit?.guides ?? [])
99
+ out.lines.push(g.axis === "x" ? { x1: at.px(g.at), y1: 0, x2: at.px(g.at), y2: height } : { x1: 0, y1: at.py(g.at), x2: width, y2: at.py(g.at) });
100
+ return out;
101
+ }
102
+ const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
103
+ const f = (n) => (Math.round(n * 100) / 100).toString();
104
+ /** The shapes as SVG elements (no wrapper), in the picture's pixels. */
105
+ export function shapesSvg(s) {
106
+ const circles = s.circles.map((c) => (c.kind === "orbit" ? `<circle cx="${f(c.cx)}" cy="${f(c.cy)}" r="${f(c.r)}" fill="none" stroke="#9fb4d6" stroke-opacity="0.45" stroke-width="1"/>` : `<circle cx="${f(c.cx)}" cy="${f(c.cy)}" r="${f(c.r)}" fill="none" stroke="#6ab7ff" stroke-width="2" stroke-dasharray="5 4"/>`));
107
+ const lines = s.lines.map((l) => `<line x1="${f(l.x1)}" y1="${f(l.y1)}" x2="${f(l.x2)}" y2="${f(l.y2)}" stroke="#ff5fa8" stroke-width="1" stroke-dasharray="4 3"/>`);
108
+ const texts = s.texts.map((t) => `<text x="${f(t.x)}" y="${f(t.y)}" font-size="${f(t.size)}" text-anchor="middle" font-family="system-ui,sans-serif" fill="#f2f6ff" stroke="#050914" stroke-width="3" stroke-opacity="0.8" paint-order="stroke" stroke-linejoin="round">${esc(t.text)}</text>`);
109
+ return [...circles, ...lines, ...texts].join("");
110
+ }
111
+ /** The shapes on a 2D canvas. */
112
+ export function drawShapes(ctx, s, ratio = 1) {
113
+ ctx.save();
114
+ ctx.scale(ratio, ratio);
115
+ for (const c of s.circles) {
116
+ ctx.beginPath();
117
+ ctx.arc(c.cx, c.cy, c.r, 0, Math.PI * 2);
118
+ if (c.kind === "orbit") {
119
+ ctx.strokeStyle = "rgba(159,180,214,0.45)";
120
+ ctx.lineWidth = 1;
121
+ ctx.setLineDash([]);
122
+ }
123
+ else {
124
+ ctx.strokeStyle = "#6ab7ff";
125
+ ctx.lineWidth = 2;
126
+ ctx.setLineDash([5, 4]);
127
+ }
128
+ ctx.stroke();
129
+ }
130
+ ctx.setLineDash([4, 3]);
131
+ ctx.strokeStyle = "#ff5fa8";
132
+ ctx.lineWidth = 1;
133
+ for (const l of s.lines) {
134
+ ctx.beginPath();
135
+ ctx.moveTo(l.x1, l.y1);
136
+ ctx.lineTo(l.x2, l.y2);
137
+ ctx.stroke();
138
+ }
139
+ ctx.setLineDash([]);
140
+ ctx.textAlign = "center";
141
+ ctx.lineJoin = "round";
142
+ for (const t of s.texts) {
143
+ ctx.font = `${t.size}px system-ui, sans-serif`;
144
+ ctx.lineWidth = 3;
145
+ ctx.strokeStyle = "rgba(5,9,20,0.8)";
146
+ ctx.strokeText(t.text, t.x, t.y);
147
+ ctx.fillStyle = "#f2f6ff";
148
+ ctx.fillText(t.text, t.x, t.y);
149
+ }
150
+ ctx.restore();
151
+ }
152
+ /**
153
+ * The portrait in the line look: one SVG, the bodies drawn one over another in the order of the list, each as a nested picture sized so its disc has the radius the layout gave it.
154
+ * Anything a body's rings or glow need past the disc is inside its own picture.
155
+ */
156
+ export function lineScene(p, resolved, width, height, poses, lang = "en") {
157
+ const at = toScreen(resolved.camera, width, height);
158
+ const dark = p.bg !== "transparent";
159
+ const stroke = dark ? undefined : "#33415c";
160
+ const parts = [];
161
+ resolved.placed.forEach((q, i) => {
162
+ const facts = BODIES[q.body];
163
+ const reach = Math.max(facts.rings === undefined ? 1 : Math.max(...facts.rings.map((b) => b.to)) / facts.radiusKm, q.body === "sun" ? 1.08 : 1);
164
+ const fill = 0.94 / reach;
165
+ const rPx = Math.max(q.r * at.scale, 1.2);
166
+ const side = (2 * rPx) / fill;
167
+ const inner = lineSvg(q.body, { size: 480, fill, labels: false, pose: poses[i], lang, ...(stroke !== undefined ? { stroke } : {}) });
168
+ const nested = inner.replace(/^<svg [^>]*>/, () => `<svg x="${f(at.px(q.x) - side / 2)}" y="${f(at.py(q.y) - side / 2)}" width="${f(side)}" height="${f(side)}" viewBox="0 0 480 480" role="img" aria-label="${esc(facts.name[lang])}" overflow="hidden">`);
169
+ parts.push(nested);
170
+ });
171
+ return parts.join("");
172
+ }
@@ -0,0 +1,13 @@
1
+ import { type Pose } from "../ephemeris.ts";
2
+ import { type BodyId } from "../facts.ts";
3
+ import { type Item } from "./model.ts";
4
+ /** The pitch a body is drawn at when none is chosen: a little over the pole, more for a body with rings. */
5
+ export declare const defaultPitch: (id: BodyId) => number;
6
+ /**
7
+ * The pole and the prime meridian of a body in the picture's frame (x right, y up, z toward the viewer), after `seconds` of its own turning.
8
+ * With a date the body is turned as the Earth sees it then, and the item's `spin` is how far it is turned from that (from the longitude its portrait shows).
9
+ */
10
+ export declare function poseOf(item: Pick<Item, "body" | "spin" | "tilt" | "pitch" | "turn">, ms: number | undefined, seconds: number, light: {
11
+ az: number;
12
+ el: number;
13
+ }): Pose;
@@ -0,0 +1,31 @@
1
+ // How one body of a portrait is turned: from its own spin, tilt and pitch, or from the sky at a date, then carried forward by its own turning. Pure.
2
+ import { apparent } from "../ephemeris.js";
3
+ import { BODIES } from "../facts.js";
4
+ import { portrait } from "../pose.js";
5
+ import { turn } from "../vec.js";
6
+ import { lightVector } from "./model.js";
7
+ /** The pitch a body is drawn at when none is chosen: a little over the pole, more for a body with rings. */
8
+ export const defaultPitch = (id) => (BODIES[id].rings !== undefined ? 22 : 12);
9
+ /**
10
+ * The pole and the prime meridian of a body in the picture's frame (x right, y up, z toward the viewer), after `seconds` of its own turning.
11
+ * With a date the body is turned as the Earth sees it then, and the item's `spin` is how far it is turned from that (from the longitude its portrait shows).
12
+ */
13
+ export function poseOf(item, ms, seconds, light) {
14
+ const facts = BODIES[item.body];
15
+ const toSun = lightVector(light);
16
+ let pole;
17
+ let meridian;
18
+ if (ms !== undefined) {
19
+ const sky = apparent(item.body, ms);
20
+ pole = sky.pole;
21
+ meridian = turn(sky.meridian, pole, -(item.spin - (facts.showLon ?? 0)));
22
+ }
23
+ else {
24
+ const base = portrait(item.body, { spin: item.spin, pitch: item.pitch ?? defaultPitch(item.body), ...(item.tilt !== undefined ? { tilt: item.tilt } : {}) });
25
+ pole = base.pole;
26
+ meridian = base.meridian;
27
+ }
28
+ if (item.turn !== 0 && seconds !== 0)
29
+ meridian = turn(meridian, pole, item.turn * seconds);
30
+ return { pole, meridian, light: toSun };
31
+ }
@@ -0,0 +1,38 @@
1
+ import type { Item3D, Scene3D } from "@johnmorrisdotca/kazoku-ui/renderer";
2
+ import type { ThreeParts } from "../scene/body.ts";
3
+ import "../bodies/all.ts";
4
+ import { type Placed, type Resolved } from "./model.ts";
5
+ /** Everything that decides how the scene looks at one draw. */
6
+ export interface PortraitDraw {
7
+ placed: readonly Placed[];
8
+ camera: Resolved["camera"];
9
+ light: {
10
+ az: number;
11
+ el: number;
12
+ };
13
+ look: "realistic" | "painted";
14
+ /** The moment the sky is shown at, in milliseconds since 1970, or `undefined` for a portrait. */
15
+ ms: number | undefined;
16
+ /** Seconds of each body's own turning so far. */
17
+ seconds: number;
18
+ shadows: boolean;
19
+ }
20
+ export interface PortraitScene extends Scene3D {
21
+ apply(state: PortraitDraw): void;
22
+ /** Call `fn` right after the next draw, in the same task, while the canvas still holds the picture (it does not keep it after). */
23
+ afterDraw(fn: () => void): void;
24
+ /** The paintings the scene holds now. */
25
+ readonly bodies: number;
26
+ }
27
+ export interface PortraitItemOptions {
28
+ /** A key that changes whenever the picture would: the renderer keeps one still for each key. */
29
+ key: string;
30
+ state(): PortraitDraw;
31
+ textureBase(): string;
32
+ farBase(): string;
33
+ redraw(): void;
34
+ }
35
+ /**
36
+ * The item the shared renderer shows for a portrait: its `build` makes the scene, whose `apply` is handed each state.
37
+ */
38
+ export declare function createPortraitItem(options: PortraitItemOptions): Item3D<ThreeParts>;
@@ -0,0 +1,123 @@
1
+ import { createPainter } from "../scene/painter.js";
2
+ import "../bodies/all.js";
3
+ import { lightVector } from "./model.js";
4
+ import { poseOf } from "./pose.js";
5
+ /** The smallest a body is drawn, in CSS pixels across its radius, so a body at true scale is still a dot you can see. */
6
+ const MIN_RADIUS_PX = 1.2;
7
+ const occludersFor = (placed, index) => {
8
+ const me = placed[index];
9
+ const list = [];
10
+ placed.forEach((other, j) => {
11
+ if (j === index || other.body === "sun" || other.r <= 0)
12
+ return;
13
+ const dx = (other.x - me.x) / me.r;
14
+ const dy = (other.y - me.y) / me.r;
15
+ const rr = other.r / me.r;
16
+ const d2 = dx * dx + dy * dy;
17
+ if (d2 > 3600 || rr < 0.01)
18
+ return;
19
+ list.push({ d2, angle: (rr * rr) / Math.max(d2, 1e-6), o: [dx, dy, 0, rr] });
20
+ });
21
+ return list.sort((a, b) => b.angle - a.angle).slice(0, 6).map((l) => l.o);
22
+ };
23
+ /**
24
+ * The item the shared renderer shows for a portrait: its `build` makes the scene, whose `apply` is handed each state.
25
+ */
26
+ export function createPortraitItem(options) {
27
+ return {
28
+ key: options.key,
29
+ build({ three: T, width, height }) {
30
+ const painter = createPainter({ textureBase: options.textureBase(), farBase: options.farBase() });
31
+ const scene = new T.Scene();
32
+ const camera = new T.OrthographicCamera(-1, 1, 1, -1, 0.1, 10);
33
+ const held = new Map();
34
+ let viewWidth = width;
35
+ let viewHeight = height;
36
+ let latest = options.state();
37
+ let after = [];
38
+ const ratio = () => Math.min(2, globalThis.devicePixelRatio || 1);
39
+ const drop = (id) => {
40
+ const one = held.get(id);
41
+ if (one === undefined)
42
+ return;
43
+ scene.remove(one.painting.object);
44
+ one.painting.dispose();
45
+ held.delete(id);
46
+ };
47
+ const apply = (state) => {
48
+ latest = state;
49
+ const { placed, camera: cam } = state;
50
+ const present = new Set(placed.map((p) => p.body));
51
+ for (const id of [...held.keys()])
52
+ if (!present.has(id) || held.get(id).look !== state.look)
53
+ drop(id);
54
+ const aspect = viewWidth / Math.max(1, viewHeight);
55
+ const halfH = cam.h;
56
+ const halfW = halfH * aspect;
57
+ const pxPerUnit = viewHeight / (2 * halfH);
58
+ const slot = 7 * Math.max(1e-6, ...placed.map((p) => Math.max(p.r, MIN_RADIUS_PX / pxPerUnit)));
59
+ const far = (placed.length + 1) * slot;
60
+ const distance = far + 300 * Math.max(halfW, halfH);
61
+ camera.left = cam.x - halfW;
62
+ camera.right = cam.x + halfW;
63
+ camera.top = cam.y + halfH;
64
+ camera.bottom = cam.y - halfH;
65
+ camera.near = Math.max(1e-6, distance - far - slot);
66
+ camera.far = distance + slot;
67
+ camera.position.set(cam.x, cam.y, distance);
68
+ camera.updateProjectionMatrix();
69
+ const toSun = lightVector(state.light);
70
+ placed.forEach((p, i) => {
71
+ let one = held.get(p.body);
72
+ if (one === undefined) {
73
+ const painting = painter.make(T, p.body, { look: state.look, invalidate: () => options.redraw(), ratio: ratio(), maxTexture: placed.length > 6 ? 1024 : 2048 });
74
+ scene.add(painting.object);
75
+ one = { painting, look: state.look };
76
+ held.set(p.body, one);
77
+ }
78
+ const r = Math.max(p.r, MIN_RADIUS_PX / pxPerUnit);
79
+ one.painting.object.position.set(p.x, p.y, i * slot);
80
+ one.painting.object.scale.setScalar(r);
81
+ const pose = poseOf(p, state.ms, state.seconds, state.light);
82
+ one.painting.update({
83
+ toSun,
84
+ ms: state.ms ?? 0,
85
+ radiusPx: r * pxPerUnit,
86
+ pose: { pole: pose.pole, meridian: pose.meridian },
87
+ ...(state.shadows ? { occluders: occludersFor(placed, i) } : {}),
88
+ });
89
+ });
90
+ };
91
+ apply(latest);
92
+ return {
93
+ scene,
94
+ camera,
95
+ apply,
96
+ get bodies() {
97
+ return held.size;
98
+ },
99
+ afterDraw(fn) {
100
+ after.push(fn);
101
+ },
102
+ resize(w, h) {
103
+ viewWidth = w;
104
+ viewHeight = h;
105
+ apply(latest);
106
+ },
107
+ update() {
108
+ if (after.length > 0) {
109
+ const run = after;
110
+ after = [];
111
+ queueMicrotask(() => run.forEach((fn) => fn()));
112
+ }
113
+ return false;
114
+ },
115
+ dispose() {
116
+ for (const id of [...held.keys()])
117
+ drop(id);
118
+ scene.clear();
119
+ },
120
+ };
121
+ },
122
+ };
123
+ }
@@ -4,7 +4,7 @@ import type { Pose } from "../ephemeris.ts";
4
4
  import { type BodyFacts, type BodyId } from "../facts.ts";
5
5
  import { type BodyLook } from "./look.ts";
6
6
  /** The parts of three.js the bodies use: a page may hand over a module with only these (see `setThreeLoader`). */
7
- export type ThreeParts = Pick<typeof THREE, "AdditiveBlending" | "BufferAttribute" | "BufferGeometry" | "ClampToEdgeWrapping" | "DataTexture" | "DoubleSide" | "Line" | "LineBasicMaterial" | "LinearFilter" | "Mesh" | "NoColorSpace" | "OrthographicCamera" | "PlaneGeometry" | "RepeatWrapping" | "RGBAFormat" | "RingGeometry" | "Scene" | "ShaderMaterial" | "SphereGeometry" | "TextureLoader" | "Vector2" | "Vector3" | "WebGLRenderer">;
7
+ export type ThreeParts = Pick<typeof THREE, "AdditiveBlending" | "BufferAttribute" | "BufferGeometry" | "ClampToEdgeWrapping" | "DataTexture" | "DoubleSide" | "Group" | "Line" | "LineBasicMaterial" | "LinearFilter" | "Mesh" | "NoColorSpace" | "OrthographicCamera" | "PlaneGeometry" | "RepeatWrapping" | "RGBAFormat" | "RingGeometry" | "Scene" | "ShaderMaterial" | "SphereGeometry" | "TextureLoader" | "Vector2" | "Vector3" | "WebGLRenderer">;
8
8
  /** One moon in a system view: its place in planet radii (viewer's frame) and the points of its orbit, if they changed. */
9
9
  export interface MoonDraw {
10
10
  id: BodyId;
@@ -23,6 +23,8 @@ export interface SceneState {
23
23
  panX: number;
24
24
  panY: number;
25
25
  moons?: readonly MoonDraw[];
26
+ /** The body is drawn as the sky shows it at a date: its real phase, with no light on the night side from a wrapped terminator or a rim of air. */
27
+ sky?: boolean;
26
28
  /** The smallest a moon is drawn, in CSS pixels across its radius. Default 2.5. */
27
29
  minMoonPx?: number;
28
30
  /** The colour of the orbit lines, `#rrggbb`. */
@@ -23,6 +23,7 @@ function makeBodyMaterial(T, look, painted) {
23
23
  uWrap: { value: look.wrap ?? (a === undefined ? 0.04 : 0.3) },
24
24
  uAtmos: { value: col3(T, a?.colour ?? [0, 0, 0]) },
25
25
  uAtmosPow: { value: a?.power ?? 3 },
26
+ uRimBase: { value: 0.12 },
26
27
  uEmissive: { value: look.emissive === true ? 1 : 0 },
27
28
  uRingN: { value: new T.Vector3(0, 1, 0) },
28
29
  uRingR: { value: new T.Vector2(1, 2) },
@@ -56,6 +57,8 @@ export function createBodyItem(options) {
56
57
  const geometry = assets.acquire("wk:sphere", () => new T.SphereGeometry(1, 128, 64), (g) => g.dispose());
57
58
  const shader = assets.acquire(`wk:body:${options.id}:r`, () => makeBodyMaterial(T, look, false), (m) => m.dispose());
58
59
  const paintedMaterial = wantPainted ? assets.acquire(`wk:body:${options.id}:p`, () => makeBodyMaterial(T, look, true), (m) => m.dispose()) : undefined;
60
+ const baseWrap = look.wrap ?? (look.atmosphere === undefined ? 0.04 : 0.3);
61
+ const baseAmbient = look.ambient ?? 0.04;
59
62
  const scene = new T.Scene();
60
63
  const camera = new T.OrthographicCamera(-1, 1, 1, -1, 0.1, 400);
61
64
  camera.position.set(0, 0, 200);
@@ -279,8 +282,15 @@ export function createBodyItem(options) {
279
282
  mat.uniforms.uRingTex.value = ringTexture;
280
283
  }
281
284
  }
282
- for (const mat of [shader, paintedMaterial])
283
- mat?.uniforms.uLight.value.copy(v3(T, state.pose.light));
285
+ for (const mat of [shader, paintedMaterial]) {
286
+ if (mat === undefined)
287
+ continue;
288
+ mat.uniforms.uLight.value.copy(v3(T, state.pose.light));
289
+ const sky = state.sky === true;
290
+ mat.uniforms.uWrap.value = sky ? 0.02 : (mat === paintedMaterial ? baseWrap : baseWrap);
291
+ mat.uniforms.uAmbient.value = sky ? 0.012 : baseAmbient;
292
+ mat.uniforms.uRimBase.value = sky ? 0.02 : 0.12;
293
+ }
284
294
  if (glow !== undefined)
285
295
  glow.scale.setScalar(1);
286
296
  // a sharper picture, when the disc is big enough to show it
@@ -61,6 +61,7 @@ uniform float uLimb;
61
61
  uniform float uWrap;
62
62
  uniform vec3 uAtmos;
63
63
  uniform float uAtmosPow;
64
+ uniform float uRimBase;
64
65
  uniform float uEmissive;
65
66
  uniform vec3 uRingN;
66
67
  uniform vec2 uRingR;
@@ -70,7 +71,7 @@ ${painted ? "uniform sampler2D uMap;" : ""}
70
71
  varying vec3 vObj;
71
72
  varying vec3 vN;
72
73
  varying vec3 vW;
73
- ${world ? "varying vec3 vV;" : ""}
74
+ ${world ? "varying vec3 vV;\nuniform int uOccN;\nuniform vec4 uOcc[6];" : ""}
74
75
  ${PRELUDE}
75
76
  ${surface}
76
77
  void main() {
@@ -103,10 +104,19 @@ void main() {
103
104
  }
104
105
  }
105
106
  }
107
+ ${world ? ` for (int i = 0; i < 6; i++) {
108
+ if (i >= uOccN) break;
109
+ vec3 oc = uOcc[i].xyz - vW;
110
+ float along = dot(oc, uLight);
111
+ if (along > 0.0) {
112
+ float miss = length(oc - along * uLight);
113
+ diff *= 1.0 - 0.92 * (1.0 - smoothstep(uOcc[i].w * 0.8, uOcc[i].w * 1.04 + 0.01, miss));
114
+ }
115
+ }` : ""}
106
116
  vec3 alb = pow(s.rgb, vec3(2.2));
107
117
  vec3 lit = alb * (uAmbient + (1.0 - uAmbient) * diff * 1.18);
108
118
  float rim = pow(1.0 - mu, uAtmosPow);
109
- lit += pow(uAtmos, vec3(2.2)) * rim * (0.12 + 0.88 * clamp(dg * 1.6 + 0.35, 0.0, 1.0));
119
+ lit += pow(uAtmos, vec3(2.2)) * rim * (uRimBase + (1.0 - uRimBase) * clamp(dg * 1.6 + 0.35, 0.0, 1.0));
110
120
  lit *= mix(1.0, pow(mu, 0.45), uLimb);
111
121
  gl_FragColor = vec4(pow(lit, vec3(1.0 / 2.2)), 1.0);
112
122
  }`;
@@ -63,6 +63,13 @@ export interface PaintInfo {
63
63
  toSun: Vec3;
64
64
  ms: number;
65
65
  radiusPx: number;
66
+ /** The pole and the prime meridian to draw instead of the body's real ones at `ms` (a portrait's own pose), in the scene's axes. */
67
+ pose?: {
68
+ pole: Vec3;
69
+ meridian: Vec3;
70
+ };
71
+ /** Spheres that may shade this body from the Sun: x, y, z (from this body's centre) and radius, all in this body's radii, in the scene's axes. At most six are used. */
72
+ occluders?: readonly (readonly [number, number, number, number])[];
66
73
  }
67
74
  /**
68
75
  * One body made by the painter: an object of radius 1 at the origin for the caller to move and scale.
@@ -87,6 +87,7 @@ function paint(T, id, context, bases) {
87
87
  uWrap: { value: look.wrap ?? (a === undefined ? 0.04 : 0.3) },
88
88
  uAtmos: { value: new T.Vector3(...(a?.colour ?? [0, 0, 0])) },
89
89
  uAtmosPow: { value: a?.power ?? 3 },
90
+ uRimBase: { value: 0.12 },
90
91
  uEmissive: { value: look.emissive === true ? 1 : 0 },
91
92
  uRingN: { value: new T.Vector3(0, 1, 0) },
92
93
  uRingR: { value: new T.Vector2(1, 2) },
@@ -94,6 +95,8 @@ function paint(T, id, context, bases) {
94
95
  uRingTex: { value: null },
95
96
  uMap: { value: null },
96
97
  uScale: { value: 1 },
98
+ uOccN: { value: 0 },
99
+ uOcc: { value: new Float32Array(24) },
97
100
  };
98
101
  };
99
102
  const material = (painted) => {
@@ -279,17 +282,23 @@ function paint(T, id, context, bases) {
279
282
  light.set(info.toSun[0], info.toSun[1], info.toSun[2]);
280
283
  const scale = group.scale.x || 1;
281
284
  if (look.emissive !== true) {
282
- const { pole, meridian } = orientation(id, info.ms);
285
+ const { pole, meridian } = info.pose ?? orientation(id, info.ms);
283
286
  const east = cross(pole, meridian);
284
287
  basis(mesh, east, pole, meridian, scaleX, scaleY, scaleZ);
285
288
  if (ringMesh !== undefined)
286
289
  basis(ringMesh, meridian, east, pole);
287
290
  poleVec.set(pole[0], pole[1], pole[2]);
288
291
  }
292
+ const occluders = info.occluders ?? [];
293
+ const shaded = Math.min(6, occluders.length);
289
294
  for (const m of materials) {
290
295
  m.uniforms.uLight.value.copy(light);
291
296
  m.uniforms.uScale.value = scale;
292
297
  m.uniforms.uRingN.value.copy(poleVec);
298
+ m.uniforms.uOccN.value = shaded;
299
+ const packed = m.uniforms.uOcc.value;
300
+ for (let i = 0; i < shaded; i += 1)
301
+ packed.set(occluders[i], i * 4);
293
302
  }
294
303
  if (ringMaterial !== undefined) {
295
304
  ringMaterial.uniforms.uLight.value.copy(light);
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const VERSION = "0.5.1";
1
+ export declare const VERSION = "0.7.0";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.5.1";
1
+ export const VERSION = "0.7.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@johnmorrisdotca/wakusei-planets",
3
- "version": "0.5.1",
3
+ "version": "0.7.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",
@@ -34,8 +34,10 @@
34
34
  "type": "module",
35
35
  "sideEffects": [
36
36
  "./dist/element.js",
37
+ "./dist/portrait/element.js",
37
38
  "./dist/bodies/*.js",
38
39
  "./src/element.ts",
40
+ "./src/portrait/element.ts",
39
41
  "./src/bodies/*.ts"
40
42
  ],
41
43
  "main": "./dist/index.js",
@@ -52,6 +54,11 @@
52
54
  "import": "./dist/element.js",
53
55
  "default": "./dist/element.js"
54
56
  },
57
+ "./portrait": {
58
+ "types": "./dist/portrait/element.d.ts",
59
+ "import": "./dist/portrait/element.js",
60
+ "default": "./dist/portrait/element.js"
61
+ },
55
62
  "./look": {
56
63
  "types": "./dist/scene/look.d.ts",
57
64
  "import": "./dist/scene/look.js",