LOT Q0 : socle du démonstrateur Quelque part en 2035
Scaffold Vite + TypeScript + Three.js (scène vide : sol + boîte, marqueur de build), store et encodage de l'état dans l'URL (5 tests), lint de frontières entre modules, pipeline d'extraction Rhino (graine du 26/09) et première géométrie v0, deploy.sh statique (releases + lien symbolique + smoke), AGENTS.md, RUNBOOK, licences MIT + CC BY-SA 4.0. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
// content/ : chargeur de public/content.json (compilé depuis content/**/*.md par scripts/build-content), schémas zod, index. (LOT Q2)
|
||||
export {};
|
||||
@@ -0,0 +1,2 @@
|
||||
// Caméra : orbite tactile bornée, survol par waypoints, flyTo (LOT Q2). Ne lit jamais le contenu.
|
||||
export {};
|
||||
@@ -0,0 +1,96 @@
|
||||
// Scène : renderer, lumière, sol, boucle. Aucune logique métier ici (ARCHITECTURE §4).
|
||||
// LOT Q0 : un sol et une boîte, pour prouver le pipeline de bout en bout. gen/ remplacera la boîte au LOT Q2.
|
||||
import {
|
||||
AmbientLight,
|
||||
BoxGeometry,
|
||||
Color,
|
||||
DirectionalLight,
|
||||
Mesh,
|
||||
MeshStandardMaterial,
|
||||
PerspectiveCamera,
|
||||
PlaneGeometry,
|
||||
Scene,
|
||||
WebGLRenderer,
|
||||
} from 'three';
|
||||
import type { Store } from './state.ts';
|
||||
|
||||
export interface SceneQuartier {
|
||||
demarrer(): void;
|
||||
arreter(): void;
|
||||
}
|
||||
|
||||
export function creerScene(canvas: HTMLCanvasElement, store: Store): SceneQuartier | null {
|
||||
let renderer: WebGLRenderer;
|
||||
try {
|
||||
renderer = new WebGLRenderer({ canvas, antialias: true, alpha: false, powerPreference: 'low-power' });
|
||||
} catch {
|
||||
return null; // pas de WebGL : le repli reste affiché
|
||||
}
|
||||
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
|
||||
|
||||
const scene = new Scene();
|
||||
scene.background = new Color('#f6f1e7');
|
||||
|
||||
const camera = new PerspectiveCamera(40, 1, 1, 2000);
|
||||
camera.position.set(120, 90, 120);
|
||||
camera.lookAt(0, 0, 0);
|
||||
|
||||
scene.add(new AmbientLight('#ffffff', 0.6));
|
||||
const soleil = new DirectionalLight('#fff4e0', 1.2);
|
||||
soleil.position.set(-80, 120, 40);
|
||||
scene.add(soleil);
|
||||
|
||||
const sol = new Mesh(new PlaneGeometry(600, 600), new MeshStandardMaterial({ color: '#efe8d8' }));
|
||||
sol.rotation.x = -Math.PI / 2;
|
||||
scene.add(sol);
|
||||
|
||||
// La boîte du LOT Q0 : une maison de 10 × 8 × 7 m, blanche. Remplacée par gen/extrude au Q2.
|
||||
const maison = new Mesh(new BoxGeometry(10, 7, 8), new MeshStandardMaterial({ color: '#ffffff' }));
|
||||
maison.position.y = 3.5;
|
||||
scene.add(maison);
|
||||
|
||||
const redimensionner = () => {
|
||||
const { clientWidth: w, clientHeight: h } = canvas;
|
||||
renderer.setSize(w, h, false);
|
||||
camera.aspect = w / h;
|
||||
camera.updateProjectionMatrix();
|
||||
};
|
||||
|
||||
let actif = false;
|
||||
let derive = 0;
|
||||
const boucle = () => {
|
||||
if (!actif) return;
|
||||
// Régime ambiance : dérive lente de la caméra (60 s par tour). Démo : immobile tant qu'il n'y a pas d'orbite (Q2).
|
||||
if (store.mode === 'ambiance' && !document.hidden) {
|
||||
derive += (2 * Math.PI) / (60 * 60);
|
||||
camera.position.set(170 * Math.cos(derive), 90, 170 * Math.sin(derive));
|
||||
camera.lookAt(0, 0, 0);
|
||||
}
|
||||
renderer.render(scene, camera);
|
||||
requestAnimationFrame(boucle);
|
||||
};
|
||||
|
||||
const surVisibilite = () => {
|
||||
if (document.hidden) actif = false;
|
||||
else if (!actif) {
|
||||
actif = true;
|
||||
requestAnimationFrame(boucle);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
demarrer() {
|
||||
redimensionner();
|
||||
addEventListener('resize', redimensionner);
|
||||
document.addEventListener('visibilitychange', surVisibilite);
|
||||
actif = true;
|
||||
requestAnimationFrame(boucle);
|
||||
},
|
||||
arreter() {
|
||||
actif = false;
|
||||
removeEventListener('resize', redimensionner);
|
||||
document.removeEventListener('visibilitychange', surVisibilite);
|
||||
renderer.dispose();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// Store minimal : un état, des abonnés, l'URL tenue à jour. Aucune dépendance au DOM au-delà de history.
|
||||
// Frontière : ui/ et gen/ ne se parlent que par ce store (ARCHITECTURE §4).
|
||||
import { ETAT_DEFAUT, encoderEtat, type Etat, type Mode } from './url.ts';
|
||||
|
||||
export type Evenement =
|
||||
| { type: 'couche:toggle'; id: string }
|
||||
| { type: 'dispositif:toggle'; id: string }
|
||||
| { type: 'temps:set'; cran: Etat['temps'] }
|
||||
| { type: 'parcours:set'; id: string | null };
|
||||
|
||||
export interface Store {
|
||||
readonly mode: Mode;
|
||||
etat(): Etat;
|
||||
emettre(ev: Evenement): void;
|
||||
abonner(fn: (etat: Etat, ev: Evenement | null) => void): () => void;
|
||||
}
|
||||
|
||||
function bascule(liste: string[], id: string): string[] {
|
||||
return liste.includes(id) ? liste.filter((x) => x !== id) : [...liste, id];
|
||||
}
|
||||
|
||||
export function reduire(etat: Etat, ev: Evenement): Etat {
|
||||
switch (ev.type) {
|
||||
case 'couche:toggle':
|
||||
return { ...etat, couches: bascule(etat.couches, ev.id) };
|
||||
case 'dispositif:toggle':
|
||||
return { ...etat, dispositifs: bascule(etat.dispositifs, ev.id) };
|
||||
case 'temps:set':
|
||||
return { ...etat, temps: ev.cran };
|
||||
case 'parcours:set':
|
||||
return { ...etat, parcours: ev.id };
|
||||
}
|
||||
}
|
||||
|
||||
export function creerStore(initial: Partial<Etat>, mode: Mode): Store {
|
||||
let etat: Etat = { ...ETAT_DEFAUT, ...initial };
|
||||
const abonnes = new Set<(etat: Etat, ev: Evenement | null) => void>();
|
||||
const ecrireUrl = () => {
|
||||
if (typeof history === 'undefined') return;
|
||||
const h = encoderEtat(etat);
|
||||
if (location.hash !== h) history.replaceState(null, '', `${location.pathname}${location.search}${h}`);
|
||||
};
|
||||
return {
|
||||
mode,
|
||||
etat: () => etat,
|
||||
emettre(ev) {
|
||||
etat = reduire(etat, ev);
|
||||
ecrireUrl();
|
||||
for (const fn of abonnes) fn(etat, ev);
|
||||
},
|
||||
abonner(fn) {
|
||||
abonnes.add(fn);
|
||||
fn(etat, null);
|
||||
return () => abonnes.delete(fn);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// Encodage de l'état dans l'URL. Fonctions pures, testées en node (tests/url.test.ts).
|
||||
// Format : ?mode=ambiance|demo #c=eau,vegetal&d=noue,cuve&t=2035&p=pluie
|
||||
// L'état visible est partageable : la même URL restaure la même vue (ARCHITECTURE §2).
|
||||
|
||||
export type Mode = 'ambiance' | 'demo';
|
||||
export type Cran = 'aujourdhui' | '2030' | '2035';
|
||||
|
||||
export interface Etat {
|
||||
couches: string[];
|
||||
dispositifs: string[];
|
||||
temps: Cran;
|
||||
parcours: string | null;
|
||||
}
|
||||
|
||||
export const ETAT_DEFAUT: Etat = { couches: [], dispositifs: [], temps: '2035', parcours: null };
|
||||
const CRANS: readonly Cran[] = ['aujourdhui', '2030', '2035'];
|
||||
|
||||
export function lireMode(url: URL): Mode {
|
||||
return url.searchParams.get('mode') === 'ambiance' ? 'ambiance' : 'demo';
|
||||
}
|
||||
|
||||
function liste(v: string | null): string[] {
|
||||
if (!v) return [];
|
||||
return [...new Set(v.split(',').map((s) => s.trim()).filter((s) => /^[a-z0-9-]+$/.test(s)))];
|
||||
}
|
||||
|
||||
export function decoderEtat(hash: string): Etat {
|
||||
const p = new URLSearchParams(hash.replace(/^#/, ''));
|
||||
const t = p.get('t');
|
||||
return {
|
||||
couches: liste(p.get('c')),
|
||||
dispositifs: liste(p.get('d')),
|
||||
temps: (CRANS as readonly string[]).includes(t ?? '') ? (t as Cran) : ETAT_DEFAUT.temps,
|
||||
parcours: p.get('p') && /^[a-z0-9-]+$/.test(p.get('p')!) ? p.get('p') : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function encoderEtat(e: Etat): string {
|
||||
const p = new URLSearchParams();
|
||||
if (e.couches.length) p.set('c', e.couches.join(','));
|
||||
if (e.dispositifs.length) p.set('d', e.dispositifs.join(','));
|
||||
if (e.temps !== ETAT_DEFAUT.temps) p.set('t', e.temps);
|
||||
if (e.parcours) p.set('p', e.parcours);
|
||||
const s = p.toString();
|
||||
return s ? `#${s}` : '';
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// fx/ : pluie, temps (aujourd'hui · 2030 · 2035), signes de vie. Lisent le store, n'ont pas d'état propre. (LOT Q4)
|
||||
export {};
|
||||
@@ -0,0 +1,3 @@
|
||||
// gen/ : constructeurs de formes depuis data/quartier.geo.json (extrude, band, water, instances, flux).
|
||||
// Frontière : reçoit géométrie + paramètres ; ne choisit jamais de contenu ; n'importe jamais ui/. (LOT Q2)
|
||||
export {};
|
||||
@@ -0,0 +1,2 @@
|
||||
// horscamp/ : l'anneau des macro-infrastructures à l'horizon, les flux vers les maisons, leur rétraction. (LOT Q4)
|
||||
export {};
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
// Point d'entrée. Lit le régime (?mode=ambiance|demo) et l'état (#…), monte la scène.
|
||||
// Frontière : main.ts orchestre core/ ; il n'importe jamais gen/ ni ui/ directement au-delà de leur index.
|
||||
import { creerScene } from './core/scene.ts';
|
||||
import { creerStore } from './core/state.ts';
|
||||
import { lireMode, decoderEtat } from './core/url.ts';
|
||||
|
||||
const canvas = document.getElementById('scene') as HTMLCanvasElement | null;
|
||||
const repli = document.getElementById('repli');
|
||||
const hashEl = document.getElementById('build-hash');
|
||||
if (hashEl) hashEl.textContent = __BUILD_HASH__;
|
||||
|
||||
const mode = lireMode(new URL(location.href));
|
||||
const store = creerStore(decoderEtat(location.hash), mode);
|
||||
|
||||
if (canvas) {
|
||||
const scene = creerScene(canvas, store);
|
||||
if (scene) {
|
||||
repli?.setAttribute('hidden', '');
|
||||
scene.demarrer();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// ui/ : barre des couches, dispositifs, fiche, curseur du temps, lecteur de parcours, compteur. HTML + tokens @transformer/ui.
|
||||
// Frontière : ne touche jamais à WebGL ; ne parle à gen/ que par le store. (LOT Q2)
|
||||
export {};
|
||||
Reference in New Issue
Block a user