@linhey/rhine-design 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +19 -8
  3. package/components/Actions.tsx +18 -1
  4. package/components/AppShell.tsx +46 -17
  5. package/components/Media.tsx +7 -7
  6. package/components/NeutronStarBackdrop.tsx +43 -0
  7. package/components/OrbitBackdrop.tsx +4 -2
  8. package/components/Overlays.tsx +37 -8
  9. package/components/README.md +27 -3
  10. package/components/StatusTimeline.tsx +72 -6
  11. package/components/StatusTimelineList.tsx +38 -0
  12. package/components/ThemePicker.tsx +49 -0
  13. package/components/assets/orbit-bird.svg +578 -0
  14. package/components/assets/orbit-crystal.svg +11 -0
  15. package/components/assets/orbit-donut.svg +16 -0
  16. package/components/assets/theme-previews/arknights.png +0 -0
  17. package/components/assets/theme-previews/manifest.json +24 -0
  18. package/components/assets/theme-previews/neo-brutalism.png +0 -0
  19. package/components/assets/theme-previews/rhine.png +0 -0
  20. package/components/common.css +3 -3
  21. package/components/components.tsx +3 -3
  22. package/components/dialog-settings.css +9 -0
  23. package/components/extensions.css +8 -32
  24. package/components/index.ts +11 -5
  25. package/components/media-parity.css +55 -2
  26. package/components/neutron.css +53 -0
  27. package/components/neutronRenderer.ts +327 -0
  28. package/components/orbit.css +79 -1
  29. package/components/orbitBirdFlight.ts +34 -0
  30. package/components/orbitBirdGeometry.ts +158 -0
  31. package/components/orbitDonut.ts +134 -0
  32. package/components/orbitDonutThree.ts +208 -0
  33. package/components/orbitObjectMaterials.ts +76 -0
  34. package/components/orbitObjects.ts +71 -0
  35. package/components/orbitObjectsThree.ts +215 -0
  36. package/components/orbitProjection.ts +21 -0
  37. package/components/orbitRenderer.ts +163 -46
  38. package/components/styles.css +3 -1
  39. package/components/theme-neo-brutalism.css +3 -2
  40. package/components/theme-picker.css +17 -0
  41. package/components/theme.tsx +2 -8
  42. package/components/themeMetadata.ts +7 -0
  43. package/components/timeline.css +17 -6
  44. package/components/useFloatingScale.ts +96 -0
  45. package/docs/APPLICATION_COMPONENTS.md +52 -3
  46. package/docs/COMPACT_ACCOUNT_PATTERNS.md +70 -0
  47. package/docs/DATE_RANGE_FILTER.md +60 -0
  48. package/docs/DESIGN_SYSTEM.md +7 -0
  49. package/docs/FLOATING_OVERLAYS.md +15 -0
  50. package/docs/INTERACTION_PATTERNS.md +5 -1
  51. package/docs/NEUTRON_STAR_BACKDROP.md +122 -0
  52. package/docs/ORBIT_BACKDROP.md +38 -46
  53. package/docs/SETTINGS_DIALOG.md +21 -0
  54. package/docs/THEME_PICKER.md +26 -0
  55. package/docs/THEMING.md +2 -0
  56. package/docs/assets/compact-account-neo.png +0 -0
  57. package/docs/assets/neutron-star-original.png +0 -0
  58. package/docs/assets/neutron-star-theme.png +0 -0
  59. package/docs/assets/orbit-bird.png +0 -0
  60. package/docs/assets/orbit-crystal.png +0 -0
  61. package/docs/assets/orbit-strawberry.png +0 -0
  62. package/package.json +8 -3
  63. package/preview/CompactAccountGallery.tsx +178 -0
  64. package/preview/DateRangeFilterGallery.tsx +109 -0
  65. package/preview/ExtensionsGallery.tsx +27 -18
  66. package/preview/GalleryShell.tsx +3 -3
  67. package/preview/MonitoringGallery.tsx +2 -0
  68. package/preview/NeutronStarGallery.tsx +56 -0
  69. package/preview/OrbitGallery.tsx +35 -32
  70. package/preview/OverlayBoundsGallery.tsx +49 -0
  71. package/preview/SettingsDialogGallery.tsx +33 -0
  72. package/preview/ThemePickerGallery.tsx +24 -0
  73. package/preview/ThemeSample.tsx +14 -0
  74. package/preview/TimelineListGallery.tsx +36 -0
  75. package/preview/extensions.css +9 -0
  76. package/preview/main.tsx +16 -3
  77. package/preview/preview.css +8 -0
  78. package/preview/theme-sample.html +1 -0
  79. package/scripts/check-overlay-bounds.js +76 -0
  80. package/scripts/check-settings-theme.js +88 -0
  81. package/skills/rhine-design/SKILL.md +3 -2
  82. package/skills/rhine-design/references/API_MAP.md +13 -2
  83. package/skills/rhine-design/references/TASK_MAP.md +8 -2
  84. package/skills/rhine-design/references/integration.md +1 -1
  85. package/tests/AppShellControls.test.tsx +82 -0
  86. package/tests/ButtonLink.test.tsx +94 -0
  87. package/tests/CompactAccountGallery.test.tsx +116 -0
  88. package/tests/DateRangeFilterGallery.test.tsx +85 -0
  89. package/tests/NeutronStarBackdrop.test.tsx +288 -0
  90. package/tests/NeutronStarGallery.test.tsx +62 -0
  91. package/tests/NeutronStarImport.test.tsx +15 -0
  92. package/tests/OrbitBackdrop.test.tsx +246 -9
  93. package/tests/OrbitDonutThree.test.tsx +91 -0
  94. package/tests/OrbitObjectsThree.test.tsx +164 -0
  95. package/tests/SettingsDialog.test.tsx +66 -0
  96. package/tests/StatusTimelineList.test.tsx +63 -0
  97. package/tests/ThemePicker.test.tsx +30 -0
  98. package/tests/TimelineLegend.test.tsx +60 -0
