Repository Public Research preview
1#!/usr/bin/env python32# IMM1033 — séance 7 : génère parties/seance07_qs_cas.tex (atelier « quantity survey » : cas complets plans → quantités → coûts RSMeans)3# à partir des exports UQO Éval (images/qs-cas/casN/data.json + plans PNG, localisation.json). Toutes les valeurs viennent des révisions4# figées de la plateforme (RSMeans 2026 T4) — rien n'est inventé. Budget « zéro débordement » : vérifier avec check_overflow.py.5import json, os, re67ROOT = os.path.dirname(os.path.abspath(__file__))8IMG = os.path.join(ROOT, "images", "qs-cas")9OUT = os.path.join(ROOT, "parties", "seance07_qs_cas.tex")10CASES = [7, 1, 2, 3, 6] # ordre pédagogique : du plus simple au plus particulier11ALL = [1, 2, 3, 4, 5, 6, 7, 8]12DIV = {"01": "Exigences générales", "03": "Béton", "04": "Maçonnerie", "05": "Métaux", "06": "Bois et composites", "07": "Enveloppe et toiture", "08": "Ouvertures", "09": "Finitions", "11": "Cuisine", "12": "Ameublement", "22": "Plomberie", "23": "CVCA", "26": "Électricité", "31": "Terrassement", "32": "Aménagements extérieurs", "33": "Services publics"}13UNIT = {"m": "m", "m2": "m\\textsuperscript{2}", "m3": "m\\textsuperscript{3}", "ft": "pi", "ft2": "pi\\textsuperscript{2}", "ft3": "pi\\textsuperscript{3}", "yd3": "vg\\textsuperscript{3}", "unit": "unité", "lump": "forfait", "h": "h", "in": "po"}14SPEC_FR = {"poured_concrete": "béton coulé", "concrete_block": "blocs de béton", "slab_on_grade": "dalle sur sol", "piers": "pieux", "icf": "coffrage isolant", "unfinished": "non fini", "finished": "fini", "none": "aucun", "crawl": "vide sanitaire", "asphalt_shingle": "bardeaux d'asphalte", "membrane": "membrane", "metal": "tôle", "gable": "deux versants", "hip": "quatre versants", "flat": "plat", "pvc": "PVC", "wood": "bois", "hybrid": "hybride", "aluminum": "aluminium", "electric_baseboard": "plinthes électriques", "heat_pump": "thermopompe centrale", "furnace_electric": "fournaise électrique", "furnace_gas": "fournaise au gaz", "standard": "standard", "economy": "économique", "superior": "supérieure", "prestige": "prestige", "vinyl": "vinyle", "brick": "brique", "fiber_cement": "fibrociment", "stone": "pierre", "steel": "acier", "hardwood": "bois franc", "ceramic": "céramique", "vinyl_plank": "vinyle", "laminate": "stratifié", "engineered": "ingénierie", "carpet": "tapis", "detached": "détaché", "attached": "attenant", "integrated": "intégré", "asphalt": "asphalte", "gravel": "gravier", "pavers": "pavés"}1516def tex(s):17 if s is None: return ""18 s = str(s)19 rep = [("\\", "\\textbackslash{}"), ("&", "\\&"), ("%", "\\%"), ("$", "\\$"), ("#", "\\#"), ("_", "\\_"), ("~", "\\textasciitilde{}"), ("^", "\\^{}"), ("²", "\\textsuperscript{2}"), ("³", "\\textsuperscript{3}"), ("½", "1/2"), ("¼", "1/4"), ("¾", "3/4"), ("⅝", "5/8"), ("⅜", "3/8"), ("×", "$\\times$"), ("—", "---"), ("–", "--"), ("·", "$\\cdot$"), ("≈", "$\\approx$"), ("≥", "$\\geq$"), ("≤", "$\\leq$"), ("°", "$^{\\circ}$"), ("→", "$\\rightarrow$"), ("\u00a0", "~"), ("″", "''"), ("“", "``"), ("”", "''"), ("’", "'"), ("Σ", "$\\Sigma$"), ("÷", "$\\div$"), ("−", "--"), ("ƒ", "f")]20 for a, b in rep: s = s.replace(a, b)21 return s.replace('"', "''")2223def num(v, d=0):24 if v is None: return "---"25 return f"{v:,.{d}f}".replace(",", "~").replace(".", ",")26def money(v, d=0): return num(v, d) + "~\\$"27def unit(u): return UNIT.get(u, tex(u))28def fr(key): return SPEC_FR.get(key, tex(key))29def mix(d):30 if not d: return "---"31 return ", ".join(f"{fr(k)} {round(v*100)}\\,\\%" for k, v in sorted(d.items(), key=lambda kv: -kv[1]) if v > 0)32def short_name(p): return re.sub(r" \(RSMeans.*\)$", "", p["name"])33def cut(s, n):34 """Tronque à n caractères sans couper une commande TeX (\textsuperscript{2}), un groupe ou un mode math — 2026-10-30."""35 if len(s) <= n:36 return s37 c = s[:n - 1]38 k = c.rfind("\\")39 if k >= 0 and (re.fullmatch(r"\\[A-Za-z]*", c[k:]) or c[k:].count("{") != c[k:].count("}")):40 c = c[:k]41 if c.count("$") % 2:42 c = c[:c.rfind("$")]43 return c.rstrip() + "…"4445def load(n):46 d = json.load(open(os.path.join(IMG, f"cas{n}", "data.json")))47 d["n"] = n; d["dir"] = f"images/qs-cas/cas{n}"48 r = d["version"]["result"]; lines = [l for l in r["lines"] if not l["excluded"]]49 by_meas = {l["measurementId"]: l for l in lines if l.get("measurementId")}50 meas = []51 for m in d["measurements"]:52 if not m["name"].startswith("QS · "): continue53 note = (m.get("inputs") or {}).get("note", "") or ""54 formula, inputs = note, ""55 if " — " in note:56 parts = note.split(" — "); formula = parts[0]; inputs = parts[1] if len(parts) > 1 and "=" in parts[1] else ""57 l = by_meas.get(m["id"]); wbs = l["wbs"] if l else "00"58 meas.append({"name": m["name"][5:], "value": m["value"], "unit": m["unit"], "formula": formula.replace(" (metre-qs-1.0)", "").strip(), "inputs": inputs.replace(" (metre-qs-1.0)", "").strip(), "wbs": wbs})59 d["qs"] = meas; d["lines"] = lines60 emp = None61 for m in meas:62 mm = re.search(r"empreinte_m2=([\d.]+)", m["inputs"])63 if mm: emp = float(mm.group(1)); break64 heated = next((m["value"] for m in meas if m["name"].startswith("Filage brut")), None)65 if heated is None: heated = next((m["value"] for m in meas if m["name"].startswith("Gypse") and "plafond" in m["name"]), None)66 d["footprint"] = emp; d["heated"] = heated67 pages = d["pages"]; ground = next((x for x in pages if (x["level"] or "").startswith("Rez")), pages[-1] if pages else None)68 d["ground"] = ground69 above = [x for x in pages if not (x["level"] or "").lower().startswith("sous")]70 d["extGross"] = sum(w["grossAreaM2"] for x in above for w in x["walls"] if w["exterior"]); d["extNet"] = sum(w["netAreaM2"] for x in above for w in x["walls"] if w["exterior"])71 d["intGross"] = sum(w["grossAreaM2"] for x in above for w in x["walls"] if not w["exterior"]); d["roomsArea"] = sum(r["areaM2"] for x in above for r in x["rooms"])72 d["windowsAll"] = sum(x["windows"] for x in above); d["doorsAll"] = sum(x["doors"] for x in above)73 per = next((m for m in meas if m["name"].startswith("Semelles")), None); d["perimeter"] = per["value"] if per else sum(w["lengthM"] for w in ground["walls"] if w["exterior"])74 pitch = d["project"]["spec"].get("roofPitch", 4) or 0; d["slope"] = (1 + (pitch / 12) ** 2) ** 0.575 return d7677def kpi(color, value, label): return f"\\qskpi[{color}]{{{value}}}{{{label}}}"7879def table(rows, cols, align, size="\\scriptsize", stretch="0.95"):80 out = [f"\\begin{{center}}{size}\\renewcommand{{\\arraystretch}}{{{stretch}}}\\setlength{{\\tabcolsep}}{{3.5pt}}%", f"\\begin{{tabular}}{{@{{}}{align}@{{}}}}", "\\toprule", " & ".join(f"\\textbf{{{c}}}" for c in cols) + " \\\\", "\\midrule"]81 out += [" & ".join(r) + " \\\\" for r in rows]82 out += ["\\bottomrule", "\\end{tabular}\\end{center}"]83 return "\n".join(out)8485STEPS = ["Plan", "Fiche", "Relevé", "Prix", "Majorations", "Révision"]86def steps(active):87 """indicateur du fil conducteur (6 étapes), placé en haut à droite du contenu"""88 cells = "".join(f"\\qsstep{{{1 if i + 1 in active else 0}}}{{{i + 1}}}{{{t}}}" for i, t in enumerate(STEPS))89 return f"\\hfill{{\\tiny{cells}}}\\par\\vspace{{-0.5mm}}"9091def kv_fr(inputs):92 """« perimetre_m=42.6, hauteur_m=2.44 » → « périmètre 42,6 m ; hauteur 2,44 m »"""93 out = []94 for part in [x.strip() for x in inputs.split(",") if "=" in x]:95 k, v = part.split("=", 1); k = k.strip(); v = v.strip()96 unit = ""97 if k.endswith("_m2"): unit = " m\\textsuperscript{2}"; k = k[:-3]98 elif k.endswith("_m3"): unit = " m\\textsuperscript{3}"; k = k[:-3]99 elif k.endswith("_m"): unit = " m"; k = k[:-2]100 k = k.replace("_", " ").replace("perimetre", "périmètre").replace("aire", "aire").replace("profondeur", "profondeur").replace("empreinte", "empreinte").replace("facteur pente", "facteur de pente").replace("hauteur", "hauteur")101 try: v = num(float(v), 2 if abs(float(v)) < 100 else 0)102 except ValueError: v = tex(v)103 out.append(f"{tex(k)} {v}{unit}")104 return " ; ".join(out)105106def frame_fiche(d):107 p = d["project"]; s = p["spec"]; t = d["version"]["result"]["totals"]; short = short_name(p)108 levels = ", ".join(x["level"] or x["label"] for x in d["pages"]); units = s.get("units", 1)109 bs = fr(s.get("basement")) + (f" ({round(s.get('basementFinishedPct', 0)*100)}\\,\\% fini)" if s.get("basement") == "finished" else "")110 out = [f"\\begin{{frame}}{{{tex(short)} --- fiche du cas}}", steps([1, 2]), "\\begin{center}"]111 out.append(kpi("uqoBleu", num(d["footprint"]) + "~m\\textsuperscript{2}", "empreinte") + kpi("uqoTeal", num(d["heated"]) + "~m\\textsuperscript{2}", "aire chauffée") + kpi("uqoOr", f"{len(d['pages'])} / {units}", "niveaux / logements") + kpi("uqoNavy", money(t["total"]), "total hors taxes"))112 out.append("\\end{center}\\vspace{0.5mm}\\footnotesize")113 out.append("\\begin{columns}[T]\\begin{column}{0.48\\textwidth}\\uqotitle{Étape 1 --- le plan donne}\\begin{itemize}\\setlength{\\itemsep}{1pt}")114 out.append(f" \\item Niveaux : {tex(levels)} (50~px/m, calibration exacte)")115 out.append(f" \\item {sum(len(x['rooms']) for x in d['pages'])} pièces nommées, {sum(x['windows'] for x in d['pages'])} fenêtres, {sum(x['doors'] for x in d['pages'])} portes")116 out.append(f" \\item Ville RSMeans : \\textbf{{{tex(p['municipality'])}}} (MO syndiquée)")117 out.append(f" \\item Secteur : {tex(cut(p['address'], 40))}")118 out.append("\\end{itemize}\\end{column}\\begin{column}{0.5\\textwidth}\\uqotitle{Étape 2 --- la fiche technique fixe}\\begin{itemize}\\setlength{\\itemsep}{1pt}")119 out.append(f" \\item Fondation {fr(s.get('foundation'))}, mur {num(s.get('foundationWallHeightM'), 2)}~m ; sous-sol {bs}")120 out.append(f" \\item Revêtements : {mix(s.get('siding'))} ; toit {fr(s.get('roofType'))} {s.get('roofPitch')}/12")121 out.append(f" \\item Fenêtres {fr(s.get('windows'))} ; chauffage {fr(s.get('heating'))}" + (" + clim." if s.get("hasAirConditioning") else "") + (" + VRC" if s.get("hasAirExchanger") else ""))122 out.append(f" \\item Cuisine et bains {fr(s.get('kitchenQuality'))} ; planchers : {mix(s.get('flooring'))}")123 out.append(f" \\item Garage : {fr(s.get('garage'))}" + (f" ; terrasse {num(s.get('deckM2'))}~m\\textsuperscript{{2}}" if s.get("deckM2") else "") + (f" ; entrée {fr(s.get('driveway'))}" if s.get("drivewayM2") else ""))124 out.append("\\end{itemize}\\end{column}\\end{columns}\\end{frame}\n")125 return "\n".join(out)126127def frame_plans(d):128 """un plan plein cadre par niveau, puis le rez-de-chaussée annoté et sa table d'objets"""129 short = short_name(d["project"]); out = []130 for x in d["pages"]:131 lvl = x["level"] or x["label"]132 ext = [w for w in x["walls"] if w["exterior"]]; ints = [w for w in x["walls"] if not w["exterior"]]133 out.append(f"\\begin{{frame}}{{{tex(short)} --- plan : {tex(lvl)}}}")134 out.append(steps([1]))135 out.append(f"\\begin{{center}}\\includegraphics[height=0.74\\textheight,width=0.96\\textwidth,keepaspectratio]{{{d['dir']}/{x['png']}}}\\end{{center}}")136 out.append(f"\\vspace{{-1mm}}{{\\scriptsize\\textcolor{{uqoGris}}{{Échelle {x['pxPerMeter']}~px/m, calibration exacte $\\cdot$ {len(x['rooms'])} pièce(s), {x['windows']} fenêtre(s), {x['doors']} porte(s) $\\cdot$ murs extérieurs {num(sum(w['lengthM'] for w in ext),1)}~m, cloisons {num(sum(w['lengthM'] for w in ints),1)}~m $\\cdot$ aire des pièces {num(x['totals']['floorAreaM2'])}~m\\textsuperscript{{2}}" + (f" $\\cdot$ empreinte {num(x['footprintM2'])}~m\\textsuperscript{{2}}" if x.get('footprintM2') else "") + "}}\\end{frame}\n")137 g = d["ground"]138 # plan annoté : six épingles → ce que chaque objet devient139 ext = [w for w in g["walls"] if w["exterior"]]; ints = [w for w in g["walls"] if not w["exterior"]]140 legend = {"ext": f"\\textbf{{Murs extérieurs}} : {num(sum(w['lengthM'] for w in ext),1)}~m, brut {num(sum(w['grossAreaM2'] for w in ext))}~m\\textsuperscript{{2}}, net {num(sum(w['netAreaM2'] for w in ext))}~m\\textsuperscript{{2}} $\\rightarrow$ ossature, isolation, revêtements",141 "int": f"\\textbf{{Cloisons}} : {num(sum(w['lengthM'] for w in ints),1)}~m $\\rightarrow$ ossature 2$\\times$4, gypse deux faces",142 "win": f"\\textbf{{Fenêtres}} : {g['windows']} comptées $\\rightarrow$ unités, déduites des murs nets",143 "door": f"\\textbf{{Portes}} : {g['doors']} (ext., int., patio, garage) $\\rightarrow$ unités",144 "room": f"\\textbf{{Pièces}} : {len(g['rooms'])}, aire {num(g['totals']['floorAreaM2'])}~m\\textsuperscript{{2}} $\\rightarrow$ planchers par usage, plafonds, plinthes",145 "fp": f"\\textbf{{Empreinte}} {num(g['footprintM2'] or d['footprint'])}~m\\textsuperscript{{2}}, périmètre {num(d['perimeter'],1)}~m $\\rightarrow$ excavation, semelles, fondation, dalle, toiture"}146 out.append(f"\\begin{{frame}}{{{tex(short)} --- lire le plan : ce que chaque objet devient}}")147 out.append(steps([1, 3]))148 out.append("\\begin{columns}[T]\\begin{column}{0.5\\textwidth}\\begin{center}\\begin{tikzpicture}")149 out.append(f"\\node[anchor=south west, inner sep=0pt] (img) at (0,0) {{\\includegraphics[width=\\textwidth,height=0.62\\textheight,keepaspectratio]{{{d['dir']}/{g['png']}}}}};")150 order = ["ext", "int", "win", "door", "room", "fp"]; pins = {pn["key"]: pn for pn in g["pins"]}151 k = 0152 for key in order:153 if key in pins and key in legend: k += 1; out.append(f"\\qspin{{{pins[key]['at']['x']}}}{{{pins[key]['at']['y']}}}{{{k}}}")154 out.append("\\end{tikzpicture}\\end{center}\\end{column}\\begin{column}{0.48\\textwidth}\\scriptsize\\begin{enumerate}\\setlength{\\itemsep}{1pt}")155 for key in order:156 if key in pins and key in legend: out.append(f" \\item {legend[key]}")157 out.append("\\end{enumerate}\\end{column}\\end{columns}\\end{frame}\n")158 # table des objets du rez-de-chaussée159 out.append(f"\\begin{{frame}}{{{tex(short)} --- les objets mesurés du plan ({tex(g['level'] or g['label'])})}}")160 out.append(steps([1, 3]))161 wrows = [[cut(tex(w["name"]), 19), "ext." if w["exterior"] else "cloison", num(w["lengthM"], 1), num(w["grossAreaM2"], 1), num(w["openingsAreaM2"], 1), num(w["netAreaM2"], 1)] for w in sorted(g["walls"], key=lambda w: -w["lengthM"])[:9]]162 rrows = [[cut(tex(r["name"]), 18), num(r["areaM2"], 1), num(r["perimeterM"], 1)] for r in sorted(g["rooms"], key=lambda r: -r["areaM2"])[:9]]163 out.append("\\begin{columns}[T]\\begin{column}{0.55\\textwidth}")164 out.append(table(wrows, ["Mur (h " + num(g["walls"][0]["heightM"], 2) + "~m)", "Type", "L (m)", "Brut", "Ouv.", "Net"], "l l r r r r", "\\scriptsize", "0.92"))165 out.append("\\end{column}\\begin{column}{0.43\\textwidth}")166 out.append(table(rrows, ["Pièce (usage sur le plan)", "Aire m\\textsuperscript{2}", "Périm. m"], "l r r", "\\scriptsize", "0.92"))167 out.append("\\end{column}\\end{columns}")168 opn = {}169 for o in g["openings"]: key = ("fenêtre" if o["kind"] == "window" else "porte ext." if o["exterior"] and o["kind"] == "door" else "porte-patio" if o["kind"] == "patio" else "porte garage" if o["kind"] == "garage" else "porte int."); opn[key] = opn.get(key, 0) + 1170 out.append("{\\scriptsize\\textcolor{uqoGris}{Ouvertures de ce niveau : " + ", ".join(f"{v} {tex(k)}" for k, v in opn.items()) + f" ; aire des ouvertures déduite une seule fois des murs (net = brut -- ouvertures). {len(g['walls'])} murs, {len(g['rooms'])} pièces au total" + (" (9 plus grands affichés)" if len(g['walls']) > 9 or len(g['rooms']) > 9 else "") + ".}}\\end{frame}\n")171 return "\n".join(out)172173def frame_demarche_a(d):174 """du plan aux mesures de base : les grandeurs d'entrée des formules"""175 short = short_name(d["project"]); g = d["ground"]; s = d["project"]["spec"]176 ext = [w for w in g["walls"] if w["exterior"]]177 rows = [["Empreinte $F$", f"{num(d['footprint'])}~m\\textsuperscript{{2}}", "polygone « empreinte » du rez-de-chaussée (ou boucle des murs extérieurs)"],178 ["Périmètre $P$", f"{num(d['perimeter'],1)}~m", "longueur de la boucle des murs extérieurs du rez-de-chaussée"],179 ["Hauteur d'étage $h$", f"{num(s.get('ceilingHeightM', 2.44),2)}~m", "fiche technique (hauteur de plafond)"],180 ["Hauteur du mur de fondation", f"{num(s.get('foundationWallHeightM'),2)}~m", "fiche technique"],181 ["Murs extérieurs bruts (tous niveaux)", f"{num(d['extGross'])}~m\\textsuperscript{{2}}", "$\\Sigma$ longueur $\\times$ hauteur, + pignons"],182 ["Murs extérieurs nets", f"{num(d['extNet'])}~m\\textsuperscript{{2}}", "brut -- aire des ouvertures (déduite une seule fois)"],183 ["Cloisons (tous niveaux)", f"{num(d['intGross'])}~m\\textsuperscript{{2}}", "$\\Sigma$ longueur $\\times$ hauteur"],184 ["Aire des pièces (hors sol)", f"{num(d['roomsArea'])}~m\\textsuperscript{{2}}", "$\\Sigma$ polygones des pièces, par usage"],185 ["Fenêtres / portes", f"{d['windowsAll']} / {d['doorsAll']}", "comptées sur tous les niveaux, avec leur mur"],186 ["Facteur de pente du toit", f"{num(d['slope'],3)}", f"$\\sqrt{{1 + ({s.get('roofPitch')}/12)^2}}$ (fiche : pente {s.get('roofPitch')}/12)"]]187 out = [f"\\begin{{frame}}{{{tex(short)} --- démarche A : du plan aux grandeurs de base}}", steps([1, 2, 3])]188 out.append(table(rows, ["Grandeur", "Valeur", "D'où elle vient"], "l r l", "\\scriptsize", "0.95"))189 out.append("{\\tiny\\textcolor{uqoGris}{Ces dix grandeurs alimentent presque toutes les formules du relevé ; elles sont recalculées à chaque modification du plan ou de la fiche.}}\\end{frame}\n")190 return "\n".join(out)191192def frames_demarche_b(d):193 """des grandeurs aux quantités : formule, entrées, résultat (données du registre)"""194 short = short_name(d["project"]); out = []195 sel1 = [m for m in d["qs"] if m["wbs"] in ("31", "03", "05", "06") and m["inputs"]][:8]196 sel2 = [m for m in d["qs"] if m["wbs"] in ("07", "08", "09", "22", "23", "26") and m["inputs"]][:8]197 for title, sel in (("site, fondations, structure", sel1), ("enveloppe, finitions, mécanique", sel2)):198 if not sel: continue199 out.append(f"\\begin{{frame}}{{{tex(short)} --- démarche B : des grandeurs aux quantités ({tex(title)})}}")200 out.append(steps([3]))201 rows = [[cut(tex(m["name"]), 26), cut(tex(m["formula"].replace("Σ ", "somme des ")), 80), cut(kv_fr(m["inputs"]), 80), f"\\textbf{{{num(m['value'], 2 if m['value'] < 100 else 0)}}} {unit(m['unit'])}"] for m in sel[:7]]202 out.append(table(rows, ["Famille", "Formule", "Entrées (du plan ou de la fiche)", "Résultat"], "p{2.7cm} p{4.5cm} p{4.5cm} r", "\\scriptsize", "0.92"))203 out.append("{\\tiny\\textcolor{uqoGris}{Formule, entrées et résultat sont ceux du registre de mesures d'UQO Éval ; une mesure modifiée à la main rend la ligne « périmée » jusqu'au recalcul.}}\\end{frame}\n")204 return "\n".join(out)205206def frame_demarche_c(d):207 """de la quantité au coût : quatre lignes travaillées"""208 short = short_name(d["project"])209 pick = []210 for divs in (("03",), ("06", "05"), ("07",), ("08",), ("09",), ("22", "23", "26")):211 c = [l for l in d["lines"] if l["basis"] == "rsmeans" and l["wbs"] in divs and l.get("takeoffQuantity")]212 if c: pick.append(max(c, key=lambda l: l["located"]))213 pick = sorted(pick, key=lambda l: -l["located"])[:5]214 out = [f"\\begin{{frame}}{{{tex(short)} --- démarche C : de la quantité au coût (cinq lignes travaillées)}}", steps([3, 4])]215 rows = []216 for l in pick:217 m = re.match(r"^(.*?) — (.*?) — RSMeans (\d{12})$", l["description"]); fam, lab, ln = (m.group(1), m.group(2), m.group(3)) if m else (l["description"], "", "")218 conv = l["quantity"] / l["takeoffQuantity"] if l["takeoffQuantity"] else 1219 tq = next((mm for mm in d["qs"] if mm["name"] == fam), None); tu = unit(tq["unit"]) if tq else ""220 q_txt = f"{num(l['takeoffQuantity'], 2 if l['takeoffQuantity'] < 100 else 0)} {tu}" + (f" $\\times$ {num(conv, 3)} = {num(l['quantity'], 1 if l['quantity'] < 100 else 0)} {unit(l['unit'])}" if abs(conv - 1) > 1e-6 else "")221 price = f"{money(l['unitMaterial'],2)} + {money(l['unitLabour'],2)}" + (f" + {money(l['unitEquipment'],2)}" if l["unitEquipment"] else "") + f" = {money(l['unitDirect'],2)}"222 rows.append([cut(tex(fam), 26), f"\\texttt{{{ln}}} {cut(tex(lab), 44)}", q_txt, price, f"\\textbf{{{money(l['located'])}}}"])223 out.append(table(rows, ["Famille", "Ligne RSMeans", "Quantité (relevé $\\rightarrow$ RSMeans)", "Bare : mat. + MO (+ éq.) / unité", "Montant"], "p{2.2cm} p{3.6cm} p{3.2cm} p{3.0cm} r", "\\scriptsize", "0.92"))224 loc = d["version"]["result"]["lines"][0]["provenance"]["source"]225 out.append(f"{{\\tiny\\textcolor{{uqoGris}}{{Montant = quantité convertie $\\times$ coût unitaire bare ({tex(loc)}). Les conversions (m\\textsuperscript{{2}} $\\rightarrow$ pi\\textsuperscript{{2}} $\\times$ 10,764 ; m\\textsuperscript{{3}} $\\rightarrow$ vg\\textsuperscript{{3}} $\\times$ 1,308 ; m $\\rightarrow$ pi $\\times$ 3,281) et les facteurs de quantité documentés (ex. m\\textsuperscript{{2}} de mur $\\rightarrow$ m d'ossature $\\div$ 2,44) sont faits par le moteur.}}}}\\end{{frame}}\n")226 return "\n".join(out)227228def frames_releve(d):229 short = short_name(d["project"]); out = []230 groups = [("site, fondations, structure", lambda w: w in ("31", "33", "03", "05", "06")), ("enveloppe, toiture, ouvertures", lambda w: w in ("07", "08")), ("finitions, mécanique, extérieur", lambda w: w in ("09", "11", "12", "22", "23", "26", "32"))]231 for title, pred in groups:232 rows = [m for m in d["qs"] if pred(m["wbs"])]233 if not rows: continue234 nch = -(-len(rows) // 11); size = -(-len(rows) // nch); chunks = [rows[i:i + size] for i in range(0, len(rows), size)]235 for ci, ch in enumerate(chunks):236 suffix = f" ({ci+1}/{len(chunks)})" if len(chunks) > 1 else ""237 out.append(f"\\begin{{frame}}{{{tex(short)} --- relevé complet : {tex(title)}{suffix}}}")238 out.append(steps([3]))239 trows = [[m["wbs"], cut(tex(m["name"]), 46), num(m["value"], 2 if m["value"] < 100 else 0), unit(m["unit"]), cut(tex(m["formula"].replace("Σ ", "somme des ")), 58)] for m in ch]240 out.append(table(trows, ["Div.", "Mesure du registre (QS)", "Quantité", "Unité", "Formule (plan ou fiche)"], "l l r l l", "\\scriptsize", "0.95"))241 if ci == len(chunks) - 1:242 out.append("{\\tiny\\textcolor{uqoGris}{Chaque mesure garde sa formule, ses entrées et ses pages sources ; « fiche » = fiche technique, sinon objets du plan.}}")243 out.append("\\end{frame}\n")244 return "\n".join(out)245246def frame_prix(d):247 short = short_name(d["project"])248 lines = sorted([l for l in d["lines"] if l["basis"] == "rsmeans"], key=lambda l: -l["located"])[:11]249 rows = []250 for l in lines:251 m = re.match(r"^(.*?) — (.*?) — RSMeans (\d{12})$", l["description"])252 fam, ln = (m.group(1), m.group(3)) if m else (l["description"], "")253 rows.append([cut(tex(fam), 28), f"\\texttt{{{ln}}}", num(l["quantity"], 1 if l["quantity"] < 100 else 0), unit(l["unit"]), money(l["unitMaterial"], 2), money(l["unitLabour"], 2), money(l["unitEquipment"], 2) if l["unitEquipment"] else "---", money(l["located"])])254 out = [f"\\begin{{frame}}{{{tex(short)} --- prix unitaires RSMeans (11 lignes les plus lourdes)}}", steps([4])]255 out.append(table(rows, ["Famille", "Ligne RSMeans", "Qté", "Unité", "Matériau", "M.-d'\\oe uvre", "Équip.", "Montant"], "l l r l r r r r", "\\scriptsize", "0.95"))256 loc = d["version"]["result"]["lines"][0]["provenance"]["source"]257 out.append(f"{{\\tiny\\textcolor{{uqoGris}}{{Coûts unitaires bare (matériau + MO + équipement, sans O\\&P), {tex(loc)} ; quantités converties par le moteur (m\\textsuperscript{{2}} $\\rightarrow$ pi\\textsuperscript{{2}}, m\\textsuperscript{{3}} $\\rightarrow$ vg\\textsuperscript{{3}}).}}}}\\end{{frame}}\n")258 return "\n".join(out)259260def frame_divisions(d):261 short = short_name(d["project"]); r = d["version"]["result"]262 by = sorted(r["byWbs"], key=lambda w: w["wbs"])263 rows = [[f"{w['wbs']} --- {DIV.get(w['wbs'], '')}", money(w["located"]), f"{num(w['sharePct'],1)}\\,\\%"] for w in by]264 rows.append(["\\textbf{Total direct}", f"\\textbf{{{money(r['totals']['located'])}}}", "\\textbf{100\\,\\%}"])265 top = sorted(by, key=lambda w: -w["located"])[:3]266 mat, lab, eq = r["totals"]["material"], r["totals"]["labour"], r["totals"]["equipment"]; tot = mat + lab + eq267 out = [f"\\begin{{frame}}{{{tex(short)} --- coûts directs par division}}", steps([4]), "\\begin{columns}[T]\\begin{column}{0.55\\textwidth}"]268 out.append(table(rows, ["Division MasterFormat", "Direct", "Part"], "l r r", "\\scriptsize", "0.9"))269 out.append("\\end{column}\\begin{column}{0.43\\textwidth}\\footnotesize")270 out.append("\\begin{notebox}[Les trois divisions lourdes]" + " ; ".join(f"\\textbf{{{w['wbs']}}} {DIV.get(w['wbs'],'')} ({num(w['sharePct'],1)}\\,\\%)" for w in top) + ".\\end{notebox}")271 out.append(f"\\begin{{exbox}}[Nature des coûts]Matériaux {num(100*mat/tot,0)}\\,\\%, main-d'\\oe uvre {num(100*lab/tot,0)}\\,\\%, équipement {num(100*eq/tot,0)}\\,\\% du direct.\\end{{exbox}}")272 out.append("\\end{column}\\end{columns}\\end{frame}\n")273 return "\n".join(out)274275def frame_total(d):276 short = short_name(d["project"]); r = d["version"]["result"]; t = r["totals"]; pa = r["params"]277 rows = [["Direct (quantités $\\times$ coûts unitaires bare)", money(t["direct"])], ["Ajustement régional du module (RSMeans déjà localisé)", money(t["locationAdjustment"])], ["= Direct localisé", f"\\textbf{{{money(t['located'])}}}"], [f"Conditions générales {pa['generalConditionsPct']}\\,\\% du direct", money(t["generalConditions"])], [f"Frais généraux {pa['overheadPct']}\\,\\% du cumul", money(t["overhead"])], [f"Profit {pa['profitPct']}\\,\\% du cumul", money(t["profit"])], [f"Contingence {pa['contingencyPct']}\\,\\% du direct", money(t["contingency"])], ["\\textbf{Total hors taxes (coût à neuf 2026 T4)}", f"\\textbf{{{money(t['total'])}}}"]]278 heated = d["heated"] or 1279 out = [f"\\begin{{frame}}{{{tex(short)} --- étape 5 : du coût direct au coût à neuf}}", steps([5, 6]), "\\begin{columns}[T]\\begin{column}{0.58\\textwidth}"]280 out.append(table(rows, ["Étape (ordre d'application)", "Montant"], "l r", "\\footnotesize", "1.0"))281 out.append("\\end{column}\\begin{column}{0.4\\textwidth}\\footnotesize\\begin{center}")282 out.append(kpi("uqoBleu", money(t["total"] / heated), "par m\\textsuperscript{2} chauffé") + kpi("uqoTeal", money(t["total"] / heated / 10.7639), "par pi\\textsuperscript{2} chauffé"))283 out.append("\\end{center}")284 out.append(f"\\begin{{importbox}}[Multiplicateur]Direct $\\rightarrow$ total : $\\times$~{num(t['total']/t['direct'],3)}. Le Total O\\&P de RSMeans n'est pas additionné (il contient déjà l'O\\&P de l'installateur). Révision figée : {tex(d['version']['label'])}, empreinte SHA-256 {tex(d['version']['contentHash'][:12])}….\\end{{importbox}}")285 out.append("\\end{column}\\end{columns}\\end{frame}\n")286 return "\n".join(out)287288LECTURE = {289 7: ("Le garage : le cas d'école", ["Sans sous-sol, la fondation se réduit à des semelles peu profondes, une dalle isolée et des murs courts --- mais excavation, remblai et drain restent", "Porte de garage, ossature et toiture pèsent plus que tout le reste : sur un petit bâtiment, les \\textbf{éléments unitaires} dominent", "Le \\$/m\\textsuperscript{2} est élevé pour un bâtiment « simple » : branchements et entrée électrique ne se diluent pas"], "Pourquoi le \\$/m\\textsuperscript{2} d'un garage dépasse-t-il celui d'un bungalow ? Que se passe-t-il si l'on double la surface ?"),290 1: ("Le bungalow : la référence du cours", ["Fondations (03) et enveloppe (07) sont les postes lourds : sous-sol complet obligatoire au Québec (profondeur de gel)", "Les ouvertures se comptent sur le plan ; les finitions suivent les \\textbf{usages des pièces} (céramique dans les pièces d'eau, bois franc ailleurs)", "Gatineau est une ville extraite en entier : chaque prix est celui de RSMeans pour C57, sans facteur intermédiaire"], "Quelles lignes changeraient si le sous-sol était fini ? (Cas 8 : même bungalow, sous-sol fini, à Saguenay.)"),291 2: ("Le cottage avec garage : trois niveaux de plan", ["Murs extérieurs et toiture ne doublent pas avec la surface : l'étage partage fondation et toit --- d'où un \\$/m\\textsuperscript{2} inférieur au bungalow", "Brique (60\\,\\%) et sous-sol fini déplacent le poids vers l'enveloppe et les finitions", "Le garage attenant ajoute dalle, porte et murs, mais partage un mur avec la maison"], "Comparez le \\$/m\\textsuperscript{2} chauffé du cottage à celui du bungalow : lequel est le plus économique à construire, et pourquoi ?"),292 3: ("Le triplex : trois logements superposés", ["Trois cuisines, trois salles de bain : les \\textbf{postes par logement} (11, 22, 26) grimpent", "La structure est mutualisée (un toit, une fondation) : le coût par logement reste inférieur à trois bungalows", "Québec (C12) : prix extraits en entier ; à comparer avec Montréal (Cas 2) et Laval (Cas 4)"], "Calculez le coût à neuf \\textbf{par logement} et comparez-le au Cas 1 : que mutualise le plex ?"),293 6: ("Le chalet sur pieux : la fiche change la méthode", ["Sans sous-sol ni dalle, « fondations » devient pieux + vide sanitaire : le relevé le sait grâce à la \\textbf{fiche technique}", "Rimouski (C79) : prix RSMeans extraits (livres commercial et résidentiel), facteurs de section ailleurs", "Plinthes électriques et une seule salle de bain : mécanique légère, enveloppe dominante"], "Quelles lignes du relevé disparaissent par rapport au bungalow, et lesquelles apparaissent ?"),294}295296def frame_lecture(d):297 short = short_name(d["project"]); title, pts, q = LECTURE[d["n"]]298 out = [f"\\begin{{frame}}{{{tex(short)} --- lecture de l'évaluateur}}", steps([6]), f"\\uqotitle{{{title}}}", "\\begin{itemize}"] + [f" \\item {x}" for x in pts]299 out.append(f"\\end{{itemize}}\\vspace{{1mm}}\\begin{{quizbox}}[Question à la classe]{q}\\end{{quizbox}}\\end{{frame}}\n")300 return "\n".join(out)301302def intro(loc):303 out = []304 out.append("""\\begin{frame}{Pourquoi des cas complets?}305\\begin{columns}[T]\\begin{column}{0.54\\textwidth}\\footnotesize306\\uqotitle{Jusqu'ici :} des coûts par composante et des \\$/pi\\textsuperscript{2} de guides.\\\\[1mm]307\\uqotitle{Maintenant :} la \\textbf{méthode du relevé des quantités} (\\emph{quantity survey}), de bout en bout, sur de vrais plans :308\\begin{enumerate}\\setlength{\\itemsep}{0pt}309 \\item lire un plan d'architecte calibré ;310 \\item fixer la fiche technique ;311 \\item relever \\textbf{toutes} les quantités, avec leurs formules ;312 \\item attacher un coût unitaire RSMeans localisé ;313 \\item appliquer les majorations dans l'ordre ;314 \\item figer une révision et la lire en évaluateur.315\\end{enumerate}316\\end{column}\\begin{column}{0.44\\textwidth}\\footnotesize317\\begin{importbox}[Cinq cas, cinq villes]Garage (Trois-Rivières), bungalow (Gatineau), cottage + garage (Montréal), triplex (Québec), chalet sur pieux (Rimouski) ; trois autres (Laval, Sherbrooke, Saguenay) dans UQO Éval.\\end{importbox}318\\begin{notebox}[Source des chiffres]Révisions figées du module \\textbf{Quantity Survey} d'UQO Éval, prix \\textbf{RSMeans 2026 T4} localisés par ville.\\end{notebox}319\\end{column}\\end{columns}320\\end{frame}321""")322 out.append("""\\begin{frame}{La méthode en six étapes}323\\begin{center}\\footnotesize324\\begin{tikzpicture}[node distance=0.2cm]325\\node[uqostep, text width=11.5cm] (s1) {\\textbf{1. Plan} --- murs, cloisons, pièces et usages, portes, fenêtres, empreinte, lot, à l'échelle};326\\node[uqostep, text width=11.5cm, below=of s1] (s2) {\\textbf{2. Fiche technique} --- fondation, sous-sol, isolation, revêtements, toiture, chauffage, qualité};327\\node[uqostep, text width=11.5cm, below=of s2] (s3) {\\textbf{3. Relevé} --- 40 à 75 mesures, chacune avec sa formule et ses entrées (périmètre, aires, hauteurs, comptages)};328\\node[uqostep, text width=11.5cm, below=of s3] (s4) {\\textbf{4. Prix unitaires} --- une ou plusieurs lignes RSMeans par famille, coûts \\emph{bare} localisés à la ville};329\\node[uqostep, text width=11.5cm, below=of s4] (s5) {\\textbf{5. Majorations} --- conditions générales, frais généraux, profit, contingence (actualisation, taxes)};330\\node[uqostep, text width=11.5cm, below=of s5, fill=uqoBleu, text=uqoBlanc] (s6) {\\textbf{6. Révision figée} --- instantané immuable (lignes, prix, versions, SHA-256) $\\rightarrow$ rapport};331\\foreach \\a/\\b in {s1/s2,s2/s3,s3/s4,s4/s5,s5/s6} \\draw[uqoarrow] (\\a) -- (\\b);332\\end{tikzpicture}333\\end{center}334\\end{frame}335""")336 out.append("""\\begin{frame}{Le fil conducteur de chaque cas}337\\begin{columns}[T]\\begin{column}{0.56\\textwidth}\\footnotesize338Chaque cas suit le même chemin, repéré par l'indicateur en haut à droite des diapositives :339\\begin{enumerate}\\setlength{\\itemsep}{1pt}340 \\item \\textbf{Fiche du cas} --- ce que le plan donne, ce que la fiche fixe341 \\item \\textbf{Plans} plein cadre par niveau, puis plan annoté (six objets) et table des objets mesurés342 \\item \\textbf{Démarche A} --- du plan aux dix grandeurs de base ($F$, $P$, $h$, murs, pièces, ouvertures, pente)343 \\item \\textbf{Démarche B} --- des grandeurs aux quantités : formule, entrées, résultat344 \\item \\textbf{Relevé complet} --- toutes les mesures du registre, par division345 \\item \\textbf{Démarche C} --- de la quantité au coût : conversion, coût unitaire bare, montant346 \\item \\textbf{Prix unitaires}, \\textbf{divisions}, \\textbf{majorations} $\\rightarrow$ coût à neuf347 \\item \\textbf{Lecture de l'évaluateur} et question à la classe348\\end{enumerate}349\\end{column}\\begin{column}{0.42\\textwidth}\\footnotesize350\\begin{center}{\\tiny\\qsstep{1}{1}{Plan}\\qsstep{1}{2}{Fiche}\\qsstep{0}{3}{Relevé}\\qsstep{0}{4}{Prix}\\qsstep{0}{5}{Majorations}\\qsstep{0}{6}{Révision}}\\end{center}351\\begin{notebox}[Lire l'indicateur]Les étapes en bleu sont celles que la diapositive travaille ; les six étapes sont celles de la méthode.\\end{notebox}352\\begin{exbox}[Pourquoi tant de détail]Au TP2 et à l'examen final, on vous demande exactement cette traçabilité : quantité $\\leftarrow$ formule $\\leftarrow$ plan, prix $\\leftarrow$ source datée, total $\\leftarrow$ majorations ordonnées.\\end{exbox}353\\end{column}\\end{columns}354\\end{frame}355""")356 out.append("""\\begin{frame}{Étape 1 --- lire le plan : ce qui devient une quantité}357\\begin{columns}[T]\\begin{column}{0.46\\textwidth}358\\begin{center}\\includegraphics[width=\\textwidth]{images/qs-cas/cas1/Rez_de_chausse_e.png}\\end{center}359\\end{column}\\begin{column}{0.52\\textwidth}\\footnotesize360\\begin{itemize}\\setlength{\\itemsep}{1pt}361 \\item \\textbf{Murs extérieurs} (0,30~m) : longueur, aire brute, aire nette (ouvertures déduites une seule fois)362 \\item \\textbf{Cloisons} (0,12~m) : longueur $\\times$ hauteur $\\rightarrow$ gypse des deux faces363 \\item \\textbf{Pièces} nommées avec usage : aire et périmètre $\\rightarrow$ planchers, plinthes, plafonds364 \\item \\textbf{Ouvertures} : type (porte, fenêtre, patio, garage), largeur, hauteur, mur porteur365 \\item \\textbf{Empreinte} : excavation, semelles, dalle, toiture ; \\textbf{niveaux} calibrés un à un366 \\item Plans dessinés : échelle exacte (50~px/m) ; plans importés : calibration sur deux points, incertitude notée au registre367\\end{itemize}368\\end{column}\\end{columns}369\\end{frame}370""")371 out.append("""\\begin{frame}{Étape 2 --- la fiche technique : ce que le plan ne dit pas}372\\begin{columns}[T]\\begin{column}{0.5\\textwidth}\\footnotesize373\\uqotitle{Structure et enveloppe}374\\begin{itemize}\\setlength{\\itemsep}{1pt}375 \\item Fondation : béton coulé, blocs, dalle sur sol, pieux, coffrage isolant376 \\item Sous-sol : aucun, vide sanitaire, non fini, fini (\\% fini) ; hauteur des murs377 \\item Revêtements extérieurs en parts (vinyle 70\\,\\% / brique 30\\,\\%)378 \\item Toiture : type, géométrie, pente (facteur $\\sqrt{1+(p/12)^2}$)379 \\item Fenêtres : PVC, bois, hybride, aluminium380\\end{itemize}381\\end{column}\\begin{column}{0.48\\textwidth}\\footnotesize382\\uqotitle{Mécanique et finitions}383\\begin{itemize}\\setlength{\\itemsep}{1pt}384 \\item Chauffage : plinthes, thermopompe, fournaise électrique ou gaz ; climatisation ; VRC385 \\item Qualité : économique, standard, supérieure, prestige (cuisine et bains)386 \\item Planchers en parts (bois franc, céramique, vinyle, stratifié, tapis)387 \\item Extérieur : garage, terrasse, entrée, clôture, aménagement388\\end{itemize}389\\begin{importbox}[Règle]Chaque choix sélectionne des \\textbf{familles} du relevé, donc des lignes RSMeans --- une fiche incomplète donne un relevé incomplet, jamais un relevé inventé.\\end{importbox}390\\end{column}\\end{columns}391\\end{frame}392""")393 rows = [["Excavation", "(empreinte + 0,9~m $\\times$ périmètre) $\\times$ profondeur", "m\\textsuperscript{3}"], ["Semelles filantes", "périmètre (+ ½ périmètre du garage)", "m"], ["Murs de fondation", "périmètre $\\times$ hauteur de mur", "m\\textsuperscript{2}"], ["Murs extérieurs 2$\\times$6", "$\\Sigma$ murs extérieurs bruts + pignons", "m\\textsuperscript{2}"], ["Cloisons 2$\\times$4", "$\\Sigma$ cloisons (longueur $\\times$ hauteur)", "m\\textsuperscript{2}"], ["Fermes + support de toit", "(empreinte du dernier niveau + garage) $\\times$ pente $\\times$ 1,10", "m\\textsuperscript{2}"], ["Revêtement extérieur", "murs extérieurs nets $\\times$ part du revêtement", "m\\textsuperscript{2}"], ["Fenêtres, portes", "comptées sur les plans (type, mur extérieur ou non)", "unité"], ["Gypse murs", "face intérieure des murs ext. + 2 faces des cloisons", "m\\textsuperscript{2}"], ["Planchers finis", "aire des pièces par usage $\\times$ part du revêtement", "m\\textsuperscript{2}"], ["Plinthes électriques", "aire chauffée $\\div$ 14~m\\textsuperscript{2}", "unité"]]394 out.append("\\begin{frame}{Étape 3 --- le relevé : des formules explicites, jamais un forfait caché}")395 out.append(table(rows, ["Famille", "Formule", "Unité"], "l l l", "\\scriptsize", "0.95"))396 out.append("{\\tiny\\textcolor{uqoGris}{Extrait des $\\approx$ 55 familles du relevé automatique d'UQO Éval ; chaque mesure est inscrite au registre avec sa formule, ses entrées et ses pages sources, et peut être vérifiée ou corrigée.}}\n\\end{frame}\n")397 out.append("""\\begin{frame}{Étape 4 --- les prix unitaires : RSMeans 2026 T4 localisé}398\\begin{columns}[T]\\begin{column}{0.5\\textwidth}\\footnotesize399\\uqotitle{La base}400\\begin{itemize}\\setlength{\\itemsep}{1pt}401 \\item 79~044 lignes de coûts unitaires MasterFormat 2018 (Gordian) ; 4 livres : commercial (syndiqué / non syndiqué), rénovation, résidentiel, entretien402 \\item 18 localisations : moyenne nationale + \\textbf{17 villes du Québec}403 \\item Par ligne : unité, équipe, production, heures de MO, coûts \\textbf{bare} (matériau, MO, équipement) et Total O\\&P404\\end{itemize}405\\end{column}\\begin{column}{0.48\\textwidth}\\footnotesize406\\uqotitle{Les règles d'usage}407\\begin{itemize}\\setlength{\\itemsep}{1pt}408 \\item On attache le coût \\textbf{bare} ; les majorations du bordereau s'ajoutent ensuite409 \\item Unités converties : S.F. $\\rightarrow$ pi\\textsuperscript{2}, C.Y. $\\rightarrow$ vg\\textsuperscript{3}, Sq. = 100~pi\\textsuperscript{2}, M.S.F. = 1~000~pi\\textsuperscript{2}410 \\item Une famille = une ou plusieurs lignes (salle de bain = toilette + lavabo + robinet + baignoire + robinetterie)411 \\item Classe d'évidence « barème publié » ; licence Gordian (usage pédagogique, pas de redistribution)412\\end{itemize}413\\end{column}\\end{columns}414\\end{frame}415""")416 keep = ["NA", "MONTREAL", "QUEBEC", "GATINEAU", "TROIS-RIVIERES", "RIMOUSKI", "CHICOUTIMI"]417 names = {"NA": "Nat.", "MONTREAL": "Montréal", "QUEBEC": "Québec", "GATINEAU": "Gatineau", "TROIS-RIVIERES": "T.-Riv.", "RIMOUSKI": "Rimouski", "CHICOUTIMI": "Chicoutimi"}418 rows = []419 for c in loc["cmp"]:420 pr = {x["loc"]: x["p"] for x in c["prices"]}421 rows.append([cut(tex(c["desc"]), 36) + f" ({tex(c['unit'])})"] + [money(pr[k]["total"], 2) if pr.get(k) and pr[k]["total"] is not None else "---" for k in keep])422 out.append("\\begin{frame}{Localisation --- la même ligne RSMeans dans sept villes (coût bare, MO syndiquée)}")423 out.append(table(rows, ["Ligne (unité RSMeans)"] + [names[k] for k in keep], "l r r r r r r r", "\\scriptsize", "0.95"))424 out.append("\\begin{notebox}[Ce qu'on observe]\\footnotesize Les matériaux varient peu (fenêtre, toilette) ; la \\textbf{main-d'\\oe uvre} fait la différence (ossature, gypse). RSMeans localise par \\textbf{section MasterFormat} : les facteurs de section reproduisent les prix extraits à l'arrondi près (p90 $<$ 0,1\\,\\%) ; un facteur par division se trompe de 20\\,\\% une fois sur dix.\\end{notebox}\n\\end{frame}\n")425 out.append("""\\begin{frame}{Étape 5 --- les majorations, dans l'ordre}426\\begin{columns}[T]\\begin{column}{0.52\\textwidth}\\footnotesize427\\begin{center}428\\begin{tikzpicture}[node distance=0.2cm]429\\node[uqostep, text width=6.6cm] (a) {Direct = $\\Sigma$ quantité $\\times$ coût unitaire bare};430\\node[uqostep, text width=6.6cm, below=of a] (b) {+ Conditions générales (\\% du direct)};431\\node[uqostep, text width=6.6cm, below=of b] (c) {+ Frais généraux et profit (\\% du cumul)};432\\node[uqostep, text width=6.6cm, below=of c] (d) {+ Contingence (\\% du direct)};433\\node[uqostep, text width=6.6cm, below=of d] (e) {$\\times$ Actualisation --- + TPS / TVQ non composées};434\\node[uqostep, text width=6.6cm, below=of e, fill=uqoBleu, text=uqoBlanc] (f) {= Coût à neuf};435\\foreach \\x/\\y in {a/b,b/c,c/d,d/e,e/f} \\draw[uqoarrow] (\\x) -- (\\y);436\\end{tikzpicture}437\\end{center}438\\end{column}\\begin{column}{0.46\\textwidth}\\footnotesize439\\begin{importbox}[Deux pièges]\\textbf{Double majoration} : le Total O\\&P de RSMeans contient déjà l'O\\&P de l'installateur --- on part du bare. \\textbf{Double localisation} : un prix RSMeans « Gatineau » ne reçoit pas en plus le facteur régional du module.\\end{importbox}440\\begin{exbox}[Paramètres des cas]CG 8\\,\\%, FG 7\\,\\%, profit 8\\,\\%, contingence 5\\,\\% ; prix 2026 T4 sans actualisation, hors taxes ; multiplicateur direct $\\rightarrow$ total $\\approx$ 1,30.\\end{exbox}441\\end{column}\\end{columns}442\\end{frame}443""")444 out.append("""\\begin{frame}{Étape 6 --- dans UQO Éval : du gabarit au rapport}445\\begin{columns}[T]\\begin{column}{0.5\\textwidth}446\\begin{center}\\begin{uqoshot}[\\textwidth]{images/qs-cas/ui/q3-cas1-estimation.png}{www.uqo-eval.app/quantity-survey}\\end{uqoshot}\\end{center}447\\end{column}\\begin{column}{0.48\\textwidth}\\footnotesize448\\begin{enumerate}\\setlength{\\itemsep}{1pt}449 \\item \\textbf{Nouveau plan} : galerie de 38 gabarits (bungalows, cottages, plex, chalets, garages), plan vierge, carte satellite ou PDF450 \\item Onglet \\textbf{Fiche} : fondation, revêtements, chauffage, qualité451 \\item Onglet \\textbf{Estimation} : « Générer le relevé des quantités » avec \\textbf{prix RSMeans localisés}452 \\item Chaque ligne : quantité liée au registre, ligne RSMeans, coût unitaire, provenance453 \\item \\textbf{Figer une révision} : PDF FR/EN, XLSX, CSV, comparaison de révisions454 \\item Les huit cas sont verrouillés dans « Gabarits et cas d'étude » : dupliquez-les pour les modifier455\\end{enumerate}456\\end{column}\\end{columns}457\\end{frame}458""")459 return "\n".join(out)460461def synthese(all_ds):462 out = []; rows = []463 for d in all_ds:464 p = d["project"]; t = d["version"]["result"]["totals"]; short = short_name(p).replace("Cas ", ""); heated = d["heated"] or 0; u = p["spec"].get("units", 1)465 rows.append([cut(tex(short), 44), num(d["footprint"]), num(heated), str(u), money(t["total"]), money(t["total"] / heated) if heated else "---", money(t["total"] / heated / 10.7639) if heated else "---", money(t["total"] / u)])466 out.append("\\begin{frame}{Synthèse --- les huit cas côte à côte}")467 out.append(table(rows, ["Cas", "Empreinte", "Aire ch.", "Log.", "Total HT", "\\$/m\\textsuperscript{2}", "\\$/pi\\textsuperscript{2}", "\\$/logement"], "l r r r r r r r", "\\scriptsize", "0.95"))468 out.append("{\\tiny\\textcolor{uqoGris}{Aires en m\\textsuperscript{2} ; aire chauffée = pièces habitables + sous-sol fini ; totaux hors taxes avec majorations (CG 8\\,\\%, FG 7\\,\\%, profit 8\\,\\%, contingence 5\\,\\%) ; RSMeans 2026 T4 localisé par ville.}}\n\\end{frame}\n")469 out.append("""\\begin{frame}{Synthèse --- ce que les cas enseignent}470\\begin{columns}[T]\\begin{column}{0.5\\textwidth}\\footnotesize471\\begin{exbox}[Effet d'échelle]Le \\$/m\\textsuperscript{2} \\textbf{baisse} du garage au cottage puis au plex : fondation et toiture sont partagées par plus de surface ; les coûts fixes (branchements, entrée électrique, chauffe-eau) se diluent.\\end{exbox}472\\begin{exbox}[Effet de la fiche]Sous-sol fini, brique, thermopompe, qualité supérieure : chaque choix ajoute des \\emph{familles} de lignes --- comparez les Cas 1 et 8 (même bungalow, sous-sol fini, autre ville).\\end{exbox}473\\end{column}\\begin{column}{0.48\\textwidth}\\footnotesize474\\begin{exbox}[Effet de la ville]Entre villes du Québec, l'écart vient surtout de la main-d'\\oe uvre ; il reste modeste comparé à l'effet de la qualité des finitions.\\end{exbox}475\\begin{importbox}[Un bordereau sérieux montre]Pour chaque ligne : la quantité \\emph{et} sa formule, la source du prix \\emph{et} sa date, les majorations \\emph{et} leur ordre --- exactement ce qu'on attend au TP2.\\end{importbox}476\\end{column}\\end{columns}477\\end{frame}478""")479 d4 = next(x for x in all_ds if x["n"] == 4); s = d4["project"]["spec"]; t = d4["version"]["result"]["totals"]480 head = f"\\begin{{frame}}{{À vous --- Cas 4 : duplex à Laval}}\\footnotesize\n\\begin{{quizbox}}[Mandat]Dans UQO Éval, dupliquez le \\textbf{{Cas 4 --- Duplex, Laval}} ({num(d4['footprint'])}~m\\textsuperscript{{2}} d'empreinte, {len(d4['pages'])} niveaux, 2 logements, {fr(s.get('foundation'))}, revêtement {mix(s.get('siding'))}, chauffage {fr(s.get('heating'))}) et, \\textbf{{sans regarder la révision figée}} :\\end{{quizbox}}\n"481 body = """\\begin{enumerate}\\setlength{\\itemsep}{1pt}482 \\item relevez à la main : périmètre, empreinte, murs extérieurs bruts, nombre de fenêtres, aire des pièces d'eau ; comparez aux mesures « QS » du registre (écart $<$ 2\\,\\%) ;483 \\item régénérez le relevé avec le \\textbf{catalogue UQO Éval} plutôt que RSMeans : expliquez les trois plus gros écarts ;484 \\item changez la ville RSMeans pour Montréal : de combien bouge le total, et par quelles divisions ?485\\end{enumerate}486"""487 tail = f"\\begin{{notebox}}[Réponse attendue]Total RSMeans Laval figé : \\textbf{{{money(t['total'])}}} hors taxes ({money(t['total']/d4['heated'])}/m\\textsuperscript{{2}} chauffé). Retrouvez les quantités, pas le total au dollar près.\\end{{notebox}}\n\\end{{frame}}\n"488 out.append(head + body + tail)489 out.append("""\\begin{frame}{Résumé de l'atelier}490\\begin{enumerate}\\setlength{\\itemsep}{2pt}491 \\item Un relevé des quantités part d'un \\textbf{plan calibré} et d'une \\textbf{fiche technique} ; chaque quantité a une formule et des entrées traçables492 \\item Les prix unitaires viennent d'un \\textbf{barème publié, daté et localisé} (RSMeans 2026 T4 par ville) : coûts bare, puis majorations dans l'ordre493 \\item Divisions lourdes d'une résidence : fondations, enveloppe/toiture, finitions ; les plex déplacent le poids vers cuisine, plomberie, électricité494 \\item Le \\$/m\\textsuperscript{2} dépend de l'échelle, de la fiche et de la ville --- dans cet ordre d'importance495 \\item Une \\textbf{révision figée} rend l'estimation reproductible et discutable ligne par ligne496\\end{enumerate}497\\vspace{1mm}498\\begin{exbox}[Pour le TP2]Le bordereau du quadruplex suit la même logique : quantités formulées, prix sourcés, indirects documentés (séance 8), profit sur la bonne base.\\end{exbox}499\\end{frame}500""")501 return "\n".join(out)502503def main():504 loc = json.load(open(os.path.join(IMG, "localisation.json")))505 all_ds = [load(n) for n in ALL]; ds = {d["n"]: d for d in all_ds}506 parts = ["% Généré par gen_qs_cas.py — ne pas éditer à la main (données : images/qs-cas/*/data.json, révisions figées UQO Éval / RSMeans 2026 T4)",507 "\\providecommand{\\qskpi}[3][uqoBleu]{\\begin{tikzpicture}[baseline=(b.base)]\\node[fill=#1!8, draw=#1!40, rounded corners=2pt, inner xsep=4pt, inner ysep=2.5pt, align=center, text width=26mm] (b) {{\\textcolor{#1}{\\normalsize\\textbf{#2}}}\\\\[-1pt]{\\tiny\\textcolor{uqoGris}{#3}}};\\end{tikzpicture}}",508 "\\providecommand{\\qsstep}[3]{\\ifnum#1=1\\colorlet{qsfill}{uqoBleu}\\colorlet{qstext}{uqoBlanc}\\else\\colorlet{qsfill}{uqoGrisClair}\\colorlet{qstext}{uqoGris}\\fi\\tikz[baseline=-0.5ex]{\\node[rounded corners=1pt, inner xsep=3pt, inner ysep=1.5pt, fill=qsfill, text=qstext, font=\\tiny\\bfseries] {#2 #3};}\\hspace{1pt}}",509 "\\providecommand{\\qspin}[3]{\\path ($(img.south west)!#1!(img.south east)$) coordinate (qx); \\path ($(img.south west)!#2!(img.north west)$) coordinate (qy); \\node[circle, fill=uqoOr, text=uqoNavy, font=\\fontsize{6.5}{7}\\selectfont\\bfseries, inner sep=0.8pt, minimum size=3.4mm, draw=uqoNavy, line width=0.4pt] at (qx |- qy) {#3};}",510 "\\section{Atelier --- Quantity survey : cinq cas complets (plans, quantités, coûts)}", intro(loc)]511 for n in CASES:512 d = ds[n]; short = short_name(d["project"])513 parts.append(f"% ---------------- {short}")514 parts.append(f"\\begin{{frame}}[plain]\\begin{{center}}\\vspace{{1.2cm}}{{\\Huge\\textcolor{{uqoBleu}}{{\\textbf{{{tex(short)}}}}}}}\\\\[4mm]{{\\large\\textcolor{{uqoGris}}{{{tex(d['project']['municipality'])} $\\cdot$ RSMeans 2026 T4 $\\cdot$ {len(d['qs'])} mesures $\\cdot$ {len(d['lines'])} lignes chiffrées}}}}\\end{{center}}\\end{{frame}}\n")515 parts += [frame_fiche(d), frame_plans(d), frame_demarche_a(d), frames_demarche_b(d), frames_releve(d), frame_demarche_c(d), frame_prix(d), frame_divisions(d), frame_total(d), frame_lecture(d)]516 parts.append(synthese(all_ds))517 os.makedirs(os.path.dirname(OUT), exist_ok=True)518 txt = "\n".join(parts); open(OUT, "w").write(txt)519 print("frames :", txt.count("\\begin{frame}"), "→", OUT)520521if __name__ == "__main__": main()522