Test axonométrique du 27/09 : pipeline de fabrication et prototype

pipeline/axo : extraction du Rhino, recalage sur la planche (1:640), maisons,
arbres (positions réelles des blocs, couleurs de la planche), scène en mètres,
sols depuis le PDF Illustrator vectoriel (calques pilotés), bulles de la page 15,
planche de l'eau nettoyée. prototypes/axo-test : la page de test et son assembleur.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jules Nény
2026-09-27 16:46:14 +02:00
co-authored by Claude Opus 5.5
parent a9e0143536
commit 731531dbe2
17 changed files with 1334 additions and 0 deletions
+43
View File
@@ -0,0 +1,43 @@
# Extrait toute la géométrie utile du plan Rhino TRANSFO en mètres (JSON), une fois pour toutes.
import sys, json, rhino3dm, math
m = rhino3dm.File3dm.Read(sys.argv[1])
L = {i: l.Name for i, l in enumerate(m.Layers)}
def pts(c, n=48):
if isinstance(c, rhino3dm.PolylineCurve):
return [(c.Point(i).X/100, c.Point(i).Y/100) for i in range(c.PointCount)]
if isinstance(c, rhino3dm.LineCurve):
return [(c.PointAtStart.X/100, c.PointAtStart.Y/100), (c.PointAtEnd.X/100, c.PointAtEnd.Y/100)]
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)]
courbes = []
instances = []
defs = {str(d.Id): d.Name for d in m.InstanceDefinitions}
for o in m.Objects:
g = o.Geometry; ln = L.get(o.Attributes.LayerIndex, "")
if isinstance(g, rhino3dm.InstanceReference):
x = g.Xform
px, py = x.M03/100, x.M13/100
sx = math.hypot(x.M00, x.M10);
instances.append({"l": ln, "d": defs.get(str(g.ParentIdefId), "?"), "x": round(px, 3), "y": round(py, 3), "s": round(sx, 4)})
continue
if not isinstance(g, rhino3dm.Curve): continue
try: P = pts(g)
except Exception: continue
if len(P) < 2: continue
courbes.append({"l": ln, "f": bool(g.IsClosed), "p": [[round(a, 3), round(b, 3)] for a, b in P]})
# boîte englobante de chaque définition de bloc (pour le rayon des arbres)
bb = {}
for d in m.InstanceDefinitions:
xs, ys = [], []
for oid in d.GetObjectIds():
ob = m.Objects.FindId(oid)
if ob is None: continue
try:
b = ob.Geometry.GetBoundingBox(); xs += [b.Min.X, b.Max.X]; ys += [b.Min.Y, b.Max.Y]
except Exception: pass
if xs: bb[d.Name] = [round((max(xs)-min(xs))/100, 3), round((max(ys)-min(ys))/100, 3)]
json.dump({"courbes": courbes, "instances": instances, "defs_bbox": bb}, open(sys.argv[2], "w"))
import collections
print("courbes", len(courbes), "instances", len(instances))
print(collections.Counter(i["l"] for i in instances).most_common(12))
print("defs bbox (échantillon)", list(bb.items())[:8])
+55
View File
@@ -0,0 +1,55 @@
import json, numpy as np
from PIL import Image, ImageDraw
R = json.load(open("rhino.json"))
img = np.asarray(Image.open("base100-1.png").convert("RGB")).astype(int)
H, W, _ = img.shape
r, g, b = img[..., 0], img[..., 1], img[..., 2]
bleu = ((b - r) > 30) & (b > 150) # eau
vert = ((g - r) > 6) & ((g - b) > 25) & (g > 170) # limites de parcelles (vert pâle)
cible = (bleu * 2.0 + vert * 1.0).astype(np.float32)
print("pixels bleus", bleu.sum(), "verts", vert.sum())
eau = [c for c in R["courbes"] if c["l"] == "B_EAU noues" and c["f"]]
parc = [c for c in R["courbes"] if c["l"] == "PARCELLES"]
allp = np.array([p for c in parc for p in c["p"]])
x0, y1 = allp[:, 0].min(), allp[:, 1].max()
def raster(s, shape):
im = Image.new("F", (shape[1], shape[0]), 0); d = ImageDraw.Draw(im)
for c in eau:
d.polygon([((x - x0) * s, (y1 - y) * s) for x, y in c["p"]], fill=2.0)
for c in parc:
d.line([((x - x0) * s, (y1 - y) * s) for x, y in c["p"]], fill=1.0, width=max(1, int(s * 1.2)))
return np.asarray(im, dtype=np.float32)
def correl(a, bimg):
P = (2 * H, 2 * W)
Fa = np.fft.rfft2(a, P); Fb = np.fft.rfft2(bimg, P)
c = np.fft.irfft2(Fa * np.conj(Fb), P)
k = np.unravel_index(np.argmax(c), c.shape)
return c[k], k
best = None
for s in np.arange(2.0, 9.0, 0.05):
rs = raster(s, (H, W))
v, k = correl(cible, rs)
v = v / (np.sqrt((rs ** 2).sum()) + 1e-6)
if best is None or v > best[0]: best = (v, s, k)
v, s, k = best
dy = k[0] if k[0] < H else k[0] - 2 * H
dx = k[1] if k[1] < W else k[1] - 2 * W
print("grossier : s=%.3f px/m dx=%d dy=%d score=%.1f" % (s, dx, dy, v))
# affinage
best2 = None
for s2 in np.arange(s - 0.06, s + 0.06, 0.005):
rs = raster(s2, (H, W)); v2, k2 = correl(cible, rs); v2 /= (np.sqrt((rs ** 2).sum()) + 1e-6)
if best2 is None or v2 > best2[0]: best2 = (v2, s2, k2)
v, s, k = best2
dy = k[0] if k[0] < H else k[0] - 2 * H
dx = k[1] if k[1] < W else k[1] - 2 * W
print("fin : s=%.4f px/m à 100 dpi dx=%d dy=%d" % (s, dx, dy))
# transformation mètres -> pixels (100 dpi) : px = (x - x0)*s + dx ; py = (y1 - y)*s + dy
T = {"dpi": 100, "s": float(s), "x0": float(x0), "y1": float(y1), "dx": int(dx), "dy": int(dy)}
json.dump(T, open("transfo.json", "w"))
# contrôle visuel : géométrie Rhino en rouge par-dessus le dessin
ov = Image.open("base100-1.png").convert("RGB"); d = ImageDraw.Draw(ov)
f = lambda x, y: ((x - x0) * s + dx, (y1 - y) * s + dy)
for c in parc: d.line([f(*p) for p in c["p"]], fill=(220, 30, 30), width=1)
for c in eau: d.line([f(*p) for p in c["p"]] + [f(*c["p"][0])], fill=(200, 0, 200), width=2)
ov.save("controle-recalage.png")
+33
View File
@@ -0,0 +1,33 @@
import json, numpy as np, collections
from PIL import Image, ImageDraw
from shapely.geometry import Polygon
R = json.load(open("rhino.json")); T = json.load(open("transfo.json"))
s, x0, y1, dx, dy = T["s"], T["x0"], T["y1"], T["dx"], T["dy"]
f = lambda x, y: ((x - x0) * s + dx, (y1 - y) * s + dy)
W, H = 1654, 1170
cand = []
for c in R["courbes"]:
if not c["f"] or len(c["p"]) < 4: continue
try: P = Polygon(c["p"])
except Exception: continue
if not P.is_valid: continue
a = P.area
if not (40 <= a <= 1500): continue
mrr = P.minimum_rotated_rectangle
if mrr.area <= 0 or a / mrr.area < 0.88: continue
cx, cy = f(P.centroid.x, P.centroid.y)
if not (0 <= cx <= W and 0 <= cy <= H): continue
cand.append({"l": c["l"], "a": round(a, 1), "poly": P, "c": (cx, cy)})
print(collections.Counter(c["l"] for c in cand).most_common())
ov = Image.open("base100-1.png").convert("RGB"); d = ImageDraw.Draw(ov)
pal = {}
cols = [(230, 40, 40), (40, 90, 230), (20, 160, 60), (200, 120, 0), (160, 0, 200), (0, 170, 170), (120, 120, 120), (230, 0, 130)]
for c in cand:
col = pal.setdefault(c["l"], cols[len(pal) % len(cols)])
xs, ys = c["poly"].exterior.xy
d.line([f(x, y) for x, y in zip(xs, ys)], fill=col, width=3)
y = 10
for l, col in pal.items():
d.rectangle([10, y, 30, y + 14], fill=col); d.text((36, y), f"{l}", fill=(0, 0, 0)); y += 18
ov.save("controle-maisons.png")
json.dump([{"l": c["l"], "a": c["a"], "p": [list(p) for p in c["poly"].exterior.coords]} for c in cand], open("cand_maisons.json", "w"))
+77
View File
@@ -0,0 +1,77 @@
import json, numpy as np, collections, math
from PIL import Image, ImageDraw
from shapely.geometry import Polygon, MultiPoint, LineString, Point
R = json.load(open("rhino.json")); T = json.load(open("transfo.json"))
s, x0, y1, dx, dy = T["s"], T["x0"], T["y1"], T["dx"], T["dy"]
f = lambda x, y: ((x - x0) * s + dx, (y1 - y) * s + dy)
W, H = 1654, 1170
inimg = lambda x, y: 0 <= f(x, y)[0] <= W and 0 <= f(x, y)[1] <= H
# 1. maisons existantes : rectangles de 3_ TRAITS VUS
cand = json.load(open("cand_maisons.json"))
exist = [Polygon(c["p"]) for c in cand if c["l"] == "3_ TRAITS VUS"]
# faîtage : ligne intérieure la plus longue de 3_ TRAITS VUS contenue dans la maison
lignes = [LineString(c["p"]) for c in R["courbes"] if c["l"] in ("3_ TRAITS VUS", "4_ TRAITS VUS FIINS") and not c["f"] and len(c["p"]) >= 2]
maisons = []
for P in exist:
inner = [l for l in lignes if P.buffer(0.3).contains(l) and l.length > 3]
mrr = list(P.minimum_rotated_rectangle.exterior.coords)[:4]
e1 = np.subtract(mrr[1], mrr[0]); e2 = np.subtract(mrr[2], mrr[1])
if inner:
l = max(inner, key=lambda l: l.length); a, b = l.coords[0], l.coords[-1]
ang = math.atan2(b[1] - a[1], b[0] - a[0])
else:
e = e1 if np.linalg.norm(e1) >= np.linalg.norm(e2) else e2; ang = math.atan2(e[1], e[0])
maisons.append({"type": "existante", "p": [list(map(lambda v: round(v, 2), q)) for q in mrr], "faitage": round(ang, 4)})
# 2. maisons transformées et grand bâtiment : grappes du calque A_ISOL _ COUPE
iso = [c for c in R["courbes"] if c["l"] == "A_ISOL _ COUPE"]
pts = [(p, i) for i, c in enumerate(iso) for p in c["p"]]
centres = [np.mean(np.array(c["p"]), axis=0) for c in iso]
# regroupement simple par distance (8 m)
groupes = []
for i, cc in enumerate(centres):
for g in groupes:
if min(np.linalg.norm(cc - centres[j]) for j in g) < 8: g.append(i); break
else: groupes.append([i])
# fusion itérative des groupes proches
chg = True
while chg:
chg = False
for a in range(len(groupes)):
for b in range(a + 1, len(groupes)):
if min(np.linalg.norm(centres[i] - centres[j]) for i in groupes[a] for j in groupes[b]) < 8:
groupes[a] += groupes[b]; del groupes[b]; chg = True; break
if chg: break
transfo = []
for g in groupes:
P = MultiPoint([tuple(p) for i in g for p in iso[i]["p"]]).minimum_rotated_rectangle
if P.area < 60: continue
cx, cy = P.centroid.x, P.centroid.y
if not inimg(cx, cy): continue
mrr = list(P.exterior.coords)[:4]
e1 = np.subtract(mrr[1], mrr[0]); e2 = np.subtract(mrr[2], mrr[1])
e = e1 if np.linalg.norm(e1) >= np.linalg.norm(e2) else e2
transfo.append({"type": "grand" if P.area > 300 else "transformee", "p": [[round(v, 2) for v in q] for q in mrr], "faitage": round(math.atan2(e[1], e[0]), 4), "aire": round(P.area, 1)})
print("existantes", len(maisons), "| transformées/grand", [(t["type"], t["aire"]) for t in transfo])
# 3. arbres : instances de blocs, couleur échantillonnée sur le dessin TOTAL
tot = np.asarray(Image.open("total100-1.png").convert("RGB")).astype(int)
arbres = []
rayons = {"Ishigami's fruit tree's 1": 1.6, "ishigami foret": 2.6, "B_Existantbuisson": 1.2, "A_Existant arbre": 2.4, "0": 2.0, "Ishigami's tree's 2": 2.4}
INST = json.load(open("instances_monde.json"))
for it in INST:
if it["l"] not in rayons or not inimg(it["x"], it["y"]): continue
rayons[it["l"]] = rayons[it["l"]]
px, py = f(it["x"], it["y"]); r = max(2, int(max(it["r"], 0.8) * s * 0.7))
x_, y_ = int(px), int(py)
zone = tot[max(0, y_ - r):y_ + r, max(0, x_ - r):x_ + r].reshape(-1, 3)
col = zone[(zone.sum(1) < 690) & (zone.max(1) - zone.min(1) > 18)]
c = col.mean(0) if len(col) > 3 else np.array([150, 185, 120])
arbres.append({"x": it["x"], "y": it["y"], "k": it["l"], "r": round(max(it["r"], 0.8), 2), "c": "#%02x%02x%02x" % tuple(int(v) for v in c)})
print("arbres", len(arbres), collections.Counter(a["k"] for a in arbres))
json.dump({"maisons": maisons, "speciaux": transfo, "arbres": arbres}, open("batiments.json", "w"))
# contrôle sur TOTAL
ov = Image.open("total100-1.png").convert("RGB"); d = ImageDraw.Draw(ov)
for m_ in maisons: d.polygon([f(*q) for q in m_["p"]], outline=(0, 60, 230), width=3)
for t in transfo: d.polygon([f(*q) for q in t["p"]], outline=(230, 0, 0), width=4)
for a in arbres[::3]:
px, py = f(a["x"], a["y"]); rr = a["r"] * s * 0.5; d.ellipse([px - rr, py - rr, px + rr, py + rr], outline=(0, 0, 0))
ov.save("controle-batiments.png")
+32
View File
@@ -0,0 +1,32 @@
import sys, json, rhino3dm, math
m = rhino3dm.File3dm.Read(sys.argv[1])
L = {i: l.Name for i, l in enumerate(m.Layers)}
centre = {}
for d in m.InstanceDefinitions:
xs, ys = [], []
for oid in d.GetObjectIds():
ob = m.Objects.FindId(oid)
if ob is None: continue
try:
b = ob.Geometry.GetBoundingBox(); xs += [b.Min.X, b.Max.X]; ys += [b.Min.Y, b.Max.Y]
except Exception: pass
if xs: centre[str(d.Id)] = ((min(xs) + max(xs)) / 2, (min(ys) + max(ys)) / 2, (max(xs) - min(xs)), (max(ys) - min(ys)))
out = []
for o in m.Objects:
g = o.Geometry
if not isinstance(g, rhino3dm.InstanceReference): continue
ln = L.get(o.Attributes.LayerIndex, "")
c = centre.get(str(g.ParentIdefId))
if not c: continue
x = g.Xform
wx = x.M00 * c[0] + x.M01 * c[1] + x.M03
wy = x.M10 * c[0] + x.M11 * c[1] + x.M13
sc = math.hypot(x.M00, x.M10)
out.append({"l": ln, "x": round(wx / 100, 3), "y": round(wy / 100, 3), "r": round(max(c[2], c[3]) * sc / 200, 3)})
json.dump(out, open(sys.argv[2], "w"))
import collections
print(len(out), collections.Counter(o["l"] for o in out).most_common(8))
for l in ["Ishigami's fruit tree's 1", "ishigami foret", "PERSOS"]:
a = [o for o in out if o["l"] == l]
xs = [o["x"] for o in a]; ys = [o["y"] for o in a]; rs = sorted(o["r"] for o in a)
print(l, "x %.0f..%.0f y %.0f..%.0f rayon médian %.2f m" % (min(xs), max(xs), min(ys), max(ys), rs[len(rs)//2]))
+99
View File
@@ -0,0 +1,99 @@
import json, math, random, numpy as np
from PIL import Image
from shapely.geometry import Polygon, Point, LineString
random.seed(7)
R = json.load(open("rhino.json")); T = json.load(open("transfo.json")); B = json.load(open("batiments.json"))
INST = json.load(open("instances_monde.json")); ROUTES = json.load(open("routes_px.json"))
s, x0, y1, dx, dy = T["s"], T["x0"], T["y1"], T["dx"], T["dy"]
W, H = 1654, 1170
px = lambda x, y: ((x - x0) * s + dx, (y1 - y) * s + dy) # Rhino m -> pixel (100 dpi)
wd = lambda X, Y: (round((X - W / 2) / s, 2), round((Y - H / 2) / s, 2)) # pixel -> monde (x, z)
rw = lambda x, y: wd(*px(x, y)) # Rhino m -> monde
dedans = lambda X, Y, m=0: -m <= X <= W + m and -m <= Y <= H + m
# --- routes : retouche ouest et carrefour (relevé sur zoom), reste = axe calculé
princ = ROUTES["principale"]
ouest = [(0, 180), (100, 236), (200, 292), (300, 349), (400, 405), (460, 440)]
milieu = [p for p in princ if 490 <= p[0] <= 846 and p[1] < 880]
bas = [p for p in princ if p[1] >= 960]
ROUTES["principale"] = ouest + milieu + [(868, 888), (930, 935)] + bas
routes = {k: [wd(*p) for p in v] for k, v in ROUTES.items()}
# --- maisons
def poly_monde(pts): return [rw(*p) for p in pts]
maisons = [{"t": m["type"], "p": poly_monde(m["p"]), "f": m["faitage"]} for m in B["maisons"]]
for sp in B["speciaux"]:
maisons.append({"t": "grand" if sp["aire"] > 200 else "transformee", "p": poly_monde(sp["p"]), "f": sp["faitage"]})
# --- arbres : palette quantifiée
arbres = B["arbres"]
def hexrgb(h): return tuple(int(h[i:i + 2], 16) for i in (1, 3, 5))
# densification de la forêt (zone est, délimitée sur le dessin)
foret = Polygon([(1335, 0), (1654, 0), (1654, 780), (1565, 752), (1160, 596), (1330, 40)])
fond = np.asarray(Image.open("fond1654.png").convert("RGB")).astype(int)
existants = [px(a["x"], a["y"]) for a in arbres]
grille = {}
for (X, Y) in existants: grille.setdefault((int(X // 20), int(Y // 20)), []).append((X, Y))
sentier_foret = [LineString([px(*p) for p in c["p"]]) for c in R["courbes"] if c["l"] == "C_SENTIER" and not c["f"] and len(c["p"]) > 5]
ajouts = 0
for _ in range(9000):
X, Y = random.uniform(1160, 1654), random.uniform(0, 780)
if not foret.contains(Point(X, Y)): continue
voisins = [q for gx in range(int(X // 20) - 1, int(X // 20) + 2) for gy in range(int(Y // 20) - 1, int(Y // 20) + 2) for q in grille.get((gx, gy), [])]
if any((X - a) ** 2 + (Y - b) ** 2 < (2.9 * s) ** 2 for a, b in voisins): continue
if any(l.distance(Point(X, Y)) < 1.6 * s for l in sentier_foret): continue
z = fond[max(0, int(Y) - 4):int(Y) + 5, max(0, int(X) - 4):int(X) + 5].reshape(-1, 3)
z = z[(z.sum(1) < 690) & (z.max(1) - z.min(1) > 18)]
c = z.mean(0) if len(z) > 4 else np.array([140, 190, 140])
r = random.uniform(1.3, 2.4)
Xm, Ym = (X - dx) / s + x0, y1 - (Y - dy) / s
arbres.append({"x": Xm, "y": Ym, "k": "foret+", "r": r, "c": "#%02x%02x%02x" % tuple(int(v) for v in c)})
grille.setdefault((int(X // 20), int(Y // 20)), []).append((X, Y)); ajouts += 1
# palette : couleurs éclaircies façon lavis, quantifiées
def lavis(c):
c = np.array(c, float); g = c.mean(); c = g + (c - g) * 1.15; c = c * 0.85 + 255 * 0.15
return tuple(int(max(0, min(255, v))) for v in c)
cols = [lavis(hexrgb(a["c"])) for a in arbres]
pal, idx = [], []
for c in cols:
q = tuple(int(v // 24 * 24 + 12) for v in c)
if q not in pal: pal.append(q)
idx.append(pal.index(q))
kind = {"Ishigami's fruit tree's 1": 0, "ishigami foret": 1, "foret+": 1, "A_Existant arbre": 2, "B_Existantbuisson": 3}
arbres_out = []
for a, i in zip(arbres, idx):
X, Z = rw(a["x"], a["y"]); arbres_out.append([X, Z, round(a["r"], 2), i, kind.get(a["k"], 1)])
# --- personnages
persos = [rw(i["x"], i["y"]) for i in INST if i["l"] == "PERSOS" and dedans(*px(i["x"], i["y"]))]
# --- eau, sentiers, étals, bassins plantés, liaisons
def polys(layer, ferme=True, min_pts=3):
out = []
for c in R["courbes"]:
if c["l"] != layer or c["f"] != ferme or len(c["p"]) < min_pts: continue
P = [px(*p) for p in c["p"]]
if not any(dedans(*q) for q in P): continue
out.append([wd(*q) for q in P])
return out
noues = polys("B_EAU noues", True)
liaisons = [l for l in polys("TIRE-T", False, 2) if LineString(l).length < 40]
plantes = polys("planté", True) + polys("planté", False, 2)
sentiers = polys("C_SENTIER", True)
chemins = polys("C_SENTIER", False, 2)
etals = []
for c in R["courbes"]:
if c["l"] == "5_ tire-T pointillés" and c["f"]:
P = Polygon(c["p"])
if 6 < P.area < 60:
q = px(P.centroid.x, P.centroid.y)
if dedans(*q): etals.append({"p": [rw(*p) for p in list(P.minimum_rotated_rectangle.exterior.coords)[:4]], "c": wd(*q)})
# --- bulles de la page 15 (centres relevés sur la page alignée, en pixels 100 dpi)
bulles = [("film", 880, 95, 30), ("marche", 388, 238, 38), ("ballons", 775, 240, 37), ("barbecue", 856, 238, 37), ("enfant", 937, 240, 37),
("confiture", 1153, 203, 37), ("bouteille", 1233, 196, 37), ("pomme", 1287, 292, 35), ("miel", 1494, 300, 38), ("abeille", 1403, 360, 38),
("fleur", 1310, 418, 38), ("arbre", 1376, 503, 37), ("biere", 157, 383, 38), ("couverts", 157, 463, 38), ("outils", 157, 543, 38),
("musique", 573, 513, 37), ("oiseaux", 684, 578, 27), ("promeneurs", 857, 388, 38), ("bols", 1036, 525, 28), ("soin", 963, 782, 28),
("entraide", 1259, 780, 28), ("reemploi", 225, 880, 28), ("garage", 1233, 992, 28), ("apaise1", 293, 352, 17), ("apaise2", 857, 860, 17)]
json.dump([{"id": b[0], "x": b[1], "y": b[2], "r": b[3], "w": wd(b[1], b[2])} for b in bulles], open("bulles_px.json", "w"))
scene = {"s": s, "W": W, "H": H, "Wm": round(W / s, 2), "Hm": round(H / s, 2), "maisons": maisons, "pal": pal, "arbres": arbres_out, "persos": persos,
"noues": noues, "liaisons": liaisons, "plantes": plantes, "sentiers": sentiers, "chemins": chemins, "etals": etals, "routes": routes,
"bulles": [{"id": b[0], "w": wd(b[1], b[2])} for b in bulles]}
json.dump(scene, open("scene.json", "w"), separators=(",", ":"))
print("maisons", len(maisons), "arbres", len(arbres_out), "(forêt ajoutée", ajouts, ") palette", len(pal), "persos", len(persos))
print("noues", len(noues), "liaisons", len(liaisons), "plantés", len(plantes), "sentiers", len(sentiers), "chemins", len(chemins), "étals", len(etals))
import os; print("scene.json %.0f Ko" % (os.path.getsize("scene.json") / 1024))
+62
View File
@@ -0,0 +1,62 @@
"""06 — Les deux sols du quartier (2035 et aujourd'hui), depuis le plan vectoriel Illustrator.
Entrées : TRANSFO_TOTAL.pdf (Illustrator, 155 calques), la planche de fond du concours (lavis),
la texture de papier aquarelle, transfo.json (recalage), scene.json (axes des routes).
Sorties : lignes2035.png, lignesauj.png, sol2035.jpg, solauj.jpg.
Principe : on éteint dans le PDF les calques qui deviendront des objets 3D (arbres, ombres,
personnages) ; pour « aujourd'hui », on éteint aussi tous les calques « 8_ NEW » et « 10_NEW ».
Le lavis (jardins vert pâle, routes grises, forêt ou champ) est multiplié sous les traits,
avec un grain de papier passe-haut (les grandes taches de la texture sont retirées).
Usage : python 06_sols.py <TOTAL.pdf> <texture_papier.jpeg> [dpi=155]
"""
import sys, json
import numpy as np
import pymupdf
from PIL import Image, ImageDraw, ImageFilter
PDF, PAPIER = sys.argv[1], sys.argv[2]
DPI = int(sys.argv[3]) if len(sys.argv) > 3 else 155
T = json.load(open("transfo.json")); s = T["s"] # px/m à 100 dpi
SC = json.load(open("scene.json"))
def rendre(eteindre, sortie):
doc = pymupdf.open(PDF)
for u in doc.layer_ui_configs():
if eteindre(u["text"]):
doc.set_layer_ui_config(u["number"], action=2) # 2 = éteint
doc[0].get_pixmap(dpi=DPI).save(sortie)
objets_3d = lambda t: any(k in t for k in ["FORET", "VEGETATION", "ombres", "OMBRES", "PERSOS", "Calque 37"]) or t == "0"
rendre(objets_3d, "lignes2035.png")
rendre(lambda t: objets_3d(t) or t.startswith("8_ NEW") or t.startswith("10_NEW"), "lignesauj.png")
L35 = Image.open("lignes2035.png").convert("RGB"); Wt, Ht = L35.size; k = Wt / 1654
tex = Image.open(PAPIER).convert("L").resize((Wt // 2, Ht // 2))
t = np.asarray(tex).astype(np.float32); lo = np.asarray(tex.filter(ImageFilter.GaussianBlur(14))).astype(np.float32)
g = np.clip((t - lo) / ((t - lo).std() + 1e-6), -2.5, 2.5)
g = np.asarray(Image.fromarray(((g + 2.5) / 5 * 255).astype(np.uint8)).resize((Wt, Ht), Image.BILINEAR)).astype(np.float32) / 255 - 0.5
pap = 1.0 + 0.035 * g
routes = {n: [(x * s + 827, z * s + 585) for x, z in pts] for n, pts in SC["routes"].items()}
largeur = {"principale": 9.5, "verticale": 7.5, "anneau": 6.5, "liaison_anneau": 6.5, "droite": 7.0}
foret = [(1335, 0), (1654, 0), (1654, 780), (1565, 752), (1160, 596), (1330, 40)] # relevé sur la planche
def lavis(couleur_est):
w = Image.new("RGB", (Wt, Ht), (239, 240, 219)); d = ImageDraw.Draw(w)
d.polygon([(x * k, y * k) for x, y in foret], fill=couleur_est)
for n, pts in routes.items():
wpx = largeur[n] * s * k
d.line([(x * k, y * k) for x, y in pts], fill=(229, 229, 224), width=int(wpx), joint="curve")
for x, y in pts:
d.ellipse([x * k - wpx / 2, y * k - wpx / 2, x * k + wpx / 2, y * k + wpx / 2], fill=(229, 229, 224))
return np.asarray(w.filter(ImageFilter.GaussianBlur(2))).astype(np.float32) * pap[..., None]
def composer(lignes, lav, sortie):
l = np.asarray(lignes.convert("RGB").resize((Wt, Ht))).astype(np.float32) / 255
Image.fromarray(np.clip(lav * l, 0, 255).astype(np.uint8)).save(sortie, quality=80, optimize=True, progressive=True)
composer(L35, lavis((226, 236, 212)), "sol2035.jpg") # forêt nourricière
composer(Image.open("lignesauj.png"), lavis((241, 236, 214)), "solauj.jpg") # aujourd'hui : un champ
print("sols écrits", Wt, "x", Ht)
+26
View File
@@ -0,0 +1,26 @@
"""07 — Les bulles d'icônes de la planche « le quartier en 2030 » (TMIP 2024, p. 15).
La carte de cette page est une image de 1 190 px (72 dpi) ; les bulles, elles, sont vectorielles.
On retire l'image de la page (rédaction PDF, images seules), on rend à 300 dpi et on découpe
chaque bulle en rond sur fond blanc. Les centres sont relevés sur la page, alignée au pixel
près sur la planche du concours (décalage mesuré : 1 px).
Usage : python 07_bulles.py <presentation_TMIP_2024.pdf> (lit bulles_px.json, écrit bulles/*.png)
"""
import sys, os, json
import pymupdf
from PIL import Image, ImageDraw, ImageFilter
doc = pymupdf.open(sys.argv[1]); page = doc[14]
page.add_redact_annot(page.rect)
page.apply_redactions(images=pymupdf.PDF_REDACT_IMAGE_REMOVE, graphics=pymupdf.PDF_REDACT_LINE_ART_NONE, text=pymupdf.PDF_REDACT_TEXT_NONE)
page.get_pixmap(dpi=300).save("p15_sans_carte.png")
p = Image.open("p15_sans_carte.png").convert("RGBA")
os.makedirs("bulles", exist_ok=True)
for b in json.load(open("bulles_px.json")):
cx, cy, r = b["x"] * 3, b["y"] * 3, b["r"] * 3 + 6
c = p.crop((int(cx - r), int(cy - r), int(cx + r), int(cy + r)))
m = Image.new("L", c.size, 0); ImageDraw.Draw(m).ellipse([2, 2, c.size[0] - 3, c.size[1] - 3], fill=255)
fond = Image.new("RGBA", c.size, (255, 255, 255, 255)); fond.alpha_composite(c); fond.putalpha(m.filter(ImageFilter.GaussianBlur(1)))
fond.resize((112, 112), Image.LANCZOS).save("bulles/%s.png" % b["id"], optimize=True)
print(len(os.listdir("bulles")), "bulles")
+39
View File
@@ -0,0 +1,39 @@
"""08 — La planche du réseau hydrique alternatif, nettoyée de ses textes, pour le zoom « cycle de l'eau ».
Source : le PDF d'impression du concours Habiter Demain 2020 (page 6, dessins à 417 dpi),
identique au pixel près à la page 13 de la présentation TMIP 2024 (décalage mesuré : 0).
Les blocs de texte sont effacés : en dégradé dans la bande haute (le fond y passe du blanc au crème),
en aplat clair ailleurs. Les numéros d'étape deviennent des points actifs de la page web.
Si Jules fournit les originaux sans texte, remplacer cette étape par eux.
Usage : python 08_planche_eau.py <concours_IMPRESSION.pdf>
Sorties : eau_crop.jpg (2422 x 1290), eau_crop.json (points actifs et trajets, repère 100 dpi recadré)
"""
import sys, json, subprocess
import numpy as np
from PIL import Image
from scipy.ndimage import median_filter
subprocess.run(["pdftoppm", "-f", "6", "-l", "6", "-r", "150", "-png", sys.argv[1], "c6_150"], check=True)
im = np.asarray(Image.open("c6_150-06.png").convert("RGB")).astype(np.float32); k = 1.5
haut = [(55,28,1210,118),(85,443,305,543),(205,452,640,462),(632,452,642,532),(950,430,1113,572),(1118,428,1342,567)]
bas = [(958,568,968,652),(958,645,1182,655),(1226,560,1236,640),(78,722,248,818),(222,724,325,736),(88,925,282,1098),(230,932,495,944),
(578,936,752,952),(578,954,803,1118),(897,938,995,952),(915,954,1098,1118),(751,896,763,936),(915,905,926,938),
(1438,493,1620,752),(1410,657,1442,667),(1445,745,1622,962),(1355,752,1450,762),(1445,980,1615,1078),(1275,868,1462,878),(1453,868,1463,992),
(1158,978,1372,1090),(1165,948,1175,986),(480,1112,1220,1152),(1520,1100,1615,1145)]
for x0, y0, x1, y1 in haut:
X0, Y0, X1, Y1 = int(x0 * k), int(y0 * k), int(x1 * k), int(y1 * k)
h = median_filter(np.median(im[max(0, Y0 - 5):Y0, X0:X1], axis=0), size=(41, 1))
b = median_filter(np.median(im[Y1:Y1 + 5, X0:X1], axis=0), size=(41, 1))
t = np.linspace(0, 1, Y1 - Y0)[:, None, None]; im[Y0:Y1, X0:X1] = h[None] * (1 - t) + b[None] * t
for x0, y0, x1, y1 in bas:
X0, Y0, X1, Y1 = int(x0 * k), int(y0 * k), int(x1 * k), int(y1 * k)
a = np.concatenate([im[max(0, Y0 - 8):Y0, X0:X1].reshape(-1, 3), im[Y1:Y1 + 8, X0:X1].reshape(-1, 3), im[Y0:Y1, max(0, X0 - 8):X0].reshape(-1, 3), im[Y0:Y1, X1:X1 + 8].reshape(-1, 3)])
clair = a[a.mean(1) > 228]; im[Y0:Y1, X0:X1] = np.median(clair if len(clair) > 20 else a, axis=0)
Image.fromarray(np.clip(im, 0, 255).astype(np.uint8)).crop((30, 195, 2452, 1485)).save("eau_crop.jpg", quality=78, optimize=True, progressive=True)
pts = {"h1": (430, 790), "h2": (610, 850), "h3": (637, 612), "h4": (840, 660), "h5": (855, 800), "h6": (1130, 700), "h7": (1215, 790), "h8": (1378, 625),
"h9": (1320, 780), "h10": (1255, 880), "h11": (1105, 875), "h1b": (630, 300), "h5b": (1025, 372)}
flux = {"usees": [(592,822),(640,872),(700,925),(760,948),(830,950),(885,935),(940,895),(1000,850),(1050,805),(1090,765)], "pluie": [(585,748),(630,750),(690,758)]}
off = (20, 130)
json.dump({"W": 1615, "H": 860, "pts": {n: [x - off[0], y - off[1]] for n, (x, y) in pts.items()}, "flux": {n: [[x - off[0], y - off[1]] for x, y in l] for n, l in flux.items()}}, open("eau_crop.json", "w"))
print("planche de l'eau écrite")
+37
View File
@@ -0,0 +1,37 @@
# Pipeline « axo » — du dossier du concours à la maquette 3D
Écrit pendant le test axonométrique du 27/09/2026. Chaque étape est rejouable ; aucune géométrie n'est dessinée à la main hors des relevés signalés.
## Sources (hors Dropbox : copier dans `sources/`, ignoré par git)
| Fichier | Où dans le vault | Rôle |
|---|---|---|
| `plan de quartier-concours VP TRANSFO.3dm` | `Archives TMIP/04_ArchiJeunes/02_rhino,autocad,illus/PL-quartier transformé/` | géométrie en mètres (maisons, noues, sentiers, liaisons, étals, 3 962 blocs d'arbres et de personnages) |
| `…_TRANSFO_TOTAL.pdf` (Illustrator, **vectoriel, 155 calques**) | même dossier, `01_ai, pdf/` | le sol : ton dessin exact, calques pilotables |
| `…_TRANSFO_fond.png` | même dossier, `doc final/` | lavis de la planche finale (sert au recalage des couleurs) |
| `grey-watercolor-texture-7.jpeg` | même dossier, `00_psd textures/` | grain du papier |
| présentation TMIP 2024 (PDF) | `1 PROJETS/TMIP/TMIP présentation/` | page 15 : bulles vectorielles (la carte y est une image de 1 190 px, inutilisable en grand) |
| rendu A3 d'impression du concours 2020 | `Archives TMIP/04_ArchiJeunes/rendu/…IMPRESSION…pdf` | page 6 : planche de l'eau à 417 dpi |
À ne pas prendre : le rendu A3 « léger » (144 dpi) et ORENO 10p (200 dpi, c'est la maquette du site RE(in)NOVER de 2023, référence pour la page de couverture).
## Ordre
```
01_dump_rhino.py <TRANSFO.3dm> rhino.json # toutes les courbes et instances, en mètres
02_recalage.py # échelle et décalage Rhino -> planche (corrélation eau + parcelles) : 6,15 px/m à 100 dpi, soit 1:640
03a_maisons_candidates.py # rectangles du calque 3_ TRAITS VUS = 24 maisons existantes
04_instances_monde.py <TRANSFO.3dm> instances_monde.json # vraie position des blocs (centre de la définition x transformation)
03b_batiments_arbres.py # maisons transformées et grand bâtiment (grappes du calque d'isolation), arbres et couleurs échantillonnées
05_scene.py # scene.json en mètres monde : maisons, arbres (palette lavis), forêt densifiée, personnages, eau, sentiers, étals, routes, bulles
06_sols.py <TOTAL.pdf> <papier.jpeg> # sol2035.jpg, solauj.jpg
07_bulles.py <presentation_2024.pdf> # bulles/*.png
08_planche_eau.py <concours_IMPRESSION.pdf> # eau_crop.jpg, eau_crop.json
../../prototypes/axo-test/assembler.py # la maquette autonome
```
Contrôles visuels à regarder à chaque étape : superposition Rhino/planche (recalage), rectangles des maisons, cercles des arbres, axes des routes. Les relevés faits à l'œil (et donc à revérifier si la planche change) : axes des routes hors grande diagonale, contour de la forêt, centres des 25 bulles, points actifs et trajets de la planche de l'eau.
## Chiffres de ce premier passage
31 volumes (24 maisons existantes, 6 transformées, 1 grand bâtiment) · 3 704 arbres (2 577 de haies, 776 de forêt dont 150 ajoutés, 54 existants, 297 buissons) · 53 personnages · 11 noues · 39 liaisons maison-noue · 9 étals · 5 routes · 25 bulles · page autonome de 1,66 Mo.
+4
View File
@@ -1,3 +1,7 @@
rhino3dm rhino3dm
shapely shapely
matplotlib matplotlib
numpy
scipy
pillow
pymupdf
+21
View File
@@ -0,0 +1,21 @@
"""Assemble la maquette autonome (une seule page HTML, images intégrées) depuis template.html.
À lancer dans le dossier où le pipeline a écrit ses sorties (scene.json, eau_crop.json,
sol2035.jpg, solauj.jpg, eau_crop.jpg, bulles/). C'est la maquette de test publiée le 27/09 ;
la version de production (LOT Q2+) vit dans src/ et ne passe pas par ce fichier.
"""
import base64, json, os, sys
ici = os.path.dirname(os.path.abspath(__file__))
b64 = lambda f, m: "data:%s;base64,%s" % (m, base64.b64encode(open(f, "rb").read()).decode())
t = open(os.path.join(ici, "template.html")).read()
bulles = {f[:-4]: b64("bulles/" + f, "image/png") for f in sorted(os.listdir("bulles"))}
t = (t.replace("__SCENE__", open("scene.json").read())
.replace("__BULLES__", json.dumps(bulles))
.replace("__EAUPTS__", open("eau_crop.json").read())
.replace("__SOL2035__", b64("sol2035.jpg", "image/jpeg"))
.replace("__SOLAUJ__", b64("solauj.jpg", "image/jpeg"))
.replace("__EAU__", b64("eau_crop.jpg", "image/jpeg")))
sortie = sys.argv[1] if len(sys.argv) > 1 else "quelque-part-en-2035.html"
open(sortie, "w").write(t)
print(sortie, "%.2f Mo" % (len(t.encode()) / 1e6))
+1
View File
@@ -0,0 +1 @@
[{"id": "film", "x": 880, "y": 95, "r": 30, "w": [8.62, -79.67]}, {"id": "marche", "x": 388, "y": 238, "r": 38, "w": [-71.38, -56.42]}, {"id": "ballons", "x": 775, "y": 240, "r": 37, "w": [-8.46, -56.1]}, {"id": "barbecue", "x": 856, "y": 238, "r": 37, "w": [4.72, -56.42]}, {"id": "enfant", "x": 937, "y": 240, "r": 37, "w": [17.89, -56.1]}, {"id": "confiture", "x": 1153, "y": 203, "r": 37, "w": [53.01, -62.11]}, {"id": "bouteille", "x": 1233, "y": 196, "r": 37, "w": [66.02, -63.25]}, {"id": "pomme", "x": 1287, "y": 292, "r": 35, "w": [74.8, -47.64]}, {"id": "miel", "x": 1494, "y": 300, "r": 38, "w": [108.46, -46.34]}, {"id": "abeille", "x": 1403, "y": 360, "r": 38, "w": [93.66, -36.59]}, {"id": "fleur", "x": 1310, "y": 418, "r": 38, "w": [78.54, -27.15]}, {"id": "arbre", "x": 1376, "y": 503, "r": 37, "w": [89.27, -13.33]}, {"id": "biere", "x": 157, "y": 383, "r": 38, "w": [-108.94, -32.85]}, {"id": "couverts", "x": 157, "y": 463, "r": 38, "w": [-108.94, -19.84]}, {"id": "outils", "x": 157, "y": 543, "r": 38, "w": [-108.94, -6.83]}, {"id": "musique", "x": 573, "y": 513, "r": 37, "w": [-41.3, -11.71]}, {"id": "oiseaux", "x": 684, "y": 578, "r": 27, "w": [-23.25, -1.14]}, {"id": "promeneurs", "x": 857, "y": 388, "r": 38, "w": [4.88, -32.03]}, {"id": "bols", "x": 1036, "y": 525, "r": 28, "w": [33.98, -9.76]}, {"id": "soin", "x": 963, "y": 782, "r": 28, "w": [22.11, 32.03]}, {"id": "entraide", "x": 1259, "y": 780, "r": 28, "w": [70.24, 31.71]}, {"id": "reemploi", "x": 225, "y": 880, "r": 28, "w": [-97.89, 47.97]}, {"id": "garage", "x": 1233, "y": 992, "r": 28, "w": [66.02, 66.18]}, {"id": "apaise1", "x": 293, "y": 352, "r": 17, "w": [-86.83, -37.89]}, {"id": "apaise2", "x": 857, "y": 860, "r": 17, "w": [4.88, 44.72]}]
+1
View File
@@ -0,0 +1 @@
{"W": 1615, "H": 860, "pts": {"h1": [410, 660], "h2": [590, 720], "h3": [617, 482], "h4": [820, 530], "h5": [835, 670], "h6": [1110, 570], "h7": [1195, 660], "h8": [1358, 495], "h9": [1300, 650], "h10": [1235, 750], "h11": [1085, 745], "h1b": [610, 170], "h5b": [1005, 242]}, "flux": {"usees": [[572, 692], [620, 742], [680, 795], [740, 818], [810, 820], [865, 805], [920, 765], [980, 720], [1030, 675], [1070, 635]], "pluie": [[565, 618], [610, 620], [670, 628]]}}
File diff suppressed because one or more lines are too long
+802
View File
@@ -0,0 +1,802 @@
<title>Quelque part en 2035</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Barlow:wght@400;500;600;700&family=Barlow+Condensed:wght@500;600&display=swap">
<style>
/* Une seule ambiance, volontairement : le papier des planches TMIP. Couleurs posées explicitement. */
:root {
--papier: #f1ece0; --papier-2: #fbf9f3; --encre: #2a2925; --encre-2: #66615a; --trait: #dcd4c3;
--eau: #2d86bf; --vivant: #5a9837; --chaleur: #c4850f; --quartier: #d4662a; --neutre: #6c665b;
--ombre: 0 10px 30px rgba(42, 41, 37, .16);
color-scheme: light;
}
[hidden] { display: none !important; }
html, body { height: 100%; }
body { margin: 0; overflow: hidden; background: var(--papier); color: var(--encre);
font: 15px/1.45 Barlow, "Segoe UI", system-ui, -apple-system, sans-serif; -webkit-font-smoothing: antialiased; }
button { font: inherit; color: inherit; cursor: pointer; }
button:focus-visible, [tabindex]:focus-visible { outline: 2px solid var(--eau); outline-offset: 2px; }
#scene { position: fixed; inset: 0; width: 100%; height: 100%; display: block; touch-action: none; cursor: grab; }
#scene.tire { cursor: grabbing; }
#repli { position: fixed; inset: 0; background: var(--papier) center / cover no-repeat; }
/* ---------- barre du haut ---------- */
.barre { position: fixed; z-index: 5; top: calc(env(safe-area-inset-top, 0px) + 10px); left: 12px; right: 12px;
display: flex; justify-content: space-between; align-items: flex-start; gap: 8px; flex-wrap: wrap; pointer-events: none; }
.barre > * { pointer-events: auto; }
.titre { background: rgba(251, 249, 243, .92); border: 1px solid var(--trait); border-radius: 12px; padding: 7px 12px; box-shadow: var(--ombre); }
.titre h1 { margin: 0; font: 600 20px/1.1 "Barlow Condensed", Barlow, sans-serif; letter-spacing: .01em; }
.titre p { margin: 1px 0 0; font-size: 12.5px; color: var(--encre-2); }
.commandes { display: flex; gap: 6px; flex-wrap: wrap; justify-content: flex-end; }
.groupe { display: flex; background: rgba(251, 249, 243, .92); border: 1px solid var(--trait); border-radius: 12px; padding: 3px; box-shadow: var(--ombre); }
.groupe button { border: 0; background: transparent; border-radius: 9px; padding: 0 11px; min-height: 38px; min-width: 40px; font-weight: 500; font-size: 14px; }
.groupe button[aria-pressed="true"] { background: var(--encre); color: var(--papier-2); }
.groupe svg { width: 20px; height: 20px; display: block; margin: auto; }
@media (max-width: 520px) { .titre p { display: none; } .titre h1 { font-size: 18px; } }
/* ---------- fiches ---------- */
#fiches { position: fixed; z-index: 6; left: 0; right: 0; bottom: 0; background: var(--papier-2); border-top: 1px solid var(--trait);
border-radius: 16px 16px 0 0; box-shadow: 0 -8px 28px rgba(42, 41, 37, .14); padding: 10px 14px calc(env(safe-area-inset-bottom, 0px) + 10px);
display: flex; flex-direction: column; gap: 8px; max-height: 50%; }
@media (min-width: 900px) { #fiches { left: 16px; right: auto; bottom: 16px; width: 430px; border: 1px solid var(--trait); border-radius: 16px; max-height: calc(100% - 110px); } }
.onglets { display: flex; gap: 6px; overflow-x: auto; scrollbar-width: none; flex: none; }
.onglets::-webkit-scrollbar { display: none; }
.onglets button { flex: none; display: flex; align-items: center; gap: 6px; border: 1px solid var(--trait); background: #fff; border-radius: 999px; padding: 5px 11px; min-height: 34px; font-size: 13.5px; font-weight: 500; }
.onglets button i { width: 9px; height: 9px; border-radius: 50%; background: var(--c, var(--neutre)); }
.onglets button[aria-pressed="true"] { background: var(--c, var(--encre)); border-color: var(--c, var(--encre)); color: #fff; }
.onglets button[aria-pressed="true"] i { background: #fff; }
.fiche { display: grid; grid-template-columns: 56px 1fr; gap: 12px; overflow-y: auto; min-height: 0; }
.fiche .icone { width: 56px; height: 56px; border-radius: 50%; display: block; background: #fff; box-shadow: 0 2px 8px rgba(0,0,0,.08); }
.fiche .icone svg, .fiche .icone img { width: 56px; height: 56px; display: block; border-radius: 50%; }
.couche { font: 600 12.5px/1.2 "Barlow Condensed", Barlow, sans-serif; text-transform: uppercase; letter-spacing: .07em; color: var(--c, var(--neutre)); }
.fiche h2 { margin: 2px 0 5px; font-size: 19px; line-height: 1.22; font-weight: 600; text-wrap: balance; }
.fiche p { margin: 0 0 8px; }
.chiffres { display: flex; flex-wrap: wrap; gap: 8px 16px; margin: 2px 0 6px; }
.chiffres div { font-variant-numeric: tabular-nums; }
.chiffres b { display: block; font-size: 21px; font-weight: 700; line-height: 1.1; color: var(--c, var(--encre)); }
.chiffres small { display: block; font-size: 12.5px; line-height: 1.3; color: var(--encre-2); max-width: 22ch; }
.source { font-size: 11.5px; color: var(--encre-2); }
.action { margin-top: 8px; border: 0; background: var(--eau); color: #fff; font-weight: 600; border-radius: 10px; padding: 10px 14px; min-height: 42px; }
.pied { display: flex; align-items: center; gap: 8px; flex: none; }
.barre-temps { flex: 1; height: 3px; background: var(--trait); border-radius: 2px; overflow: hidden; }
.barre-temps span { display: block; height: 100%; width: 0; background: var(--c, var(--encre)); }
.pied button { border: 1px solid var(--trait); background: #fff; border-radius: 10px; width: 40px; height: 40px; display: grid; place-items: center; }
.pied svg { width: 18px; height: 18px; }
.compteur { font-size: 12.5px; color: var(--encre-2); font-variant-numeric: tabular-nums; min-width: 42px; text-align: right; }
.maquette { font-size: 11px; color: var(--encre-2); }
/* ---------- le cycle de l'eau (planche 13) ---------- */
#cycle { position: fixed; inset: 0; z-index: 20; background: var(--papier); display: grid; grid-template-rows: auto 1fr auto; }
#cycle header { display: flex; align-items: center; gap: 10px; padding: calc(env(safe-area-inset-top, 0px) + 10px) 14px 8px; }
#cycle header button { border: 1px solid var(--trait); background: #fff; border-radius: 10px; min-height: 40px; padding: 0 12px; font-weight: 500; }
#cycle header h2 { margin: 0; font: 600 20px/1.1 "Barlow Condensed", Barlow, sans-serif; }
#cycle header p { margin: 0; font-size: 12.5px; color: var(--encre-2); }
.vue-planche { position: relative; overflow: hidden; touch-action: none; cursor: grab; }
.plateau { position: absolute; left: 0; top: 0; width: 1615px; height: 860px; transform-origin: 0 0; will-change: transform; }
.plateau img { position: absolute; inset: 0; width: 100%; height: 100%; user-select: none; -webkit-user-drag: none; pointer-events: none; }
.plateau svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }
.flux { fill: none; stroke: var(--eau); stroke-width: 7; stroke-linecap: round; stroke-dasharray: 2 16; opacity: .25; transition: opacity .5s; }
.flux.actif { opacity: .95; animation: couler 1.1s linear infinite; }
@keyframes couler { to { stroke-dashoffset: -18; } }
.pe { cursor: pointer; }
.pe .halo { fill: var(--eau); opacity: 0; }
.pe .coeur { fill: #fff; stroke: var(--eau); stroke-width: 3; }
.pe text { font: 700 15px Barlow, sans-serif; fill: var(--eau); text-anchor: middle; dominant-baseline: central; pointer-events: none; }
.pe.actif .coeur { fill: var(--eau); }
.pe.actif text { fill: #fff; }
.pe.actif .halo { animation: halo 1.6s ease-out infinite; }
@keyframes halo { 0% { opacity: .45; r: 16; } 100% { opacity: 0; r: 46; } }
#cycle .bas { background: var(--papier-2); border-top: 1px solid var(--trait); padding: 10px 14px calc(env(safe-area-inset-bottom, 0px) + 10px); display: flex; flex-direction: column; gap: 8px; }
@media (min-width: 900px) { #cycle .bas { position: absolute; left: 16px; bottom: 16px; width: 430px; border: 1px solid var(--trait); border-radius: 16px; box-shadow: var(--ombre); } #cycle { grid-template-rows: auto 1fr; } }
#cycle .etape h3 { margin: 2px 0 5px; font-size: 18px; font-weight: 600; }
#cycle .etape p { margin: 0 0 6px; }
@media (prefers-reduced-motion: reduce) { .flux.actif, .pe.actif .halo { animation: none; } .pe.actif .halo { opacity: .25; } }
</style>
<canvas id="scene" aria-label="Maquette en trois dimensions du quartier type TMIP en 2035"></canvas>
<div id="repli" hidden></div>
<div class="barre">
<div class="titre">
<h1>Quelque part en 2035</h1>
<p>un quartier pavillonnaire, un jour de marché</p>
</div>
<div class="commandes">
<div class="groupe" role="group" aria-label="Époque">
<button type="button" id="b-auj" aria-pressed="false">Aujourd'hui</button>
<button type="button" id="b-2035" aria-pressed="true">2035</button>
</div>
<div class="groupe" role="group" aria-label="Vue">
<button type="button" id="b-axo" aria-pressed="true">Axo</button>
<button type="button" id="b-plan" aria-pressed="false">Plan</button>
</div>
<div class="groupe">
<button type="button" id="b-tourner" aria-label="Tourner d'un quart de tour"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 11a8 8 0 1 0-2.3 5.7"/><path d="M20 4v7h-7"/></svg></button>
</div>
</div>
</div>
<section id="fiches" aria-label="Fiches du quartier">
<div class="onglets" id="onglets"></div>
<article class="fiche" id="fiche" aria-live="polite"></article>
<div class="pied">
<div class="barre-temps" aria-hidden="true"><span id="temps"></span></div>
<span class="compteur" id="compteur"></span>
<button type="button" id="b-prec" aria-label="Fiche précédente"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 5l-7 7 7 7"/></svg></button>
<button type="button" id="b-lecture" aria-label="Pause"><svg viewBox="0 0 24 24" fill="currentColor" id="i-lecture"><rect x="6" y="5" width="4" height="14" rx="1"/><rect x="14" y="5" width="4" height="14" rx="1"/></svg></button>
<button type="button" id="b-suiv" aria-label="Fiche suivante"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 5l7 7-7 7"/></svg></button>
</div>
<div class="maquette">Maquette de test du 27/09 · plan et dessins : Jules Nény, concours Habiter Demain 2020 et TMIP 2024</div>
</section>
<section id="cycle" hidden aria-label="Le cycle de l'eau">
<header>
<button type="button" id="b-retour">← Retour au quartier</button>
<div><h2>Le cycle de l'eau</h2><p>de la gouttière à la parcelle, en onze étapes</p></div>
</header>
<div class="vue-planche" id="vue-planche">
<div class="plateau" id="plateau">
<img id="img-eau" src="__EAU__" alt="Planche du réseau hydrique alternatif : récupération, assainissement, maîtrise hydraulique, phytoépuration, production de biogaz. Dessins de Jules Nény.">
<svg viewBox="0 0 1615 860" id="svg-eau"></svg>
</div>
</div>
<div class="bas">
<article class="etape" id="etape" aria-live="polite"></article>
<div class="pied">
<div class="barre-temps" aria-hidden="true"><span id="temps-eau" style="background: var(--eau)"></span></div>
<span class="compteur" id="compteur-eau"></span>
<button type="button" id="e-prec" aria-label="Étape précédente"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 5l-7 7 7 7"/></svg></button>
<button type="button" id="e-suiv" aria-label="Étape suivante"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 5l7 7-7 7"/></svg></button>
</div>
</div>
</section>
<script src="https://cdn.jsdelivr.net/npm/three@0.158.0/build/three.min.js"></script>
<script>
(() => {
'use strict';
const D = __SCENE__;
const BULLES = __BULLES__;
const EAU = __EAUPTS__;
const IMG = { sol2035: '__SOL2035__', solauj: '__SOLAUJ__' };
const reduit = matchMedia('(prefers-reduced-motion: reduce)').matches;
const $ = (id) => document.getElementById(id);
const COUL = { eau: '#2d86bf', vivant: '#5a9837', chaleur: '#c4850f', quartier: '#d4662a' };
const NOM = { eau: "L'eau", vivant: 'Le vivant nourricier', chaleur: "L'enveloppe et la chaleur", quartier: 'La vie de quartier' };
// coordonnées : pixels du plan à 100 dpi -> mètres monde (x vers l'est, z vers le sud)
const P = (x, y) => ({ x: +((x - D.W / 2) / D.s).toFixed(1), z: +((y - D.H / 2) / D.s).toFixed(1) });
// ---------------------------------------------------------------- icônes des fiches (même style que les bulles)
const svgIcone = (coul, corps) => `<svg viewBox="0 0 56 56" role="img" aria-hidden="true"><circle cx="28" cy="28" r="25" fill="#fff" stroke="${coul}" stroke-width="2.5"/>${corps}</svg>`;
const ICONES = {
toit: svgIcone(COUL.eau, '<path d="M14 27l12-10 12 10" fill="none" stroke="#2d86bf" stroke-width="2.6" stroke-linejoin="round"/><rect x="33" y="31" width="9" height="12" rx="2" fill="#9fd0ec" stroke="#2d86bf" stroke-width="2"/><path d="M38 22c2 3 3 4.5 3 6a3 3 0 0 1-6 0c0-1.5 1-3 3-6z" fill="#2d86bf"/><path d="M17 30v12h12V30" fill="none" stroke="#2d86bf" stroke-width="2.2"/>'),
rigole: svgIcone(COUL.eau, '<path d="M13 22h9l6 6h8l7 7" fill="none" stroke="#2d86bf" stroke-width="2.6" stroke-dasharray="3 3" stroke-linecap="round"/><path d="M39 31l4 4-5 1" fill="none" stroke="#2d86bf" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/><path d="M18 36c2 3 3 4.5 3 6a3 3 0 0 1-6 0c0-1.5 1-3 3-6z" fill="#2d86bf"/>'),
noue: svgIcone(COUL.eau, '<path d="M11 34c4 5 9 7 17 7s13-2 17-7" fill="#bfe0f2" stroke="#2d86bf" stroke-width="2.4"/><path d="M20 33v-9M24 33v-12M32 33v-10M36 33v-7" stroke="#5a9837" stroke-width="2.4" stroke-linecap="round"/>'),
bassin: svgIcone(COUL.eau, '<path d="M11 30h34" stroke="#8a847a" stroke-width="2"/><path d="M13 31c3 8 8 11 15 11s12-3 15-11z" fill="#bfe0f2" stroke="#2d86bf" stroke-width="2.4"/><path d="M19 36c3-2 6 2 9 0s6-2 9 0" fill="none" stroke="#2d86bf" stroke-width="2"/><path d="M34 29v-9M38 29v-6" stroke="#5a9837" stroke-width="2.4" stroke-linecap="round"/>'),
paille: svgIcone(COUL.chaleur, '<path d="M14 28l14-12 14 12" fill="none" stroke="#c4850f" stroke-width="2.6" stroke-linejoin="round"/><rect x="17" y="27" width="22" height="15" fill="#f1cf6b" stroke="#c4850f" stroke-width="2.2"/><path d="M21 31h6M29 35h6M21 39h6" stroke="#c4850f" stroke-width="1.6"/><path d="M34 14v7" stroke="#8a847a" stroke-width="3"/>'),
quartier: svgIcone(COUL.vivant, '<path d="M9 36l7-6 7 6v7H9zM24 33l8-7 8 7v10H24zM37 37l5-4 5 4v6H37z" fill="#fff" stroke="#6c665b" stroke-width="2" stroke-linejoin="round"/><circle cx="17" cy="21" r="4" fill="#9dc07a"/><circle cx="41" cy="24" r="3.5" fill="#f0a860"/>'),
liens: svgIcone(COUL.quartier, '<path d="M28 12l-8 31M28 12l8 31M22 30h12M24 22h8" fill="none" stroke="#6c665b" stroke-width="2.4" stroke-linecap="round"/><path d="M10 44c6-4 12-4 18 0s12 4 18 0" fill="none" stroke="#d4662a" stroke-width="2.2" stroke-dasharray="3 3"/>'),
route: svgIcone(COUL.quartier, '<rect x="13" y="26" width="30" height="10" rx="4" fill="#fff" stroke="#6c665b" stroke-width="2.4"/><path d="M18 26l4-6h12l4 6" fill="none" stroke="#6c665b" stroke-width="2.4" stroke-linejoin="round"/><circle cx="20" cy="37" r="3" fill="#6c665b"/><circle cx="36" cy="37" r="3" fill="#6c665b"/>'),
};
const icone = (k) => ICONES[k] ? ICONES[k] : (BULLES[k] ? `<img src="${BULLES[k]}" alt="">` : ICONES.quartier);
// ---------------------------------------------------------------- les fiches
const C2035 = [
{ id: 'intro', ic: 'quartier', t: 'Quelque part en 2035', p: "Un quartier pavillonnaire ordinaire, vingt ans après. Les maisons sont isolées, les jardins nourrissent, l'eau reste sur place et la place a retrouvé un marché. Chaque couleur suit un système.", ch: [], s: 'TMIP 2024, p. 15 et 20', cam: { ...P(827, 560), w: 210 } },
{ id: 'toits', c: 'eau', ic: 'toit', t: 'Les toits récoltent la pluie', p: "Chaque maison transformée capte l'eau de son toit : gouttière, filtre, cuve. Cette eau suffit pour les WC, le lave-linge et le jardin.", ch: [['+80 %', "d'eau de pluie récupérée par maison"], ['98 %', "des usages n'ont pas besoin d'eau potable"]], s: 'TMIP 2024, p. 10-11', cam: { ...P(898, 712), w: 62 }, fx: 'pluie' },
{ id: 'rigoles', c: 'eau', ic: 'rigole', t: 'De petites rigoles vers les noues', p: "L'eau grise de la maison et le trop-plein des cuves partent à l'air libre, par une rigole, vers la noue plantée au fond du jardin.", ch: [['82 %', 'des rejets de la maison sont des eaux grises']], s: 'TMIP 2024, p. 13', cam: { ...P(1330, 790), w: 95 }, fx: 'liaisons' },
{ id: 'noues', c: 'eau', ic: 'noue', t: 'Les noues épurent et arrosent', p: "Une noue est un fossé planté. Les racines filtrent l'eau, la ralentissent et arrosent les haies fruitières qui la bordent.", ch: [['−80 %', "d'eau rejetée à l'égout"], ['−250 €', 'par an et par maison']], s: 'TMIP 2024, p. 11 et 13', cam: { ...P(560, 400), w: 150 }, fx: 'noues' },
{ id: 'mare', c: 'eau', ic: 'oiseaux', t: "L'eau se rassemble dans la mare", p: "Les noues descendent en pente douce jusqu'à la mare de la place. Elle rafraîchit l'été, accueille oiseaux et libellules, et retient l'orage.", ch: [], s: 'TMIP 2024, p. 13 et 15', cam: { ...P(700, 450), w: 64 }, fx: 'mare' },
{ id: 'bassins', c: 'eau', ic: 'bassin', t: 'Des bassins à ciel ouvert', p: "Le long de la route, un bassin planté recueille les grosses pluies. Il fait le travail d'un bassin d'orage, et il épure, rafraîchit, nourrit la vie.", ch: [], s: 'TMIP 2024, p. 13 §4-5', cam: { ...P(720, 760), w: 82 }, fx: 'bassins', action: "Entrer dans le cycle de l'eau" },
{ id: 'haies', c: 'vivant', ic: 'pomme', t: 'Les limites deviennent des haies fruitières', p: "Sur trois côtés du jardin, une haie de pommiers, poiriers et petits fruits. Après cinq ans, elle produit plus que la famille ne consomme.", ch: [['+12', 'personnes nourries en fruits, par maison'], ['85 m', "de haie autour d'un jardin de 750 m²"]], s: 'TMIP 2024, p. 11', cam: { ...P(270, 760), w: 95 }, fx: 'haies' },
{ id: 'sentiers', c: 'vivant', ic: 'promeneurs', t: "Le sentier d'entretien s'ouvre aux voisins", p: "La loi impose deux mètres entre un arbre et la limite voisine. Cette bande devient un sentier, ouvert par un droit d'usage : on y passe, on y cueille.", ch: [['2 m', 'de recul obligatoire, devenus un chemin']], s: 'TMIP 2024, p. 14', cam: { ...P(1330, 800), w: 110 }, fx: 'sentiers' },
{ id: 'foret', c: 'vivant', ic: 'abeille', t: 'La forêt nourricière', p: "À l'est, une forêt de fruitiers, de mellifères et d'essences qui dépolluent l'air et le sol. Des ruches, du miel, un chemin qui la traverse.", ch: [], s: 'TMIP 2024, p. 14-15', cam: { ...P(1470, 330), w: 120 }, fx: 'foret' },
{ id: 'maison', c: 'chaleur', ic: 'paille', t: 'La maison se couvre de paille', p: "Isolation par l'extérieur en bottes de paille et enduit terre, une serre au sud, un poêle à bois d'appoint. La facture de chauffage fond.", ch: [['−85 %', "de consommation d'énergie"], ['1 000 à 5 000 €', "d'économies par an"]], s: 'TMIP 2024, p. 11-12', cam: { ...P(1180, 712), w: 58 }, fx: 'maisons' },
{ id: 'chantier', c: 'chaleur', ic: 'outils', t: 'On isole ensemble', p: "Un chantier-école par maison : les voisins apprennent en faisant. En autoconstruction accompagnée, l'isolation coûte deux à trois fois moins cher.", ch: [['11 700 à 23 400 €', 'en autoconstruction accompagnée'], ['39 900 à 56 100 €', 'par une entreprise']], s: 'TMIP 2024, p. 16 et 28', cam: { ...P(760, 100), w: 58 }, fx: 'maisons' },
{ id: 'marche', c: 'quartier', ic: 'marche', t: 'Le parking devient le marché', p: "Sur la place, les emplacements de stationnement accueillent les étals : fruits des haies, conserves, pain, miel.", ch: [], s: 'TMIP 2024, p. 15 et 20', cam: { ...P(505, 300), w: 56 }, fx: 'marche' },
{ id: 'communs', c: 'quartier', ic: 'biere', t: 'Le grand bâtiment devient un lieu commun', p: "Brasserie, cantine, atelier de réparation, potagers devant la porte. Les activités qu'on allait chercher en zone commerciale reviennent dans le quartier.", ch: [], s: 'TMIP 2024, p. 14-15', cam: { ...P(330, 540), w: 62 }, fx: 'communs' },
{ id: 'mobilite', c: 'quartier', ic: 'enfant', t: 'On se déplace à pied et à vélo', p: "Les sentiers relient les jardins, la forêt et la place. La route se calme, les enfants jouent dehors.", ch: [['80 km', "de voiture par jour et par habitant, aujourd'hui"]], s: 'concours Habiter Demain 2020, planche 1', cam: { ...P(720, 520), w: 130 }, fx: 'mobilite' },
{ id: 'travail', c: 'quartier', ic: 'route', t: 'Ce qui ne change pas : le travail', p: "La voiture du matin vers le bassin d'emploi reste. TMIP ne prétend pas y répondre ; le montrer rend le reste crédible.", ch: [], s: 'proposition de la maquette, à valider', cam: { ...P(230, 300), w: 340 }, fx: 'travail' },
{ id: 'horschamp', ic: 'liens', t: "Ce dont le quartier s'est libéré", p: "Autour, les grandes infrastructures dont il dépendait : centrale et gaz, supermarché, station d'épuration, gravière, agriculture lointaine. Leurs liens se sont détendus. Celui du travail reste tendu.", ch: [['850 km', 'parcourus par le contenu des rayons'], ['3 %', "des fruits mangés en Île-de-France y poussent"]], s: 'TMIP 2024, p. 10', cam: { x: -20, z: 0, w: 900 }, fx: 'flux' },
];
const CAUJ = [
{ id: 'auj1', ic: 'quartier', t: "Aujourd'hui : un quartier ordinaire", p: "Pelouses, haies de thuyas, une voiture par adulte. La pluie part à l'égout, la chaleur vient du gaz, les courses du supermarché.", ch: [['23 %', "du bâti français est de l'habitat individuel périurbain"], ['750 m²', 'de jardin en moyenne']], s: 'TMIP 2024, p. 7', cam: { ...P(827, 560), w: 210 } },
{ id: 'auj2', ic: 'liens', t: 'Tout vient de loin', p: "Eau, énergie, nourriture, travail : chaque besoin tire un fil vers une grande infrastructure. Le ménage paie tous les fils.", ch: [['80 km', 'de voiture par jour et par habitant'], ['1/3', 'des revenus au crédit, sur 30 ans']], s: 'concours Habiter Demain 2020, planches 1 et 2', cam: { x: -20, z: 0, w: 900 }, fx: 'flux' },
{ id: 'auj3', ic: 'route', t: 'Sur la place, un parking', p: "La place sert à garer les voitures. Pour croiser ses voisins, il reste le supermarché.", ch: [], s: 'plan du quartier actuel, concours 2020', cam: { ...P(505, 300), w: 56 }, fx: 'parking' },
];
// bulles de la planche 2030 : fiche liée, ou une ligne quand il n'y en a pas
const SIGNES = {
marche: ['marche'], pomme: ['haies'], abeille: ['foret'], miel: ['foret'], fleur: ['foret', 'Des plantes mellifères et médicinales en lisière.'], arbre: ['foret'],
biere: ['communs'], couverts: ['communs'], outils: ['communs'], oiseaux: ['mare'], promeneurs: ['sentiers'], enfant: ['mobilite'],
film: [null, 'Un ciné-club dans un garage ouvert.', 'quartier'], ballons: [null, 'Les délaissés deviennent terrains de jeu.', 'quartier'],
barbecue: [null, 'Un barbecue partagé au bord de la place.', 'quartier'], confiture: [null, 'Une conserverie : le surplus des haies en bocaux.', 'quartier'],
bouteille: [null, 'Jus et cidre des vergers du quartier.', 'quartier'], musique: [null, 'Un bal sur la place, au bord de la mare.', 'quartier'],
bols: [null, 'Un atelier de céramique, celui dont rêvait Sophia en 2020.', 'quartier'], soin: [null, 'Un cabinet de soin, une herboristerie.', 'quartier'],
entraide: [null, "Garde d'enfants partagée, entraide entre voisins.", 'quartier'], reemploi: [null, 'Une recyclerie et un compost de quartier.', 'quartier'],
garage: [null, 'Un garage associatif de réparation.', 'quartier'], apaise1: ['mobilite'], apaise2: ['mobilite'],
};
const VERT = new Set(['pomme', 'abeille', 'fleur', 'arbre', 'oiseaux']);
// ---------------------------------------------------------------- moteur des fiches
const E = { epoque: '2035', filtre: 'tout', i: 0, lecture: !reduit, t0: performance.now(), pause: 0, mini: null };
const DUREE = 10000;
const liste = () => (E.epoque === 'auj' ? CAUJ : C2035).filter((c) => E.filtre === 'tout' || c.c === E.filtre);
function onglets() {
const o = $('onglets');
if (E.epoque === 'auj') { o.innerHTML = ''; o.hidden = true; return; }
o.hidden = false;
o.innerHTML = [['tout', 'Tout'], ['eau', 'Eau'], ['vivant', 'Vivant'], ['chaleur', 'Chaleur'], ['quartier', 'Quartier']]
.map(([k, n]) => `<button type="button" data-f="${k}" aria-pressed="${E.filtre === k}" style="${COUL[k] ? '--c:' + COUL[k] : ''}"><i></i>${n}</button>`).join('');
o.querySelectorAll('button').forEach((b) => b.addEventListener('click', () => { E.filtre = b.dataset.f; E.i = 0; onglets(); montrer(); }));
}
function montrer(voler = true) {
const L = liste(); if (!L.length) return;
E.i = (E.i + L.length) % L.length;
const c = E.mini || L[E.i];
const f = $('fiches'); f.style.setProperty('--c', c.c ? COUL[c.c] : 'var(--encre)');
$('fiche').innerHTML = `<div class="icone">${icone(c.ic)}</div><div>
<div class="couche">${c.c ? NOM[c.c] : (E.epoque === 'auj' ? "Aujourd'hui" : '2035')}</div>
<h2>${c.t}</h2><p>${c.p}</p>
${c.ch && c.ch.length ? `<div class="chiffres">${c.ch.map(([v, l]) => `<div><b>${v}</b><small>${l}</small></div>`).join('')}</div>` : ''}
<div class="source">${c.ch && c.ch.length ? 'Chiffres pour une maison · ' : ''}source : ${c.s}</div>
${c.action ? `<button type="button" class="action" id="b-cycle">${c.action} →</button>` : ''}</div>`;
const b = $('b-cycle'); if (b) b.addEventListener('click', ouvrirCycle);
$('compteur').textContent = E.mini ? '' : `${E.i + 1} / ${L.length}`;
E.t0 = performance.now();
if (T3) T3.activer(c, voler);
}
function aller(d) { E.mini = null; E.i += d; montrer(); }
function suspendre(ms = 25000) { E.pause = performance.now() + ms; }
$('b-prec').addEventListener('click', () => { suspendre(); aller(-1); });
$('b-suiv').addEventListener('click', () => { suspendre(); aller(1); });
$('b-lecture').addEventListener('click', () => { E.lecture = !E.lecture; E.pause = 0; E.t0 = performance.now(); majLecture(); });
function majLecture() {
$('b-lecture').setAttribute('aria-label', E.lecture ? 'Pause' : 'Lecture');
$('i-lecture').innerHTML = E.lecture ? '<rect x="6" y="5" width="4" height="14" rx="1"/><rect x="14" y="5" width="4" height="14" rx="1"/>' : '<path d="M7 5l12 7-12 7z"/>';
}
function tic(now) {
if (!$('cycle').hidden) return;
const actif = E.lecture && now > E.pause && !E.mini;
if (!actif) { if (now <= E.pause) E.t0 = now - (DUREE * (parseFloat($('temps').style.width) || 0)) / 100; return; }
const k = (now - E.t0) / DUREE;
$('temps').style.width = Math.min(100, k * 100) + '%';
if (k >= 1) aller(1);
}
// ---------------------------------------------------------------- la scène 3D
let T3 = null;
try { T3 = window.THREE ? scene3D() : null; } catch (e) { console.error(e); T3 = null; }
if (!T3) { $('scene').hidden = true; const r = $('repli'); r.hidden = false; r.style.backgroundImage = `url(${IMG.sol2035})`; }
function scene3D() {
const canvas = $('scene');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: 'high-performance' });
if (!renderer.getContext()) return null;
renderer.setPixelRatio(Math.min(devicePixelRatio || 1, 2));
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.useLegacyLights = true;
renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFSoftShadowMap;
const scene = new THREE.Scene();
scene.background = new THREE.Color('#ece6d8');
const cam = new THREE.OrthographicCamera(-1, 1, 1, -1, 1, 4000);
const sRGB = (h) => new THREE.Color(h);
const rnd = (i) => { const x = Math.sin(i * 12.9898 + 78.233) * 43758.5453; return x - Math.floor(x); };
// lumière : ciel chaud + soleil bas du sud-ouest
scene.add(new THREE.HemisphereLight(0xfffbf2, 0xd8d0bd, 0.78));
const soleil = new THREE.DirectionalLight(0xfff0d8, 0.36);
soleil.position.set(-110, 170, 120); soleil.target.position.set(0, 0, 0);
soleil.castShadow = true; soleil.shadow.mapSize.set(2048, 2048);
Object.assign(soleil.shadow.camera, { left: -170, right: 170, top: 130, bottom: -130, near: 20, far: 520 });
soleil.shadow.bias = -0.0006; soleil.shadow.normalBias = 0.35;
scene.add(soleil, soleil.target);
// sol : le plan vectoriel, en deux états
const chargeur = new THREE.TextureLoader();
const tex = (url) => { const t = chargeur.load(url, () => (besoin = true)); t.colorSpace = THREE.SRGBColorSpace; t.anisotropy = renderer.capabilities.getMaxAnisotropy(); return t; };
const papier = new THREE.Mesh(new THREE.PlaneGeometry(5000, 5000).rotateX(-Math.PI / 2), new THREE.MeshLambertMaterial({ color: '#e6dfcf' }));
papier.position.y = -2.3; papier.receiveShadow = true; scene.add(papier);
// socle de maquette : le plan posé sur une épaisseur
const socle = new THREE.Mesh(new THREE.BoxGeometry(D.Wm, 2.2, D.Hm), new THREE.MeshLambertMaterial({ color: '#d9cfba' }));
socle.position.y = -1.13; socle.receiveShadow = true; scene.add(socle);
const geoSol = new THREE.PlaneGeometry(D.Wm, D.Hm).rotateX(-Math.PI / 2);
const sol35 = new THREE.Mesh(geoSol, new THREE.MeshLambertMaterial({ map: tex(IMG.sol2035) }));
sol35.receiveShadow = true; scene.add(sol35);
const matAuj = new THREE.MeshLambertMaterial({ map: tex(IMG.solauj), transparent: true, opacity: 0, depthWrite: false, polygonOffset: true, polygonOffsetFactor: -2, polygonOffsetUnits: -2 });
const solAuj = new THREE.Mesh(geoSol, matAuj); solAuj.receiveShadow = true; solAuj.position.y = 0.02; scene.add(solAuj);
const G = { v2035: new THREE.Group(), vAuj: new THREE.Group() };
scene.add(G.v2035, G.vAuj);
const surv = { maisons: [], haies: null, foret: null, etals: [], grand: null, noues: [], bassins: [], liaisons: [], sentiers: [], chemins: [], flux: {}, bulles: [], pluie: [], ondes: [], fumee: [] };
const cliquables = [];
// ---------- maisons
const mat = {
mur: new THREE.MeshLambertMaterial({ color: '#f8f5ee' }), toit: new THREE.MeshLambertMaterial({ color: '#cfc8ba', side: THREE.DoubleSide }),
paille: new THREE.MeshLambertMaterial({ color: '#efc85a', emissive: '#000000' }), toitT: new THREE.MeshLambertMaterial({ color: '#c9bfab', side: THREE.DoubleSide }),
verre: new THREE.MeshLambertMaterial({ color: '#cde9e5', transparent: true, opacity: 0.55, depthWrite: false }), bord: new THREE.LineBasicMaterial({ color: '#86ada7' }),
chem: new THREE.MeshLambertMaterial({ color: '#a19a8e' }), cuve: new THREE.MeshLambertMaterial({ color: '#6fb0d8' }),
grandMur: new THREE.MeshLambertMaterial({ color: '#f6f1e7' }), grandToit: new THREE.MeshLambertMaterial({ color: '#d7a07e', side: THREE.DoubleSide, emissive: '#000000' }),
};
function toit(Lu, Lv, eave, rh, o) {
const hu = Lu / 2 + o, hv = Lv / 2 + o, ry = eave + rh, yb = eave - rh * (o / (Lv / 2));
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.Float32BufferAttribute([
-hu, yb, -hv, hu, yb, -hv, hu, ry, 0, -hu, yb, -hv, hu, ry, 0, -hu, ry, 0,
-hu, yb, hv, -hu, ry, 0, hu, ry, 0, -hu, yb, hv, hu, ry, 0, hu, yb, hv], 3));
g.computeVertexNormals(); return g;
}
function pignons(Lu, Lv, eave, rh) {
const g = new THREE.BufferGeometry(), x = Lu / 2, v = Lv / 2, ry = eave + rh;
g.setAttribute('position', new THREE.Float32BufferAttribute([-x, eave, -v, -x, eave, v, -x, ry, 0, x, eave, v, x, eave, -v, x, ry, 0], 3));
g.computeVertexNormals(); return g;
}
function maison(h, variante) {
const p = h.p.map(([x, z]) => new THREE.Vector2(x, z));
const c = p.reduce((a, b) => a.add(b), new THREE.Vector2()).multiplyScalar(0.25);
const e1 = p[1].clone().sub(p[0]), e2 = p[2].clone().sub(p[1]);
const fw = new THREE.Vector2(Math.cos(h.f), -Math.sin(h.f));
let u, Lu, Lv;
if (Math.abs(e1.clone().normalize().dot(fw)) >= Math.abs(e2.clone().normalize().dot(fw))) { u = e1.clone().normalize(); Lu = e1.length(); Lv = e2.length(); }
else { u = e2.clone().normalize(); Lu = e2.length(); Lv = e1.length(); }
const g = new THREE.Group(); g.position.set(c.x, 0, c.y); g.rotation.y = Math.atan2(-u.y, u.x);
const grand = h.t === 'grand', transfo = h.t === 'transformee' && variante === '2035';
const eave = grand ? 7.4 : 5.4, rh = Math.min(grand ? 5 : 3.4, Lv * (grand ? 0.27 : 0.31)), ite = transfo ? 0.7 : 0;
const mMur = grand ? mat.grandMur : transfo ? mat.paille : mat.mur, mToit = grand ? mat.grandToit : transfo ? mat.toitT : mat.toit;
const murs = new THREE.Mesh(new THREE.BoxGeometry(Lu + ite, eave, Lv + ite), mMur); murs.position.y = eave / 2;
const t = new THREE.Mesh(toit(Lu + ite, Lv + ite, eave, rh, 0.55), mToit);
const pg = new THREE.Mesh(pignons(Lu + ite, Lv + ite, eave, rh), new THREE.MeshLambertMaterial({ color: mMur.color, side: THREE.DoubleSide }));
if (transfo) pg.material = mat.paille;
[murs, t, pg].forEach((m) => { m.castShadow = true; m.receiveShadow = true; g.add(m); });
if (transfo) {
// serre sur la face la plus au sud
const th = g.rotation.y, faces = [[1, 0, Math.cos(th), -Math.sin(th)], [-1, 0, -Math.cos(th), Math.sin(th)], [0, 1, Math.sin(th), Math.cos(th)], [0, -1, -Math.sin(th), -Math.cos(th)]];
const f = faces.reduce((a, b) => (b[3] > a[3] ? b : a));
const sh = 3.3, d = 2.4, cote = f[0] !== 0 ? Lv : Lu;
const serre = new THREE.Mesh(new THREE.BoxGeometry(f[0] !== 0 ? d : cote * 0.7, sh, f[0] !== 0 ? cote * 0.7 : d), mat.verre);
serre.position.set(f[0] * ((Lu + ite) / 2 + d / 2), sh / 2, f[1] * ((Lv + ite) / 2 + d / 2)); serre.renderOrder = 1; g.add(serre);
const ar = new THREE.LineSegments(new THREE.EdgesGeometry(serre.geometry), mat.bord); ar.position.copy(serre.position); g.add(ar);
const ch = new THREE.Mesh(new THREE.BoxGeometry(0.7, 2.6, 0.7), mat.chem); ch.position.set(Lu * 0.2, eave + rh * 0.62 + 0.7, -f[1] * Lv * 0.12); ch.castShadow = true; g.add(ch);
// la cuve, au pied de la descente de gouttière, à l'opposé de la serre
const bout = (Lu + ite) / 2 + 1.1;
const cu = new THREE.Mesh(new THREE.CylinderGeometry(0.85, 0.85, 1.8, 16), mat.cuve);
if (f[0] !== 0) cu.position.set(-f[0] * bout, 0.9, Lv / 2 - 1); else cu.position.set(bout, 0.9, -f[1] * (Lv / 2 - 1));
cu.castShadow = true; g.add(cu);
g.updateMatrixWorld(true);
surv.maisons.push({ g, chem: ch.getWorldPosition(new THREE.Vector3()).add(new THREE.Vector3(0, 1.4, 0)), cuve: cu.getWorldPosition(new THREE.Vector3()), eave, c });
}
if (grand) surv.grand = g;
g.userData = { t: h.t };
return g;
}
for (const h of D.maisons) {
if (h.t === 'transformee') { G.v2035.add(maison(h, '2035')); G.vAuj.add(maison(h, 'auj')); }
else scene.add(maison(h, '2035'));
}
G.v2035.children.forEach((g) => cliquables.push({ o: g, carte: 'maison' }));
if (surv.grand) cliquables.push({ o: surv.grand, carte: 'communs' });
// ---------- arbres (positions et couleurs tirées du plan)
const geoC = new THREE.IcosahedronGeometry(1, 1), geoT = new THREE.CylinderGeometry(0.1, 0.17, 1, 5).translate(0, 0.5, 0);
const TYPES = [{ base: 1.3, rmin: 1.1, sy: 1.05, tronc: true, g: 'v2035' }, { base: 2.6, rmin: 1.5, sy: 1.2, tronc: true, g: 'v2035' }, { base: 3.0, rmin: 2.0, sy: 1.15, tronc: true, g: 'tous' }, { base: 0, rmin: 0.75, sy: 0.8, tronc: false, g: 'tous' }];
const matTronc = new THREE.MeshLambertMaterial({ color: '#8a7258' });
const couleurs = D.pal.map(([r, g, b]) => new THREE.Color().setRGB(r / 255, g / 255, b / 255, THREE.SRGBColorSpace));
TYPES.forEach((T, k) => {
const A = D.arbres.filter((a) => a[4] === k); if (!A.length) return;
const m = new THREE.MeshLambertMaterial({ color: '#ffffff', emissive: '#000000' });
const im = new THREE.InstancedMesh(geoC, m, A.length); im.castShadow = true;
const it = T.tronc ? new THREE.InstancedMesh(geoT, matTronc, A.length) : null;
const M = new THREE.Matrix4(), Q = new THREE.Quaternion(), S = new THREE.Vector3(), V = new THREE.Vector3();
A.forEach((a, i) => {
const j = 0.88 + rnd(i + k * 7919) * 0.24, r = Math.max(T.rmin, a[2] * (k === 1 ? 1.25 : 1)) * j;
V.set(a[0], T.base + r * T.sy * 0.85, a[1]); S.set(r, r * T.sy, r); Q.setFromAxisAngle(new THREE.Vector3(0, 1, 0), rnd(i) * 6.28);
im.setMatrixAt(i, M.compose(V, Q, S)); im.setColorAt(i, couleurs[a[3]]);
if (it) { V.set(a[0], 0, a[1]); S.set(1, T.base + r * 0.4, 1); it.setMatrixAt(i, M.compose(V, new THREE.Quaternion(), S)); }
});
im.instanceColor.needsUpdate = true;
const cible = T.g === 'v2035' ? G.v2035 : scene; cible.add(im); if (it) cible.add(it);
if (k === 0) surv.haies = m; if (k === 1) surv.foret = m;
});
// ---------- personnages
const geoP = new THREE.CapsuleGeometry(0.28, 0.95, 3, 8);
const PAL_P = ['#d86a3a', '#3d6fa8', '#e3b23c', '#6b8f3a', '#b0556b', '#4a4a4a', '#c9c2b0', '#8a5a9e'].map(sRGB);
const imP = new THREE.InstancedMesh(geoP, new THREE.MeshLambertMaterial({ color: '#ffffff' }), D.persos.length); imP.castShadow = true;
D.persos.forEach(([x, z], i) => { imP.setMatrixAt(i, new THREE.Matrix4().makeTranslation(x, 0.76, z)); imP.setColorAt(i, PAL_P[i % PAL_P.length]); });
imP.instanceColor.needsUpdate = true; G.v2035.add(imP);
// ---------- étals du marché (2035) / voitures garées (aujourd'hui)
const PAL_E = ['#e98a4a', '#f2c14e', '#e36d5a', '#7fb069', '#5aa2d6'];
const matEtal = PAL_E.map((c) => new THREE.MeshLambertMaterial({ color: c, emissive: '#000000' }));
const matPoteau = new THREE.MeshLambertMaterial({ color: '#7b6a55' }), matTable = new THREE.MeshLambertMaterial({ color: '#c49a64' });
function axes(pts) {
const p = pts.map(([x, z]) => new THREE.Vector2(x, z)), c = p.reduce((a, b) => a.add(b), new THREE.Vector2()).multiplyScalar(0.25);
const e1 = p[1].clone().sub(p[0]), e2 = p[2].clone().sub(p[1]); const [u, L, W] = e1.length() >= e2.length() ? [e1.clone().normalize(), e1.length(), e2.length()] : [e2.clone().normalize(), e2.length(), e1.length()];
return { c, rot: Math.atan2(-u.y, u.x), L, W };
}
const PAL_V = ['#8f98a3', '#b24a3b', '#3e5f8a', '#d9d4c7', '#565a5e', '#7a8b5a'].map(sRGB);
function voiture(k) {
const g = new THREE.Group(), m = new THREE.MeshLambertMaterial({ color: PAL_V[k % PAL_V.length] });
const a = new THREE.Mesh(new THREE.BoxGeometry(4.2, 0.9, 1.8), m); a.position.y = 0.65;
const b = new THREE.Mesh(new THREE.BoxGeometry(2.3, 0.75, 1.62), m); b.position.set(-0.25, 1.45, 0);
[a, b].forEach((x) => { x.castShadow = true; g.add(x); }); return g;
}
D.etals.forEach((e, i) => {
const a = axes(e.p), g = new THREE.Group(); g.position.set(a.c.x, 0, a.c.y); g.rotation.y = a.rot;
const L = Math.min(a.L, 4.5), W = Math.min(a.W, 3);
const toile = new THREE.Mesh(new THREE.BoxGeometry(L + 0.4, 0.16, W + 0.4), matEtal[i % matEtal.length]); toile.position.y = 2.45; toile.castShadow = true; g.add(toile);
for (const [sx, sz] of [[1, 1], [1, -1], [-1, 1], [-1, -1]]) { const po = new THREE.Mesh(new THREE.CylinderGeometry(0.06, 0.06, 2.45, 5), matPoteau); po.position.set(sx * L / 2, 1.22, sz * W / 2); g.add(po); }
const tb = new THREE.Mesh(new THREE.BoxGeometry(L * 0.9, 0.85, W * 0.45), matTable); tb.position.set(0, 0.42, W * 0.18); tb.castShadow = true; g.add(tb);
G.v2035.add(g); surv.etals.push(g); cliquables.push({ o: g, carte: 'marche' });
const v = voiture(i); v.position.set(a.c.x, 0, a.c.y); v.rotation.y = a.rot + Math.PI / 2; G.vAuj.add(v);
});
// ---------- eau, sentiers : surfaces qui s'allument
const aire = (pts) => Math.abs(pts.reduce((s, p, i) => { const q = pts[(i + 1) % pts.length]; return s + p[0] * q[1] - q[0] * p[1]; }, 0)) / 2;
function surface(pts, couleur, y) {
const sh = new THREE.Shape(pts.map(([x, z]) => new THREE.Vector2(x, -z)));
const g = new THREE.ShapeGeometry(sh).rotateX(-Math.PI / 2);
const m = new THREE.Mesh(g, new THREE.MeshBasicMaterial({ color: couleur, transparent: true, opacity: 0, depthWrite: false }));
m.position.y = y; m.renderOrder = 2; G.v2035.add(m); return m;
}
D.noues.forEach((pts) => { const m = surface(pts, '#1f7fc0', 0.08); surv.noues.push(m); cliquables.push({ o: m, carte: 'noues' }); });
D.plantes.forEach((pts) => { if (pts.length > 3 && aire(pts) > 60) { const m = surface(pts, '#1c9aa8', 0.08); surv.bassins.push(m); cliquables.push({ o: m, carte: 'bassins' }); } });
D.sentiers.forEach((pts) => surv.sentiers.push(surface(pts, '#e3a92c', 0.07)));
// rubans en pointillés qui coulent (liaisons, chemins, liens du hors-champ)
const vs = 'varying vec2 vUv; void main(){ vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }';
const fs = 'uniform float uT; uniform float uA; uniform float uV; uniform float uP; uniform vec3 uC; varying vec2 vUv; void main(){ float d = fract(vUv.x / uP - uT * uV); float t = smoothstep(0.0, 0.08, d) * (1.0 - smoothstep(0.45, 0.55, d)); float e = 1.0 - smoothstep(0.3, 0.5, abs(vUv.y - 0.5)); gl_FragColor = vec4(uC, uA * (0.22 + 0.78 * t) * e); }';
const hex3 = (h) => { const c = parseInt(h.slice(1), 16); return new THREE.Vector3((c >> 16 & 255) / 255, (c >> 8 & 255) / 255, (c & 255) / 255); };
function ruban(pts, largeur, couleur, vitesse, pas, y, groupe) {
const pos = [], uv = [], idx = []; let L = 0;
for (let i = 0; i < pts.length; i++) {
const [x, z] = pts[i], a = pts[Math.max(0, i - 1)], b = pts[Math.min(pts.length - 1, i + 1)];
let dx = b[0] - a[0], dz = b[1] - a[1]; const n = Math.hypot(dx, dz) || 1; dx /= n; dz /= n;
const rx = -dz * largeur / 2, rz = dx * largeur / 2;
if (i > 0) L += Math.hypot(x - pts[i - 1][0], z - pts[i - 1][1]);
pos.push(x - rx, 0, z - rz, x + rx, 0, z + rz); uv.push(L, 0, L, 1);
if (i > 0) { const k = 2 * i; idx.push(k - 2, k - 1, k, k - 1, k + 1, k); }
}
const g = new THREE.BufferGeometry(); g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3)); g.setAttribute('uv', new THREE.Float32BufferAttribute(uv, 2)); g.setIndex(idx);
const m = new THREE.ShaderMaterial({ vertexShader: vs, fragmentShader: fs, transparent: true, depthWrite: false, side: THREE.DoubleSide,
uniforms: { uT: { value: 0 }, uA: { value: 0 }, uV: { value: vitesse }, uP: { value: pas }, uC: { value: hex3(couleur) } } });
const mesh = new THREE.Mesh(g, m); mesh.position.y = y; mesh.renderOrder = 3; (groupe || G.v2035).add(mesh); return mesh;
}
const sommetsNoues = D.noues.flat();
const dNoue = ([x, z]) => Math.min(...sommetsNoues.map(([a, b]) => (a - x) ** 2 + (b - z) ** 2));
D.liaisons.forEach((l) => { const pts = dNoue(l[0]) < dNoue(l[l.length - 1]) ? l.slice().reverse() : l; surv.liaisons.push(ruban(pts, 1.0, '#1f7fc0', 0.9, 2.2, 0.14)); });
D.chemins.forEach((l) => { let L = 0; for (let i = 1; i < l.length; i++) L += Math.hypot(l[i][0] - l[i - 1][0], l[i][1] - l[i - 1][1]); if (L > 12) surv.chemins.push(ruban(l, 1.1, '#d0901f', 0.35, 3.0, 0.13)); });
// pluie des toits vers les cuves, ondes sur la mare, fumée des poêles
const disque = (() => { const c = document.createElement('canvas'); c.width = c.height = 64; const x = c.getContext('2d'); const g = x.createRadialGradient(32, 32, 2, 32, 32, 30); g.addColorStop(0, 'rgba(255,255,255,1)'); g.addColorStop(1, 'rgba(255,255,255,0)'); x.fillStyle = g; x.fillRect(0, 0, 64, 64); const t = new THREE.CanvasTexture(c); t.colorSpace = THREE.SRGBColorSpace; return t; })();
surv.maisons.forEach((m, k) => {
for (let i = 0; i < 7; i++) {
const s = new THREE.Sprite(new THREE.SpriteMaterial({ map: disque, color: '#2d86bf', transparent: true, opacity: 0, depthWrite: false }));
s.scale.set(0.9, 0.9, 1); s.userData = { m, ph: i / 7 }; G.v2035.add(s); surv.pluie.push(s);
}
for (let i = 0; i < 6; i++) {
const s = new THREE.Sprite(new THREE.SpriteMaterial({ map: disque, color: '#b9b2a6', transparent: true, opacity: 0.5, depthWrite: false }));
s.userData = { m, ph: (i + rnd(k * 13 + i)) / 6 }; G.v2035.add(s); surv.fumee.push(s);
}
});
const MARE = P(700, 468);
for (let i = 0; i < 3; i++) {
const o = new THREE.Mesh(new THREE.RingGeometry(0.85, 1.15, 48).rotateX(-Math.PI / 2), new THREE.MeshBasicMaterial({ color: '#1f7fc0', transparent: true, opacity: 0, depthWrite: false }));
o.position.set(MARE.x, 0.16, MARE.z); o.userData = { ph: i / 3 }; G.v2035.add(o); surv.ondes.push(o);
}
// ---------- véhicules et marcheurs le long des voies
function trajet(pts) { const c = [0]; for (let i = 1; i < pts.length; i++) c.push(c[i - 1] + Math.hypot(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1])); return { pts, c, L: c[c.length - 1] }; }
function surTrajet(t, s) {
s = Math.max(0, Math.min(t.L, s)); let i = 1; while (i < t.c.length - 1 && t.c[i] < s) i++;
const a = t.pts[i - 1], b = t.pts[i], k = (s - t.c[i - 1]) / ((t.c[i] - t.c[i - 1]) || 1);
return { x: a[0] + (b[0] - a[0]) * k, z: a[1] + (b[1] - a[1]) * k, dx: b[0] - a[0], dz: b[1] - a[1] };
}
function velo(k) {
const g = new THREE.Group(), cadre = new THREE.Mesh(new THREE.BoxGeometry(1.7, 0.12, 0.14), new THREE.MeshLambertMaterial({ color: '#3b3a36' })); cadre.position.y = 0.62;
const r = new THREE.Mesh(new THREE.CapsuleGeometry(0.26, 0.7, 3, 8), new THREE.MeshLambertMaterial({ color: PAL_P[(k + 2) % PAL_P.length] })); r.position.y = 1.35;
[cadre, r].forEach((x) => { x.castShadow = true; g.add(x); }); return g;
}
const mobiles = [];
const R = D.routes; const tr = {}; for (const k in R) tr[k] = trajet(R[k]);
const cheminsLongs = D.chemins.map(trajet).filter((t) => t.L > 40).sort((a, b) => b.L - a.L);
const ajoute = (obj, t, vitesse, decal, depart, groupe) => { groupe.add(obj); mobiles.push({ obj, t, v: vitesse, d: decal, s: depart * t.L, sens: 1 }); };
ajoute(velo(0), tr.principale, 4.2, 2.8, 0.1, G.v2035); ajoute(velo(1), tr.principale, 3.8, 2.8, 0.65, G.v2035);
ajoute(velo(2), tr.verticale, 4.0, 2.2, 0.4, G.v2035); ajoute(velo(3), tr.anneau, 3.5, 2.0, 0.2, G.v2035);
ajoute(voiture(0), tr.principale, 8, 1.8, 0.45, G.v2035); ajoute(voiture(2), tr.verticale, 7, 1.6, 0.8, G.v2035);
if (cheminsLongs[0]) ajoute(velo(4), cheminsLongs[0], 3.2, 0, 0.3, G.v2035);
cheminsLongs.slice(0, 5).forEach((t, i) => { const p = new THREE.Mesh(geoP, new THREE.MeshLambertMaterial({ color: PAL_P[(i + 3) % PAL_P.length] })); p.castShadow = true; const g = new THREE.Group(); p.position.y = 0.76; g.add(p); ajoute(g, t, 1.3, 0, rnd(i + 3), G.v2035); });
[['principale', 0.05], ['principale', 0.35], ['principale', 0.7], ['verticale', 0.2], ['verticale', 0.6], ['liaison_anneau', 0.5], ['anneau', 0.3], ['droite', 0.5]]
.forEach(([k, d], i) => ajoute(voiture(i + 1), tr[k], 9, 1.8, d, G.vAuj));
// ---------- bulles de la planche 2030, plantées comme des panneaux
const matTige = { v: new THREE.MeshLambertMaterial({ color: '#8fb76a' }), q: new THREE.MeshLambertMaterial({ color: '#e1a35c' }) };
const geoTige = new THREE.CylinderGeometry(0.1, 0.1, 1, 6).translate(0, 0.5, 0);
D.bulles.forEach((b, i) => {
const [x, z] = b.w, vert = VERT.has(b.id);
const tige = new THREE.Mesh(geoTige, vert ? matTige.v : matTige.q); tige.position.set(x, 0, z); tige.scale.y = 8.4; G.v2035.add(tige);
const t = chargeur.load(BULLES[b.id], () => (besoin = true)); t.colorSpace = THREE.SRGBColorSpace;
const s = new THREE.Sprite(new THREE.SpriteMaterial({ map: t, transparent: true, depthTest: false }));
const petit = b.id.startsWith('apaise'); s.scale.set(petit ? 3.4 : 6.4, petit ? 3.4 : 6.4, 1); s.position.set(x, petit ? 7.4 : 10.4, z); s.renderOrder = 10;
s.userData = { id: b.id, base: s.position.y, ph: rnd(i) * 6.28, taille: s.scale.x }; G.v2035.add(s); surv.bulles.push(s);
});
// ---------- hors-champ : les grandes infrastructures et leurs liens
const matI = (c) => new THREE.MeshLambertMaterial({ color: c });
function etiquette(texte, couleur) {
const c = document.createElement('canvas'); c.width = 640; c.height = 120; const x = c.getContext('2d');
x.fillStyle = 'rgba(251,249,243,0.94)'; x.beginPath(); if (x.roundRect) x.roundRect(4, 4, 632, 112, 22); else x.rect(4, 4, 632, 112); x.fill(); x.strokeStyle = couleur; x.lineWidth = 5; x.stroke();
x.fillStyle = '#2a2925'; x.font = '600 50px "Barlow Condensed", Barlow, sans-serif'; x.textAlign = 'center'; x.textBaseline = 'middle'; x.fillText(texte, 320, 62);
const t = new THREE.CanvasTexture(c); t.colorSpace = THREE.SRGBColorSpace;
const s = new THREE.Sprite(new THREE.SpriteMaterial({ map: t, transparent: true, depthTest: false })); s.scale.set(64, 12, 1); s.renderOrder = 9; return s;
}
const INFRA = [
{ k: 'centrale', n: 'Centrale, gaz importé', c: '#c4850f', x: -130, z: -330, sys: 'chaleur' },
{ k: 'potab', n: 'Puisement, potabilisation', c: '#2d86bf', x: 170, z: -300, sys: 'eau' },
{ k: 'agri', n: 'Agriculture industrielle · 850 km', c: '#5a9837', x: 360, z: -40, sys: 'vivant' },
{ k: 'step', n: "Station d'épuration", c: '#2d86bf', x: 110, z: 300, sys: 'eau' },
{ k: 'super', n: 'Supermarché', c: '#d4662a', x: -230, z: 260, sys: 'quartier' },
{ k: 'ciment', n: 'Gravière, cimenterie', c: '#c4850f', x: -380, z: 70, sys: 'chaleur' },
{ k: 'emploi', n: "Bassin d'emploi", c: '#6c665b', x: -400, z: -200, sys: 'travail' },
];
const HC = new THREE.Group(); HC.position.y = -2.3; scene.add(HC); // posé sur le papier, au pied du socle
for (const f of INFRA) {
const g = new THREE.Group(); g.position.set(f.x, 0, f.z);
const add = (geo, m, x, y, z) => { const o = new THREE.Mesh(geo, m); o.position.set(x, y, z); o.castShadow = true; g.add(o); return o; };
if (f.k === 'centrale') { add(new THREE.CylinderGeometry(8, 12, 34, 24), matI('#dcd6ca'), -12, 17, 0); add(new THREE.CylinderGeometry(8, 12, 34, 24), matI('#dcd6ca'), 14, 17, 8); add(new THREE.BoxGeometry(16, 14, 16), matI('#c9c3b7'), 2, 7, -22); }
if (f.k === 'potab') { add(new THREE.CylinderGeometry(2.5, 2.5, 26, 12), matI('#d3cec4'), 0, 13, 0); add(new THREE.CylinderGeometry(9, 6, 8, 20), matI('#bfd6e2'), 0, 30, 0); }
if (f.k === 'agri') { for (let i = 0; i < 6; i++) add(new THREE.BoxGeometry(120, 0.3, 16), matI(['#dcd49a', '#c8d49a', '#e4d08c'][i % 3]), 0, 0.15, -48 + i * 17); for (let i = 0; i < 3; i++) add(new THREE.CylinderGeometry(4, 4, 22, 14), matI('#cdc7bb'), -70, 11, -12 + i * 10); }
if (f.k === 'step') { for (let i = 0; i < 3; i++) { add(new THREE.CylinderGeometry(12, 12, 2.4, 32), matI('#c9c5bd'), -30 + i * 28, 1.2, 0); add(new THREE.CylinderGeometry(11, 11, 0.2, 32), matI('#9fc9dd'), -30 + i * 28, 2.45, 0); } }
if (f.k === 'super') { add(new THREE.BoxGeometry(72, 9, 44), matI('#e2dbcd'), 0, 4.5, 0); add(new THREE.BoxGeometry(90, 0.2, 42), matI('#d3cfc6'), 0, 0.1, 48); }
if (f.k === 'ciment') { add(new THREE.ConeGeometry(14, 11, 18), matI('#cfbd9d'), -14, 5.5, 8); add(new THREE.ConeGeometry(10, 8, 18), matI('#c7b48f'), 14, 4, 16); add(new THREE.CylinderGeometry(4, 4, 34, 14), matI('#bdb6aa'), 6, 17, -14); }
if (f.k === 'emploi') { add(new THREE.BoxGeometry(16, 44, 16), matI('#cfd3d8'), -12, 22, 0); add(new THREE.BoxGeometry(14, 30, 14), matI('#c3c8ce'), 12, 15, 10); add(new THREE.BoxGeometry(12, 22, 12), matI('#d8dbe0'), 6, 11, -16); }
const e = etiquette(f.n, f.c); e.position.set(0, 58, 0); g.add(e);
HC.add(g);
// le lien : une courbe vers le cœur du quartier
const pts = []; const cx = f.x * 0.45 + (f.z > 0 ? 30 : -30), cz = f.z * 0.45;
for (let i = 0; i <= 40; i++) { const t = i / 40, a = (1 - t) * (1 - t), b = 2 * (1 - t) * t, c = t * t; pts.push([a * f.x + b * cx + c * MARE.x, a * f.z + b * cz + c * MARE.z]); }
surv.flux[f.k] = { r: ruban(pts, 4, f.c, 0.35, 14, 0.2, HC), sys: f.sys };
}
// ---------- caméra : axonométrie orthographique, pilotable au doigt
const vue = { x: 0, z: 0, az: 0, el: 0.62, w: 210 };
let vol = null, besoin = true;
const taille = () => ({ W: canvas.clientWidth, H: canvas.clientHeight });
function place() {
const { W, H } = taille(), a = W / H, vh = vue.w / a;
cam.left = -vue.w / 2; cam.right = vue.w / 2; cam.top = vh / 2; cam.bottom = -vh / 2; cam.updateProjectionMatrix();
const d = 1500; cam.position.set(vue.x + Math.sin(vue.az) * Math.cos(vue.el) * d, Math.sin(vue.el) * d, vue.z + Math.cos(vue.az) * Math.cos(vue.el) * d);
cam.lookAt(vue.x, 0, vue.z);
}
function decalagePanneau(w) {
const { W, H } = taille(); const f = $('fiches').getBoundingClientRect(); const haut = 64;
const bas = W >= 900 ? 0 : Math.max(0, H - f.top);
return ((bas - haut) / 2) * (w / W) / Math.sin(vue.el);
}
function versCible(c0, duree = 1500) {
// en portrait, on resserre le cadrage : la largeur de l'écran est la dimension qui manque
const { W, H } = taille(), c = { ...c0, w: W < H && c0.w < 400 ? c0.w * 0.72 : c0.w };
const avant = { ...vue }, off = decalagePanneau(c.w);
const fx = -Math.sin(vue.az), fz = -Math.cos(vue.az);
const apres = { x: c.x - fx * off, z: c.z - fz * off, az: vue.az, el: vue.el, w: c.w };
if (reduit || duree === 0) { Object.assign(vue, apres); besoin = true; return; }
vol = { avant, apres, t0: performance.now(), d: duree };
}
const bornes = () => { vue.w = Math.max(18, Math.min(1000, vue.w)); vue.x = Math.max(-450, Math.min(450, vue.x)); vue.z = Math.max(-450, Math.min(450, vue.z)); };
function solSous(px, py) {
const { W, H } = taille(), wpp = vue.w / W, sx = px - W / 2, sy = py - H / 2;
const rx = Math.cos(vue.az), rz = -Math.sin(vue.az), fx = -Math.sin(vue.az), fz = -Math.cos(vue.az), k = wpp / Math.sin(vue.el);
return { x: vue.x + rx * sx * wpp - fx * sy * k, z: vue.z + rz * sx * wpp - fz * sy * k };
}
function zoomVers(px, py, f) { const p = solSous(px, py); vue.w *= f; vue.x = p.x + (vue.x - p.x) * f; vue.z = p.z + (vue.z - p.z) * f; bornes(); besoin = true; }
// gestes
const ptrs = new Map(); let geste = null;
canvas.addEventListener('pointerdown', (e) => { canvas.setPointerCapture(e.pointerId); ptrs.set(e.pointerId, { x: e.clientX, y: e.clientY }); geste = { t: performance.now(), bouge: 0, x: e.clientX, y: e.clientY }; vol = null; canvas.classList.add('tire'); suspendre(); });
canvas.addEventListener('pointermove', (e) => {
if (!ptrs.has(e.pointerId)) return;
const avant = [...ptrs.values()].map((p) => ({ ...p })); ptrs.set(e.pointerId, { x: e.clientX, y: e.clientY }); const apres = [...ptrs.values()];
const { W } = taille(), wpp = vue.w / W;
const cA = avant.reduce((a, p) => ({ x: a.x + p.x / avant.length, y: a.y + p.y / avant.length }), { x: 0, y: 0 });
const cB = apres.reduce((a, p) => ({ x: a.x + p.x / apres.length, y: a.y + p.y / apres.length }), { x: 0, y: 0 });
const dx = cB.x - cA.x, dy = cB.y - cA.y; if (geste) geste.bouge += Math.abs(dx) + Math.abs(dy);
const rx = Math.cos(vue.az), rz = -Math.sin(vue.az), fx = -Math.sin(vue.az), fz = -Math.cos(vue.az), k = wpp / Math.sin(vue.el);
vue.x -= rx * dx * wpp - fx * dy * k; vue.z -= rz * dx * wpp - fz * dy * k;
if (apres.length === 2 && avant.length === 2) {
const dA = Math.hypot(avant[0].x - avant[1].x, avant[0].y - avant[1].y), dB = Math.hypot(apres[0].x - apres[1].x, apres[0].y - apres[1].y);
if (dA > 0 && dB > 0) zoomVers(cB.x, cB.y, dA / dB);
const aA = Math.atan2(avant[1].y - avant[0].y, avant[1].x - avant[0].x), aB = Math.atan2(apres[1].y - apres[0].y, apres[1].x - apres[0].x);
let da = aB - aA; if (da > Math.PI) da -= 2 * Math.PI; if (da < -Math.PI) da += 2 * Math.PI; vue.az -= da;
}
bornes(); besoin = true;
});
const fin = (e) => {
ptrs.delete(e.pointerId); if (!ptrs.size) canvas.classList.remove('tire');
if (geste && !ptrs.size && geste.bouge < 8 && performance.now() - geste.t < 450) toucher(e.clientX, e.clientY);
if (!ptrs.size) geste = null;
};
canvas.addEventListener('pointerup', fin); canvas.addEventListener('pointercancel', fin);
canvas.addEventListener('wheel', (e) => { e.preventDefault(); vol = null; zoomVers(e.clientX, e.clientY, e.deltaY > 0 ? 1.12 : 1 / 1.12); suspendre(); }, { passive: false });
const ray = new THREE.Raycaster();
function toucher(px, py) {
const { W, H } = taille(); ray.setFromCamera(new THREE.Vector2((px / W) * 2 - 1, -(py / H) * 2 + 1), cam);
if (E.epoque === '2035') {
const hb = ray.intersectObjects(surv.bulles, false)[0];
if (hb) { const id = hb.object.userData.id, s = SIGNES[id] || [null, '', 'quartier']; return s[0] ? allerA(s[0]) : mini(id, s[1], s[2]); }
}
const cand = cliquables.filter((c) => c.o.parent && c.o.parent.visible !== false && (c.o.parent === scene || c.o.parent.visible));
const hits = ray.intersectObjects(cand.map((c) => c.o), true);
if (hits.length) { let o = hits[0].object; const trouve = cand.find((c) => { let x = o; while (x) { if (x === c.o) return true; x = x.parent; } return false; }); if (trouve && E.epoque === '2035') allerA(trouve.carte); }
}
function allerA(id) { const L = liste(); let i = L.findIndex((c) => c.id === id); if (i < 0) { E.filtre = 'tout'; onglets(); i = liste().findIndex((c) => c.id === id); } if (i >= 0) { E.mini = null; E.i = i; montrer(); } }
function mini(id, texte, couche) {
const b = D.bulles.find((x) => x.id === id);
E.mini = { id: 'mini', c: couche, ic: id, t: texte.split(/[.:]/)[0], p: texte, ch: [], s: 'bulle de la planche « le quartier en 2030 », TMIP 2024, p. 15', cam: { x: b.w[0], z: b.w[1], w: 50 } };
montrer();
}
// ---------- état : époque, vue, surlignage
const etat = { epoque: '2035', fx: null, auj: 0, cibleAuj: 0 };
function epoque(e) {
etat.epoque = e; etat.cibleAuj = e === 'auj' ? 1 : 0;
G.v2035.visible = e === '2035'; G.vAuj.visible = e === 'auj'; besoin = true;
}
function activer(c, voler) { etat.fx = c.fx || null; if (voler && c.cam) versCible(c.cam); besoin = true; }
function vueAxo(axo) { vol = { avant: { ...vue }, apres: { ...vue, el: axo ? 0.62 : 1.54 }, t0: performance.now(), d: reduit ? 0 : 900 }; }
function tourner() { vol = { avant: { ...vue }, apres: { ...vue, az: vue.az + Math.PI / 2 }, t0: performance.now(), d: reduit ? 0 : 900 }; }
// ---------- boucle
const horloge = new THREE.Clock(); let t = 0;
const ease = (k) => (k < 0.5 ? 4 * k * k * k : 1 - Math.pow(-2 * k + 2, 3) / 2);
function redim() { const { W, H } = taille(); renderer.setSize(W, H, false); besoin = true; }
addEventListener('resize', redim); redim();
function boucle(now) {
requestAnimationFrame(boucle);
tic(now);
if (document.hidden) return;
const dt = Math.min(0.05, horloge.getDelta()); t += dt;
if (vol) { const k = Math.min(1, (now - vol.t0) / (vol.d || 1)), e = ease(k); for (const key of ['x', 'z', 'az', 'el', 'w']) vue[key] = vol.avant[key] + (vol.apres[key] - vol.avant[key]) * e; if (k >= 1) vol = null; }
// fondu des sols
etat.auj += (etat.cibleAuj - etat.auj) * Math.min(1, dt * 4); matAuj.opacity = etat.auj;
const puls = 0.5 + 0.5 * Math.sin(t * 2.6), fx = etat.fx, eau = E.epoque === '2035';
surv.noues.forEach((m) => (m.material.opacity = fx === 'noues' ? 0.3 + 0.3 * puls : fx === 'mare' || fx === 'liaisons' ? 0.22 : 0));
surv.bassins.forEach((m) => (m.material.opacity = fx === 'bassins' ? 0.3 + 0.3 * puls : 0));
surv.sentiers.forEach((m) => (m.material.opacity = fx === 'sentiers' ? 0.3 + 0.35 * puls : 0));
surv.liaisons.forEach((m) => { m.material.uniforms.uT.value = t; m.material.uniforms.uA.value = fx === 'liaisons' || fx === 'pluie' ? 0.95 : fx === 'noues' ? 0.45 : 0; });
surv.chemins.forEach((m) => { m.material.uniforms.uT.value = t; m.material.uniforms.uA.value = fx === 'sentiers' || fx === 'mobilite' ? 0.9 : fx === 'foret' ? 0.5 : 0; });
for (const k in surv.flux) {
const f = surv.flux[k], u = f.r.material.uniforms; u.uT.value = t;
const tendu = etat.epoque === 'auj' || f.sys === 'travail';
const vise = fx === 'flux' || (fx === 'travail' && f.sys === 'travail');
u.uA.value = tendu ? (vise ? 0.95 : 0.7) : (vise ? 0.22 : 0); u.uV.value = tendu ? 0.45 : 0.05;
}
if (surv.haies) surv.haies.emissive.setRGB(0.12 * puls * (fx === 'haies'), 0.26 * puls * (fx === 'haies'), 0.02 * (fx === 'haies'));
if (surv.foret) surv.foret.emissive.setRGB(0.08 * puls * (fx === 'foret'), 0.2 * puls * (fx === 'foret'), 0.04 * puls * (fx === 'foret'));
mat.paille.emissive.setRGB(fx === 'maisons' ? 0.28 * puls : 0, fx === 'maisons' ? 0.17 * puls : 0, 0);
mat.grandToit.emissive.setRGB(fx === 'communs' ? 0.35 * puls : 0, fx === 'communs' ? 0.12 * puls : 0, 0);
matEtal.forEach((m) => m.emissive.setRGB(fx === 'marche' ? 0.25 * puls : 0, fx === 'marche' ? 0.14 * puls : 0, 0));
surv.pluie.forEach((s) => {
const on = fx === 'pluie'; s.material.opacity = on ? 0.85 : 0; if (!on) return;
const k = (t * 0.55 + s.userData.ph) % 1, m = s.userData.m; s.position.set(m.cuve.x, 0.2 + (m.eave + 0.6) * (1 - k), m.cuve.z);
});
surv.fumee.forEach((s) => {
const k = (t * 0.12 + s.userData.ph) % 1, m = s.userData.m; s.position.set(m.chem.x - k * 5, m.chem.y + k * 9, m.chem.z - k * 2.5);
const sc = 1.2 + k * 4.5; s.scale.set(sc, sc, 1); s.material.opacity = (fx === 'maisons' ? 0.75 : 0.5) * (1 - k) * Math.min(1, k * 6);
});
surv.ondes.forEach((o) => { const k = (t * 0.35 + o.userData.ph) % 1, on = fx === 'mare' || fx === 'noues'; o.scale.setScalar(1 + k * 11); o.material.opacity = on ? 0.6 * (1 - k) : 0; });
const lieesA = { marche: ['marche', 'musique'], haies: ['pomme', 'confiture', 'bouteille'], foret: ['abeille', 'miel', 'fleur', 'arbre'], communs: ['biere', 'couverts', 'outils'], mare: ['oiseaux', 'musique'], sentiers: ['promeneurs'], mobilite: ['enfant', 'promeneurs', 'apaise1', 'apaise2', 'ballons'] }[fx] || [];
surv.bulles.forEach((s) => { const u = s.userData, on = lieesA.includes(u.id), k = on ? 1.35 : 1; s.position.y = u.base + Math.sin(t * 1.4 + u.ph) * 0.35; s.scale.set(u.taille * k, u.taille * k, 1); s.material.opacity = lieesA.length && !on ? 0.55 : 1; });
mobiles.forEach((m) => {
if (!m.obj.parent.visible) return; m.s += m.v * dt * m.sens; if (m.s > m.t.L) { m.s = m.t.L; m.sens = -1; } if (m.s < 0) { m.s = 0; m.sens = 1; }
const p = surTrajet(m.t, m.s); let dx = p.dx * m.sens, dz = p.dz * m.sens; const n = Math.hypot(dx, dz) || 1; dx /= n; dz /= n;
m.obj.position.set(p.x - dz * m.d, 0, p.z + dx * m.d); m.obj.rotation.y = Math.atan2(-dz, dx);
});
place(); renderer.render(scene, cam); besoin = false;
}
place(); requestAnimationFrame(boucle);
return { activer, epoque, vueAxo, tourner, versCible };
}
// ---------------------------------------------------------------- commandes
function setEpoque(e) {
E.epoque = e; E.filtre = 'tout'; E.i = 0; E.mini = null;
$('b-auj').setAttribute('aria-pressed', e === 'auj'); $('b-2035').setAttribute('aria-pressed', e === '2035');
if (T3) T3.epoque(e); onglets(); montrer();
}
$('b-auj').addEventListener('click', () => setEpoque('auj'));
$('b-2035').addEventListener('click', () => setEpoque('2035'));
$('b-axo').addEventListener('click', () => { $('b-axo').setAttribute('aria-pressed', 'true'); $('b-plan').setAttribute('aria-pressed', 'false'); if (T3) T3.vueAxo(true); });
$('b-plan').addEventListener('click', () => { $('b-plan').setAttribute('aria-pressed', 'true'); $('b-axo').setAttribute('aria-pressed', 'false'); if (T3) T3.vueAxo(false); });
$('b-tourner').addEventListener('click', () => { if (T3) T3.tourner(); });
// ---------------------------------------------------------------- le cycle de l'eau
const ETAPES = [
{ n: 1, h: ['h1', 'h1b'], t: 'Potentiels', p: "La maison dispose des ressources pour être autonome en eau : un toit, un jardin, de la place.", ch: [['+80 %', "d'eau récupérée"], ['98 %', "des usages sans eau potable"]], f: ['pluie'] },
{ n: 2, h: ['h2'], t: 'Séparer les flux', p: "Les eaux grises partent vers une noue à l'air libre. Les eaux noires vont au centre de phytoépuration.", ch: [['82 %', 'des rejets sont des eaux grises']], f: ['usees'] },
{ n: 3, h: ['h3'], t: 'Phytoépuration', p: "Des plantes aquifères épurent les eaux grises : graisses, sucres, amidon.", ch: [], f: ['usees'] },
{ n: 4, h: ['h4'], t: 'Maîtriser les pluies', p: "Noues et bassins absorbent l'essentiel des précipitations. Les stations d'épuration ne débordent plus à l'orage.", ch: [['−21 %', "de charge sur le réseau national, si la moitié des maisons le faisaient (estimation, p. 29)"]], f: ['pluie'] },
{ n: 5, h: ['h5', 'h5b'], t: 'Un écosystème', p: "Même rôle qu'un maillage de bassins d'orage, plus rentable à long terme : il épure, accueille la biodiversité, soutient l'agriculture.", ch: [['−250 €', 'par an et par maison']], f: ['pluie'] },
{ n: 6, h: ['h6'], t: 'Cuve de décantation', p: "Les eaux vannes décantent ; la matière se dépose et part vers le biométhaniseur.", ch: [], f: ['usees'] },
{ n: 7, h: ['h7'], t: 'Filtre vertical', p: "L'essentiel de l'épuration : les bactéries des rhizomes changent la boue en terreau. Une ligne curée chaque année donne des tonnes de compost.", ch: [], f: ['usees'] },
{ n: 8, h: ['h8'], t: 'Biométhanisation', p: "Le biogaz produit reste modeste : au mieux, le gaz de la cuisine.", ch: [], f: ['usees'] },
{ n: 9, h: ['h9'], t: 'Filtre horizontal', p: "Entre les rhizomes, les plantes traitent ce qui reste : métaux, composés chimiques, médicaments.", ch: [], f: ['usees'] },
{ n: 10, h: ['h10'], t: 'Lagunage', p: "Dernière étape : l'eau s'oxygène, les UV éliminent bactéries et microbes.", ch: [], f: ['usees'] },
{ n: 11, h: ['h11'], t: 'Rejet à la parcelle', p: "L'eau épurée n'est pas encore reconnue conforme par l'administration : on l'absorbe sur place.", ch: [], f: ['usees'] },
];
const svg = $('svg-eau'), NS = 'http://www.w3.org/2000/svg';
for (const [k, l] of Object.entries(EAU.flux)) { const p = document.createElementNS(NS, 'path'); p.setAttribute('d', 'M' + l.map((q) => q.join(' ')).join(' L')); p.setAttribute('class', 'flux'); p.dataset.f = k; svg.appendChild(p); }
const etapeDe = {}; ETAPES.forEach((e, i) => e.h.forEach((h) => (etapeDe[h] = i)));
for (const [k, [x, y]] of Object.entries(EAU.pts)) {
const g = document.createElementNS(NS, 'g'); g.setAttribute('class', 'pe'); g.setAttribute('transform', `translate(${x} ${y})`); g.dataset.h = k;
g.setAttribute('tabindex', '0'); g.setAttribute('role', 'button'); g.setAttribute('aria-label', 'Étape ' + ETAPES[etapeDe[k]].n + ' : ' + ETAPES[etapeDe[k]].t);
g.innerHTML = `<circle class="halo" r="16"/><circle class="coeur" r="14"/><text>${ETAPES[etapeDe[k]].n}</text>`;
const go = () => { C.i = etapeDe[k]; C.pause = performance.now() + 20000; etape(); };
g.addEventListener('click', go); g.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); go(); } });
svg.appendChild(g);
}
const C = { i: 0, t0: 0, pause: 0, s: 1, x: 0, y: 0, vol: null };
const plateau = $('plateau'), vp = $('vue-planche');
const appliqueP = () => (plateau.style.transform = `translate(${C.x}px, ${C.y}px) scale(${C.s})`);
function ajuste() { const r = vp.getBoundingClientRect(); return Math.min(r.width / 1615, r.height / 860); }
function borneP(o, r) {
// la planche couvre la vue quand elle est assez grande ; sinon elle reste centrée
const w = 1615 * o.s, h = 860 * o.s;
o.x = w >= r.width ? Math.min(0, Math.max(r.width - w, o.x)) : (r.width - w) / 2;
o.y = h >= r.height ? Math.min(0, Math.max(r.height - h, o.y)) : (r.height - h) / 2;
return o;
}
function viser(x, y, s) {
const r = vp.getBoundingClientRect(), fin = borneP({ s, x: r.width / 2 - x * s, y: r.height / 2 - y * s }, r);
if (reduit) { Object.assign(C, fin); appliqueP(); return; }
const debut = { s: C.s, x: C.x, y: C.y }, t0 = performance.now();
const pas = (now) => { const k = Math.min(1, (now - t0) / 900), e = k < 0.5 ? 2 * k * k : 1 - Math.pow(-2 * k + 2, 2) / 2; C.s = debut.s + (fin.s - debut.s) * e; C.x = debut.x + (fin.x - debut.x) * e; C.y = debut.y + (fin.y - debut.y) * e; appliqueP(); if (k < 1) C.vol = requestAnimationFrame(pas); };
cancelAnimationFrame(C.vol); C.vol = requestAnimationFrame(pas);
}
function etape() {
const e = ETAPES[C.i];
$('etape').innerHTML = `<div class="couche" style="--c: var(--eau)">Étape ${e.n} sur 11</div><h3>${e.t}</h3><p>${e.p}</p>${e.ch.length ? `<div class="chiffres" style="--c: var(--eau)">${e.ch.map(([v, l]) => `<div><b>${v}</b><small>${l}</small></div>`).join('')}</div>` : ''}<div class="source">source : TMIP 2024, p. 13 ; concours Habiter Demain 2020, planche 6</div>`;
$('compteur-eau').textContent = `${e.n} / 11`;
svg.querySelectorAll('.pe').forEach((g) => g.classList.toggle('actif', e.h.includes(g.dataset.h)));
svg.querySelectorAll('.flux').forEach((p) => p.classList.toggle('actif', e.f.includes(p.dataset.f)));
const [x, y] = EAU.pts[e.h[0]], sf = ajuste(), r = vp.getBoundingClientRect();
// en portrait, on zoome assez pour que le dessin remplisse la hauteur
const zoom = r.width < r.height ? Math.max(sf * 2.3, (r.height * 0.98) / 860) : sf * 2.3;
viser(x, y, Math.min(Math.max(zoom, sf), 1.25));
C.t0 = performance.now();
}
function ouvrirCycle() { $('cycle').hidden = false; C.i = 0; C.pause = 0; const sf = ajuste(); C.s = sf; C.x = 0; C.y = 0; appliqueP(); etape(); $('b-retour').focus(); }
function fermerCycle() { $('cycle').hidden = true; E.t0 = performance.now(); }
$('b-retour').addEventListener('click', fermerCycle);
addEventListener('keydown', (e) => { if (e.key === 'Escape' && !$('cycle').hidden) fermerCycle(); });
$('e-prec').addEventListener('click', () => { C.i = (C.i + 10) % 11; C.pause = performance.now() + 20000; etape(); });
$('e-suiv').addEventListener('click', () => { C.i = (C.i + 1) % 11; C.pause = performance.now() + 20000; etape(); });
// pan et pince sur la planche
const pp = new Map();
vp.addEventListener('pointerdown', (e) => { if (e.target.closest('.pe')) return; vp.setPointerCapture(e.pointerId); pp.set(e.pointerId, [e.clientX, e.clientY]); C.pause = performance.now() + 20000; cancelAnimationFrame(C.vol); });
vp.addEventListener('pointermove', (e) => {
if (!pp.has(e.pointerId)) return; const av = [...pp.values()].map((p) => [...p]); pp.set(e.pointerId, [e.clientX, e.clientY]); const ap = [...pp.values()];
const c = (L) => [L.reduce((s, p) => s + p[0], 0) / L.length, L.reduce((s, p) => s + p[1], 0) / L.length], a = c(av), b = c(ap);
C.x += b[0] - a[0]; C.y += b[1] - a[1];
if (av.length === 2 && ap.length === 2) { const r = vp.getBoundingClientRect(), dA = Math.hypot(av[0][0] - av[1][0], av[0][1] - av[1][1]), dB = Math.hypot(ap[0][0] - ap[1][0], ap[0][1] - ap[1][1]); const k = dB / dA, ns = Math.max(ajuste() * 0.9, Math.min(2.5, C.s * k)), kk = ns / C.s, px = b[0] - r.left, py = b[1] - r.top; C.x = px - (px - C.x) * kk; C.y = py - (py - C.y) * kk; C.s = ns; }
appliqueP();
});
const finP = (e) => pp.delete(e.pointerId); vp.addEventListener('pointerup', finP); vp.addEventListener('pointercancel', finP);
vp.addEventListener('wheel', (e) => { e.preventDefault(); const r = vp.getBoundingClientRect(), ns = Math.max(ajuste() * 0.9, Math.min(2.5, C.s * (e.deltaY > 0 ? 0.9 : 1.11))), kk = ns / C.s, px = e.clientX - r.left, py = e.clientY - r.top; C.x = px - (px - C.x) * kk; C.y = py - (py - C.y) * kk; C.s = ns; appliqueP(); C.pause = performance.now() + 20000; }, { passive: false });
(function ticEau(now) {
requestAnimationFrame(ticEau);
if ($('cycle').hidden) return;
if (now < C.pause) { C.t0 = now; $('temps-eau').style.width = '0%'; return; }
const k = (now - C.t0) / 8000; $('temps-eau').style.width = Math.min(100, k * 100) + '%';
if (k >= 1 && !reduit) { C.i = (C.i + 1) % 11; etape(); }
})(performance.now());
// ---------------------------------------------------------------- départ
majLecture(); onglets(); montrer(false);
if (!T3) { /* sans WebGL : les fiches restent lisibles sur l'image du plan */ }
document.fonts && document.fonts.ready.then(() => {});
if (!T3) (function boucleSans(now) { requestAnimationFrame(boucleSans); tic(now); })(performance.now());
// démarrage : un premier survol sur la première fiche ; liens directs #aujourdhui et #cycle
if (location.hash === '#aujourdhui') setEpoque('auj');
if (T3) setTimeout(() => montrer(true), 400);
if (location.hash === '#cycle') setTimeout(ouvrirCycle, 300);
})();
</script>
+1
View File
@@ -0,0 +1 @@
{"dpi": 100, "s": 6.149999999999984, "x0": 348.346, "y1": 542.703, "dx": -1, "dy": -1}