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
+10
View File
@@ -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
+41
View File
@@ -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é.
+26
View File
@@ -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).
+30
View File
@@ -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.
+8
View File
@@ -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
Executable
+84
View File
@@ -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."
+56
View File
@@ -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é).
Vendored
+1
View File
@@ -0,0 +1 @@
declare const __BUILD_HASH__: string;
+30
View File
@@ -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>
+1236
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -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"
}
}
+16
View File
@@ -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`.
+34
View File
@@ -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"]))
+19
View File
@@ -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])
+12
View File
@@ -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])
+11
View File
@@ -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")
+3
View File
@@ -0,0 +1,3 @@
rhino3dm
shapely
matplotlib
+48
View File
@@ -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');
+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 {};
+37
View File
@@ -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');
});
+17
View File
@@ -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"]
}
+42
View File
@@ -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() }),
});
},
},
],
});