morfeo 0.2.1 → 0.3.1
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/bases/2d/Mundo.tsx +43 -0
- package/bases/2d/guion.mjs +32 -0
- package/bases/3d/Mundo.tsx +45 -0
- package/bases/3d/guion.mjs +26 -0
- package/bin/morfeo.mjs +21 -3
- package/index.d.ts +27 -5
- package/lib/config.mjs +47 -10
- package/lib/entorno.mjs +22 -2
- package/lib/estructura.mjs +15 -4
- package/lib/guion.mjs +2 -2
- package/lib/hoja.mjs +3 -0
- package/lib/nuevo-video.mjs +65 -0
- package/lib/palabras.mjs +79 -0
- package/lib/render.mjs +81 -16
- package/lib/whisper.mjs +47 -3
- package/package.json +4 -3
- package/plantilla/morfeo.config.mjs +3 -1
- package/python/musica.py +40 -7
- package/python/preparar.py +10 -1
- package/remotion/kit/datos.tsx +180 -0
- package/remotion/kit/diorama.tsx +489 -0
- package/remotion/kit/escenarios2d.tsx +250 -0
- package/remotion/kit/index.ts +9 -1
- package/remotion/kit/mundo3d.tsx +251 -0
- package/remotion/kit/personas.tsx +439 -0
- package/remotion/kit/tres.tsx +126 -10
- package/remotion/motor/Video.tsx +13 -7
- package/remotion/motor/capas.tsx +29 -2
- package/remotion/motor/fichas.tsx +255 -0
- package/remotion/motor/modelo3d.tsx +1 -1
- package/remotion/motor/subtitulos.tsx +2 -2
- package/remotion/motor/tema.ts +20 -1
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// Gráficas 2D que se dibujan solas (van DENTRO de un <svg>): barras, línea, comparación y lupa.
|
|
2
|
+
// `t` es el tiempo y `en` el momento en que empiezan (segundos); todo se anima con el tiempo.
|
|
3
|
+
//
|
|
4
|
+
// <GraficaBarras x={140} y={700} ancho={800} alto={500} t={t} en={1} datos={[['0 m', 100], ['1 m', 50], ['2 m', 25]]} />
|
|
5
|
+
// <GraficaLinea x={140} y={700} ancho={800} alto={420} t={t} puntos={[100, 50, 25, 12]} etiquetas={['0', '1', '2', '3']} />
|
|
6
|
+
// <Comparacion x={540} y={1300} t={t} a={['ANTES', 10]} b={['DESPUÉS', 27.4]} unidad="kg" />
|
|
7
|
+
// <Lupa x={540} y={900} radio={220} t={t}> ...lo que se ve aumentado... </Lupa>
|
|
8
|
+
import React from 'react';
|
|
9
|
+
|
|
10
|
+
const k = (t: number, en: number, dur: number) => {
|
|
11
|
+
const x = Math.max(0, Math.min(1, (t - en) / dur));
|
|
12
|
+
return 1 - (1 - x) ** 3;
|
|
13
|
+
};
|
|
14
|
+
const fmt = (v: number, dec = 0) => v.toLocaleString('es-MX', {minimumFractionDigits: dec, maximumFractionDigits: dec});
|
|
15
|
+
|
|
16
|
+
type Comun = {x: number; y: number; t: number; en?: number; color?: string; texto?: string; fuente?: string};
|
|
17
|
+
|
|
18
|
+
/** Barras horizontales: [etiqueta, valor]. La más grande ocupa todo el ancho; el valor cuenta mientras crece. */
|
|
19
|
+
export const GraficaBarras: React.FC<Comun & {ancho?: number; alto?: number; datos: [string, number][]; unidad?: string; colores?: string[]; decimales?: number; maximo?: number}> = ({
|
|
20
|
+
x,
|
|
21
|
+
y,
|
|
22
|
+
t,
|
|
23
|
+
en = 0,
|
|
24
|
+
ancho = 820,
|
|
25
|
+
alto = 480,
|
|
26
|
+
datos,
|
|
27
|
+
unidad = '',
|
|
28
|
+
color = '#F2B632',
|
|
29
|
+
colores,
|
|
30
|
+
texto = '#FFFFFF',
|
|
31
|
+
fuente = 'JetBrains Mono, monospace',
|
|
32
|
+
decimales = 0,
|
|
33
|
+
maximo,
|
|
34
|
+
}) => {
|
|
35
|
+
const max = maximo ?? Math.max(...datos.map(([, v]) => v), 1);
|
|
36
|
+
const fila = alto / datos.length;
|
|
37
|
+
const etiqueta = 150;
|
|
38
|
+
return (
|
|
39
|
+
<g transform={`translate(${x} ${y})`}>
|
|
40
|
+
{datos.map(([e, v], i) => {
|
|
41
|
+
const p = k(t, en + i * 0.18, 0.9);
|
|
42
|
+
const w = (ancho - etiqueta - 150) * (v / max) * p;
|
|
43
|
+
return (
|
|
44
|
+
<g key={i} transform={`translate(0 ${i * fila})`} opacity={Math.min(1, p * 4)}>
|
|
45
|
+
<text x={etiqueta - 20} y={fila * 0.42} textAnchor="end" dominantBaseline="middle" fontFamily={fuente} fontSize={fila * 0.32} fill={texto} opacity={0.75}>
|
|
46
|
+
{e}
|
|
47
|
+
</text>
|
|
48
|
+
<rect x={etiqueta} y={fila * 0.18} width={Math.max(4, w)} height={fila * 0.48} rx={fila * 0.1} fill={colores?.[i] ?? color} />
|
|
49
|
+
<text x={etiqueta + w + 16} y={fila * 0.42} dominantBaseline="middle" fontFamily={fuente} fontWeight={700} fontSize={fila * 0.32} fill={texto}>
|
|
50
|
+
{fmt(v * p, decimales)}
|
|
51
|
+
{unidad}
|
|
52
|
+
</text>
|
|
53
|
+
</g>
|
|
54
|
+
);
|
|
55
|
+
})}
|
|
56
|
+
</g>
|
|
57
|
+
);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** Línea que se dibuja de izquierda a derecha, con puntos y etiquetas en el eje. */
|
|
61
|
+
export const GraficaLinea: React.FC<Comun & {ancho?: number; alto?: number; puntos: number[]; etiquetas?: string[]; minimo?: number; maximo?: number; relleno?: boolean; dur?: number}> = ({
|
|
62
|
+
x,
|
|
63
|
+
y,
|
|
64
|
+
t,
|
|
65
|
+
en = 0,
|
|
66
|
+
ancho = 820,
|
|
67
|
+
alto = 420,
|
|
68
|
+
puntos,
|
|
69
|
+
etiquetas = [],
|
|
70
|
+
color = '#F07A3A',
|
|
71
|
+
texto = '#FFFFFF',
|
|
72
|
+
fuente = 'JetBrains Mono, monospace',
|
|
73
|
+
minimo,
|
|
74
|
+
maximo,
|
|
75
|
+
relleno = true,
|
|
76
|
+
dur = 1.4,
|
|
77
|
+
}) => {
|
|
78
|
+
const mn = minimo ?? Math.min(0, ...puntos);
|
|
79
|
+
const mx = maximo ?? Math.max(...puntos);
|
|
80
|
+
const px = (i: number) => (i / Math.max(1, puntos.length - 1)) * ancho;
|
|
81
|
+
const py = (v: number) => alto - ((v - mn) / Math.max(1e-6, mx - mn)) * alto;
|
|
82
|
+
const p = k(t, en, dur);
|
|
83
|
+
const d = puntos.map((v, i) => `${i ? 'L' : 'M'} ${px(i)} ${py(v)}`).join(' ');
|
|
84
|
+
const largo = puntos.slice(1).reduce((s, v, i) => s + Math.hypot(px(i + 1) - px(i), py(v) - py(puntos[i])), 0);
|
|
85
|
+
const id = `linea-${Math.round(x)}-${Math.round(y)}`;
|
|
86
|
+
return (
|
|
87
|
+
<g transform={`translate(${x} ${y})`}>
|
|
88
|
+
<line x1={0} y1={alto} x2={ancho} y2={alto} stroke={texto} strokeOpacity={0.35} strokeWidth={3} />
|
|
89
|
+
<line x1={0} y1={0} x2={0} y2={alto} stroke={texto} strokeOpacity={0.35} strokeWidth={3} />
|
|
90
|
+
{relleno ? (
|
|
91
|
+
<>
|
|
92
|
+
<defs>
|
|
93
|
+
<linearGradient id={id} x1="0" y1="0" x2="0" y2="1">
|
|
94
|
+
<stop offset="0" stopColor={color} stopOpacity={0.35} />
|
|
95
|
+
<stop offset="1" stopColor={color} stopOpacity={0} />
|
|
96
|
+
</linearGradient>
|
|
97
|
+
<clipPath id={`${id}-c`}>
|
|
98
|
+
<rect x={0} y={-50} width={ancho * p} height={alto + 100} />
|
|
99
|
+
</clipPath>
|
|
100
|
+
</defs>
|
|
101
|
+
<path d={`${d} L ${ancho} ${alto} L 0 ${alto} Z`} fill={`url(#${id})`} clipPath={`url(#${id}-c)`} />
|
|
102
|
+
</>
|
|
103
|
+
) : null}
|
|
104
|
+
<path d={d} fill="none" stroke={color} strokeWidth={7} strokeLinecap="round" strokeLinejoin="round" strokeDasharray={largo} strokeDashoffset={largo * (1 - p)} />
|
|
105
|
+
{puntos.map((v, i) =>
|
|
106
|
+
px(i) <= ancho * p + 1 ? <circle key={i} cx={px(i)} cy={py(v)} r={11} fill={color} stroke="#fff" strokeWidth={4} /> : null,
|
|
107
|
+
)}
|
|
108
|
+
{etiquetas.map((e, i) => (
|
|
109
|
+
<text key={i} x={px(i)} y={alto + 44} textAnchor="middle" fontFamily={fuente} fontSize={28} fill={texto} opacity={0.7}>
|
|
110
|
+
{e}
|
|
111
|
+
</text>
|
|
112
|
+
))}
|
|
113
|
+
</g>
|
|
114
|
+
);
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/** Dos columnas que crecen hasta su valor (antes / después, A contra B), con el número contando encima. */
|
|
118
|
+
export const Comparacion: React.FC<Comun & {a: [string, number]; b: [string, number]; unidad?: string; alto?: number; colorA?: string; colorB?: string; decimales?: number}> = ({
|
|
119
|
+
x,
|
|
120
|
+
y,
|
|
121
|
+
t,
|
|
122
|
+
en = 0,
|
|
123
|
+
a,
|
|
124
|
+
b,
|
|
125
|
+
unidad = '',
|
|
126
|
+
alto = 520,
|
|
127
|
+
colorA = '#8A8F9C',
|
|
128
|
+
colorB = '#2FD08A',
|
|
129
|
+
texto = '#FFFFFF',
|
|
130
|
+
fuente = 'Montserrat, sans-serif',
|
|
131
|
+
decimales = 0,
|
|
132
|
+
}) => {
|
|
133
|
+
const max = Math.max(a[1], b[1], 1e-6);
|
|
134
|
+
const col = (lbl: string, v: number, c: string, dx: number, retraso: number) => {
|
|
135
|
+
const p = k(t, en + retraso, 1.1);
|
|
136
|
+
const h = (alto * v * p) / max;
|
|
137
|
+
return (
|
|
138
|
+
<g transform={`translate(${dx} 0)`}>
|
|
139
|
+
<rect x={-90} y={-h} width={180} height={Math.max(2, h)} rx={18} fill={c} />
|
|
140
|
+
<text x={0} y={-h - 28} textAnchor="middle" fontFamily={fuente} fontWeight={900} fontSize={64} fill={texto}>
|
|
141
|
+
{fmt(v * p, decimales)}
|
|
142
|
+
<tspan fontSize={34}>{unidad}</tspan>
|
|
143
|
+
</text>
|
|
144
|
+
<text x={0} y={56} textAnchor="middle" fontFamily={fuente} fontWeight={800} fontSize={36} fill={texto} opacity={0.8}>
|
|
145
|
+
{lbl}
|
|
146
|
+
</text>
|
|
147
|
+
</g>
|
|
148
|
+
);
|
|
149
|
+
};
|
|
150
|
+
return (
|
|
151
|
+
<g transform={`translate(${x} ${y})`}>
|
|
152
|
+
<line x1={-280} y1={0} x2={280} y2={0} stroke={texto} strokeOpacity={0.4} strokeWidth={4} />
|
|
153
|
+
{col(a[0], a[1], colorA, -150, 0)}
|
|
154
|
+
{col(b[0], b[1], colorB, 150, 0.5)}
|
|
155
|
+
</g>
|
|
156
|
+
);
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
/** Lupa: aro con mango; dentro se ve `children` (lo que dibujes, normalmente más grande). Entra con un pequeño rebote. */
|
|
160
|
+
export const Lupa: React.FC<{x: number; y: number; radio?: number; t: number; en?: number; fondo?: string; children?: React.ReactNode}> = ({x, y, radio = 220, t, en = 0, fondo = '#F4E6D8', children}) => {
|
|
161
|
+
const p = k(t, en, 0.5);
|
|
162
|
+
const s = 0.6 + 0.4 * p + Math.sin(Math.min(1, (t - en) / 0.5) * Math.PI) * 0.05;
|
|
163
|
+
const id = `lupa-${Math.round(x)}-${Math.round(y)}`;
|
|
164
|
+
if (p <= 0) return null;
|
|
165
|
+
return (
|
|
166
|
+
<g transform={`translate(${x} ${y}) scale(${s})`} opacity={Math.min(1, p * 3)}>
|
|
167
|
+
<defs>
|
|
168
|
+
<clipPath id={id}>
|
|
169
|
+
<circle r={radio} />
|
|
170
|
+
</clipPath>
|
|
171
|
+
</defs>
|
|
172
|
+
<rect x={radio * 0.62} y={radio * 0.62} width={radio * 0.3} height={radio * 0.95} rx={radio * 0.12} transform={`rotate(-45 ${radio * 0.77} ${radio * 0.62})`} fill="#5A3A28" />
|
|
173
|
+
<circle r={radio} fill={fondo} />
|
|
174
|
+
<g clipPath={`url(#${id})`}>{children}</g>
|
|
175
|
+
<circle r={radio} fill="none" stroke="#E9EDF2" strokeWidth={radio * 0.11} />
|
|
176
|
+
<circle r={radio * 1.05} fill="none" stroke="rgba(0,0,0,0.25)" strokeWidth={4} />
|
|
177
|
+
<path d={`M ${-radio * 0.55} ${-radio * 0.35} A ${radio * 0.7} ${radio * 0.7} 0 0 1 ${-radio * 0.2} ${-radio * 0.62}`} stroke="rgba(255,255,255,0.7)" strokeWidth={10} fill="none" strokeLinecap="round" />
|
|
178
|
+
</g>
|
|
179
|
+
);
|
|
180
|
+
};
|
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
// Kit diorama: maqueta 3D low-poly flotante con corte de tierra, personajes, tráfico, rayos X y modelos .glb.
|
|
2
|
+
// Va dentro de un <Lienzo3D>. Todo depende de `t` (segundos): nada corre solo.
|
|
3
|
+
//
|
|
4
|
+
// <Maqueta ancho={20} fondo={14} /> isla con estratos, pasto y flores
|
|
5
|
+
// <Modelo src={props.coche.src} pos={[0, 0, 2]} tam={2} animacion t={t} /> un .glb (o un recurso resuelto)
|
|
6
|
+
// <Persona pos={[1, 0, 3]} pose="saludar" t={t} /> trabajador low-poly
|
|
7
|
+
// <Trafico camino={[[-9, 0, 2], [9, 0, 2]]} n={8} t={t} pluma={{d: 9, cerrada: [[2, 6]]}} />
|
|
8
|
+
// <Corte k={abrir}> <Tinaco /> </Corte> rayos X: abre lo que envuelve
|
|
9
|
+
import React, {useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react';
|
|
10
|
+
import {continueRender, delayRender} from 'remotion';
|
|
11
|
+
import * as THREE from 'three';
|
|
12
|
+
import {clone as clonarEsqueleto} from 'three/examples/jsm/utils/SkeletonUtils.js';
|
|
13
|
+
import {cargarGltf} from '../motor/modelo3d';
|
|
14
|
+
import {recurso} from '../motor/util';
|
|
15
|
+
import {azarN} from './ilustrado';
|
|
16
|
+
import {Piedras} from './tres';
|
|
17
|
+
|
|
18
|
+
type V3 = [number, number, number];
|
|
19
|
+
type Pieza = {pos?: V3; rot?: V3; escala?: number};
|
|
20
|
+
const RAD = Math.PI / 180;
|
|
21
|
+
const grupo = (p: Pieza) => ({position: p.pos ?? ([0, 0, 0] as V3), rotation: (p.rot ?? [0, 0, 0]).map((g) => g * RAD) as V3, scale: p.escala ?? 1});
|
|
22
|
+
const clamp01 = (x: number) => Math.max(0, Math.min(1, x));
|
|
23
|
+
const suave = (x: number) => {
|
|
24
|
+
const c = clamp01(x);
|
|
25
|
+
return c * c * (3 - 2 * c);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Maqueta flotante: una isla de `ancho`×`fondo` con estratos de tierra a la vista en los cuatro lados, pasto, flores,
|
|
30
|
+
* matas y piedritas. La superficie queda en y = 0. `hueco` deja un rectángulo sin pasto (para río, calle, alberca).
|
|
31
|
+
*/
|
|
32
|
+
export const Maqueta: React.FC<
|
|
33
|
+
Pieza & {
|
|
34
|
+
ancho?: number;
|
|
35
|
+
fondo?: number;
|
|
36
|
+
capas?: [string, number][];
|
|
37
|
+
pasto?: string;
|
|
38
|
+
flores?: number;
|
|
39
|
+
matas?: number;
|
|
40
|
+
colores?: string[];
|
|
41
|
+
semilla?: number;
|
|
42
|
+
/** Zonas sin flores ni matas: [x, z, ancho, fondo]. */
|
|
43
|
+
libres?: [number, number, number, number][];
|
|
44
|
+
}
|
|
45
|
+
> = ({ancho = 20, fondo = 14, capas = [['#79C24F', 0.4], ['#9A6A45', 1.6], ['#7D553A', 1.4], ['#8C8580', 1.2]], pasto, flores = 60, matas = 140, colores = ['#B57BE8', '#FFD23F', '#FF6B6B', '#FFFFFF'], semilla = 0, libres = [], ...p}) => {
|
|
46
|
+
const libre = (x: number, z: number) => libres.some(([lx, lz, la, lf]) => Math.abs(x - lx) < la / 2 && Math.abs(z - lz) < lf / 2);
|
|
47
|
+
const total = capas.reduce((a, [, g]) => a + g, 0);
|
|
48
|
+
const refMatas = useRef<THREE.InstancedMesh>(null);
|
|
49
|
+
const refFlores = useRef<THREE.InstancedMesh>(null);
|
|
50
|
+
const refTallos = useRef<THREE.InstancedMesh>(null);
|
|
51
|
+
const puntos = (n: number, s: number) =>
|
|
52
|
+
Array.from({length: n}, (_, i) => [(azarN(s + i * 2) - 0.5) * (ancho - 0.6), (azarN(s + i * 2 + 1) - 0.5) * (fondo - 0.6)] as [number, number]).filter(([x, z]) => !libre(x, z));
|
|
53
|
+
useLayoutEffect(() => {
|
|
54
|
+
const o = new THREE.Object3D();
|
|
55
|
+
const m = refMatas.current;
|
|
56
|
+
if (m) {
|
|
57
|
+
const lista = puntos(matas, semilla + 11);
|
|
58
|
+
lista.forEach(([x, z], i) => {
|
|
59
|
+
o.position.set(x, 0.12, z);
|
|
60
|
+
o.rotation.set(0, azarN(semilla + i * 13) * 6, 0);
|
|
61
|
+
const k = 0.6 + azarN(semilla + i * 7) * 0.8;
|
|
62
|
+
o.scale.set(k, k * (1 + azarN(semilla + i) * 0.8), k);
|
|
63
|
+
o.updateMatrix();
|
|
64
|
+
m.setMatrixAt(i, o.matrix);
|
|
65
|
+
});
|
|
66
|
+
m.count = lista.length;
|
|
67
|
+
m.instanceMatrix.needsUpdate = true;
|
|
68
|
+
}
|
|
69
|
+
const f = refFlores.current;
|
|
70
|
+
const tl = refTallos.current;
|
|
71
|
+
if (f && tl) {
|
|
72
|
+
const lista = puntos(flores, semilla + 97);
|
|
73
|
+
const c = new THREE.Color();
|
|
74
|
+
lista.forEach(([x, z], i) => {
|
|
75
|
+
const alto = 0.25 + azarN(semilla + i * 5) * 0.25;
|
|
76
|
+
o.position.set(x, alto / 2, z);
|
|
77
|
+
o.rotation.set(0, 0, 0);
|
|
78
|
+
o.scale.set(1, alto / 0.4, 1);
|
|
79
|
+
o.updateMatrix();
|
|
80
|
+
tl.setMatrixAt(i, o.matrix);
|
|
81
|
+
o.position.set(x, alto + 0.05, z);
|
|
82
|
+
o.scale.setScalar(0.8 + azarN(semilla + i * 3) * 0.6);
|
|
83
|
+
o.updateMatrix();
|
|
84
|
+
f.setMatrixAt(i, o.matrix);
|
|
85
|
+
f.setColorAt(i, c.set(colores[i % colores.length]));
|
|
86
|
+
});
|
|
87
|
+
f.count = lista.length;
|
|
88
|
+
tl.count = lista.length;
|
|
89
|
+
f.instanceMatrix.needsUpdate = true;
|
|
90
|
+
tl.instanceMatrix.needsUpdate = true;
|
|
91
|
+
if (f.instanceColor) f.instanceColor.needsUpdate = true;
|
|
92
|
+
}
|
|
93
|
+
}, [ancho, fondo, matas, flores, semilla, JSON.stringify(libres), colores.join()]);
|
|
94
|
+
let y = 0;
|
|
95
|
+
return (
|
|
96
|
+
<group {...grupo(p)}>
|
|
97
|
+
{capas.map(([color, grosor], i) => {
|
|
98
|
+
const centro = y - grosor / 2;
|
|
99
|
+
y -= grosor;
|
|
100
|
+
return (
|
|
101
|
+
<mesh key={i} position={[0, centro, 0]} receiveShadow castShadow={i === 0}>
|
|
102
|
+
<boxGeometry args={[ancho, grosor, fondo]} />
|
|
103
|
+
<meshStandardMaterial color={i === 0 && pasto ? pasto : color} roughness={0.95} />
|
|
104
|
+
</mesh>
|
|
105
|
+
);
|
|
106
|
+
})}
|
|
107
|
+
{/* piedritas en las cuatro caras */}
|
|
108
|
+
<Piedras z={fondo / 2 + 0.01} ancho={ancho} y0={-capas[0][1] - 0.2} y1={-total + 0.3} n={Math.round(ancho * 2.4)} semilla={semilla + 3} />
|
|
109
|
+
<group rotation={[0, Math.PI / 2, 0]}>
|
|
110
|
+
<Piedras z={ancho / 2 + 0.01} ancho={fondo} y0={-capas[0][1] - 0.2} y1={-total + 0.3} n={Math.round(fondo * 2.4)} semilla={semilla + 41} />
|
|
111
|
+
</group>
|
|
112
|
+
<group rotation={[0, -Math.PI / 2, 0]}>
|
|
113
|
+
<Piedras z={ancho / 2 + 0.01} ancho={fondo} y0={-capas[0][1] - 0.2} y1={-total + 0.3} n={Math.round(fondo * 2.4)} semilla={semilla + 77} />
|
|
114
|
+
</group>
|
|
115
|
+
<instancedMesh ref={refMatas} args={[undefined as any, undefined as any, matas]} castShadow frustumCulled={false}>
|
|
116
|
+
<coneGeometry args={[0.07, 0.32, 4]} />
|
|
117
|
+
<meshStandardMaterial color={pasto ?? '#5FAE3C'} roughness={0.9} flatShading />
|
|
118
|
+
</instancedMesh>
|
|
119
|
+
<instancedMesh ref={refTallos} args={[undefined as any, undefined as any, flores]} frustumCulled={false}>
|
|
120
|
+
<cylinderGeometry args={[0.018, 0.018, 0.4, 4]} />
|
|
121
|
+
<meshStandardMaterial color="#3E8A35" roughness={0.9} />
|
|
122
|
+
</instancedMesh>
|
|
123
|
+
<instancedMesh ref={refFlores} args={[undefined as any, undefined as any, flores]} castShadow frustumCulled={false}>
|
|
124
|
+
<icosahedronGeometry args={[0.09, 0]} />
|
|
125
|
+
<meshStandardMaterial roughness={0.7} flatShading />
|
|
126
|
+
</instancedMesh>
|
|
127
|
+
</group>
|
|
128
|
+
);
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Un modelo .glb dentro del lienzo: tuyo (`src: 'castor.glb'` en assets/) o un recurso de la biblioteca
|
|
133
|
+
* (`src: props.coche`, lo que deja `{tipo: 'recurso', buscar: 'coche'}` en las props del componente).
|
|
134
|
+
* Se escala para que su lado mayor mida `tam` y se apoya en y = 0. `animacion` reproduce los clips del archivo
|
|
135
|
+
* sincronizados con `t` (true = todos, o el nombre de uno).
|
|
136
|
+
*/
|
|
137
|
+
export const Modelo: React.FC<Pieza & {src: string | {src: string}; tam?: number; t?: number; animacion?: boolean | string; velocidad?: number; color?: string; sombras?: boolean}> = ({src, tam = 1, t = 0, animacion, velocidad = 1, color, sombras = true, ...p}) => {
|
|
138
|
+
const ruta = typeof src === 'string' ? src : src.src;
|
|
139
|
+
const url = recurso(ruta);
|
|
140
|
+
const [g, setG] = useState<{obj: THREE.Object3D; mixer: THREE.AnimationMixer | null} | null>(null);
|
|
141
|
+
const [handle] = useState(() => delayRender(`Cargando modelo ${ruta}`));
|
|
142
|
+
useEffect(() => {
|
|
143
|
+
cargarGltf(url)
|
|
144
|
+
.then((gl) => {
|
|
145
|
+
const copia = clonarEsqueleto(gl.scene) as THREE.Object3D;
|
|
146
|
+
const caja = new THREE.Box3().setFromObject(copia);
|
|
147
|
+
const t3 = caja.getSize(new THREE.Vector3());
|
|
148
|
+
const k = tam / Math.max(t3.x, t3.y, t3.z, 0.0001);
|
|
149
|
+
const centro = caja.getCenter(new THREE.Vector3());
|
|
150
|
+
const envoltura = new THREE.Group();
|
|
151
|
+
copia.position.set(-centro.x, -caja.min.y, -centro.z);
|
|
152
|
+
envoltura.add(copia);
|
|
153
|
+
envoltura.scale.setScalar(k);
|
|
154
|
+
copia.traverse((o: any) => {
|
|
155
|
+
if (o.isMesh) {
|
|
156
|
+
o.castShadow = sombras;
|
|
157
|
+
o.receiveShadow = sombras;
|
|
158
|
+
if (color) {
|
|
159
|
+
o.material = o.material.clone();
|
|
160
|
+
o.material.color = new THREE.Color(color);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
let mixer: THREE.AnimationMixer | null = null;
|
|
165
|
+
if (animacion && gl.animations.length) {
|
|
166
|
+
mixer = new THREE.AnimationMixer(copia);
|
|
167
|
+
const clips = animacion === true ? gl.animations : gl.animations.filter((a) => a.name === animacion || a.name.toLowerCase().includes(String(animacion).toLowerCase()));
|
|
168
|
+
for (const clip of clips.length ? clips : gl.animations.slice(0, 1)) mixer.clipAction(clip).play();
|
|
169
|
+
}
|
|
170
|
+
setG({obj: envoltura, mixer});
|
|
171
|
+
continueRender(handle);
|
|
172
|
+
})
|
|
173
|
+
.catch((e) => {
|
|
174
|
+
continueRender(handle);
|
|
175
|
+
throw new Error(`No pude cargar el modelo ${ruta}: ${e.message}`);
|
|
176
|
+
});
|
|
177
|
+
}, [url, handle, tam, color, String(animacion)]);
|
|
178
|
+
useLayoutEffect(() => {
|
|
179
|
+
g?.mixer?.setTime(Math.max(0, t) * velocidad);
|
|
180
|
+
});
|
|
181
|
+
return <group {...grupo(p)}>{g ? <primitive object={g.obj} /> : null}</group>;
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const MAT = (color: string, r = 0.8) => <meshStandardMaterial color={color} roughness={r} flatShading />;
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Persona low-poly (trabajador con casco y chaleco por defecto). Mide ~1.6 de alto con `escala` 1.
|
|
188
|
+
* `pose`: 'parado' | 'saludar' | 'caminar' | 'senalar' | 'celebrar'. `t` mueve brazos y piernas.
|
|
189
|
+
*/
|
|
190
|
+
export const Persona: React.FC<Pieza & {t?: number; pose?: 'parado' | 'saludar' | 'caminar' | 'senalar' | 'señalar' | 'celebrar'; piel?: string; ropa?: string; chaleco?: string | false; casco?: string | false; pantalon?: string}> = ({
|
|
191
|
+
t = 0,
|
|
192
|
+
pose = 'parado',
|
|
193
|
+
piel = '#F2C19B',
|
|
194
|
+
ropa = '#3A6EC8',
|
|
195
|
+
chaleco = '#FF7A1A',
|
|
196
|
+
casco = '#FFC928',
|
|
197
|
+
pantalon = '#2E3A59',
|
|
198
|
+
...p
|
|
199
|
+
}) => {
|
|
200
|
+
const paso = pose === 'caminar' ? Math.sin(t * 9) * 0.6 : 0;
|
|
201
|
+
const respira = Math.sin(t * 2.2) * 0.015;
|
|
202
|
+
let brazoI = 0.15;
|
|
203
|
+
let brazoD = -0.15;
|
|
204
|
+
if (pose === 'saludar') brazoD = -2.6 + Math.sin(t * 10) * 0.35;
|
|
205
|
+
if (pose === 'celebrar') {
|
|
206
|
+
brazoI = 2.7 + Math.sin(t * 9) * 0.2;
|
|
207
|
+
brazoD = -2.7 - Math.sin(t * 9) * 0.2;
|
|
208
|
+
}
|
|
209
|
+
if (pose === 'senalar' || pose === 'señalar') brazoD = -1.55;
|
|
210
|
+
if (pose === 'caminar') {
|
|
211
|
+
brazoI = -paso * 0.8;
|
|
212
|
+
brazoD = paso * 0.8;
|
|
213
|
+
}
|
|
214
|
+
const salto = pose === 'celebrar' ? Math.abs(Math.sin(t * 9)) * 0.12 : pose === 'caminar' ? Math.abs(Math.sin(t * 9)) * 0.04 : 0;
|
|
215
|
+
const brazo = (lado: 1 | -1, ang: number, adelante = 0) => (
|
|
216
|
+
<group position={[0.3 * lado, 1.18, 0]} rotation={[pose === 'caminar' ? ang : adelante, 0, pose === 'caminar' ? 0 : ang]}>
|
|
217
|
+
<mesh position={[0, -0.26, 0]} castShadow>
|
|
218
|
+
<boxGeometry args={[0.14, 0.52, 0.14]} />
|
|
219
|
+
{MAT(chaleco ? ropa : ropa)}
|
|
220
|
+
</mesh>
|
|
221
|
+
<mesh position={[0, -0.56, 0]} castShadow>
|
|
222
|
+
<sphereGeometry args={[0.085, 8, 6]} />
|
|
223
|
+
{MAT(piel)}
|
|
224
|
+
</mesh>
|
|
225
|
+
</group>
|
|
226
|
+
);
|
|
227
|
+
return (
|
|
228
|
+
<group {...grupo(p)}>
|
|
229
|
+
<group position={[0, salto + respira, 0]}>
|
|
230
|
+
{/* piernas */}
|
|
231
|
+
{[-1, 1].map((l) => (
|
|
232
|
+
<group key={l} position={[0.12 * l, 0.62, 0]} rotation={[l * paso, 0, 0]}>
|
|
233
|
+
<mesh position={[0, -0.3, 0]} castShadow>
|
|
234
|
+
<boxGeometry args={[0.17, 0.6, 0.18]} />
|
|
235
|
+
{MAT(pantalon)}
|
|
236
|
+
</mesh>
|
|
237
|
+
<mesh position={[0, -0.6, 0.05]} castShadow>
|
|
238
|
+
<boxGeometry args={[0.19, 0.08, 0.28]} />
|
|
239
|
+
{MAT('#3B2A20')}
|
|
240
|
+
</mesh>
|
|
241
|
+
</group>
|
|
242
|
+
))}
|
|
243
|
+
{/* torso y chaleco */}
|
|
244
|
+
<mesh position={[0, 0.95, 0]} castShadow>
|
|
245
|
+
<boxGeometry args={[0.48, 0.62, 0.28]} />
|
|
246
|
+
{MAT(ropa)}
|
|
247
|
+
</mesh>
|
|
248
|
+
{chaleco ? (
|
|
249
|
+
<mesh position={[0, 0.93, 0]} castShadow>
|
|
250
|
+
<boxGeometry args={[0.5, 0.5, 0.3]} />
|
|
251
|
+
{MAT(chaleco)}
|
|
252
|
+
</mesh>
|
|
253
|
+
) : null}
|
|
254
|
+
{chaleco ? (
|
|
255
|
+
<mesh position={[0, 0.88, 0.152]}>
|
|
256
|
+
<boxGeometry args={[0.5, 0.06, 0.01]} />
|
|
257
|
+
<meshStandardMaterial color="#E8F2F5" roughness={0.3} emissive="#ffffff" emissiveIntensity={0.15} />
|
|
258
|
+
</mesh>
|
|
259
|
+
) : null}
|
|
260
|
+
{brazo(-1, brazoI)}
|
|
261
|
+
{brazo(1, brazoD, pose === 'senalar' || pose === 'señalar' ? -1.4 : 0)}
|
|
262
|
+
{/* cabeza */}
|
|
263
|
+
<mesh position={[0, 1.42, 0]} castShadow>
|
|
264
|
+
<sphereGeometry args={[0.22, 12, 10]} />
|
|
265
|
+
{MAT(piel, 0.7)}
|
|
266
|
+
</mesh>
|
|
267
|
+
{[-1, 1].map((l) => (
|
|
268
|
+
<mesh key={l} position={[0.08 * l, 1.45, 0.2]}>
|
|
269
|
+
<sphereGeometry args={[0.028, 6, 6]} />
|
|
270
|
+
<meshStandardMaterial color="#1B1B1F" />
|
|
271
|
+
</mesh>
|
|
272
|
+
))}
|
|
273
|
+
{casco ? (
|
|
274
|
+
<group position={[0, 1.55, 0]}>
|
|
275
|
+
<mesh castShadow>
|
|
276
|
+
<sphereGeometry args={[0.24, 12, 8, 0, Math.PI * 2, 0, Math.PI / 2]} />
|
|
277
|
+
{MAT(casco, 0.5)}
|
|
278
|
+
</mesh>
|
|
279
|
+
<mesh position={[0, 0, 0.06]}>
|
|
280
|
+
<cylinderGeometry args={[0.3, 0.3, 0.035, 14]} />
|
|
281
|
+
{MAT(casco, 0.5)}
|
|
282
|
+
</mesh>
|
|
283
|
+
</group>
|
|
284
|
+
) : null}
|
|
285
|
+
</group>
|
|
286
|
+
</group>
|
|
287
|
+
);
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
/** Coche low-poly (2 de largo). Para tráfico sin descargar nada. */
|
|
291
|
+
export const Coche: React.FC<Pieza & {color?: string; techo?: string; t?: number; rueda?: number}> = ({color = '#E8533B', techo = '#1F3550', t = 0, ...p}) => (
|
|
292
|
+
<group {...grupo(p)}>
|
|
293
|
+
<mesh position={[0, 0.38, 0]} castShadow receiveShadow>
|
|
294
|
+
<boxGeometry args={[2, 0.45, 0.95]} />
|
|
295
|
+
{MAT(color, 0.5)}
|
|
296
|
+
</mesh>
|
|
297
|
+
<mesh position={[-0.1, 0.78, 0]} castShadow>
|
|
298
|
+
<boxGeometry args={[1.1, 0.38, 0.86]} />
|
|
299
|
+
{MAT(techo, 0.4)}
|
|
300
|
+
</mesh>
|
|
301
|
+
{[
|
|
302
|
+
[0.65, 0.48],
|
|
303
|
+
[-0.65, 0.48],
|
|
304
|
+
[0.65, -0.48],
|
|
305
|
+
[-0.65, -0.48],
|
|
306
|
+
].map(([x, z], i) => (
|
|
307
|
+
<mesh key={i} position={[x, 0.18, z]} rotation={[Math.PI / 2, t * 6, 0]} castShadow>
|
|
308
|
+
<cylinderGeometry args={[0.19, 0.19, 0.14, 10]} />
|
|
309
|
+
{MAT('#1E1E22', 0.9)}
|
|
310
|
+
</mesh>
|
|
311
|
+
))}
|
|
312
|
+
{[0.2, -0.2].map((z, i) => (
|
|
313
|
+
<mesh key={i} position={[1.005, 0.42, z * 1.6]}>
|
|
314
|
+
<boxGeometry args={[0.02, 0.1, 0.16]} />
|
|
315
|
+
<meshStandardMaterial color="#FFF6C8" emissive="#FFF2B0" emissiveIntensity={0.6} />
|
|
316
|
+
</mesh>
|
|
317
|
+
))}
|
|
318
|
+
</group>
|
|
319
|
+
);
|
|
320
|
+
|
|
321
|
+
const COLORES_COCHE = ['#E8533B', '#2D7BFF', '#2BB673', '#F5B841', '#8E5BD9', '#FF7EB6', '#F2F2F2', '#33363D'];
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Tráfico: `n` coches (o lo que dibuje `pieza`) que avanzan por `camino` a `velocidad` (u/s) con `separacion`.
|
|
325
|
+
* `pluma`: una barrera a `d` unidades del inicio del camino; mientras está cerrada (intervalos [desde, hasta] en
|
|
326
|
+
* segundos) los coches que no la han cruzado se detienen y hacen fila. Todo se calcula de `t`: es determinista.
|
|
327
|
+
*/
|
|
328
|
+
export const Trafico: React.FC<{
|
|
329
|
+
camino: V3[];
|
|
330
|
+
t: number;
|
|
331
|
+
n?: number;
|
|
332
|
+
velocidad?: number;
|
|
333
|
+
separacion?: number;
|
|
334
|
+
desfase?: number;
|
|
335
|
+
pluma?: {d: number; cerrada: [number, number][]};
|
|
336
|
+
colores?: string[];
|
|
337
|
+
pieza?: (i: number, color: string, t: number) => React.ReactNode;
|
|
338
|
+
semilla?: number;
|
|
339
|
+
}> = ({camino, t, n = 8, velocidad = 2.2, separacion = 2.8, desfase = 0, pluma, colores = COLORES_COCHE, pieza, semilla = 0}) => {
|
|
340
|
+
const curva = useMemo(() => new THREE.CatmullRomCurve3(camino.map((p) => new THREE.Vector3(...p)), false, 'catmullrom', 0.02), [JSON.stringify(camino)]);
|
|
341
|
+
const L = curva.getLength();
|
|
342
|
+
// posición libre de cada coche (sin pluma), sin dar la vuelta: el coche 0 va adelante
|
|
343
|
+
const libre = (i: number, tt: number) => velocidad * tt + desfase - i * separacion;
|
|
344
|
+
const filas = (pluma?.cerrada ?? []).map(() => 0); // cuántos coches hacen fila en cada cierre
|
|
345
|
+
const pos: number[] = [];
|
|
346
|
+
for (let i = 0; i < n; i++) {
|
|
347
|
+
let retraso = 0; // segundos que este coche lleva detenido
|
|
348
|
+
if (pluma)
|
|
349
|
+
pluma.cerrada.forEach(([c0, c1], k) => {
|
|
350
|
+
const enCierre = libre(i, c0) - retraso * velocidad;
|
|
351
|
+
// la siguiente pluma por delante (la pluma está en d, d + L, d + 2L... porque el camino se repite)
|
|
352
|
+
const pl = pluma.d + Math.ceil((enCierre - pluma.d) / L) * L;
|
|
353
|
+
const lugar = pl - filas[k] * separacion;
|
|
354
|
+
const llega = c0 + Math.max(0, (lugar - enCierre) / velocidad);
|
|
355
|
+
if (llega >= c1) return; // llega cuando ya abrió
|
|
356
|
+
filas[k]++;
|
|
357
|
+
if (t > llega) retraso += Math.min(t, c1) - llega;
|
|
358
|
+
});
|
|
359
|
+
let s = libre(i, t) - retraso * velocidad;
|
|
360
|
+
if (pos.length) s = Math.min(s, pos[pos.length - 1] - separacion);
|
|
361
|
+
pos.push(s);
|
|
362
|
+
}
|
|
363
|
+
return (
|
|
364
|
+
<group>
|
|
365
|
+
{pos.map((s, i) => {
|
|
366
|
+
const u = ((s % L) + L) % L / L;
|
|
367
|
+
const p = curva.getPointAt(u);
|
|
368
|
+
const d = curva.getTangentAt(u);
|
|
369
|
+
const ang = Math.atan2(-d.z, d.x);
|
|
370
|
+
const color = colores[(i + semilla) % colores.length];
|
|
371
|
+
// aparecen y desaparecen encogiéndose en los extremos del camino (no saltan de un lado a otro)
|
|
372
|
+
const borde = suave(Math.min(u * L, (1 - u) * L) / (separacion * 0.45));
|
|
373
|
+
if (borde <= 0.01) return null;
|
|
374
|
+
return (
|
|
375
|
+
<group key={i} position={p} rotation={[0, ang, 0]} scale={borde}>
|
|
376
|
+
{pieza ? pieza(i, color, t) : <Coche color={color} t={t} />}
|
|
377
|
+
</group>
|
|
378
|
+
);
|
|
379
|
+
})}
|
|
380
|
+
</group>
|
|
381
|
+
);
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
/** Pluma (barrera) de caseta: `abierta` 0..1 la levanta. El brazo mide `largo` y apunta a +x. */
|
|
385
|
+
export const Pluma: React.FC<Pieza & {abierta?: number; largo?: number}> = ({abierta = 0, largo = 3.2, ...p}) => (
|
|
386
|
+
<group {...grupo(p)}>
|
|
387
|
+
<mesh position={[0, 0.5, 0]} castShadow>
|
|
388
|
+
<boxGeometry args={[0.4, 1, 0.4]} />
|
|
389
|
+
{MAT('#FFC928', 0.5)}
|
|
390
|
+
</mesh>
|
|
391
|
+
<group position={[0, 0.85, 0]} rotation={[0, 0, (suave(abierta) * 80 * Math.PI) / 180]}>
|
|
392
|
+
{Array.from({length: 8}, (_, i) => (
|
|
393
|
+
<mesh key={i} position={[(i + 0.5) * (largo / 8), 0, 0]} castShadow>
|
|
394
|
+
<boxGeometry args={[largo / 8, 0.12, 0.12]} />
|
|
395
|
+
{MAT(i % 2 ? '#FFFFFF' : '#E5484D', 0.5)}
|
|
396
|
+
</mesh>
|
|
397
|
+
))}
|
|
398
|
+
</group>
|
|
399
|
+
</group>
|
|
400
|
+
);
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Rayos X: recorta lo que envuelve con un plano para ver adentro. `k` (0..1) abre el corte (anímalo con el tiempo).
|
|
404
|
+
* `eje`: hacia dónde se abre ('z' quita la mitad de enfrente). Lo que va adentro (agua, flotador) ponlo fuera de <Corte>.
|
|
405
|
+
*/
|
|
406
|
+
export const Corte: React.FC<{k: number; eje?: 'x' | 'y' | 'z'; centro?: number; tam?: number; children: React.ReactNode}> = ({k, eje = 'z', centro = 0, tam = 2, children}) => {
|
|
407
|
+
const ref = useRef<THREE.Group>(null);
|
|
408
|
+
const plano = useMemo(() => new THREE.Plane(), []);
|
|
409
|
+
useLayoutEffect(() => {
|
|
410
|
+
const n = eje === 'x' ? new THREE.Vector3(-1, 0, 0) : eje === 'y' ? new THREE.Vector3(0, -1, 0) : new THREE.Vector3(0, 0, -1);
|
|
411
|
+
// k = 0: el plano queda fuera (no recorta); k = 1: corta por `centro`
|
|
412
|
+
const d = centro + (1 - suave(k)) * tam;
|
|
413
|
+
const mundo = new THREE.Vector3();
|
|
414
|
+
ref.current?.getWorldPosition(mundo);
|
|
415
|
+
const off = eje === 'x' ? mundo.x : eje === 'y' ? mundo.y : mundo.z;
|
|
416
|
+
plano.set(n, d + off);
|
|
417
|
+
ref.current?.traverse((o: any) => {
|
|
418
|
+
if (!o.isMesh) return;
|
|
419
|
+
const mats = Array.isArray(o.material) ? o.material : [o.material];
|
|
420
|
+
for (const m of mats) {
|
|
421
|
+
m.clippingPlanes = k > 0 ? [plano] : null;
|
|
422
|
+
m.side = THREE.DoubleSide;
|
|
423
|
+
}
|
|
424
|
+
});
|
|
425
|
+
});
|
|
426
|
+
return <group ref={ref}>{children}</group>;
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
/** Gotas de agua que caen de `de` a `a` (por un tubo transparente o de una llave). */
|
|
430
|
+
export const Gotas: React.FC<{de: V3; a: V3; t: number; n?: number; velocidad?: number; color?: string; tam?: number}> = ({de, a, t, n = 6, velocidad = 1.4, color = '#4FB3FF', tam = 0.09}) => (
|
|
431
|
+
<group>
|
|
432
|
+
{Array.from({length: n}, (_, i) => {
|
|
433
|
+
const k = (t * velocidad + i / n) % 1;
|
|
434
|
+
return (
|
|
435
|
+
<mesh key={i} position={[de[0] + (a[0] - de[0]) * k, de[1] + (a[1] - de[1]) * k, de[2] + (a[2] - de[2]) * k]} scale={[1, 1.4, 1]}>
|
|
436
|
+
<sphereGeometry args={[tam, 8, 6]} />
|
|
437
|
+
<meshStandardMaterial color={color} roughness={0.15} emissive={color} emissiveIntensity={0.35} transparent opacity={0.9} />
|
|
438
|
+
</mesh>
|
|
439
|
+
);
|
|
440
|
+
})}
|
|
441
|
+
</group>
|
|
442
|
+
);
|
|
443
|
+
|
|
444
|
+
/** Confeti 3D que estalla en `pos` cuando `t` pasa de `en`. */
|
|
445
|
+
export const Confeti3D: React.FC<{pos: V3; t: number; en: number; n?: number; colores?: string[]}> = ({pos, t, en, n = 60, colores = ['#FFC400', '#FF5A8A', '#4FB3FF', '#5ADC6E', '#FFFFFF']}) => {
|
|
446
|
+
const dt = t - en;
|
|
447
|
+
if (dt < 0 || dt > 3) return null;
|
|
448
|
+
return (
|
|
449
|
+
<group position={pos}>
|
|
450
|
+
{Array.from({length: n}, (_, i) => {
|
|
451
|
+
const ang = azarN(i) * Math.PI * 2;
|
|
452
|
+
const v = 2 + azarN(i * 3) * 3;
|
|
453
|
+
const vy = 4 + azarN(i * 5) * 4;
|
|
454
|
+
const x = Math.cos(ang) * v * dt;
|
|
455
|
+
const z = Math.sin(ang) * v * dt;
|
|
456
|
+
const y = vy * dt - 4.9 * dt * dt * 0.6;
|
|
457
|
+
return (
|
|
458
|
+
<mesh key={i} position={[x, y, z]} rotation={[dt * 8 + i, dt * 6, i]}>
|
|
459
|
+
<boxGeometry args={[0.12, 0.02, 0.07]} />
|
|
460
|
+
<meshStandardMaterial color={colores[i % colores.length]} roughness={0.5} />
|
|
461
|
+
</mesh>
|
|
462
|
+
);
|
|
463
|
+
})}
|
|
464
|
+
</group>
|
|
465
|
+
);
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
/** Linterna: un foco con su cono visible, de `pos` hacia `mirar` (escenas de noche). */
|
|
469
|
+
export const Linterna: React.FC<{pos: V3; mirar: V3; color?: string; fuerza?: number; angulo?: number}> = ({pos, mirar, color = '#FFF1C2', fuerza = 6, angulo = 0.38}) => {
|
|
470
|
+
const objetivo = useMemo(() => new THREE.Object3D(), []);
|
|
471
|
+
useLayoutEffect(() => {
|
|
472
|
+
objetivo.position.set(...mirar);
|
|
473
|
+
objetivo.updateMatrixWorld();
|
|
474
|
+
});
|
|
475
|
+
const dir = new THREE.Vector3(...mirar).sub(new THREE.Vector3(...pos));
|
|
476
|
+
const largo = dir.length();
|
|
477
|
+
const q = new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, -1, 0), dir.clone().normalize());
|
|
478
|
+
const medio = new THREE.Vector3(...pos).add(dir.clone().multiplyScalar(0.5));
|
|
479
|
+
return (
|
|
480
|
+
<group>
|
|
481
|
+
<primitive object={objetivo} />
|
|
482
|
+
<spotLight position={pos} target={objetivo} angle={angulo} penumbra={0.6} decay={0} intensity={fuerza} color={color} castShadow />
|
|
483
|
+
<mesh position={medio} quaternion={q}>
|
|
484
|
+
<coneGeometry args={[Math.tan(angulo) * largo, largo, 32, 1, true]} />
|
|
485
|
+
<meshBasicMaterial color={color} transparent opacity={0.09} blending={THREE.AdditiveBlending} depthWrite={false} side={THREE.DoubleSide} />
|
|
486
|
+
</mesh>
|
|
487
|
+
</group>
|
|
488
|
+
);
|
|
489
|
+
};
|