feat: replace primitives with kenney cc0 models
Swap hand-built boxes/cylinders for Kenney Car Kit, City Kit Roads, and Furniture Kit low-poly models across the bus, stops, and world, keeping ride/camera logic and Html panels untouched.
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.5 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+22
-31
@@ -1,13 +1,28 @@
|
|||||||
import { useRef } from 'react'
|
import { useRef } from 'react'
|
||||||
import { useFrame } from '@react-three/fiber'
|
import { useFrame } from '@react-three/fiber'
|
||||||
|
import { Clone, useGLTF } from '@react-three/drei'
|
||||||
import { Group, Vector3 } from 'three'
|
import { Group, Vector3 } from 'three'
|
||||||
import { pointAt, tangentAt } from './path'
|
import { pointAt, tangentAt } from './path'
|
||||||
import { useRide } from '../ride/store'
|
import { useRide } from '../ride/store'
|
||||||
|
|
||||||
const ahead = new Vector3()
|
const ahead = new Vector3()
|
||||||
|
|
||||||
|
// Kenney Car Kit van: body ~2.75 units long natively (wheel-front z=+0.76
|
||||||
|
// to body rear z=-1.4). Scale it up to match the old ~4.6-unit footprint.
|
||||||
|
const VAN_SCALE = 1.7
|
||||||
|
// wheel-front-{left,right} nodes sit at z=+0.76 (native), the body rear at
|
||||||
|
// z=-1.4 — the model's own "nose" points down its local +Z axis. For a
|
||||||
|
// plain (non-camera) Object3D, Object3D.lookAt orients local +Z toward the
|
||||||
|
// look target, so this group's +Z is already the direction of travel after
|
||||||
|
// the lookAt below: the van needs no corrective yaw.
|
||||||
|
const VAN_FORWARD_CORRECTION = 0
|
||||||
|
|
||||||
|
// roof top sits at ~2.3 world units after scaling (body top 1.35 * 1.7)
|
||||||
|
const ROOF_Y = 2.3
|
||||||
|
|
||||||
export function Bus() {
|
export function Bus() {
|
||||||
const ref = useRef<Group>(null)
|
const ref = useRef<Group>(null)
|
||||||
|
const { scene } = useGLTF('/models/van.glb')
|
||||||
useFrame((state) => {
|
useFrame((state) => {
|
||||||
const t = useRide.getState().t
|
const t = useRide.getState().t
|
||||||
const g = ref.current!
|
const g = ref.current!
|
||||||
@@ -18,43 +33,19 @@ export function Bus() {
|
|||||||
})
|
})
|
||||||
return (
|
return (
|
||||||
<group ref={ref} name="bus">
|
<group ref={ref} name="bus">
|
||||||
{/* body, two-tone Électron Fou paint job */}
|
<Clone object={scene} scale={VAN_SCALE} rotation={[0, VAN_FORWARD_CORRECTION, 0]} />
|
||||||
<mesh position={[0, 1, 0]}>
|
{/* crazy antenna: a zap sphere on a pole, mounted on the roof — the one
|
||||||
<boxGeometry args={[2.2, 1.5, 4.6]} />
|
fun accent kept from the old hand-built bus */}
|
||||||
<meshStandardMaterial color="#ff5c00" roughness={0.4} />
|
<mesh position={[0.5, ROOF_Y + 0.5, -1.7]}>
|
||||||
</mesh>
|
|
||||||
<mesh position={[0, 1.9, 0.3]}>
|
|
||||||
<boxGeometry args={[2.2, 0.5, 3.4]} />
|
|
||||||
<meshStandardMaterial color="#fff3d6" roughness={0.5} />
|
|
||||||
</mesh>
|
|
||||||
{/* windshield */}
|
|
||||||
<mesh position={[0, 1.35, 2.31]}>
|
|
||||||
<boxGeometry args={[1.8, 0.7, 0.05]} />
|
|
||||||
<meshStandardMaterial color="#7ec8ff" emissive="#153a5e" roughness={0.1} />
|
|
||||||
</mesh>
|
|
||||||
{/* wheels */}
|
|
||||||
{([[-1.1, 1.5], [1.1, 1.5], [-1.1, -1.5], [1.1, -1.5]] as const).map(([x, z], i) => (
|
|
||||||
<mesh key={i} position={[x, 0.45, z]} rotation={[0, 0, Math.PI / 2]}>
|
|
||||||
<cylinderGeometry args={[0.45, 0.45, 0.3, 12]} />
|
|
||||||
<meshStandardMaterial color="#111" roughness={0.9} />
|
|
||||||
</mesh>
|
|
||||||
))}
|
|
||||||
{/* headlights */}
|
|
||||||
{[-0.7, 0.7].map((x, i) => (
|
|
||||||
<mesh key={i} position={[x, 0.9, 2.32]}>
|
|
||||||
<sphereGeometry args={[0.16, 8, 8]} />
|
|
||||||
<meshStandardMaterial color="#fffbe6" emissive="#ffd23f" emissiveIntensity={2} />
|
|
||||||
</mesh>
|
|
||||||
))}
|
|
||||||
{/* crazy antenna: a zap sphere on a pole */}
|
|
||||||
<mesh position={[0.8, 2.6, -1.6]}>
|
|
||||||
<cylinderGeometry args={[0.03, 0.03, 1, 6]} />
|
<cylinderGeometry args={[0.03, 0.03, 1, 6]} />
|
||||||
<meshStandardMaterial color="#999" />
|
<meshStandardMaterial color="#999" />
|
||||||
</mesh>
|
</mesh>
|
||||||
<mesh position={[0.8, 3.2, -1.6]}>
|
<mesh position={[0.5, ROOF_Y + 1.15, -1.7]}>
|
||||||
<sphereGeometry args={[0.18, 8, 8]} />
|
<sphereGeometry args={[0.18, 8, 8]} />
|
||||||
<meshStandardMaterial color="#9be564" emissive="#9be564" emissiveIntensity={2.5} />
|
<meshStandardMaterial color="#9be564" emissive="#9be564" emissiveIntensity={2.5} />
|
||||||
</mesh>
|
</mesh>
|
||||||
</group>
|
</group>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useGLTF.preload('/models/van.glb')
|
||||||
|
|||||||
+31
-1
@@ -1,5 +1,5 @@
|
|||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { Instance, Instances } from '@react-three/drei'
|
import { Clone, Instance, Instances, useGLTF } from '@react-three/drei'
|
||||||
import { TubeGeometry, Vector3 } from 'three'
|
import { TubeGeometry, Vector3 } from 'three'
|
||||||
import { busPath, pointAt, tangentAt } from './path'
|
import { busPath, pointAt, tangentAt } from './path'
|
||||||
import { mulberry32 } from './rng'
|
import { mulberry32 } from './rng'
|
||||||
@@ -10,6 +10,28 @@ interface Item {
|
|||||||
scale: [number, number, number]
|
scale: [number, number, number]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface Lamp {
|
||||||
|
pos: [number, number, number]
|
||||||
|
rotY: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const LAMP_TS = [0.06, 0.18, 0.3, 0.42, 0.55, 0.68, 0.8, 0.92]
|
||||||
|
const LAMP_SCALE = 6
|
||||||
|
const LAMP_OFFSET = 5
|
||||||
|
|
||||||
|
function lampLayout(): Lamp[] {
|
||||||
|
return LAMP_TS.map((t, i) => {
|
||||||
|
const p = pointAt(t)
|
||||||
|
const tan = tangentAt(t)
|
||||||
|
const n = new Vector3(-tan.z, 0, tan.x) // unit normal, tangent is already unit length
|
||||||
|
const side = i % 2 === 0 ? 1 : -1
|
||||||
|
const pos = p.clone().addScaledVector(n, side * LAMP_OFFSET)
|
||||||
|
// face the lamp's curved arm back toward the road regardless of path curvature
|
||||||
|
const rotY = Math.atan2(side * n.x, side * n.z)
|
||||||
|
return { pos: [pos.x, 0, pos.z], rotY }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function layout(seed: number, count: number, minOff: number, maxOff: number): Item[] {
|
function layout(seed: number, count: number, minOff: number, maxOff: number): Item[] {
|
||||||
const rng = mulberry32(seed)
|
const rng = mulberry32(seed)
|
||||||
const items: Item[] = []
|
const items: Item[] = []
|
||||||
@@ -35,7 +57,9 @@ export function World() {
|
|||||||
const caps = useMemo(() => layout(4242, 50, 8, 38), [])
|
const caps = useMemo(() => layout(4242, 50, 8, 38), [])
|
||||||
const resistors = useMemo(() => layout(90210, 50, 7, 36), [])
|
const resistors = useMemo(() => layout(90210, 50, 7, 36), [])
|
||||||
const vias = useMemo(() => layout(555, 80, 6, 44), [])
|
const vias = useMemo(() => layout(555, 80, 6, 44), [])
|
||||||
|
const lamps = useMemo(() => lampLayout(), [])
|
||||||
const trace = useMemo(() => new TubeGeometry(busPath, 200, 0.5, 6, false), [])
|
const trace = useMemo(() => new TubeGeometry(busPath, 200, 0.5, 6, false), [])
|
||||||
|
const { scene: lampScene } = useGLTF('/models/light-curved.glb')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group>
|
<group>
|
||||||
@@ -80,6 +104,12 @@ export function World() {
|
|||||||
<Instance key={i} position={[it.pos[0], 0.05, it.pos[2]]} />
|
<Instance key={i} position={[it.pos[0], 0.05, it.pos[2]]} />
|
||||||
))}
|
))}
|
||||||
</Instances>
|
</Instances>
|
||||||
|
{/* street lamps lining the road */}
|
||||||
|
{lamps.map((l, i) => (
|
||||||
|
<Clone key={i} object={lampScene} position={l.pos} rotation={[0, l.rotY, 0]} scale={LAMP_SCALE} />
|
||||||
|
))}
|
||||||
</group>
|
</group>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useGLTF.preload('/models/light-curved.glb')
|
||||||
|
|||||||
@@ -1,14 +1,46 @@
|
|||||||
import { useRef } from 'react'
|
import { useRef } from 'react'
|
||||||
import { useFrame } from '@react-three/fiber'
|
import { useFrame } from '@react-three/fiber'
|
||||||
|
import { Clone, useGLTF } from '@react-three/drei'
|
||||||
import { Mesh, MeshStandardMaterial } from 'three'
|
import { Mesh, MeshStandardMaterial } from 'three'
|
||||||
import { StopPanel, stopAnchor } from './StopPanel'
|
import { StopPanel, stopAnchor } from './StopPanel'
|
||||||
import { STOPS } from '../../content/stops'
|
import { STOPS } from '../../content/stops'
|
||||||
import { MUSIC } from '../../content/music'
|
import { MUSIC } from '../../content/music'
|
||||||
|
|
||||||
|
// Kenney furniture-kit pieces are authored with a corner origin (spanning
|
||||||
|
// +X, +Y, -Z from [0,0,0]) rather than a centered pivot — these helpers
|
||||||
|
// re-center each clone at a given world position.
|
||||||
|
function centered(w: number, h: number, d: number, x: number, yBottom: number, z: number): [number, number, number] {
|
||||||
|
return [x - w / 2, yBottom, z + d / 2]
|
||||||
|
}
|
||||||
|
|
||||||
|
const SPEAKER_SCALE = 5
|
||||||
|
const SPK_W = 0.148 * SPEAKER_SCALE
|
||||||
|
const SPK_H = 0.6364 * SPEAKER_SCALE
|
||||||
|
const SPK_D = 0.148 * SPEAKER_SCALE
|
||||||
|
const COL_GAP = SPK_W * 1.08
|
||||||
|
const COL_X = [-COL_GAP / 2, COL_GAP / 2]
|
||||||
|
const ROW_Y = [0, SPK_H]
|
||||||
|
const STACK_TOP = ROW_Y[1] + SPK_H
|
||||||
|
|
||||||
|
const SMALL_SCALE = 4.4
|
||||||
|
const SMALL_W = 0.148 * SMALL_SCALE
|
||||||
|
const SMALL_D = 0.1332 * SMALL_SCALE
|
||||||
|
|
||||||
|
const GEAR_SCALE = 3.2
|
||||||
|
const RADIO_W = 0.315 * GEAR_SCALE
|
||||||
|
const RADIO_D = 0.0975 * GEAR_SCALE
|
||||||
|
const TV_W = 0.41 * GEAR_SCALE
|
||||||
|
const TV_D = 0.27 * GEAR_SCALE
|
||||||
|
|
||||||
|
const TUBE_Y = STACK_TOP + 1.0
|
||||||
|
|
||||||
export function StopElectronFou() {
|
export function StopElectronFou() {
|
||||||
const tubes = useRef<Array<Mesh | null>>([])
|
const tubes = useRef<Array<Mesh | null>>([])
|
||||||
const needles = useRef<Array<Mesh | null>>([])
|
const ampPos = stopAnchor(STOPS[2], 8)
|
||||||
const ampPos = stopAnchor(STOPS[2], 12)
|
const { scene: speakerScene } = useGLTF('/models/speaker.glb')
|
||||||
|
const { scene: speakerSmallScene } = useGLTF('/models/speakerSmall.glb')
|
||||||
|
const { scene: radioScene } = useGLTF('/models/radio.glb')
|
||||||
|
const { scene: tvScene } = useGLTF('/models/televisionVintage.glb')
|
||||||
|
|
||||||
useFrame((state) => {
|
useFrame((state) => {
|
||||||
const time = state.clock.elapsedTime
|
const time = state.clock.elapsedTime
|
||||||
@@ -17,9 +49,6 @@ export function StopElectronFou() {
|
|||||||
const mat = m.material as MeshStandardMaterial
|
const mat = m.material as MeshStandardMaterial
|
||||||
mat.emissiveIntensity = 1.4 + Math.sin(time * 4 + i * 1.7) * 0.9
|
mat.emissiveIntensity = 1.4 + Math.sin(time * 4 + i * 1.7) * 0.9
|
||||||
})
|
})
|
||||||
needles.current.forEach((m, i) => {
|
|
||||||
if (m) m.rotation.z = Math.sin(time * 6 + i) * 0.5
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -29,35 +58,48 @@ export function StopElectronFou() {
|
|||||||
<p>{MUSIC.blurb}</p>
|
<p>{MUSIC.blurb}</p>
|
||||||
<a className="sign" href={MUSIC.listenUrl}>♫ {MUSIC.listenLabel}</a>
|
<a className="sign" href={MUSIC.listenUrl}>♫ {MUSIC.listenLabel}</a>
|
||||||
</StopPanel>
|
</StopPanel>
|
||||||
{/* the giant tube amp behind the panel */}
|
{/* the wall of speakers behind the panel */}
|
||||||
<group position={ampPos}>
|
<group position={ampPos}>
|
||||||
<mesh position={[0, 2.2, 0]}>
|
{ROW_Y.map((y, ri) =>
|
||||||
<boxGeometry args={[9, 4.4, 3.2]} />
|
COL_X.map((x, ci) => (
|
||||||
<meshStandardMaterial color="#17171c" roughness={0.5} />
|
<Clone
|
||||||
</mesh>
|
key={`${ri}-${ci}`}
|
||||||
<mesh position={[0, 2.2, 1.65]}>
|
object={speakerScene}
|
||||||
<boxGeometry args={[8.2, 3.4, 0.1]} />
|
scale={SPEAKER_SCALE}
|
||||||
<meshStandardMaterial color="#3b2c1a" roughness={0.9} />
|
position={centered(SPK_W, SPK_H, SPK_D, x, y, 0)}
|
||||||
</mesh>
|
/>
|
||||||
{[-3.2, -1.1, 1.1, 3.2].map((x, i) => (
|
)),
|
||||||
<mesh key={i} ref={(el) => { tubes.current[i] = el }} position={[x, 5.2, 0]}>
|
)}
|
||||||
<cylinderGeometry args={[0.5, 0.6, 1.8, 10]} />
|
<Clone
|
||||||
|
object={speakerSmallScene}
|
||||||
|
scale={SMALL_SCALE}
|
||||||
|
position={centered(SMALL_W, 0, SMALL_D, 0, STACK_TOP, 0.45)}
|
||||||
|
/>
|
||||||
|
<Clone
|
||||||
|
object={radioScene}
|
||||||
|
scale={GEAR_SCALE}
|
||||||
|
position={centered(RADIO_W, 0, RADIO_D, -0.55, STACK_TOP, 0)}
|
||||||
|
rotation={[0, 0.3, 0]}
|
||||||
|
/>
|
||||||
|
<Clone
|
||||||
|
object={tvScene}
|
||||||
|
scale={GEAR_SCALE}
|
||||||
|
position={centered(TV_W, 0, TV_D, 0.55, STACK_TOP, 0)}
|
||||||
|
rotation={[0, -0.4, 0]}
|
||||||
|
/>
|
||||||
|
{/* pulsing emissive tubes rising behind the stack — demoscene heartbeat */}
|
||||||
|
{[-0.9, -0.32, 0.32, 0.9].map((x, i) => (
|
||||||
|
<mesh key={i} ref={(el) => { tubes.current[i] = el }} position={[x, TUBE_Y, -0.5]}>
|
||||||
|
<cylinderGeometry args={[0.11, 0.14, 2.2, 10]} />
|
||||||
<meshStandardMaterial color="#331100" emissive="#ff5c00" emissiveIntensity={1.4} transparent opacity={0.92} />
|
<meshStandardMaterial color="#331100" emissive="#ff5c00" emissiveIntensity={1.4} transparent opacity={0.92} />
|
||||||
</mesh>
|
</mesh>
|
||||||
))}
|
))}
|
||||||
{[-2, 2].map((x, i) => (
|
|
||||||
<group key={i} position={[x, 2.2, 1.75]}>
|
|
||||||
<mesh>
|
|
||||||
<boxGeometry args={[1.6, 1, 0.05]} />
|
|
||||||
<meshStandardMaterial color="#f5e6c8" emissive="#4a4230" />
|
|
||||||
</mesh>
|
|
||||||
<mesh ref={(el) => { needles.current[i] = el }} position={[0, -0.2, 0.05]}>
|
|
||||||
<boxGeometry args={[0.05, 0.7, 0.02]} />
|
|
||||||
<meshStandardMaterial color="#e03131" />
|
|
||||||
</mesh>
|
|
||||||
</group>
|
|
||||||
))}
|
|
||||||
</group>
|
</group>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useGLTF.preload('/models/speaker.glb')
|
||||||
|
useGLTF.preload('/models/speakerSmall.glb')
|
||||||
|
useGLTF.preload('/models/radio.glb')
|
||||||
|
useGLTF.preload('/models/televisionVintage.glb')
|
||||||
|
|||||||
@@ -1,22 +1,61 @@
|
|||||||
import { StopPanel } from './StopPanel'
|
import { useMemo } from 'react'
|
||||||
|
import { Clone, useGLTF } from '@react-three/drei'
|
||||||
|
import { StopPanel, stopAnchor } from './StopPanel'
|
||||||
import { STOPS } from '../../content/stops'
|
import { STOPS } from '../../content/stops'
|
||||||
import { GREETINGS } from '../../content/greetings'
|
import { GREETINGS } from '../../content/greetings'
|
||||||
import { useRide } from '../../ride/store'
|
import { useRide } from '../../ride/store'
|
||||||
|
|
||||||
|
// "end of the road" wink: cones + barriers clustered near the terminus
|
||||||
|
const CONES = [
|
||||||
|
{ dx: 3, dz: 1, scale: 1.4, rotY: 0.2 },
|
||||||
|
{ dx: 3.8, dz: -0.4, scale: 1.2, rotY: 1.1 },
|
||||||
|
{ dx: 2.6, dz: -1.6, scale: 1.5, rotY: 2.4 },
|
||||||
|
] as const
|
||||||
|
const BARRIERS = [
|
||||||
|
{ dx: 4.6, dz: 0.4, scale: 7.5, rotY: -0.4 },
|
||||||
|
{ dx: 4.9, dz: -1.4, scale: 7.2, rotY: 0.6 },
|
||||||
|
] as const
|
||||||
|
|
||||||
export function StopGreetings() {
|
export function StopGreetings() {
|
||||||
|
const base = useMemo(() => stopAnchor(STOPS[3]), [])
|
||||||
|
const { scene: coneScene } = useGLTF('/models/cone.glb')
|
||||||
|
const { scene: barrierScene } = useGLTF('/models/construction-barrier.glb')
|
||||||
return (
|
return (
|
||||||
<StopPanel stop={STOPS[3]} width={380}>
|
<>
|
||||||
<h2>Greetings</h2>
|
<StopPanel stop={STOPS[3]} width={380}>
|
||||||
<p>Respect éternel à :</p>
|
<h2>Greetings</h2>
|
||||||
<ul>
|
<p>Respect éternel à :</p>
|
||||||
{GREETINGS.map((g) => (
|
<ul>
|
||||||
<li key={g}>{g}</li>
|
{GREETINGS.map((g) => (
|
||||||
))}
|
<li key={g}>{g}</li>
|
||||||
</ul>
|
))}
|
||||||
<p className="muted">Code + design : Clément Saillant. Aucun cookie n'a été blessé.</p>
|
</ul>
|
||||||
<button className="sign" onClick={() => useRide.getState().reset()}>
|
<p className="muted">Code + design : Clément Saillant. Aucun cookie n'a été blessé.</p>
|
||||||
⟲ RIDE AGAIN ?
|
<button className="sign" onClick={() => useRide.getState().reset()}>
|
||||||
</button>
|
⟲ RIDE AGAIN ?
|
||||||
</StopPanel>
|
</button>
|
||||||
|
</StopPanel>
|
||||||
|
{CONES.map((c, i) => (
|
||||||
|
<Clone
|
||||||
|
key={i}
|
||||||
|
object={coneScene}
|
||||||
|
position={[base.x + c.dx, base.y, base.z + c.dz]}
|
||||||
|
rotation={[0, c.rotY, 0]}
|
||||||
|
scale={c.scale}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{BARRIERS.map((b, i) => (
|
||||||
|
<Clone
|
||||||
|
key={i}
|
||||||
|
object={barrierScene}
|
||||||
|
position={[base.x + b.dx, base.y, base.z + b.dz]}
|
||||||
|
rotation={[0, b.rotY, 0]}
|
||||||
|
scale={b.scale}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useGLTF.preload('/models/cone.glb')
|
||||||
|
useGLTF.preload('/models/construction-barrier.glb')
|
||||||
|
|||||||
@@ -1,16 +1,41 @@
|
|||||||
import { StopPanel } from './StopPanel'
|
import { useMemo } from 'react'
|
||||||
|
import { Clone, useGLTF } from '@react-three/drei'
|
||||||
|
import { StopPanel, stopAnchor } from './StopPanel'
|
||||||
import { STOPS } from '../../content/stops'
|
import { STOPS } from '../../content/stops'
|
||||||
import { LINKS } from '../../content/links'
|
import { LINKS } from '../../content/links'
|
||||||
|
|
||||||
|
// small decorative sign forest behind the panel — décor only, the real
|
||||||
|
// links stay in the Html panel below
|
||||||
|
const FOREST = [
|
||||||
|
{ dx: -3.5, dz: -2.5, scale: 4.2, rotY: 0.4 },
|
||||||
|
{ dx: 4, dz: -3.5, scale: 5.5, rotY: -0.9 },
|
||||||
|
{ dx: 1.5, dz: -5, scale: 3.4, rotY: 1.8 },
|
||||||
|
] as const
|
||||||
|
|
||||||
export function StopIdentities() {
|
export function StopIdentities() {
|
||||||
|
const base = useMemo(() => stopAnchor(STOPS[1]), [])
|
||||||
|
const { scene } = useGLTF('/models/sign-highway.glb')
|
||||||
return (
|
return (
|
||||||
<StopPanel stop={STOPS[1]} width={380}>
|
<>
|
||||||
<h2>Identités</h2>
|
<StopPanel stop={STOPS[1]} width={380}>
|
||||||
{LINKS.map((l) => (
|
<h2>Identités</h2>
|
||||||
<a key={l.id} className="sign" href={l.href} {...(l.rel ? { rel: l.rel } : {})}>
|
{LINKS.map((l) => (
|
||||||
→ {l.label}
|
<a key={l.id} className="sign" href={l.href} {...(l.rel ? { rel: l.rel } : {})}>
|
||||||
</a>
|
→ {l.label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</StopPanel>
|
||||||
|
{FOREST.map((f, i) => (
|
||||||
|
<Clone
|
||||||
|
key={i}
|
||||||
|
object={scene}
|
||||||
|
position={[base.x + f.dx, base.y, base.z + f.dz]}
|
||||||
|
rotation={[0, f.rotY, 0]}
|
||||||
|
scale={f.scale}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</StopPanel>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useGLTF.preload('/models/sign-highway.glb')
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { Html, Text } from '@react-three/drei'
|
import { Clone, Html, Text, useGLTF } from '@react-three/drei'
|
||||||
import { Vector3 } from 'three'
|
import { Vector3 } from 'three'
|
||||||
import type { Stop } from '../../content/stops'
|
import type { Stop } from '../../content/stops'
|
||||||
import { pointAt, tangentAt } from '../path'
|
import { pointAt, tangentAt } from '../path'
|
||||||
@@ -13,6 +13,31 @@ export function stopAnchor(stop: Stop, lateral = 6): Vector3 {
|
|||||||
return p.clone().addScaledVector(n, lateral)
|
return p.clone().addScaledVector(n, lateral)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sign-highway-wide.glb: twin posts (0..0.707 tall) framing a board around
|
||||||
|
// the 0.41-0.53 band, board front face on its native -X side. Scaled up so
|
||||||
|
// the board reads clearly above the Html panel (which stays untouched).
|
||||||
|
const SIGN_SCALE = 8
|
||||||
|
const BOARD_Y = 0.5 * SIGN_SCALE
|
||||||
|
const BOARD_Z = 0.11 * SIGN_SCALE
|
||||||
|
|
||||||
|
const LAMP_SCALE = 6
|
||||||
|
|
||||||
|
// small deterministic hash so each stop's lamp gets a stable yaw variation
|
||||||
|
function hash(id: string): number {
|
||||||
|
let h = 0
|
||||||
|
for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) >>> 0
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
// face the lamp's curved arm back toward the road, plus a per-stop wobble
|
||||||
|
function lampYaw(stop: Stop): number {
|
||||||
|
const tan = tangentAt(stop.t)
|
||||||
|
const n = new Vector3(-tan.z, 0, tan.x)
|
||||||
|
const base = Math.atan2(n.x, n.z)
|
||||||
|
const wobble = ((hash(stop.id) % 1000) / 1000 - 0.5) * 0.6
|
||||||
|
return base + wobble
|
||||||
|
}
|
||||||
|
|
||||||
export function StopPanel({ stop, children, width = 340 }: {
|
export function StopPanel({ stop, children, width = 340 }: {
|
||||||
stop: Stop
|
stop: Stop
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
@@ -20,24 +45,24 @@ export function StopPanel({ stop, children, width = 340 }: {
|
|||||||
}) {
|
}) {
|
||||||
const pos = useMemo(() => stopAnchor(stop), [stop])
|
const pos = useMemo(() => stopAnchor(stop), [stop])
|
||||||
const active = useRide((s) => s.stop?.id === stop.id)
|
const active = useRide((s) => s.stop?.id === stop.id)
|
||||||
|
const { scene: signScene } = useGLTF('/models/sign-highway-wide.glb')
|
||||||
|
const { scene: lampScene } = useGLTF('/models/light-curved.glb')
|
||||||
return (
|
return (
|
||||||
<group position={pos}>
|
<group position={pos}>
|
||||||
{/* pole */}
|
{/* bus-stop sign: billboard-on-posts (native board faces its local
|
||||||
<mesh position={[0, 2.5, 0]}>
|
-X; rotate 90° so the face reads along +Z toward the road) */}
|
||||||
<cylinderGeometry args={[0.12, 0.12, 5, 8]} />
|
<Clone object={signScene} scale={SIGN_SCALE} rotation={[0, Math.PI / 2, 0]} />
|
||||||
<meshStandardMaterial color="#c0c0c0" metalness={0.8} roughness={0.3} />
|
<Text position={[0, BOARD_Y, BOARD_Z + 0.05]} fontSize={0.6} color="#05010d" anchorX="center" anchorY="middle">
|
||||||
</mesh>
|
|
||||||
{/* bus-stop sign */}
|
|
||||||
<mesh position={[0, 5.4, 0]}>
|
|
||||||
<boxGeometry args={[3.6, 1.2, 0.15]} />
|
|
||||||
<meshStandardMaterial color="#ffd23f" emissive="#453400" />
|
|
||||||
</mesh>
|
|
||||||
<Text position={[0, 5.4, 0.12]} fontSize={0.55} color="#05010d" anchorX="center" anchorY="middle">
|
|
||||||
{stop.title}
|
{stop.title}
|
||||||
</Text>
|
</Text>
|
||||||
<Html transform position={[0, 2.6, 0.4]} distanceFactor={10} style={{ width }}>
|
<Html transform position={[0, 2.6, 0.4]} distanceFactor={10} style={{ width }}>
|
||||||
<div className={`panel${active ? ' panel--active' : ''}`}>{children}</div>
|
<div className={`panel${active ? ' panel--active' : ''}`}>{children}</div>
|
||||||
</Html>
|
</Html>
|
||||||
|
{/* street lamp beside the stop */}
|
||||||
|
<Clone object={lampScene} scale={LAMP_SCALE} position={[5.2, 0, -1]} rotation={[0, lampYaw(stop), 0]} />
|
||||||
</group>
|
</group>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useGLTF.preload('/models/sign-highway-wide.glb')
|
||||||
|
useGLTF.preload('/models/light-curved.glb')
|
||||||
|
|||||||
Reference in New Issue
Block a user