@@ -0,0 +1,134 @@
1
+ import { orbitCameraDistance, orbitFraming, projectOrbitPoint, rotateOrbitPoint, type OrbitVector as Vec3 } from './orbitProjection';
2
+
3
+ type Vertex = { position: Vec3; normal: Vec3 };
4
+ type Face = { points: Vertex[]; icing: boolean };
5
+ type Stroke = { points: Vertex[]; color?: string; width: number };
6
+
7
+ const TAU = Math.PI * 2;
8
+ // Match the original ring's ~1.2 outer envelope and ~.46 opening in scene units.
9
+ export const orbitDonutShape = { ringRadius: .84, tubeRadius: .34, icingThickness: .022 };
10
+ const { ringRadius, tubeRadius, icingThickness } = orbitDonutShape;
11
+ const sugarColors = ['#fff8e8', '#ffd44b', '#36735c', '#e54c77'];
12
+ const glazeShades = [[210, 75, 116], [242, 126, 165], [255, 181, 208]];
13
+ const doughShades = [[177, 96, 48], [224, 154, 83], [249, 197, 128]];
14
+
15
+ function shade(light: number, icing: boolean) {
16
+ const ramp = icing ? glazeShades : doughShades, t = Math.max(0, Math.min(1.99, (light + .2) * 1.65));
17
+ const index = Math.floor(t), blend = t - index;
18
+ return `rgb(${ramp[index].map((channel, i) => Math.round(channel + (ramp[index + 1][i] - channel) * blend)).join(',')})`;
19
+ }
20
+
21
+ function surface(a: number, b: number, icing = false): Vertex {
22
+ const radius = tubeRadius + (icing ? icingThickness : 0);
23
+ return {
24
+ position: { x: (ringRadius + radius * Math.cos(b)) * Math.cos(a), y: (ringRadius + radius * Math.cos(b)) * Math.sin(a), z: radius * Math.sin(b) },
25
+ normal: { x: Math.cos(b) * Math.cos(a), y: Math.cos(b) * Math.sin(a), z: Math.sin(b) },
26
+ };
27
+ }
28
+
29
+ function icingEdge(a: number, inner = false) {
30
+ const drip = Math.pow((1 + Math.sin(a * (inner ? 7 : 11) + .8 * Math.sin(a * 3))) / 2, 5);
31
+ return inner ? Math.PI - .12 + drip * .4 : .18 - drip * .65;
32
+ }
33
+
34
+ /** A small painted torus; the Orbit lifecycle owns its clock, pause and cleanup. */
35
+ export function createOrbitDonut() {
36
+ const faces: Face[] = [], details: Stroke[] = [];
37
+ const rings = 128;
38
+ for (const icing of [false, true]) {
39
+ const slices = icing ? 20 : 24;
40
+ const point = (i: number, j: number) => {
41
+ const a = i / rings * TAU;
42
+ const b = icing ? icingEdge(a) + (icingEdge(a, true) - icingEdge(a)) * j / slices : j / slices * TAU;
43
+ return surface(a, b, icing);
44
+ };
45
+ for (let i = 0; i < rings; i++) for (let j = 0; j < slices; j++) {
46
+ faces.push({ icing, points: [point(i, j), point(i + 1, j), point(i + 1, j + 1), point(i, j + 1)] });
47
+ }
48
+ }
49
+ for (const inner of [false, true]) for (let i = 0; i < rings; i++) {
50
+ const a = i / rings * TAU, next = (i + 1) / rings * TAU;
51
+ details.push({ points: [surface(a, icingEdge(a, inner), true), surface(next, icingEdge(next, inner), true)], width: 2.2 });
52
+ }
53
+ let seed = 57;
54
+ const random = () => { seed = (seed * 1664525 + 1013904223) >>> 0; return seed / 4294967296; };
55
+ // Decorations are attached to the icing, so they rotate with the dough rather than over it.
56
+ for (let i = 0; i < 72; i++) {
57
+ const a = (i + random() * .65) / 72 * TAU, b = .35 + random() * 2.35, angle = random() * TAU;
58
+ const da = Math.cos(angle) * .045, db = Math.sin(angle) * .12;
59
+ details.push({ points: [surface(a - da, b - db, true), surface(a + da, b + db, true)], color: sugarColors[i % sugarColors.length], width: 3.3 });
60
+ }
61
+ // Short, uneven glaze highlights give the pink surface a soft, hand-painted finish.
62
+ for (const start of [.1, 2.4, 4.2]) {
63
+ details.push({ points: Array.from({ length: 15 }, (_, i) => surface(start + i * .025, .95, true)), color: '#ffe0eb', width: 3.6 });
64
+ }
65
+
66
+ return (ctx: CanvasRenderingContext2D, width: number, height: number, time: number, ink: string) => {
67
+ ctx.clearRect(0, 0, width, height);
68
+ ctx.save();
69
+ ctx.globalAlpha = 1; ctx.lineWidth = 1; ctx.shadowBlur = 0; ctx.setLineDash([]); ctx.lineDashOffset = 0;
70
+ ctx.lineCap = 'round'; ctx.lineJoin = 'round';
71
+ const { scale, centerX: cx, centerY: cy } = orbitFraming(width, height);
72
+ // Match the original orbit's camera: only the donut's local ring rotation advances.
73
+ const spin = time * .28 - .35, cos = Math.cos(spin), sin = Math.sin(spin);
74
+ const rotate = (p: Vec3) => rotateOrbitPoint({ x: p.x * cos - p.y * sin, y: p.x * sin + p.y * cos, z: p.z });
75
+ const project = (p: Vec3) => projectOrbitPoint(p, scale, cx, cy);
76
+ const facing = (p: Vec3, n: Vec3) => n.z * (orbitCameraDistance - p.z) - n.x * p.x - n.y * p.y;
77
+ const path = (points: Vec3[]) => {
78
+ ctx.beginPath();
79
+ points.forEach((p, i) => { const v = project(p); if (i) ctx.lineTo(v.x, v.y); else ctx.moveTo(v.x, v.y); });
80
+ };
81
+ // A crisp offset shadow belongs to the same chunky illustration language as the host.
82
+ ctx.beginPath();
83
+ for (let i = 0; i <= 64; i++) {
84
+ const a = i / 64 * TAU, x = cx + Math.cos(a) * scale * 1.03 + 9, y = cy + scale * 1.45 + Math.sin(a) * scale * .12;
85
+ if (i) ctx.lineTo(x, y); else ctx.moveTo(x, y);
86
+ }
87
+ ctx.fillStyle = '#e5c8b9'; ctx.fill();
88
+
89
+ type Layer = { points: Vec3[]; depth: number; order: number; color: string; width?: number; outline?: boolean };
90
+ const layers: Layer[] = [];
91
+ for (const face of faces) {
92
+ const normals = face.points.map(p => rotate(p.normal));
93
+ const normal = normals.reduce((sum, n) => ({ x: sum.x + n.x / 4, y: sum.y + n.y / 4, z: sum.z + n.z / 4 }), { x: 0, y: 0, z: 0 });
94
+ const points = face.points.map(p => rotate(p.position));
95
+ const center = points.reduce((sum, p) => ({ x: sum.x + p.x / 4, y: sum.y + p.y / 4, z: sum.z + p.z / 4 }), { x: 0, y: 0, z: 0 });
96
+ if (facing(center, normal) <= 0) continue;
97
+ const light = -normal.x * .35 - normal.y * .45 + normal.z * .82;
98
+ layers.push({ points, depth: points.reduce((sum, p) => sum + p.z / 4, 0), order: face.icing ? 1 : 0, color: shade(light, face.icing) });
99
+ }
100
+ for (const detail of details) {
101
+ if (detail.points.some(p => facing(rotate(p.position), rotate(p.normal)) < .08)) continue;
102
+ const points = detail.points.map(p => rotate(p.position));
103
+ layers.push({ points, depth: points.reduce((sum, p) => sum + p.z / points.length, 0), order: 2,
104
+ color: detail.color ?? ink, width: detail.width, outline: !!detail.color && detail.color !== '#ffe0eb' });
105
+ }
106
+ // The visible tube contours include the inner hole; draw only their silhouette, not a mesh.
107
+ for (const inner of [false, true]) for (let i = 0; i < rings; i++) {
108
+ const contour = (a: number) => {
109
+ const radial = rotate({ x: Math.cos(a), y: Math.sin(a), z: 0 }), axis = rotate({ x: 0, y: 0, z: 1 });
110
+ const towardRing = orbitCameraDistance * radial.z - ringRadius, towardAxis = orbitCameraDistance * axis.z;
111
+ const phase = Math.atan2(towardRing, towardAxis), tangent = Math.asin(tubeRadius / Math.hypot(towardRing, towardAxis));
112
+ const b = (inner ? Math.PI - tangent : tangent) - phase;
113
+ const icing = b >= icingEdge(a) && b <= icingEdge(a, true);
114
+ return rotate(surface(a, b, icing).position);
115
+ };
116
+ const points = [contour(i / rings * TAU), contour((i + 1) / rings * TAU)];
117
+ layers.push({ points, depth: (points[0].z + points[1].z) / 2, order: 2, color: ink, width: 3 });
118
+ }
119
+ // The glaze is a coating, not intersecting geometry: paint its visible shell over the dough.
120
+ layers.sort((a, b) => a.order - b.order || a.depth - b.depth);
121
+ const strokeScale = Math.max(.7, Math.min(1.4, scale / 130));
122
+ for (const layer of layers) {
123
+ path(layer.points);
124
+ if (layer.width) {
125
+ if (layer.outline) { ctx.strokeStyle = ink; ctx.lineWidth = (layer.width + 1.7) * strokeScale; ctx.stroke(); }
126
+ ctx.strokeStyle = layer.color; ctx.lineWidth = layer.width * strokeScale; ctx.stroke();
127
+ } else {
128
+ ctx.closePath(); ctx.fillStyle = layer.color; ctx.fill();
129
+ ctx.strokeStyle = layer.color; ctx.lineWidth = .65; ctx.stroke();
130
+ }
131
+ }
132
+ ctx.restore();
133
+ };
134
+ }
@@ -0,0 +1,208 @@
1
+ import {
2
+ BackSide, DoubleSide, BufferGeometry, CapsuleGeometry, CanvasTexture, Color, DirectionalLight,
3
+ Float32BufferAttribute, Group, HemisphereLight, InstancedMesh, Material, Mesh,
4
+ MeshBasicMaterial, MeshPhysicalMaterial, MeshStandardMaterial, Object3D, PerspectiveCamera,
5
+ PlaneGeometry, Points, Scene, ShaderMaterial, SphereGeometry, SRGBColorSpace, Vector3, WebGLRenderer,
6
+ } from 'three';
7
+
8
+ import { orbitCameraDistance, orbitFraming, rotateOrbitPoint } from './orbitProjection';
9
+ import { orbitDonutShape } from './orbitDonut';
10
+
11
+ export interface DonutArtwork {
12
+ canvas: HTMLCanvasElement;
13
+ draw(width: number, height: number, time: number): boolean;
14
+ dispose(): void;
15
+ }
16
+
17
+ const TAU = Math.PI * 2;
18
+ const { ringRadius, tubeRadius, icingThickness } = orbitDonutShape;
19
+ const icingRadius = tubeRadius + icingThickness;
20
+ const sugarColors = ['#fff7de', '#ffd348', '#659f83', '#df3e76', '#bb9cdb'];
21
+ const ink = '#37212b';
22
+
23
+ /** This painter has no clock of its own: Orbit owns pause, visibility and reduced motion. */
24
+ export function createDonutArtwork(invalidate: () => void): DonutArtwork {
25
+ const geometries = new Set<BufferGeometry>(), materials = new Set<Material>(), textures = new Set<CanvasTexture>(), instances = new Set<InstancedMesh>();
26
+ const geometry = <T extends BufferGeometry>(value: T) => { geometries.add(value); return value; };
27
+ const material = <T extends Material>(value: T) => { materials.add(value); return value; };
28
+ let renderer: WebGLRenderer | undefined, disposed = false, lost = false;
29
+ const canvas = document.createElement('canvas');
30
+ canvas.className = 'rhine-orbit-canvas rhine-orbit-donut-webgl';
31
+ canvas.setAttribute('aria-hidden', 'true');
32
+ function contextLost(event: Event) { event.preventDefault(); lost = true; invalidate(); }
33
+ function contextRestored() { lost = false; invalidate(); }
34
+ function dispose() {
35
+ if (disposed) return;
36
+ disposed = true;
37
+ canvas.removeEventListener('webglcontextlost', contextLost);
38
+ canvas.removeEventListener('webglcontextrestored', contextRestored);
39
+ instances.forEach(value => value.dispose()); geometries.forEach(value => value.dispose()); materials.forEach(value => value.dispose()); textures.forEach(value => value.dispose());
40
+ renderer?.dispose(); renderer?.forceContextLoss(); canvas.remove();
41
+ }
42
+ try {
43
+ renderer = new WebGLRenderer({ canvas, alpha: true, antialias: true, powerPreference: 'low-power' });
44
+ renderer.setClearColor(0x000000, 0);
45
+ renderer.outputColorSpace = SRGBColorSpace;
46
+ const scene = new Scene();
47
+ const camera = new PerspectiveCamera(36, 1, .1, 50);
48
+ scene.add(new HemisphereLight('#fff5ec', '#9d5b77', 2.5));
49
+ const key = new DirectionalLight('#fff8eb', 3.2); key.position.set(-3, 5, 6); scene.add(key);
50
+ const rim = new DirectionalLight('#ffd2e5', 2.1); rim.position.set(4, 1, -2); scene.add(rim);
51
+ const fill = new DirectionalLight('#ffffff', .8); fill.position.set(0, -3, 4); scene.add(fill);
52
+ const frame = new Group(); frame.name = 'orbit-camera-frame'; frame.matrixAutoUpdate = false;
53
+ const basis = (x: number, y: number, z: number) => { const p = rotateOrbitPoint({ x, y, z }); return new Vector3(p.x, -p.y, p.z); };
54
+ frame.matrix.makeBasis(basis(1, 0, 0), basis(0, 1, 0), basis(0, 0, 1)); scene.add(frame);
55
+ const donut = new Group(); donut.name = 'strawberry-donut'; frame.add(donut);
56
+ let seed = 57183;
57
+ const random = () => { seed = (seed * 1664525 + 1013904223) >>> 0; return seed / 4294967296; };
58
+ const edge = (a: number, inner = false) => {
59
+ const drip = Math.pow((1 + Math.sin(a * (inner ? 7 : 11) + .8 * Math.sin(a * 3))) / 2, 4);
60
+ return inner ? Math.PI - .12 + drip * .35 : .16 - drip * .62;
61
+ };
62
+ const point = (a: number, b: number, radius: number) => {
63
+ const handmade = .009 * Math.sin(a * 9 + b * 3) + .005 * Math.sin(a * 17 - b * 7);
64
+ const tube = radius + handmade;
65
+ return new Vector3((ringRadius + tube * Math.cos(b)) * Math.cos(a), (ringRadius + tube * Math.cos(b)) * Math.sin(a), tube * Math.sin(b));
66
+ };
67
+ function surface(icing: boolean) {
68
+ const positions: number[] = [], uvs: number[] = [], indices: number[] = [];
69
+ const rings = 192, slices = icing ? 56 : 64;
70
+ for (let i = 0; i <= rings; i++) for (let j = 0; j <= slices; j++) {
71
+ const a = i / rings * TAU;
72
+ const b = icing ? edge(a) + (edge(a, true) - edge(a)) * j / slices : j / slices * TAU;
73
+ positions.push(...point(a, b, icing ? icingRadius : tubeRadius).toArray()); uvs.push(i / rings, b / TAU);
74
+ if (i < rings && j < slices) {
75
+ const k = i * (slices + 1) + j;
76
+ indices.push(k, k + slices + 1, k + 1, k + 1, k + slices + 1, k + slices + 2);
77
+ }
78
+ }
79
+ const value = geometry(new BufferGeometry());
80
+ value.setAttribute('position', new Float32BufferAttribute(positions, 3));
81
+ value.setAttribute('uv', new Float32BufferAttribute(uvs, 2)); value.setIndex(indices); value.computeVertexNormals();
82
+ return value;
83
+ }
84
+ // A baked grain map adds pores and a pale frying belt without downloading any texture.
85
+ const grainCanvas = document.createElement('canvas'); grainCanvas.width = 512; grainCanvas.height = 256;
86
+ const paint = grainCanvas.getContext('2d');
87
+ if (!paint) throw new Error('Donut grain texture unavailable');
88
+ const roast = paint.createLinearGradient(0, 0, 0, 256);
89
+ roast.addColorStop(0, '#e6b777'); roast.addColorStop(.16, '#cd863f'); roast.addColorStop(.4, '#e2a35e');
90
+ roast.addColorStop(.5, '#edbd80'); roast.addColorStop(.65, '#c47a37'); roast.addColorStop(1, '#e6b777');
91
+ paint.fillStyle = roast; paint.fillRect(0, 0, 512, 256);
92
+ for (let i = 0; i < 6500; i++) {
93
+ paint.fillStyle = i % 3 ? 'rgba(115,54,18,.13)' : 'rgba(255,235,181,.22)';
94
+ paint.beginPath(); paint.ellipse(random() * 512, random() * 256, .2 + random(), .2 + random() * .7, random() * TAU, 0, TAU); paint.fill();
95
+ }
96
+ const grain = new CanvasTexture(grainCanvas); grain.colorSpace = SRGBColorSpace; textures.add(grain);
97
+ const doughGeometry = surface(false), icingGeometry = surface(true);
98
+ const outline = material(new MeshBasicMaterial({ color: ink, side: BackSide }));
99
+ const doughOutline = new Mesh(doughGeometry, outline); doughOutline.scale.setScalar(1.018); donut.add(doughOutline);
100
+ const dough = new Mesh(doughGeometry, material(new MeshStandardMaterial({ map: grain, bumpMap: grain, bumpScale: .018, roughness: .88 })));
101
+ dough.name = 'baked-dough'; donut.add(dough);
102
+ const glaze = new Mesh(icingGeometry, material(new MeshPhysicalMaterial({
103
+ color: '#ee83ad', roughness: .28, metalness: 0, clearcoat: 1, clearcoatRoughness: .22,
104
+ sheen: .55, sheenColor: new Color('#ffc2d8'), sheenRoughness: .55,
105
+ })));
106
+ glaze.name = 'strawberry-icing'; donut.add(glaze);
107
+ // Give both uneven icing edges real thickness, including the inner hole.
108
+ for (const inner of [false, true]) {
109
+ const positions: number[] = [], indices: number[] = [];
110
+ for (let i = 0; i <= 192; i++) {
111
+ const a = i / 192 * TAU, b = edge(a, inner);
112
+ positions.push(...point(a, b, icingRadius + .001).toArray(), ...point(a, b, tubeRadius + .004).toArray());
113
+ if (i < 192) { const k = i * 2; indices.push(k, k + 1, k + 2, k + 1, k + 3, k + 2); }
114
+ }
115
+ const lip = geometry(new BufferGeometry()); lip.setAttribute('position', new Float32BufferAttribute(positions, 3)); lip.setIndex(indices); lip.computeVertexNormals();
116
+ donut.add(new Mesh(lip, material(new MeshStandardMaterial({ color: '#c75282', roughness: .4, side: DoubleSide }))));
117
+ }
118
+ const capsule = geometry(new CapsuleGeometry(.018, .076, 4, 6));
119
+ const sprinkleMaterial = material(new MeshPhysicalMaterial({ roughness: .38, clearcoat: .65 }));
120
+ const sprinkles = new InstancedMesh(capsule, sprinkleMaterial, 280); sprinkles.name = 'icing-sprinkles';
121
+ const sugarOutline = new InstancedMesh(capsule, outline, 280); instances.add(sprinkles); instances.add(sugarOutline);
122
+ const dummy = new Object3D(), up = new Vector3(0, 1, 0), color = new Color();
123
+ for (let i = 0; i < 280; i++) {
124
+ const a = random() * TAU, b = .32 + random() * (Math.PI - .64), angle = random() * TAU;
125
+ const tangent = new Vector3(-Math.sin(a), Math.cos(a), 0).multiplyScalar(Math.cos(angle))
126
+ .add(new Vector3(-Math.sin(b) * Math.cos(a), -Math.sin(b) * Math.sin(a), Math.cos(b)).multiplyScalar(Math.sin(angle))).normalize();
127
+ dummy.position.copy(point(a, b, icingRadius + .025)); dummy.quaternion.setFromUnitVectors(up, tangent);
128
+ dummy.scale.set(1, .7 + random() * .7, 1); dummy.updateMatrix(); sprinkles.setMatrixAt(i, dummy.matrix);
129
+ sprinkles.setColorAt(i, color.set(sugarColors[i % sugarColors.length]));
130
+ dummy.scale.multiplyScalar(1.17); dummy.updateMatrix(); sugarOutline.setMatrixAt(i, dummy.matrix);
131
+ }
132
+ donut.add(sugarOutline, sprinkles);
133
+ const pearls = new InstancedMesh(geometry(new SphereGeometry(.015, 8, 6)), material(new MeshStandardMaterial({ color: '#fff7dc', roughness: .28 })), 110);
134
+ pearls.name = 'sugar-pearls'; instances.add(pearls);
135
+ for (let i = 0; i < 110; i++) {
136
+ dummy.position.copy(point(random() * TAU, .25 + random() * (Math.PI - .5), icingRadius + .018));
137
+ dummy.rotation.set(0, 0, 0); dummy.scale.setScalar(.7 + random() * .7); dummy.updateMatrix(); pearls.setMatrixAt(i, dummy.matrix);
138
+ }
139
+ donut.add(pearls);
140
+
141
+ const floating = new InstancedMesh(capsule, sprinkleMaterial, 100); floating.name = 'floating-confetti'; floating.frustumCulled = false; instances.add(floating); scene.add(floating);
142
+ const confetti = Array.from({ length: 100 }, (_, i) => {
143
+ floating.setColorAt(i, color.set(sugarColors[i % sugarColors.length]));
144
+ return { phase: i / 100 * TAU, radius: 1.68 + random() * .7, speed: .08 + random() * .09, depth: random() * 1.8 - .9, tilt: random() * TAU, scale: .5 + random() * .85 };
145
+ });
146
+ // Positions are real coordinates; include the shader's small wobble in the CPU bound.
147
+ const dustPositions: number[] = [], dustColors: number[] = [], phases: number[] = [], sizes: number[] = [];
148
+ for (let i = 0; i < 460; i++) {
149
+ const a = random() * TAU, r = 1.55 + random() * 1.15;
150
+ dustPositions.push(Math.cos(a) * r, Math.sin(a) * r * .72 + .1, random() * 2.4 - 1.2);
151
+ dustColors.push(...color.set(sugarColors[i % sugarColors.length]).toArray()); phases.push(random() * TAU); sizes.push(i % 9 ? 1.2 + random() * 1.5 : 3.8);
152
+ }
153
+ const dustGeometry = geometry(new BufferGeometry());
154
+ dustGeometry.setAttribute('position', new Float32BufferAttribute(dustPositions, 3));
155
+ dustGeometry.setAttribute('color', new Float32BufferAttribute(dustColors, 3));
156
+ dustGeometry.setAttribute('phase', new Float32BufferAttribute(phases, 1)); dustGeometry.setAttribute('size', new Float32BufferAttribute(sizes, 1));
157
+ dustGeometry.computeBoundingSphere(); dustGeometry.boundingSphere!.radius += .3;
158
+ const dustMaterial = material(new ShaderMaterial({
159
+ transparent: true, depthWrite: false, vertexColors: true, uniforms: { time: { value: 0 }, pixelRatio: { value: 1 } },
160
+ vertexShader: `attribute float phase; attribute float size; uniform float time; uniform float pixelRatio; varying vec3 tint; varying float sparkle;
161
+ void main(){vec3 p=position; p.x+=sin(time*.35+phase)*.09; p.y+=cos(time*.45+phase)*.12;
162
+ vec4 mv=modelViewMatrix*vec4(p,1.); gl_Position=projectionMatrix*mv; gl_PointSize=size*pixelRatio*(3.6/-mv.z);
163
+ tint=color; sparkle=.4+.35*sin(time*.7+phase);}`,
164
+ fragmentShader: `varying vec3 tint; varying float sparkle; void main(){vec2 p=abs(gl_PointCoord-.5)*2.;
165
+ float round=1.-smoothstep(.1,1.,length(p)); float cross=exp(-min(p.x,p.y)*12.)*(1.-max(p.x,p.y));
166
+ gl_FragColor=vec4(tint,max(round*.4,cross)*sparkle);}`,
167
+ }));
168
+ const dust = new Points(dustGeometry, dustMaterial); dust.name = 'sugar-dust'; scene.add(dust);
169
+ const shadow = new Mesh(geometry(new PlaneGeometry(3.8, .7)), material(new ShaderMaterial({
170
+ transparent: true, depthWrite: false, uniforms: { opacity: { value: .2 } },
171
+ vertexShader: 'varying vec2 vUv; void main(){vUv=uv; gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.);}',
172
+ fragmentShader: 'varying vec2 vUv; uniform float opacity; void main(){float r=length((vUv-.5)*2.);gl_FragColor=vec4(.46,.24,.24,(1.-smoothstep(.1,1.,r))*opacity);}',
173
+ })));
174
+ shadow.position.set(.12, -1.45, -1.1); scene.add(shadow);
175
+ let bitmapWidth = 0, bitmapHeight = 0, ratio = 0;
176
+ canvas.addEventListener('webglcontextlost', contextLost); canvas.addEventListener('webglcontextrestored', contextRestored);
177
+ return {
178
+ canvas,
179
+ draw(width, height, time) {
180
+ if (disposed || lost || width <= 0 || height <= 0) return false;
181
+ const dpr = Math.min(window.devicePixelRatio || 1, 1.5);
182
+ if (width !== bitmapWidth || height !== bitmapHeight || ratio !== dpr) {
183
+ renderer!.setPixelRatio(dpr); renderer!.setSize(width, height, false);
184
+ const { scale, centerX, centerY } = orbitFraming(width, height);
185
+ camera.aspect = width / height; camera.position.set(0, 0, orbitCameraDistance); camera.lookAt(0, 0, 0);
186
+ camera.fov = 2 * Math.atan(height / (2 * scale * orbitCameraDistance * 1.08)) * 180 / Math.PI;
187
+ camera.updateProjectionMatrix();
188
+ camera.projectionMatrix.elements[0] /= 1.08;
189
+ camera.projectionMatrix.elements[8] = 1 - 2 * centerX / width;
190
+ camera.projectionMatrix.elements[9] = 2 * centerY / height - 1;
191
+ camera.projectionMatrixInverse.copy(camera.projectionMatrix).invert();
192
+ dustMaterial.uniforms.pixelRatio.value = dpr; bitmapWidth = width; bitmapHeight = height; ratio = dpr;
193
+ }
194
+ donut.rotation.set(0, 0, time * .28 - .35);
195
+ for (let i = 0; i < confetti.length; i++) {
196
+ const p = confetti[i], a = p.phase + time * p.speed;
197
+ dummy.position.set(Math.cos(a) * p.radius, Math.sin(a) * p.radius * .68 + .12 + Math.sin(time * .6 + p.phase) * .08, p.depth + Math.sin(a) * .25);
198
+ dummy.rotation.set(p.tilt + time * .3, a, p.tilt - time * .4); dummy.scale.setScalar(p.scale); dummy.updateMatrix(); floating.setMatrixAt(i, dummy.matrix);
199
+ }
200
+ floating.instanceMatrix.needsUpdate = true;
201
+ dustMaterial.uniforms.time.value = time;
202
+ renderer!.render(scene, camera);
203
+ return true;
204
+ },
205
+ dispose,
206
+ };
207
+ } catch (error) { dispose(); throw error; }
208
+ }
@@ -0,0 +1,76 @@
1
+ import { CanvasTexture, EquirectangularReflectionMapping, MeshPhysicalMaterial, SRGBColorSpace } from 'three';
2
+
3
+ function surface(width: number, height: number) {
4
+ const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height;
5
+ const context = canvas.getContext('2d');
6
+ if (!context) throw new Error('Object texture canvas unavailable');
7
+ return { canvas, context };
8
+ }
9
+
10
+ /** Local studio lighting keeps paper folds and polished mineral faces legible. */
11
+ export function studioEnvironment() {
12
+ const { canvas, context: c } = surface(1024, 512);
13
+ const room = c.createLinearGradient(0, 0, 0, 512);
14
+ room.addColorStop(0, '#aabcb4'); room.addColorStop(.45, '#263c38'); room.addColorStop(.52, '#111c1a'); room.addColorStop(1, '#697c70');
15
+ c.fillStyle = room; c.fillRect(0, 0, 1024, 512);
16
+ for (const [x, y, width, height, tint] of [[130, 65, 105, 300, '#f7fff8'], [670, 110, 35, 260, '#dcebee'], [830, 145, 80, 130, '#cdb68e']] as const) {
17
+ c.shadowColor = tint; c.shadowBlur = 32; c.fillStyle = tint; c.fillRect(x, y, width, height);
18
+ }
19
+ const texture = new CanvasTexture(canvas); texture.colorSpace = SRGBColorSpace;
20
+ texture.mapping = EquirectangularReflectionMapping;
21
+ return texture;
22
+ }
23
+
24
+ export function paperTexture() {
25
+ const { canvas, context: c } = surface(512, 512);
26
+ c.fillStyle = '#f7f4e8'; c.fillRect(0, 0, 512, 512);
27
+ let seed = 7619;
28
+ const random = () => { seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0; return seed / 4294967296; };
29
+ for (let i = 0; i < 24000; i++) {
30
+ c.fillStyle = i % 3 ? 'rgba(97,111,85,.035)' : 'rgba(255,255,255,.22)';
31
+ c.fillRect(random() * 512, random() * 512, .3 + random() * 1.5, .2 + random() * .5);
32
+ }
33
+ const texture = new CanvasTexture(canvas); texture.colorSpace = SRGBColorSpace;
34
+ return texture;
35
+ }
36
+
37
+ /** Surface mineral grain, branching fissures and thin internal amber light share local coordinates. */
38
+ export function mineralMaterial() {
39
+ const value = new MeshPhysicalMaterial({ color: '#263330', metalness: .62, roughness: .24, clearcoat: 1, clearcoatRoughness: .16, envMapIntensity: 1.3 });
40
+ value.onBeforeCompile = shader => {
41
+ shader.vertexShader = 'varying vec3 vMineral;\n' + shader.vertexShader;
42
+ shader.vertexShader = shader.vertexShader.replace('#include <begin_vertex>', '#include <begin_vertex>\nvMineral = position;');
43
+ shader.fragmentShader = `varying vec3 vMineral;
44
+ float hashStone(vec3 p) { return fract(sin(dot(p,vec3(127.1,311.7,74.7)))*43758.5453); }
45
+ float grainStone(vec3 p) {
46
+ vec3 i=floor(p),f=fract(p); f=f*f*(3.-2.*f);
47
+ return mix(mix(mix(hashStone(i),hashStone(i+vec3(1,0,0)),f.x),mix(hashStone(i+vec3(0,1,0)),hashStone(i+vec3(1,1,0)),f.x),f.y),
48
+ mix(mix(hashStone(i+vec3(0,0,1)),hashStone(i+vec3(1,0,1)),f.x),mix(hashStone(i+vec3(0,1,1)),hashStone(i+vec3(1,1,1)),f.x),f.y),f.z);
49
+ }
50
+ float stoneEdge(vec2 p) {
51
+ vec2 cell=floor(p),f=fract(p); float a=10.,b=10.;
52
+ for(int y=-1;y<=1;y++) for(int x=-1;x<=1;x++) {
53
+ vec2 o=vec2(float(x),float(y));
54
+ vec2 jitter=vec2(hashStone(vec3(cell+o,1.)),hashStone(vec3(cell+o,2.)));
55
+ float d=length(o+jitter-f); if(d<a){b=a;a=d;}else if(d<b){b=d;}
56
+ }
57
+ return b-a;
58
+ }
59
+ ` + shader.fragmentShader;
60
+ shader.fragmentShader = shader.fragmentShader.replace('#include <color_fragment>', `#include <color_fragment>
61
+ float mineralGrain=grainStone(vMineral*34.)*.55+grainStone(vMineral*110.)*.45;
62
+ float mineralLayer=grainStone(vMineral*3.8);
63
+ vec2 fissureUV=vec2(vMineral.x+vMineral.z*.71,vMineral.y*.76);
64
+ float fissure=stoneEdge(fissureUV*5.5+mineralLayer*.35);
65
+ float fissureMask=smoothstep(.36,.7,grainStone(vMineral*2.2+4.));
66
+ float vein=(1.-smoothstep(.009,.032,fissure))*fissureMask;
67
+ float veinHalo=(1.-smoothstep(.025,.15,fissure))*fissureMask;
68
+ diffuseColor.rgb*=.58+mineralGrain*.54+mineralLayer*.22;
69
+ diffuseColor.rgb=mix(diffuseColor.rgb,vec3(.15,.075,.025),veinHalo*.6);
70
+ `);
71
+ shader.fragmentShader = shader.fragmentShader.replace('#include <roughnessmap_fragment>', '#include <roughnessmap_fragment>\nroughnessFactor=clamp(roughnessFactor+(mineralGrain-.5)*.24,.12,.5);');
72
+ shader.fragmentShader = shader.fragmentShader.replace('#include <emissivemap_fragment>', '#include <emissivemap_fragment>\ntotalEmissiveRadiance+=vec3(1.,.32,.055)*(vein*2.3+veinHalo*.12);');
73
+ };
74
+ value.customProgramCacheKey = () => 'rhine-mineral-v1';
75
+ return value;
76
+ }
@@ -0,0 +1,71 @@
1
+ import { morphPaperPoint, paperBirdFolds } from './orbitBirdGeometry';
2
+ import { paperBirdFlight, paperMorphAmount, rotatePaperPoint } from './orbitBirdFlight';
3
+
4
+ const birdFolds = paperBirdFolds();
5
+ const butterflyFolds = paperBirdFolds('butterfly');
6
+ type Kind = 'paper' | 'technical';
7
+ type Point = { x: number; y: number; depth: number };
8
+
9
+ /** A lightweight object illustration remains available without WebGL. */
10
+ export function drawOrbitObject(ctx: CanvasRenderingContext2D, width: number, height: number, time: number, kind: Kind) {
11
+ const bird = kind === 'paper', scale = Math.min(width * (bird ? .175 : .28), Math.max(1, height - 76) * (bird ? .285 : .3));
12
+ const angle = bird ? Math.sin(time * .23) * .18 - .65 : time * .11 - .32, roll = bird ? -.12 : .08, tilt = bird ? .53 : .13;
13
+ const point = (x: number, y: number, z = 0): Point => {
14
+ const xx = x * Math.cos(angle) + z * Math.sin(angle), zz = -x * Math.sin(angle) + z * Math.cos(angle);
15
+ const yy = y * Math.cos(tilt) - zz * Math.sin(tilt), depth = y * Math.sin(tilt) + zz * Math.cos(tilt);
16
+ const p = 6 / (6 - depth);
17
+ return { x: width * .54 + (xx * Math.cos(roll) - yy * Math.sin(roll)) * scale * p, y: height / 2 - 18 - (xx * Math.sin(roll) + yy * Math.cos(roll)) * scale * p, depth };
18
+ };
19
+ const path = (points: Point[], fill: string | null, stroke: string | null, lineWidth = 1) => {
20
+ ctx.beginPath(); points.forEach((p, i) => i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y));
21
+ if (fill) { ctx.closePath(); ctx.fillStyle = fill; ctx.fill(); }
22
+ if (stroke) { ctx.strokeStyle = stroke; ctx.lineWidth = lineWidth; ctx.stroke(); }
23
+ };
24
+ const dot = (p: Point, radius: number, fill: string) => { ctx.beginPath(); ctx.arc(p.x, p.y, radius, 0, Math.PI * 2); ctx.fillStyle = fill; ctx.fill(); };
25
+ ctx.clearRect(0, 0, width, height); ctx.globalAlpha = 1; ctx.shadowBlur = 0; ctx.setLineDash([]);
26
+ ctx.lineWidth = 1; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; ctx.lineDashOffset = 0;
27
+ if (bird) {
28
+ const flight = paperBirdFlight(time);
29
+ const projectBird = (x: number, y: number, z: number): Point => {
30
+ const local = rotatePaperPoint(x, y, z, flight.pitch, flight.yaw, flight.bank);
31
+ const [px, py, depth] = rotatePaperPoint(local[0] + flight.x, local[1] + flight.y, local[2] + flight.z, .53, -.65, -.12);
32
+ const perspective = 6 / (6 - depth);
33
+ return { x: width * .54 + px * scale * perspective, y: height / 2 - 18 - py * scale * perspective, depth };
34
+ };
35
+ const faces: {points:Point[];shade:string;depth:number}[]=[];
36
+ const folded = (vertices:number[][], triangles:number[][]) => triangles.forEach((indices,i)=>{
37
+ const points=indices.map(j=>projectBird(vertices[j][0],vertices[j][1],vertices[j][2]));
38
+ faces.push({points,shade:['#f5f2e4','#d8e0d0','#fdfaf0','#bcccb9'][i%4],depth:points.reduce((n,p)=>n+p.depth,0)/points.length});
39
+ });
40
+ const amount = paperMorphAmount(time);
41
+ for (const [index, fold] of birdFolds.entries()) {
42
+ const a = fold.wing * flight.wing;
43
+ folded(fold.points.map((p, i) => {
44
+ const [x,y,z] = morphPaperPoint(p, butterflyFolds[index].points[i], amount, fold.wing, fold.name);
45
+ return [x*Math.cos(a)-y*Math.sin(a),x*Math.sin(a)+y*Math.cos(a),z];
46
+ }),fold.faces);
47
+ }
48
+ faces.sort((a,b)=>a.depth-b.depth).forEach(face=>path([...face.points,face.points[0]],face.shade,'#8a9b8138',.55));
49
+ } else {
50
+ const shards = [{ x: 0, y: .04, z: 0, s: .88 }, { x: -.48, y: -.38, z: .12, s: .56 }, { x: .47, y: -.35, z: .13, s: .64 }, { x: -.18, y: -.58, z: -.42, s: .48 }, { x: .18, y: -.65, z: .48, s: .42 }];
51
+ const faces: { points: Point[]; shade: string; depth: number }[] = [];
52
+ shards.forEach((shard, n) => {
53
+ const p = (a: number, y: number, r: number) => point(shard.x + Math.cos(a) * r * shard.s, shard.y + y * shard.s, shard.z + Math.sin(a) * r * shard.s);
54
+ for (let i = 0; i < 6; i++) {
55
+ const a = (i / 6 + n * .13) * Math.PI * 2, b = a + Math.PI / 3;
56
+ const base = [p(a, -.8, .27), p(a, .65, .37), p(b, .65, .37), p(b, -.8, .27)];
57
+ const tip = [p(a, .65, .37), p(0, 1.5, 0), p(b, .65, .37)];
58
+ for (const points of [base, tip]) faces.push({ points, shade: ['#223532', '#344a43', '#1d292a'][i % 3], depth: points.reduce((sum, v) => sum + v.depth, 0) / points.length });
59
+ }
60
+ });
61
+ faces.sort((a, b) => a.depth - b.depth).forEach(face => path([...face.points, face.points[0]], face.shade, '#9b804f', .8));
62
+ for (let i = 0; i < 9; i++) {
63
+ const x = (i % 3 - 1) * .22, y = -.85 + Math.floor(i / 3) * .5;
64
+ path([point(x, y, .38), point(x + .06, y + .22, .38), point(x + .02, y + .32, .38)], null, '#edb355', 1.5);
65
+ }
66
+ }
67
+ for (let i = 0; i < 84; i++) {
68
+ const a = i * 2.399 + time * .04, radius = .9 + i % 11 * .1;
69
+ dot(point(Math.cos(a) * radius, Math.sin(a * 1.7) * 1.4, Math.sin(a) * .7), .5 + i % 4 * .35, bird ? '#b8a07180' : '#e9b258a0');
70
+ }
71
+ }