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:
Jules Nény
2026-09-27 15:21:40 +02:00
co-authored by Claude Fable 5.1
commit 2e249f1027
33 changed files with 2026 additions and 0 deletions
+2
View File
@@ -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 {};
+2
View File
@@ -0,0 +1,2 @@
// Caméra : orbite tactile bornée, survol par waypoints, flyTo (LOT Q2). Ne lit jamais le contenu.
export {};
+96
View File
@@ -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();
},
};
}
+57
View File
@@ -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);
},
};
}
+46
View File
@@ -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}` : '';
}
+2
View File
@@ -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 {};
+3
View File
@@ -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 {};
+2
View File
@@ -0,0 +1,2 @@
// horscamp/ : l'anneau des macro-infrastructures à l'horizon, les flux vers les maisons, leur rétraction. (LOT Q4)
export {};
+21
View File
@@ -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();
}
}
+3
View File
@@ -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 {};