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:
+10
@@ -0,0 +1,10 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.vite/
|
||||||
|
v/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
# Sources Rhino copiées hors Dropbox pour le pipeline (trop lourdes pour git, chemin au RUNBOOK)
|
||||||
|
sources/*.3dm
|
||||||
|
*.tgz
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# AGENTS.md — contrat du repo `quartier`
|
||||||
|
|
||||||
|
> Lu par toute session (humaine ou agent) avant de toucher ce repo. Squelette commun aux repos de Jules (décision du 26/09/2026), rempli ici pour le démonstrateur « Quelque part en 2035 ».
|
||||||
|
|
||||||
|
## Ce que c'est
|
||||||
|
|
||||||
|
Le quartier type de TMIP en mouvement : une scène Three.js générée depuis le plan du concours 2020, en deux régimes (ambiance en fond de la page d'accueil de la plateforme REinNOVER, démo plein écran sur `quartier.trans-former.fr`). Site statique, aucun serveur, aucune donnée collectée.
|
||||||
|
|
||||||
|
## Sources de vérité (dans cet ordre)
|
||||||
|
|
||||||
|
1. Vault, `1 PROJETS/TMIP/REinNOVER/Quartier 3D/` : `PILOTE - Quartier 3D TMIP.md` (état, lots, gates) → `ARCHITECTURE - Quartier 3D.md` (prescriptif) → `CONTENU - couches, dispositifs, ramifications.md` (matrice) → `CONCEPT - Quelque part en 2035 (démonstrateur).md` (pourquoi).
|
||||||
|
2. `docs/RUNBOOK.md` (ici) pour déployer, revenir en arrière, régénérer la géométrie.
|
||||||
|
3. Le plan Rhino source ne vit pas ici (Dropbox, `Archives TMIP/`) ; `sources/` en garde une copie locale ignorée par git.
|
||||||
|
|
||||||
|
## Les trois étages, et la règle
|
||||||
|
|
||||||
|
- `content/` : ce que Jules écrit (markdown + frontmatter). **Le moteur ne connaît aucun dispositif.**
|
||||||
|
- `data/quartier.geo.json` : géométrie générée par `pipeline/`, jamais dessinée à la main hors `overrides/`.
|
||||||
|
- `src/` : le moteur. Frontières vérifiées par `npm run lint:frontieres` : `ui/` ↔ `gen/`, `fx/`, `horscamp/` ne s'importent jamais ; tout passe par `core/state`.
|
||||||
|
|
||||||
|
## Commandes
|
||||||
|
|
||||||
|
```
|
||||||
|
npm install # deps : three, zod, vite, typescript
|
||||||
|
npm run dev # http://localhost:5173 (?mode=ambiance pour le régime ambiance)
|
||||||
|
npm run check # frontières + types + tests
|
||||||
|
./deploy.sh # build local → VPS → /opt/quartier/current (voir RUNBOOK)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Ce qu'on ne fait jamais
|
||||||
|
|
||||||
|
- Coder un dispositif, un chiffre ou un texte dans `src/` : c'est du contenu.
|
||||||
|
- Multiplier un chiffre par le quartier tant que le livret de calculs n'est pas rouvert (pilote §Lignes fermées).
|
||||||
|
- Un score, un badge, un tracker, un script tiers.
|
||||||
|
- Une bibliothèque UI ou CSS hors `@transformer/ui`.
|
||||||
|
- Travailler dans une copie du repo sous Dropbox, ou commiter un secret, un `.3dm`, un `dist/`.
|
||||||
|
- Ouvrir un port : le site est servi par Caddy en `file_server`, point.
|
||||||
|
|
||||||
|
## Fin de session
|
||||||
|
|
||||||
|
`npm run check` vert · `git push` · `RECAP-LOT-Q<n>.md` dans le dossier du pilote (vault) · §État du pilote écrasé.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Jules Nény — Transformations résilientes
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
|
||||||
|
Le contenu (textes, dessins, données du quartier) dans `content/`, `data/` et `public/`
|
||||||
|
est sous licence Creative Commons Attribution - Partage dans les mêmes conditions 4.0
|
||||||
|
(CC BY-SA 4.0) — voir content/LICENSE. Ce choix est un défaut posé le 26/09/2026,
|
||||||
|
révisable par l'auteur (pilote du chantier, gate Q-B).
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Quelque part en 2035
|
||||||
|
|
||||||
|
> *« Quelque part en 2035, dans un quartier pavillonnaire, un jour de marché… »* — TMIP, présentation 2024, p. 20.
|
||||||
|
|
||||||
|
Le quartier type de **TMIP** (Transformations des Maisons Individuelles Périurbaines) en mouvement : une maquette web où l'on allume une à une les transformations d'un quartier pavillonnaire (l'eau, le vivant nourricier, l'enveloppe et la chaleur, la vie de quartier), où l'on fait pleuvoir, où l'on remonte à aujourd'hui, et où l'on voit à l'horizon les infrastructures dont ces maisons n'ont plus besoin.
|
||||||
|
|
||||||
|
- **Démo** : `https://quartier.trans-former.fr`
|
||||||
|
- **Ambiance** : la même scène, en fond de la page d'accueil de la plateforme REinNOVER (`?mode=ambiance`)
|
||||||
|
|
||||||
|
## Démarrer
|
||||||
|
|
||||||
|
```
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
npm run check
|
||||||
|
```
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
content/ couches, dispositifs, macro-infrastructures, parcours, signes de vie, habitants (markdown) — CC BY-SA 4.0
|
||||||
|
data/ quartier.geo.json (généré), overrides/
|
||||||
|
pipeline/ extraction Python du plan Rhino 2020 (rhino3dm) → géométrie
|
||||||
|
src/ core/ gen/ fx/ horscamp/ ui/ content/ — MIT
|
||||||
|
docs/ RUNBOOK.md, EMBED.md (LOT Q5)
|
||||||
|
```
|
||||||
|
|
||||||
|
Pilotage, architecture, contenu : vault `1 PROJETS/TMIP/REinNOVER/Quartier 3D/`. Contrat du repo : `AGENTS.md`.
|
||||||
|
|
||||||
|
Auteur : Jules Nény — Transformations résilientes. Code MIT, contenu CC BY-SA 4.0.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
Contenu de « Quelque part en 2035 » — textes, dessins, géométrie du quartier type, chiffres et fiches.
|
||||||
|
|
||||||
|
© 2019-2026 Jules Nény. Licence Creative Commons Attribution - Partage dans les mêmes conditions 4.0 International (CC BY-SA 4.0).
|
||||||
|
https://creativecommons.org/licenses/by-sa/4.0/deed.fr
|
||||||
|
|
||||||
|
Attribution attendue : « Jules Nény — TMIP, Transformations des Maisons Individuelles Périurbaines (2019-2026) ».
|
||||||
|
Sources : projet de fin d'études ENSAPB 2019, concours Habiter Demain 2020, présentation TMIP 2024.
|
||||||
|
Le code du démonstrateur est sous licence MIT (voir LICENSE à la racine).
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 489 KiB |
File diff suppressed because one or more lines are too long
@@ -0,0 +1,84 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# deploy.sh — Quelque part en 2035 (quartier.trans-former.fr), site statique.
|
||||||
|
#
|
||||||
|
# Pipeline : build LOCAL (aucun binaire natif : le build x86_64 sert un VPS arm64 sans problème)
|
||||||
|
# → tar de dist/ → scp → /opt/quartier/releases/<hash>/ → bascule du lien /opt/quartier/current
|
||||||
|
# → smoke LOCAL sur le VPS (Host: quartier.trans-former.fr, marqueur exigé, bloquant)
|
||||||
|
# → smoke public (informatif tant que DNS/certificat ne sont pas là) → garde 3 releases.
|
||||||
|
#
|
||||||
|
# Usage : ./deploy.sh (déploie HEAD ; refuse un arbre non commité)
|
||||||
|
# ./deploy.sh --force (déploie l'arbre courant même non commité, marqueur suffixé -dirty)
|
||||||
|
# Retour arrière : ssh vps-hetzner 'ln -sfn /opt/quartier/releases/<hash> /opt/quartier/current'
|
||||||
|
# ⚠ Ne JAMAIS lancer depuis une copie du repo dans Dropbox.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
VPS_HOST="${VPS_HOST:-vps-hetzner}"
|
||||||
|
DEST="/opt/quartier"
|
||||||
|
DOMAINE="quartier.trans-former.fr"
|
||||||
|
GARDER=3
|
||||||
|
|
||||||
|
case "$(pwd)" in *Dropbox*) echo "ÉCHEC : jamais depuis Dropbox"; exit 1;; esac
|
||||||
|
|
||||||
|
HASH="$(git rev-parse --short HEAD)"
|
||||||
|
if git status --porcelain | grep -q .; then
|
||||||
|
if [ "${1:-}" = "--force" ]; then HASH="${HASH}-dirty"; else
|
||||||
|
echo "ÉCHEC : arbre de travail non propre. Commit d'abord, ou --force (marqueur ${HASH}-dirty)."; exit 1; fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Build local, marqueur ${HASH}"
|
||||||
|
rm -rf dist
|
||||||
|
QUARTIER_BUILD_HASH="${HASH}" npm run build --silent
|
||||||
|
test -f dist/index.html && test -f dist/build.json || { echo "ÉCHEC : dist incomplet"; exit 1; }
|
||||||
|
POIDS="$(du -sk dist | cut -f1)"
|
||||||
|
echo " dist : ${POIDS} Ko"
|
||||||
|
|
||||||
|
echo "==> Transfert vers ${VPS_HOST}:${DEST}/releases/${HASH}"
|
||||||
|
tar -C dist -czf "/tmp/quartier-${HASH}.tgz" .
|
||||||
|
scp -q "/tmp/quartier-${HASH}.tgz" "${VPS_HOST}:/tmp/"
|
||||||
|
rm -f "/tmp/quartier-${HASH}.tgz"
|
||||||
|
|
||||||
|
ssh "${VPS_HOST}" bash -s -- "${HASH}" "${DEST}" "${DOMAINE}" "${GARDER}" <<'EOF'
|
||||||
|
set -euo pipefail
|
||||||
|
HASH="$1"; DEST="$2"; DOMAINE="$3"; GARDER="$4"
|
||||||
|
REL="${DEST}/releases/${HASH}"
|
||||||
|
rm -rf "${REL}"; mkdir -p "${REL}"
|
||||||
|
tar -C "${REL}" -xzf "/tmp/quartier-${HASH}.tgz"; rm -f "/tmp/quartier-${HASH}.tgz"
|
||||||
|
chmod -R a+rX "${REL}"
|
||||||
|
PREC="$(readlink -f "${DEST}/current" 2>/dev/null || true)"
|
||||||
|
ln -sfn "${REL}" "${DEST}/current"
|
||||||
|
echo "==> Smoke local (fichier en place, puis Caddy en TLS local avec --resolve)"
|
||||||
|
grep -q "\"hash\":\"${HASH}\"" "${DEST}/current/build.json" || {
|
||||||
|
echo "ÉCHEC : ${DEST}/current/build.json ne porte pas ${HASH}"
|
||||||
|
if [ -n "${PREC}" ]; then ln -sfn "${PREC}" "${DEST}/current"; echo " retour arrière sur ${PREC}"; fi
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
# Caddy redirige http → https (308) : on interroge donc le vhost en TLS, en local, sans dépendre du DNS.
|
||||||
|
# Sans certificat (DNS absent), la poignée de main TLS échoue : on l'accepte comme « route posée, certificat en attente ».
|
||||||
|
CODE="$(curl -sk -m 10 --resolve "${DOMAINE}:443:127.0.0.1" -o /tmp/quartier-smoke.json -w '%{http_code}' "https://${DOMAINE}/build.json" || echo 000)"
|
||||||
|
if [ "${CODE}" = "200" ] && grep -q "\"hash\":\"${HASH}\"" /tmp/quartier-smoke.json; then
|
||||||
|
echo " marqueur ${HASH} servi par Caddy (TLS local)"
|
||||||
|
else
|
||||||
|
HTTP="$(curl -s -m 10 -o /dev/null -w '%{http_code}' -H "Host: ${DOMAINE}" "http://127.0.0.1/build.json" || echo 000)"
|
||||||
|
if [ "${HTTP}" = "308" ]; then
|
||||||
|
echo " Caddy route bien ${DOMAINE} (308 → https) ; certificat pas encore émis (TLS local : ${CODE}). Fichier vérifié sur disque."
|
||||||
|
else
|
||||||
|
echo "ÉCHEC smoke : TLS local ${CODE}, http ${HTTP}"
|
||||||
|
if [ -n "${PREC}" ]; then ln -sfn "${PREC}" "${DEST}/current"; echo " retour arrière sur ${PREC}"; fi
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
echo "==> Rotation : garde ${GARDER} releases"
|
||||||
|
cd "${DEST}/releases"
|
||||||
|
ls -1t | grep -v "^bootstrap$" | tail -n +$((GARDER + 1)) | while read -r r; do
|
||||||
|
[ "$(readlink -f "${DEST}/current")" = "$(readlink -f "${r}")" ] || { rm -rf "${r}"; echo " supprimé ${r}"; }
|
||||||
|
done
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "==> Smoke public (informatif)"
|
||||||
|
PUB="$(curl -s -m 10 -o /dev/null -w '%{http_code}' "https://${DOMAINE}/build.json" || true)"
|
||||||
|
if [ "${PUB}" = "200" ]; then
|
||||||
|
echo " https://${DOMAINE}/build.json → 200 : $(curl -s -m 10 "https://${DOMAINE}/build.json")"
|
||||||
|
else
|
||||||
|
echo " https://${DOMAINE} → ${PUB} (DNS ou certificat pas encore en place : item MOA Q-A)"
|
||||||
|
fi
|
||||||
|
echo "OK — ${HASH} en place."
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# RUNBOOK — quartier.trans-former.fr
|
||||||
|
|
||||||
|
## 1. Architecture de déploiement
|
||||||
|
|
||||||
|
- Site **statique** (Vite build) servi par **Caddy** en `file_server` sur le VPS Hetzner (`vps-hetzner`, arm64).
|
||||||
|
- Bloc Caddy : `/etc/caddy/Caddyfile`, section `quartier.trans-former.fr` (root `/opt/quartier/current`, cache long sur `/assets/*`, `try_files → /index.html`). Sauvegarde du Caddyfile avant ajout : `Caddyfile.bak-20260927-quartier`.
|
||||||
|
- Releases : `/opt/quartier/releases/<hash>/`, lien symbolique `/opt/quartier/current` → release active. Trois releases conservées (+ `bootstrap`).
|
||||||
|
- Aucun service, aucun port, aucun conteneur, aucune base. Rien à surveiller au-delà de Caddy et du certificat.
|
||||||
|
|
||||||
|
## 2. Déployer
|
||||||
|
|
||||||
|
```
|
||||||
|
git push # HEAD doit être commité
|
||||||
|
./deploy.sh # build local, scp, bascule, smoke (marqueur de build exigé)
|
||||||
|
```
|
||||||
|
|
||||||
|
Le smoke local interroge `http://127.0.0.1/build.json` avec `Host: quartier.trans-former.fr` et exige `"hash":"<HEAD court>"`. En cas d'échec, `deploy.sh` rebascule le lien sur la release précédente.
|
||||||
|
|
||||||
|
## 3. Revenir en arrière
|
||||||
|
|
||||||
|
```
|
||||||
|
ssh vps-hetzner 'ls -1t /opt/quartier/releases; ln -sfn /opt/quartier/releases/<hash> /opt/quartier/current'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. DNS et certificat
|
||||||
|
|
||||||
|
- DNS : `quartier.trans-former.fr` → `178.104.106.195` (A ; AAAA si IPv6). **Posé par Jules (item MOA Q-A)** sur la console OVH, comme `plateforme` le 25/09.
|
||||||
|
- Tant que le DNS n'existe pas, Caddy ne peut pas obtenir de certificat : le journal (`journalctl -u caddy`) montre des tentatives ACME, bruyantes et inoffensives. Dès le DNS posé, le certificat arrive seul en quelques minutes.
|
||||||
|
- Vérifier : `curl -sI https://quartier.trans-former.fr/build.json`.
|
||||||
|
|
||||||
|
## 5. Régénérer la géométrie (LOT Q1)
|
||||||
|
|
||||||
|
```
|
||||||
|
python3 -m venv v && ./v/bin/pip install -r pipeline/requirements.txt
|
||||||
|
cp "<Dropbox>/2 CASQUETTES/Architecte/TMIP/Archives TMIP/04_ArchiJeunes/02_rhino,autocad,illus/PL-quartier transformé/plan de quartier-concours VP TRANSFO.3dm" sources/
|
||||||
|
./v/bin/python pipeline/extract_quartier.py sources/plan\ de\ quartier-concours\ VP\ TRANSFO.3dm data/quartier.geo.json
|
||||||
|
./v/bin/python pipeline/plot_quartier.py # contrôle visuel
|
||||||
|
```
|
||||||
|
|
||||||
|
`sources/*.3dm` est ignoré par git (27 Mo). Les corrections manuelles vont dans `data/overrides/`, jamais dans le JSON généré.
|
||||||
|
|
||||||
|
## 6. Ajouter un dispositif, un signe de vie, un parcours (à partir du LOT Q2)
|
||||||
|
|
||||||
|
Un fichier dans `content/<famille>/<id>.md` avec le frontmatter du schéma (`src/content/schema.ts`). `npm run build` valide et compile `public/content.json`. Aucun code.
|
||||||
|
|
||||||
|
## 7. Incident
|
||||||
|
|
||||||
|
- Page blanche : vérifier `/build.json` (marqueur), puis la console navigateur ; sans WebGL le repli doit s'afficher.
|
||||||
|
- Caddy `reloading` bloqué : `caddy reload --config /etc/caddy/Caddyfile` (pièges connus, vault).
|
||||||
|
- Le site ne doit jamais dépendre d'un service : si quelque chose « tombe », c'est Caddy ou le DNS.
|
||||||
|
|
||||||
|
## 8. Contacts et emplacements
|
||||||
|
|
||||||
|
- Pilote : vault `1 PROJETS/TMIP/REinNOVER/Quartier 3D/PILOTE - Quartier 3D TMIP.md`.
|
||||||
|
- Repo : `https://git.trans-former.fr/jules/quartier` (public, MIT + CC BY-SA 4.0). Clone local : `~/dev/quartier` (Fedora).
|
||||||
|
- Graine d'origine : `~/dev/quartier-seed/` (peut être supprimée une fois ce repo poussé).
|
||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
|
<title>Quelque part en 2035</title>
|
||||||
|
<meta name="description" content="Quelque part en 2035, dans un quartier pavillonnaire, un jour de marché. Le quartier type de TMIP, en mouvement.">
|
||||||
|
<meta name="robots" content="noindex">
|
||||||
|
<style>
|
||||||
|
/* Socle minimal du LOT Q0. La DA (tokens @transformer/ui, thème TMIP) arrive au LOT Q2/Q5. */
|
||||||
|
html, body { margin: 0; height: 100%; background: #f6f1e7; color: #2b2a26; font: 16px/1.4 system-ui, sans-serif; }
|
||||||
|
#scene { position: fixed; inset: 0; display: block; }
|
||||||
|
#repli { position: fixed; inset: 0; display: grid; place-items: center; text-align: center; padding: 16px; }
|
||||||
|
#repli[hidden] { display: none; }
|
||||||
|
footer { position: fixed; right: 12px; bottom: 8px; font-size: 12px; opacity: .6; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<canvas id="scene" aria-label="Maquette du quartier en 2035" role="img"></canvas>
|
||||||
|
<div id="repli">
|
||||||
|
<div>
|
||||||
|
<h1>Quelque part en 2035</h1>
|
||||||
|
<p>Un quartier pavillonnaire, un jour de marché. Cette maquette se charge…</p>
|
||||||
|
<p><small>Sans WebGL, une image et les fiches remplaceront la scène (LOT Q5).</small></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<footer>build <span id="build-hash">?</span></footer>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+1236
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "quartier",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "Quelque part en 2035 — le quartier type TMIP en mouvement (démonstrateur web)",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"test": "node --test tests/*.test.ts",
|
||||||
|
"lint:frontieres": "node scripts/lint-frontieres.mjs",
|
||||||
|
"check": "npm run lint:frontieres && tsc --noEmit && npm test",
|
||||||
|
"geo": "echo 'LOT Q1 : pipeline/extract_quartier.py (voir pipeline/README)' && exit 1"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"three": "^0.170.0",
|
||||||
|
"zod": "^3.23.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.20.4",
|
||||||
|
"@types/three": "^0.170.0",
|
||||||
|
"typescript": "^5.6.0",
|
||||||
|
"vite": "^6.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# quartier-seed — graine du repo `quartier` (Quartier 3D TMIP)
|
||||||
|
|
||||||
|
Produit le 2026-09-26 par la session de cadrage (Fable 5.1), hors Dropbox, pas encore un repo git.
|
||||||
|
LOT-Q0 crée le repo Gitea `jules/quartier` et adopte ce dossier (comme `reinnover` a adopté son scaffold).
|
||||||
|
|
||||||
|
- `pipeline/extract_quartier.py <plan.3dm> <sortie.json>` : lit le plan Rhino 2D du concours 2020 avec `rhino3dm`
|
||||||
|
(calques PARCELLES, B_EAU noues, C_SENTIER, 7_TOPO, planté ; maisons = quadrilatères 40-400 m² compacts
|
||||||
|
sur les calques 3_ TRAITS VUS / 4_ TRAITS VUS FIINS). Unités cm → m.
|
||||||
|
- `pipeline/plot_quartier.py` : contrôle visuel → `extraction-quartier.png`.
|
||||||
|
- `data/quartier.geo.v0.json` : première extraction (79 parcelles, 11 noues, 9 sentiers, 39 courbes topo,
|
||||||
|
5 zones plantées, 27 maisons candidates sur ~40 visibles). La voirie n'est pas sur un calque : à dériver ou tracer (LOT-Q1).
|
||||||
|
- Source : `2 CASQUETTES/Architecte/TMIP/Archives TMIP/04_ArchiJeunes/02_rhino,autocad,illus/PL-quartier transformé/plan de quartier-concours VP TRANSFO.3dm`
|
||||||
|
(le vault n'est pas lu par le build : copier le `.3dm` dans `sources/` du repo, hors Dropbox, au LOT-Q1).
|
||||||
|
|
||||||
|
Venv : `python3 -m venv v && ./v/bin/pip install -r pipeline/requirements.txt`.
|
||||||
|
Pilote : vault `1 PROJETS/TMIP/REinNOVER/Quartier 3D/PILOTE - Quartier 3D TMIP.md`.
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import sys, json, math, rhino3dm
|
||||||
|
from shapely.geometry import Polygon
|
||||||
|
p = sys.argv[1]; m = rhino3dm.File3dm.Read(p)
|
||||||
|
L = {i: l.Name for i, l in enumerate(m.Layers)}
|
||||||
|
def pts(c):
|
||||||
|
n = 64 if not isinstance(c,(rhino3dm.PolylineCurve,)) else c.PointCount
|
||||||
|
if isinstance(c, rhino3dm.PolylineCurve):
|
||||||
|
return [(c.Point(i).X/100, c.Point(i).Y/100) for i in range(c.PointCount)]
|
||||||
|
pl = c.ToPolyline(0.0,0.0,0.1,5.0,0.0,0.0,0.0,0.0,True) if hasattr(c,'ToPolyline') else None
|
||||||
|
if pl is None:
|
||||||
|
d=c.Domain; return [(c.PointAt(d.T0+(d.T1-d.T0)*i/n).X/100, c.PointAt(d.T0+(d.T1-d.T0)*i/n).Y/100) for i in range(n+1)]
|
||||||
|
return [(pl[i].X/100, pl[i].Y/100) for i in range(pl.Count)]
|
||||||
|
out = {"parcelles":[], "noues":[], "sentiers":[], "topo":[], "plante":[], "maisons_candidates":[], "autres_fermes":[]}
|
||||||
|
for o in m.Objects:
|
||||||
|
g=o.Geometry; ln=L.get(o.Attributes.LayerIndex,"")
|
||||||
|
if not isinstance(g, rhino3dm.Curve): continue
|
||||||
|
try: P=pts(g)
|
||||||
|
except Exception: continue
|
||||||
|
if len(P)<2: continue
|
||||||
|
if ln=="7_TOPO": out["topo"].append(P); continue
|
||||||
|
if not g.IsClosed: continue
|
||||||
|
try: poly=Polygon(P); a=poly.area
|
||||||
|
except Exception: continue
|
||||||
|
if ln=="PARCELLES": out["parcelles"].append(P)
|
||||||
|
elif ln=="B_EAU noues": out["noues"].append(P)
|
||||||
|
elif ln=="C_SENTIER": out["sentiers"].append(P)
|
||||||
|
elif ln=="planté": out["plante"].append(P)
|
||||||
|
elif ln in ("0","caché","3_ TRAITS VUS","4_ TRAITS VUS FIINS","traits cachés","Défaut","ombres portées") and 40<=a<=400:
|
||||||
|
# maison candidate : quadrilatère-ish compact
|
||||||
|
mrr=poly.minimum_rotated_rectangle;
|
||||||
|
if mrr.area>0 and a/mrr.area>0.7: out["maisons_candidates"].append({"layer":ln,"area":round(a,1),"pts":P})
|
||||||
|
json.dump(out, open(sys.argv[2],"w"))
|
||||||
|
print({k:len(v) for k,v in out.items()})
|
||||||
|
import collections; print(collections.Counter(c["layer"] for c in out["maisons_candidates"]))
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import sys, rhino3dm, collections
|
||||||
|
for p in sys.argv[1:]:
|
||||||
|
m = rhino3dm.File3dm.Read(p)
|
||||||
|
if m is None: print("FAIL", p); continue
|
||||||
|
types = collections.Counter(); dims = collections.Counter()
|
||||||
|
bb=None
|
||||||
|
for o in m.Objects:
|
||||||
|
g=o.Geometry; t=type(g).__name__; types[t]+=1
|
||||||
|
try:
|
||||||
|
b=g.GetBoundingBox()
|
||||||
|
if b.IsValid:
|
||||||
|
dims['3D' if (b.Max.Z-b.Min.Z)>0.01 else 'flat']+=1
|
||||||
|
bb = b if bb is None else rhino3dm.BoundingBox(min(bb.Min.X,b.Min.X),min(bb.Min.Y,b.Min.Y),min(bb.Min.Z,b.Min.Z),max(bb.Max.X,b.Max.X),max(bb.Max.Y,b.Max.Y),max(bb.Max.Z,b.Max.Z))
|
||||||
|
except Exception: pass
|
||||||
|
print("==", p.split('/')[-1], "| unités:", m.Settings.ModelUnitSystem, "| objets:", len(m.Objects), "| calques:", len(m.Layers))
|
||||||
|
print(" types:", dict(types.most_common(8)))
|
||||||
|
print(" 3D vs plat:", dict(dims))
|
||||||
|
if bb: print(" emprise: %.0f x %.0f x %.0f" % (bb.Max.X-bb.Min.X, bb.Max.Y-bb.Min.Y, bb.Max.Z-bb.Min.Z))
|
||||||
|
print(" calques:", [l.Name for l in m.Layers][:25])
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import sys, rhino3dm, collections
|
||||||
|
m = rhino3dm.File3dm.Read(sys.argv[1])
|
||||||
|
layers = {i: l.Name for i, l in enumerate(m.Layers)}
|
||||||
|
cnt = collections.Counter(); closed = collections.Counter(); blocks=collections.Counter()
|
||||||
|
for o in m.Objects:
|
||||||
|
li = o.Attributes.LayerIndex; g = o.Geometry
|
||||||
|
cnt[li]+=1
|
||||||
|
if isinstance(g, rhino3dm.Curve) and g.IsClosed: closed[li]+=1
|
||||||
|
if isinstance(g, rhino3dm.InstanceReference): blocks[li]+=1
|
||||||
|
for li,n in cnt.most_common():
|
||||||
|
print(f"{n:6d} obj | {closed[li]:5d} fermés | {blocks[li]:5d} blocs | {layers.get(li)}")
|
||||||
|
print("blocs définis:", [d.Name for d in m.InstanceDefinitions][:30])
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import json, matplotlib; matplotlib.use("Agg"); import matplotlib.pyplot as plt
|
||||||
|
d=json.load(open("quartier.json"))
|
||||||
|
fig,ax=plt.subplots(figsize=(14,12)); ax.set_aspect('equal'); ax.set_facecolor("#fbfaf5")
|
||||||
|
for P in d["topo"]: xs,ys=zip(*P); ax.plot(xs,ys,color="#d8d2c0",lw=0.5)
|
||||||
|
for P in d["parcelles"]: xs,ys=zip(*P); ax.plot(xs,ys,color="#8fbf6a",lw=0.8)
|
||||||
|
for P in d["sentiers"]: xs,ys=zip(*P); ax.fill(xs,ys,color="#e6c77a",alpha=0.6)
|
||||||
|
for P in d["noues"]: xs,ys=zip(*P); ax.fill(xs,ys,color="#6fb3d9",alpha=0.7)
|
||||||
|
for P in d["plante"]: xs,ys=zip(*P); ax.fill(xs,ys,color="#b7d9a0",alpha=0.5)
|
||||||
|
for c in d["maisons_candidates"]: xs,ys=zip(*c["pts"]); ax.fill(xs,ys,color="#444",alpha=0.85)
|
||||||
|
ax.set_title("Extraction rhino3dm — plan quartier TRANSFO 2020 (m) : %d parcelles, %d noues, %d sentiers, %d maisons candidates"%(len(d["parcelles"]),len(d["noues"]),len(d["sentiers"]),len(d["maisons_candidates"])),fontsize=10)
|
||||||
|
plt.savefig("extraction-quartier.png",dpi=110,bbox_inches="tight"); print("ok")
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
rhino3dm
|
||||||
|
shapely
|
||||||
|
matplotlib
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Lint de frontières entre modules (ARCHITECTURE §4). Échoue si un import franchit une frontière interdite.
|
||||||
|
// ui/ ne doit importer ni gen/ ni fx/ ni horscamp/ (il passe par core/state)
|
||||||
|
// gen/ fx/ horscamp/ ne doivent pas importer ui/
|
||||||
|
// personne n'importe main.ts
|
||||||
|
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
||||||
|
import { join, relative, dirname, resolve } from 'node:path';
|
||||||
|
|
||||||
|
const racine = resolve(new URL('..', import.meta.url).pathname, 'src');
|
||||||
|
const interdits = {
|
||||||
|
ui: ['gen', 'fx', 'horscamp'],
|
||||||
|
gen: ['ui'],
|
||||||
|
fx: ['ui'],
|
||||||
|
horscamp: ['ui'],
|
||||||
|
core: ['ui', 'gen', 'fx', 'horscamp'],
|
||||||
|
content: ['ui', 'gen', 'fx', 'horscamp'],
|
||||||
|
};
|
||||||
|
|
||||||
|
function fichiers(d) {
|
||||||
|
return readdirSync(d).flatMap((n) => {
|
||||||
|
const p = join(d, n);
|
||||||
|
return statSync(p).isDirectory() ? fichiers(p) : p.endsWith('.ts') ? [p] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let erreurs = 0;
|
||||||
|
for (const f of fichiers(racine)) {
|
||||||
|
const rel = relative(racine, f);
|
||||||
|
const module = rel.includes('/') ? rel.split('/')[0] : '(racine)';
|
||||||
|
const src = readFileSync(f, 'utf8');
|
||||||
|
for (const m of src.matchAll(/from\s+['"](\.[^'"]+)['"]/g)) {
|
||||||
|
const cible = relative(racine, resolve(dirname(f), m[1]));
|
||||||
|
const moduleCible = cible.includes('/') ? cible.split('/')[0] : cible.replace(/\.ts$/, '');
|
||||||
|
if (moduleCible === 'main' && rel !== 'main.ts') {
|
||||||
|
console.error(`✗ ${rel} importe main.ts`);
|
||||||
|
erreurs++;
|
||||||
|
}
|
||||||
|
if ((interdits[module] ?? []).includes(moduleCible)) {
|
||||||
|
console.error(`✗ ${rel} (${module}) importe ${cible} (${moduleCible}) : frontière interdite`);
|
||||||
|
erreurs++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (erreurs) {
|
||||||
|
console.error(`${erreurs} franchissement(s) de frontière.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log('frontières respectées');
|
||||||
@@ -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 {};
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { decoderEtat, encoderEtat, lireMode, ETAT_DEFAUT } from '../src/core/url.ts';
|
||||||
|
import { reduire } from '../src/core/state.ts';
|
||||||
|
|
||||||
|
test('aller-retour de l’état dans le hash', () => {
|
||||||
|
const e = { couches: ['eau', 'vegetal'], dispositifs: ['noue', 'cuve'], temps: '2030' as const, parcours: 'pluie' };
|
||||||
|
assert.deepEqual(decoderEtat(encoderEtat(e)), e);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('état par défaut → hash vide, hash vide → état par défaut', () => {
|
||||||
|
assert.equal(encoderEtat(ETAT_DEFAUT), '');
|
||||||
|
assert.deepEqual(decoderEtat(''), ETAT_DEFAUT);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('valeurs hostiles ignorées', () => {
|
||||||
|
const e = decoderEtat('#c=eau,<script>&d=noue,,noue&t=1999&p=../x');
|
||||||
|
assert.deepEqual(e.couches, ['eau']);
|
||||||
|
assert.deepEqual(e.dispositifs, ['noue']);
|
||||||
|
assert.equal(e.temps, '2035');
|
||||||
|
assert.equal(e.parcours, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mode : ambiance seulement si demandé', () => {
|
||||||
|
assert.equal(lireMode(new URL('https://x/?mode=ambiance')), 'ambiance');
|
||||||
|
assert.equal(lireMode(new URL('https://x/?mode=autre')), 'demo');
|
||||||
|
assert.equal(lireMode(new URL('https://x/')), 'demo');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('réducteur : toggle et temps', () => {
|
||||||
|
let e = reduire(ETAT_DEFAUT, { type: 'dispositif:toggle', id: 'noue' });
|
||||||
|
assert.deepEqual(e.dispositifs, ['noue']);
|
||||||
|
e = reduire(e, { type: 'dispositif:toggle', id: 'noue' });
|
||||||
|
assert.deepEqual(e.dispositifs, []);
|
||||||
|
e = reduire(e, { type: 'temps:set', cran: 'aujourdhui' });
|
||||||
|
assert.equal(e.temps, 'aujourdhui');
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"types": ["vite/client", "node"]
|
||||||
|
},
|
||||||
|
"include": ["src", "tests", "vite.config.ts", "env.d.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import { execSync } from 'node:child_process';
|
||||||
|
|
||||||
|
// Marqueur de build : hash git court, injecté dans la page et dans /build.json.
|
||||||
|
// Un HTTP 200 ne valide rien ; le marqueur, si.
|
||||||
|
function hashGit(): string {
|
||||||
|
try {
|
||||||
|
return execSync('git rev-parse --short HEAD', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
|
||||||
|
} catch {
|
||||||
|
return 'sans-git';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const BUILD_HASH = process.env.QUARTIER_BUILD_HASH ?? hashGit();
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
base: '/',
|
||||||
|
define: { __BUILD_HASH__: JSON.stringify(BUILD_HASH) },
|
||||||
|
build: {
|
||||||
|
target: 'es2022',
|
||||||
|
sourcemap: false,
|
||||||
|
// Budget V1 : < 5 Mo transférés en démo, < 1,5 Mo en ambiance (ARCHITECTURE §1 bis).
|
||||||
|
chunkSizeWarningLimit: 900,
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
manualChunks: { three: ['three'] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
{
|
||||||
|
name: 'quartier-build-json',
|
||||||
|
generateBundle() {
|
||||||
|
this.emitFile({
|
||||||
|
type: 'asset',
|
||||||
|
fileName: 'build.json',
|
||||||
|
source: JSON.stringify({ hash: BUILD_HASH, date: new Date().toISOString() }),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user