Commit
metre: galerie de 38 gabarits, zone de dessin agrandie, fiche technique et relevé des quantités
- templates.ts : 38 gabarits paramétriques (bungalows, 2 étages, jumelés, plex 2-9 log., rangée, chalets, mobile, condo, garages) avec niveaux complets, pièces, ouvertures, empreinte et fiche ; aperçu SVG GET /api/metre/templates - Plan Studio : galerie filtrable, canevas calc(100vh-230px), plein écran, panneau repliable - Fiche technique du bâtiment (spec_json, sanitizeSpec) et quantity-survey.ts : ~40 lignes QS mappées sur le catalogue (formule, entrées, origine plan/fiche) ; POST /api/metre/projects/[id]/quantity-survey ; lignes origin qs regénérables ; mesures QS au registre - duplicateProject copie les plans ; tests plan/qs (52) ; e2e recalcule la géométrie du canevas ; docs à jour Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
modified
docs/construction/COST_FORMULAS.md
+28 −0
@@ -80,3 +80,31 @@ Le coût à neuf retenu depuis une révision est le **sous-total avant taxes** ( | ||
| 80 | 80 | ## 7. Détection déterministe d'anomalies (`ai.ts detectAnomalies`) |
| 81 | 81 | |
| 82 | 82 | Avant tout commentaire par l'IA : (a) ligne non chiffrée ; (b) coût unitaire direct dont l'écart robuste à la médiane des autres lignes de même unité dépasse 3 (`z = (x − médiane) / (1,4826 × MAD)`, ≥ 4 lignes comparables) ; (c) hypothèse sans justification notée ; (d) facteur d'ajustement hors [0,5 ; 1,5]. Les règles ne modifient rien ; elles alimentent la liste et la suggestion « anomalies ». |
| 83 | + | |
| 84 | +## 8. Relevé des quantités depuis les plans (`quantity-survey.ts`) | |
| 85 | + | |
| 86 | +Entrées : pour chaque niveau calibré, `planQuantities` (murs extérieurs L_ext et cloisons L_int en m, ouvertures comptées par type et dimensions, pièces avec usage et aire, empreinte A_e et périmètre P en m, hauteur d'étage h) + la fiche `BuildingSpecQS` (fondation, sous-sol, hauteur de mur de fondation h_f, structure, isolation, revêtements et parts, toiture et pente, chauffage, finitions…). Chaque ligne porte `formula`, `inputs`, `origin` (`plan` ou `spec`) et `pageIds`. | |
| 87 | + | |
| 88 | +| Famille | Quantité | Formule | | |
| 89 | +|---|---|---| | |
| 90 | +| Excavation (sous-sol) | m³ | (A_e + bande 1 m) × (h_f + 0,3) | | |
| 91 | +| Semelles | m | P (niveau le plus bas) | | |
| 92 | +| Murs de fondation | m² | P × h_f | | |
| 93 | +| Dalle (sous-sol ou sur sol) | m² | A_e | | |
| 94 | +| Planchers (solives) | m² | Σ aires des niveaux hors sol au-dessus d'un vide ou sous-sol | | |
| 95 | +| Murs extérieurs 2×6 | m² | Σ L_ext × h (brut) | | |
| 96 | +| Cloisons 2×4 | m² | Σ L_int × h | | |
| 97 | +| Fermes de toit + OSB | m² | A_e(toit) × facteur de pente √(1 + (pente/12)²) | | |
| 98 | +| Isolation murs / comble | m² | murs ext. nets ; A_e(toit) | | |
| 99 | +| Revêtements extérieurs | m² | murs ext. nets × part de chaque revêtement (fiche) | | |
| 100 | +| Toiture | m² | aire de toit × facteur de pente (membrane : facteur 1) | | |
| 101 | +| Fenêtres / portes ext. / portes-patio / placards | unité | comptées sur les plans (`openingKind`, `exterior`) | | |
| 102 | +| Gypse murs | m² | 2 × cloisons + murs ext. nets ; plafonds : Σ aires des pièces | | |
| 103 | +| Peinture | m² | gypse murs + plafonds | | |
| 104 | +| Planchers | m² | aire des pièces par usage (céramique : cuisine, salle de bain, entrée ; bois : séjour, chambres ; etc.) | | |
| 105 | +| Plinthes | m | Σ périmètres des pièces − portes | | |
| 106 | +| Cuisine / salles de bain | unité | comptées par usage des pièces | | |
| 107 | +| Mécanique / électricité | unité, m² | entrée 200 A, filage et luminaires sur l'aire habitable ; chauffage et VRC selon la fiche | | |
| 108 | + | |
| 109 | +Les codes d'assemblage absents du catalogue sont ignorés et **signalés** (`warnings`) ; aucun niveau calibré → relevé vide et avertissement explicite. Le relevé alimente le registre (mesures « QS · … », `measure_kind = "count"`/`"area"`/…) et l'estimation (lignes `origin: "qs"`), puis suit l'ordre d'application de la section 3. | |
| 110 | + | |
modified
docs/construction/IMPLEMENTATION_STATUS.md
+9 −2
@@ -9,15 +9,20 @@ | ||
| 9 | 9 | | **A — Audit et baseline** | ✅ terminée | `OPENCONSTRUCTIONERP_AUDIT.md` (commit `93135eeb`), `UQO_EVAL_INTEGRATION_AUDIT.md`, baseline 69 tests / tsc / lint, journal des décisions de licence (`DATA_SOURCES_AND_LICENSES.md`) | |
| 10 | 10 | | **B — Fondations** | ✅ terminée | feature flag + entrée de navigation, schéma `metre.db` v1 (migrations forward-only), authentification par codes + sessions, rôles et isolation par projet, projets/membres/gabarits, préremplissage MAMH, registre des sources, pipeline d'ingestion (download → hash → parse → normalize → classify → validate → dedupe → publish) | |
| 11 | 11 | | **C — Estimation MVP** | ✅ terminée | registre de mesures manuelles, moteur de recettes (imbrication, pertes, productivité, cycles), moteur d'estimation en cents (ordre documenté, localisation, actualisation StatCan, TPS/TVQ), provenance par ligne (catalogue / recette / hypothèse), révisions immuables hachées, scénarios, sensibilité, rapport PDF **FR/EN**, CSV, XLSX | |
| 12 | −| **D — Plan Studio** | ✅ terminée (v2, éditeur d'architecte) | **création sans fichier** : plan vierge à l'échelle (grille 1 m, calibration exacte), gabarits (bungalow, cottage, garage), **plan de site satellite géoréférencé** (Mapbox, échelle exacte Web Mercator, géocodage d'adresse / MAMH), niveaux (duplication d'étage) ; import PDF/PNG/JPG (calibration 2 points) et DXF ; **murs à épaisseur avec jonctions en onglet**, cloisons, **portes à battant et fenêtres insérées dans les murs**, pièces nommées avec surface, cotations automatiques et manuelles, textes, escaliers, colonnes, empreinte, lot ; saisie des longueurs au clavier, ortho 15°, aimant sommets/segments/grille, zoom à la molette, déplacement, glisser-déposer des sommets, annuler/rétablir, calques, raccourcis ; **export PDF/PNG/SVG avec cartouche** ; quantités en direct et « Mesurer tout → registre » | | |
| 12 | +| **D — Plan Studio** | ✅ terminée (v2, éditeur d'architecte) | **création sans fichier** : plan vierge à l'échelle (grille 1 m, calibration exacte), **galerie de 38 gabarits paramétriques** (`templates.ts` : 10 bungalows, 9 maisons à 2 étages/jumelées, 10 plex de 2 à 9 logements, 2 maisons en rangée, 2 chalets, maison mobile, copropriété, 3 garages — niveaux complets sous-sol/RDC/étage, pièces nommées, ouvertures, empreinte, fiche technique préremplie ; aperçu SVG `GET /api/metre/templates?key=`), **zone de dessin agrandie** (hauteur = écran − 230 px, plein écran, panneau repliable), **plan de site satellite géoréférencé** (Mapbox, échelle exacte Web Mercator, géocodage d'adresse / MAMH), niveaux (duplication d'étage) ; import PDF/PNG/JPG (calibration 2 points) et DXF ; **murs à épaisseur avec jonctions en onglet**, cloisons, **portes à battant et fenêtres insérées dans les murs**, pièces nommées avec surface, cotations automatiques et manuelles, textes, escaliers, colonnes, empreinte, lot ; saisie des longueurs au clavier, ortho 15°, aimant sommets/segments/grille, zoom à la molette, déplacement, glisser-déposer des sommets, annuler/rétablir, calques, raccourcis ; **export PDF/PNG/SVG avec cartouche** ; quantités en direct et « Mesurer tout → registre » | | |
| 13 | 13 | | **E — Intelligence des données québécoises** | ✅ terminée (périmètre légalement accessible) | SEAO (OCDS, CC-BY) **ingéré en production** (totaux de contrats octroyés, UNSPSC) ; permis Montréal (CC-BY) **ingéré** : agrégats annuels de valeur déclarée des travaux par arrondissement et type (seule ressource portant une valeur) + résumé du détail (562 748 permis, sans valeur) ; DTNI-11A/11B et MTMD : **import manuel** (sites protégés HTTP 403, pas de jeu ouvert) avec classification MasterFormat assistée et revue ; indices StatCan (actualisation) et grilles APCHQ réutilisés ; classes d'évidence séparées ; facteurs régionaux = ceux de `cost_locations` (hypothèses documentées, **aucun facteur empirique calculé** faute d'observations unitaires comparables par région) ; tableau de bord QA, citations, comparaison historique côte à côte de deux révisions | |
| 14 | 14 | | **F — Évaluation et enseignement** | ✅ terminée (périmètre de base) | cas d'évaluation liés aux révisions (remplacement/reproduction, site, physique âge-vie ou %, fonctionnel curable/incurable avec garde anti-double déduction, externe, terrain), gabarits de projets, devoirs avec corrigé protégé, remises, notation, codes d'accès par cours ; lien vers l'atelier `/cout` pour la dépréciation par composante | |
| 15 | +| **C bis — Méthode du relevé des quantités** | ✅ terminée | **fiche technique du bâtiment** (onglet Fiche : fondation, sous-sol, structure, isolation, revêtements, toiture, ouvertures, finitions, cuisine/salles de bain, mécanique, électricité, extérieur ; `projects.spec_json`, validée par `sanitizeSpec`) + **relevé automatique** `quantity-survey.ts` : depuis les plans calibrés de chaque niveau (murs ext./cloisons, ouvertures, pièces et usages, empreinte, périmètre) et la fiche → ≈ 40 lignes mappées sur les codes d'assemblage du catalogue (excavation, semelles, murs et dalle de fondation, planchers, murs 2×6/2×4, fermes et OSB, isolation murs/comble, revêtements, toiture selon pente, fenêtres/portes/portes-patio/placards, gypse murs/plafonds, peinture, planchers par usage de pièce, plinthes, cuisine, salles de bain, plomberie, chauffe-eau, chauffage selon fiche, VRC, entrée 200 A, filage, luminaires, perron) avec formule, entrées, origine (plan / fiche) et pages sources ; bouton **Relevé des quantités depuis les plans** (onglet Estimation) → mesures « QS · … » au registre + lignes d'estimation `origin: "qs"` regénérables sans doublon ; `POST /api/metre/projects/[id]/quantity-survey` | | |
| 15 | 16 | | **G — Avancé** | 🟡 livrée pour l'essentiel | **Assistance IA révisable** (`src/lib/metre/ai.ts`, Claude via la clé serveur existante) : proposition d'assemblages (codes filtrés sur le catalogue), postes manquants, explication pédagogique, commentaire d'anomalies détectées par règles déterministes, classification MasterFormat des observations importées — chaque suggestion est étiquetée (modèle, version de prompt, empreinte, preuves), enregistrée en attente, acceptée/refusée par un humain, journalisée, plafonnée par budget quotidien ; **jamais de prix**. **Import DXF ASCII** (LINE, LWPOLYLINE, POLYLINE, CIRCLE ; calibration exacte depuis `$INSUNITS`). Non livré : IFC (aucun analyseur validé), reconnaissance d'images, collaboration temps réel, facteurs régionaux empiriques | |
| 16 | 17 | |
| 17 | 18 | ## Définition de « terminé » — vérifiée en production (API et navigateur Chromium via Playwright) |
| 18 | 19 | |
| 19 | 20 | Un utilisateur peut, sans mot de passe : créer un projet → dessiner un plan d'architecte (ou le capturer depuis la carte satellite, ou téléverser et calibrer un plan) → mesurer un élément → choisir un prix d'assemblage vérifié (catalogue/recette) ou explicitement supposé → produire une estimation tracée → la lier à la méthode du coût → exporter un rapport professionnel bilingue — sans perturber le reste d'UQO Éval (pages existantes et 69 tests inchangés). Chaque chiffre a une origine reproductible (révision + instantané + registre), chaque estimation a une version, chaque jeu de données a ses droits documentés. |
| 20 | 21 | |
| 22 | +## Téléchargements universels de la plateforme (2026-10-28) | |
| 23 | + | |
| 24 | +Tout élément généré par UQO Éval se télécharge **avec la mention UQO Éval** (en-tête, feuille de couverture, pied de page, enveloppe JSON) : `src/lib/export-generic.ts` (feuilles → CSV / XLSX / PDF / JSON), `POST|GET /api/export`, composant `DownloadMenu`. Branché sur : laboratoire d'économétrie (résultats du modèle : coefficients, diagnostics, validation croisée, indice temporel, effets géographiques…), estimation « le prix d'un… » (XLSX/CSV/JSON en plus des deux PDF), parc immobilier, méthode du coût (enregistrée ou non), tableau de bord des statistiques, « la valeur de l'immobilier québécois », « la mesure à l'épreuve du marché », pages de municipalité, atelier des comparables à vendre ; le Métré conserve ses exports par révision (PDF FR/EN, CSV, XLSX, JSON, plans PDF/PNG/SVG). Détails : `docs/EXPORTS.md`. | |
| 25 | + | |
| 21 | 26 | ## Limitations connues |
| 22 | 27 | |
| 23 | 28 | - **Prix** : les assemblages du catalogue combinent prix observés (détaillants, APCHQ) et prix de référence internes étiquetés « hypothèse » ; part observée affichée partout. |
@@ -36,7 +41,9 @@ Un utilisateur peut, sans mot de passe : créer un projet → dessiner un plan d | ||
| 36 | 41 | src/lib/metre/{types,money,units,geometry,measure,assemblies,estimate-engine,valuation}.ts modules purs |
| 37 | 42 | src/lib/metre/{db,auth,projects,plans,estimates,report,exports}.ts services |
| 38 | 43 | src/lib/metre/sources/{csv,seao,permits,schedule-import,ingest}.ts sources |
| 39 | −src/lib/metre/{engine,services,examples,dxf,ai,scale}.test.ts tests (45) · e2e/metre.spec.ts (Playwright) | |
| 44 | +src/lib/metre/{engine,services,examples,dxf,ai,scale,plan,qs}.test.ts tests (97) · e2e/{metre,exports}.spec.ts (Playwright, 4) | |
| 45 | +src/lib/metre/templates.ts · quantity-survey.ts 38 gabarits paramétriques · relevé des quantités | |
| 46 | +src/lib/export-generic.ts (+ .test.ts) · src/app/api/export/route.ts · src/components/DownloadMenu.tsx téléchargements universels | |
| 40 | 47 | src/lib/metre/ai.ts · src/lib/metre/sources/dxf.ts · scripts/metre-backup.sh · scripts/metre-seed-examples.ts |
| 41 | 48 | src/app/api/metre/** 37 routes |
| 42 | 49 | src/lib/metre/plan-geometry.ts · plan-render.ts (murs, ouvertures, cotations, rendu SVG partagé client/serveur) |
modified
docs/construction/TAKEOFF_SPECIFICATION.md
+1 −1
@@ -18,7 +18,7 @@ | ||
| 18 | 18 | | Source | Création | Calibration | Usage | |
| 19 | 19 | |---|---|---|---| |
| 20 | 20 | | `blank` | largeur × hauteur (m), échelle 10–200 px/m, niveau | exacte (incertitude 0) | plan d'architecte dessiné de zéro | |
| 21 | −| `template` | bungalow / cottage / garage | exacte | point de départ avec murs, pièces, 6 ouvertures | | |
| 21 | +| `template` | **38 gabarits paramétriques** (`templates.ts` : bungalows, 2 étages, jumelés, plex 2–9 log., rangée, chalets, mobile, condo, garages) — crée tous les niveaux + la fiche technique | exacte | point de départ complet (murs, pièces nommées, ouvertures, empreinte) ; aperçu SVG `GET /api/metre/templates?key=&level=` | | |
| 22 | 22 | | `map` | adresse ou MAMH → vue satellite Mapbox (zoom 14–20), image 2 560 × 1 920 px | exacte depuis la projection Web Mercator (incertitude notée 0,1 %) | lot, empreinte, aménagements extérieurs | |
| 23 | 23 | | copie | « dupliquer en nouveau niveau » | copiée | étage depuis le rez-de-chaussée | |
| 24 | 24 | |
modified
docs/construction/USER_GUIDE_FR.md
+9 −1
@@ -15,7 +15,7 @@ Tableau de bord → **+ Nouveau projet** : nom, adresse, municipalité, date d' | ||
| 15 | 15 | Onglet **Plans** → **+ Nouveau plan** : |
| 16 | 16 | |
| 17 | 17 | - **Plan vierge à l'échelle** : largeur/hauteur en mètres, échelle (px/m), niveau (Site, Sous-sol, Rez-de-chaussée, Étage, Toit). Grille de 1 m, aimant de 10 cm, calibration exacte. |
| 18 | −- **Gabarit** : bungalow, cottage, garage déjà tracés (murs, pièces, portes, fenêtres). | |
| 18 | +- **Partir d'un gabarit** : galerie de **38 gabarits** filtrables (bungalows, maisons à 2 étages et jumelées, plex de 2 à 9 logements, maisons en rangée, chalets, maison mobile, copropriété, garages), aperçu du plan, dimensions, niveaux et nombre de logements. Le gabarit crée **tous les niveaux** (sous-sol, rez-de-chaussée, étage) avec murs, pièces nommées, portes, fenêtres et empreinte, et préremplit la **Fiche** technique. Tout reste modifiable. | |
| 19 | 19 | - **Plan de site (carte satellite)** : cherchez l'adresse (ou l'identifiant MAMH), cadrez la propriété, **Capturer** → image géoréférencée dont l'échelle est calculée exactement ; tracez le **lot**, l'**empreinte**, l'entrée, la terrasse. |
| 20 | 20 | - **Importer** PDF, PNG, JPG (calibrer avec ⊢ sur deux points de distance connue) ou DXF (calibré automatiquement). |
| 21 | 21 | |
@@ -25,6 +25,8 @@ Dessin : clic = sommet ; **chiffres + Entrée** = longueur exacte du segment dan | ||
| 25 | 25 | |
| 26 | 26 | Panneau de droite : propriétés de l'objet (épaisseur, hauteur, extérieur, largeur/hauteur/nombre/battement d'une ouverture, usage et hauteur de plafond d'une pièce, décalage d'une cote), quantités du plan en direct (murs extérieurs nets, longueur de murs, surface des pièces, empreinte, portes/fenêtres), liste des objets. Barre du haut : calques, cotes/étiquettes, **Mesurer tout → registre**, exports **PDF / PNG / SVG** avec cartouche (projet, niveau, échelle, date, flèche du nord, échelle graphique), renommer / dupliquer en nouveau niveau / supprimer la page. |
| 27 | 27 | |
| 28 | +**Zone de dessin** : elle occupe la hauteur de l'écran ; **⛶ Plein écran** (Échap pour sortir) et **◧ Panneau** (replier le panneau de droite) agrandissent encore le plan. | |
| 29 | + | |
| 28 | 30 | ## 4. Registre de métré |
| 29 | 31 | |
| 30 | 32 | Onglet **Métré** : chaque mesure montre sa formule, ses entrées, sa valeur, son unité, la révision de calibration, son état (à jour / périmée) et sa vérification. **Vérifier** une mesure la certifie (une ouverture n'est déduite d'un mur qu'une fois vérifiée). Après une recalibration, cliquer **Recalculer les mesures périmées**. Une **quantité saisie manuellement** (ex. nombre d'appareils) est toujours étiquetée « estimée » ou « supposée ». |
@@ -37,6 +39,8 @@ Onglet **Assemblages** : créer une recette (code, nom FR/EN, unité et quantit | ||
| 37 | 39 | |
| 38 | 40 | Onglet **Estimation** : ajouter des lignes (division, description), les **lier à une mesure** (la quantité vient alors du registre et ne peut pas être modifiée à la main), appliquer un **facteur d'ajustement explicite** (ex. 1,05), choisir un **assemblage** (catalogue ou recette, prix et part observée affichés) ou saisir une **hypothèse** (prix unitaire, « installé » ou non, justification). Paramètres : région, date des prix, **actualisation** à une date cible (indice StatCan 18-10-0289), conditions générales, frais généraux, profit, contingence, TPS/TVQ, arrondi. L'ordre d'application est affiché. **Scénarios et sensibilité** comparent plusieurs jeux de paramètres. Le mode 🎓 masque les coûts pour un exercice. |
| 39 | 41 | |
| 42 | +**Relevé des quantités depuis les plans** (méthode du *quantity survey*) : le bouton de l'onglet Estimation lit les plans calibrés de chaque niveau et la **Fiche** du bâtiment, puis crée d'un coup ≈ 40 mesures « QS · … » au registre et autant de lignes d'estimation étiquetées **qs**, chacune avec sa formule et ses entrées (ex. murs extérieurs = longueur × hauteur d'étage ; fenêtres = comptées sur le plan ; toiture = empreinte × facteur de pente ; planchers par usage de pièce). Renseignez la Fiche d'abord (fondation, isolation, revêtements, toiture, chauffage, finitions…) : elle choisit les assemblages. Regénérer remplace les lignes qs et conserve vos lignes manuelles. | |
| 43 | + | |
| 40 | 44 | **Figer une révision** enregistre un instantané immuable (lignes, paramètres, prix utilisés, versions, empreinte SHA-256). Une révision est refusée tant qu'une mesure liée est périmée. |
| 41 | 45 | |
| 42 | 46 | **Assistance IA (section 04)** : *Proposer des assemblages* (rattachements mesure → assemblage du catalogue), *Postes manquants ?*, *Anomalies* (règles déterministes puis commentaire), *Expliquer l'estimation*. Chaque suggestion indique le modèle et la version de prompt, ne contient jamais de prix, et reste « pending » jusqu'à ce que vous l'**acceptiez** ou la **refusiez** ; un rattachement accepté s'applique à l'estimation seulement par le bouton **Appliquer**. |
@@ -49,6 +53,10 @@ Onglet **Valeur** : choisir la révision (coût à neuf = sous-total avant taxes | ||
| 49 | 53 | |
| 50 | 54 | Onglet **Rapports** : pour chaque révision, **PDF FR / PDF EN**, **CSV**, **XLSX**, **JSON** ; la section **Comparer deux révisions** affiche côte à côte les totaux, chaque étape et chaque division avec les écarts. Le PDF comprend le résultat, le récapitulatif et l'ordre d'application, les lignes par division avec provenance (● observé/officiel · ○ indexé · ◐ calculé · ◇ référence/hypothèse), le registre de métré utilisé, la méthode du coût si un cas est lié, les hypothèses et avertissements, la bibliographie des sources. Si un devoir existe, choisir « devoir… » sur la ligne de la révision pour la **remettre**. |
| 51 | 55 | |
| 56 | +### 8.1 Téléchargements partout dans UQO Éval | |
| 57 | + | |
| 58 | +Chaque résultat de la plateforme se télécharge avec la mention UQO Éval : boutons **PDF · XLSX · CSV · JSON** dans le laboratoire d'économétrie (résultats du modèle), la page d'estimation « le prix d'un… » (données, en plus des rapports PDF), le parc immobilier, la méthode du coût, les statistiques (tableau de bord, valeur du Québec, marché), chaque page de municipalité et l'atelier des comparables. Les fichiers portent l'en-tête « UQO Éval — laboratoire pédagogique… », la date, l'URL source et la mention pédagogique. | |
| 59 | + | |
| 52 | 60 | ## 9. Catalogue |
| 53 | 61 | |
| 54 | 62 | Page **Catalogue** : recherche d'assemblages et d'articles (prix du jour, provenance), grille de main-d'œuvre APCHQ/CCQ, observations de prix du module par classe d'évidence (barèmes, contrats SEAO, permis…). Un total de contrat ou une valeur déclarée au permis n'est jamais un prix unitaire. |
modified
docs/construction/VALIDATION_PLAN.md
+5 −2
@@ -12,9 +12,12 @@ | ||
| 12 | 12 | | `src/lib/metre/dxf.test.ts` | 3 | lecteur DXF (entités, calques, $INSUNITS, entités ignorées, bornes), projection (Y inversé, marge, calibration exacte), SVG, fichiers sans unités / vides | |
| 13 | 13 | | `src/lib/metre/ai.test.ts` | 5 | assistance IA avec fournisseur simulé : filtrage des codes et cibles invalides, suggestion en attente et étiquetée, cache par empreinte, revue (accepter/refuser), postes manquants, explication, anomalies déterministes (z robuste, hypothèse sans note, facteur inhabituel) + commentaire, classification MasterFormat (divisions invalides filtrées, application seulement après acceptation), absence de clé API, audit ; import DXF de bout en bout (page, calibration exacte, objets, mesure 12 × 9 = 108 m²) | |
| 14 | 14 | | `src/lib/metre/scale.test.ts` | 1 | catalogue synthétique de **10 000 articles** : recherche, prix canoniques et contexte de devis chronométrés (§5) | |
| 15 | +| `src/lib/metre/plan.test.ts` | 9 | géométrie d'architecte : décalage des murs (onglets, anneaux fermés), projection sur polyligne, insertion d'ouvertures, cotations, aimant, quantités d'un plan, pages sans fichier (vierge, gabarit, carte) | | |
| 16 | +| `src/lib/metre/qs.test.ts` | 43 | **38 gabarits générés** (un test par gabarit : niveaux attendus, ouvertures à < 1 cm de leur mur, pièces dans l'empreinte, ≥ 2 pièces et ≥ 3 ouvertures par niveau habitable, murs > 10 m) ; **relevé des quantités** pur (bungalow 12,2 × 9,1 m : 33 codes attendus, empreinte/périmètre/chambres/salles de bain, fenêtres depuis le plan, murs ext. > périmètre × 2,44 m, fondation = périmètre × hauteur ; fiche dalle/thermopompe/toit plat ; refus sans calibration) ; service (cottage → 3 niveaux → relevé → estimation chiffrée > 100 000 $, regénération sans doublon, duplication des plans) | | |
| 17 | +| `src/lib/export-generic.test.ts` | 6 | exports universels de la plateforme : feuilles (résumé, tableaux, tableaux imbriqués hissés, libellés bilingues, exclusions), CSV (BOM, mention UQO Éval, échappement), XLSX (feuille de couverture UQO Éval), PDF valide, JSON enveloppé, **ligatures JetBrains Mono neutralisées** (« // », « .. », « -- » faisaient planter fontkit) | | |
| 15 | 18 | | existants (`engine`, `cost/engine`, `cost/ai`, `cost/connectors`) | 69 | **régression : inchangés et verts** | |
| 16 | −| **Total unitaires/intégration** | **114** | `npx vitest run` : 10 fichiers, 114 tests verts ; `tsc --noEmit` propre ; ESLint 0 erreur | | |
| 17 | −| **Bout en bout (Playwright, Chromium)** `e2e/metre.spec.ts` | 2 | (1) régression : `/`, `/cout`, `/a-vendre`, `/stats`, `/methodologie`, `/hedonique` en 200 et lien « Métré » présent ; (2) **parcours complet dans un vrai navigateur** : connexion par code → projet → téléversement PNG → calibration par deux clics (400 px = 4 m) → tracé d'une pièce 4 × 3 m → mesure (12 m²) → vérification au registre → estimation (ligne liée + hypothèse 1 500 $) → révision figée → PDF téléchargé (`%PDF`, > 20 Ko) → suppression. `npx playwright test` : **2 passed (6,4 s)** le 2026-10-28 | | |
| 19 | +| **Total unitaires/intégration** | **172** | `npx vitest run` : 13 fichiers, 172 tests verts (2026-10-28) ; `tsc --noEmit` propre ; ESLint 0 erreur | | |
| 20 | +| **Bout en bout (Playwright, Chromium)** `e2e/metre.spec.ts` + `e2e/exports.spec.ts` | 4 | (3) **exports serveur** : estimation, parc, stats, province, marché, municipalité en CSV/XLSX/JSON/PDF (type MIME, taille, mention UQO Éval, 400/404) ; (4) **export client** (POST d'un résultat calculé) + boutons visibles sur `/stats`, `/stats/marche`, `/municipalite/gatineau` + téléchargement réel d'un CSV (`uqo-eval_l-immobilier-a-gatineau_….csv`). `npx playwright test` : **4 passed (7,0 s)** le 2026-10-28. (1) régression : `/`, `/cout`, `/a-vendre`, `/stats`, `/methodologie`, `/hedonique` en 200 et lien « Métré » présent ; (2) **parcours complet dans un vrai navigateur** : connexion par code → projet → téléversement PNG → calibration par deux clics (400 px = 4 m) → tracé d'une pièce 4 × 3 m → mesure (12 m²) → vérification au registre → estimation (ligne liée + hypothèse 1 500 $) → révision figée → PDF téléchargé (`%PDF`, > 20 Ko) → suppression. `npx playwright test` : **2 passed (6,4 s)** le 2026-10-28 | | |
| 18 | 21 | |
| 19 | 22 | ## 2. Tests d'intégration bout en bout (API, production) |
| 20 | 23 | |
modified
e2e/metre.spec.ts
+7 −4
@@ -34,10 +34,10 @@ test("plateforme ouverte : session automatique, plan d'architecte, registre, est | ||
| 34 | 34 | // repère du canevas : la page tient dans le canevas (« ajuster ») ; on lit la transformation pour convertir m → écran |
| 35 | 35 | const svg = page.locator(".pl-canvas svg"); |
| 36 | 36 | await page.waitForTimeout(300); |
| 37 | − const box = (await svg.boundingBox())!; | |
| 38 | − const t = await page.locator(".pl-canvas svg > g").getAttribute("transform"); | |
| 39 | − const m = t!.match(/translate\(([-\d.]+) ([-\d.]+)\) scale\(([-\d.]+)\)/)!; | |
| 40 | − const tx = Number(m[1]), ty = Number(m[2]), k = Number(m[3]); | |
| 37 | + // le panneau latéral apparaît/disparaît selon la sélection : on relit la boîte et la transformation avant chaque série de clics | |
| 38 | + let box = (await svg.boundingBox())!, tx = 0, ty = 0, k = 1; | |
| 39 | + const refresh = async () => { await page.waitForTimeout(150); box = (await svg.boundingBox())!; const t = await page.locator(".pl-canvas svg > g").getAttribute("transform"); const m = t!.match(/translate\(([-\d.]+) ([-\d.]+)\) scale\(([-\d.]+)\)/)!; tx = Number(m[1]); ty = Number(m[2]); k = Number(m[3]); }; | |
| 40 | + await refresh(); | |
| 41 | 41 | const at = (xm: number, ym: number) => ({ x: box.x + tx + xm * 40 * k, y: box.y + ty + ym * 40 * k }); |
| 42 | 42 | // murs extérieurs : rectangle 10 × 8 m fermé en revenant au premier point |
| 43 | 43 | await page.keyboard.press("w"); |
@@ -50,11 +50,14 @@ test("plateforme ouverte : session automatique, plan d'architecte, registre, est | ||
| 50 | 50 | await page.keyboard.type("8"); await page.keyboard.press("Enter"); await page.keyboard.press("Enter"); |
| 51 | 51 | await expect(page.locator(".pl-list button", { hasText: /Cloison|Partition/ })).toHaveCount(1); |
| 52 | 52 | // porte et fenêtre sur le mur extérieur |
| 53 | + await page.keyboard.press("Escape"); await refresh(); | |
| 53 | 54 | await page.keyboard.press("d"); { const p = at(4, 2); await page.mouse.click(p.x, p.y); } |
| 55 | + await page.keyboard.press("Escape"); await refresh(); | |
| 54 | 56 | await page.keyboard.press("n"); { const p = at(9, 2); await page.mouse.click(p.x, p.y); } |
| 55 | 57 | await expect(page.locator(".pl-list button", { hasText: /Porte|Door/ })).toHaveCount(1); |
| 56 | 58 | await expect(page.locator(".pl-list button", { hasText: /Fenêtre|Window/ })).toHaveCount(1); |
| 57 | 59 | // pièce rectangle 5 × 8 m par deux clics |
| 60 | + await page.keyboard.press("Escape"); await refresh(); | |
| 58 | 61 | await page.keyboard.press("x"); { const p = at(2, 2); await page.mouse.click(p.x, p.y); } { const p = at(7, 10); await page.mouse.click(p.x, p.y); } |
| 59 | 62 | await expect(page.locator(".pl-list button", { hasText: /Pièce|Room/ })).toHaveCount(1); |
| 60 | 63 | await expect(page.locator(".pl-status")).toContainText(/1 portes|1 doors/); |
modified
src/app/api/metre/projects/[id]/pages/route.ts
+4 −3
@@ -1,13 +1,14 @@ | ||
| 1 | 1 | // UQO Éval — Métré : créer une page de plan sans fichier — vierge (échelle), gabarit (bungalow, cottage, garage) ou carte (satellite géoréférencée). |
| 2 | 2 | import { NextRequest, NextResponse } from "next/server"; |
| 3 | 3 | import { HttpError, handle, readJson, requireProject, requireUser } from "@/lib/metre/auth"; |
| 4 | −import { LEVELS, TEMPLATES, createBlankPage, createMapPage, createTemplatePage } from "@/lib/metre/plans"; | |
| 4 | +import { LEVELS, createBlankPage, createMapPage, createTemplateProject } from "@/lib/metre/plans"; | |
| 5 | +import { listTemplates } from "@/lib/metre/templates"; | |
| 5 | 6 | |
| 6 | 7 | export const runtime = "nodejs"; |
| 7 | 8 | export const maxDuration = 60; |
| 8 | 9 | |
| 9 | 10 | export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { |
| 10 | − return handle(async () => { const { id } = await params; const ctx = requireUser(req); requireProject(ctx, id, "read"); return NextResponse.json({ levels: LEVELS, templates: Object.entries(TEMPLATES).map(([key, t]) => ({ key, fr: t.fr, en: t.en, widthM: t.widthM, heightM: t.heightM })) }); }); | |
| 11 | + return handle(async () => { const { id } = await params; const ctx = requireUser(req); requireProject(ctx, id, "read"); return NextResponse.json({ levels: LEVELS, templates: listTemplates() }); }); | |
| 11 | 12 | } |
| 12 | 13 | |
| 13 | 14 | export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { |
@@ -18,7 +19,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: | ||
| 18 | 19 | const b = await readJson<Record<string, unknown>>(req); |
| 19 | 20 | switch (b.kind) { |
| 20 | 21 | case "blank": return NextResponse.json(await createBlankPage(ctx, id, { widthM: Number(b.widthM), heightM: Number(b.heightM), pxPerMeter: Number(b.pxPerMeter), label: typeof b.label === "string" ? b.label : undefined, level: typeof b.level === "string" ? b.level : null }), { status: 201 }); |
| 21 | − case "template": return NextResponse.json(await createTemplatePage(ctx, id, { template: String(b.template ?? "bungalow"), level: typeof b.level === "string" ? b.level : null, pxPerMeter: Number(b.pxPerMeter) || undefined }), { status: 201 }); | |
| 22 | + case "template": { const r = await createTemplateProject(ctx, id, { template: String(b.template ?? "bungalow-c"), pxPerMeter: Number(b.pxPerMeter) || undefined }); return NextResponse.json({ ...r, page: r.pages[r.pages.length > 1 ? 1 : 0] }, { status: 201 }); } | |
| 22 | 23 | case "map": return NextResponse.json(await createMapPage(ctx, id, { lat: Number(b.lat), lng: Number(b.lng), zoom: Number(b.zoom), label: typeof b.label === "string" ? b.label : undefined, style: typeof b.style === "string" ? b.style : undefined }), { status: 201 }); |
| 23 | 24 | default: throw new HttpError(400, "kind : blank | template | map"); |
| 24 | 25 | } |
added
src/app/api/metre/projects/[id]/quantity-survey/route.ts
+18 −0
@@ -0,0 +1,18 @@ | ||
| 1 | +// UQO Éval — Métré : méthode du relevé des quantités — génère toutes les lignes depuis les plans calibrés + la fiche technique (POST {estimateId?}). | |
| 2 | +import { NextRequest, NextResponse } from "next/server"; | |
| 3 | +import { handle, readJson, requireProject, requireUser } from "@/lib/metre/auth"; | |
| 4 | +import { generateQuantitySurvey } from "@/lib/metre/estimates"; | |
| 5 | + | |
| 6 | +export const runtime = "nodejs"; | |
| 7 | +export const maxDuration = 60; | |
| 8 | + | |
| 9 | +export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { | |
| 10 | + return handle(async () => { | |
| 11 | + const { id } = await params; | |
| 12 | + const ctx = requireUser(req, { limit: { key: "qs", max: 60, windowMs: 60_000 } }); | |
| 13 | + requireProject(ctx, id, "write"); | |
| 14 | + const b = await readJson<{ estimateId?: string }>(req).catch(() => ({} as { estimateId?: string })); | |
| 15 | + const r = await generateQuantitySurvey(ctx, id, b.estimateId && /^[0-9a-f-]{36}$/i.test(b.estimateId) ? b.estimateId : null); | |
| 16 | + return NextResponse.json({ estimate: r.estimate, result: r.result, summary: r.qs.summary, warnings: r.qs.warnings, lines: r.qs.lines.length, version: r.qs.version }, { status: 201 }); | |
| 17 | + }); | |
| 18 | +} | |
added
src/app/api/metre/templates/route.ts
+22 −0
@@ -0,0 +1,22 @@ | ||
| 1 | +// UQO Éval — Métré : galerie de gabarits générés (liste + aperçu SVG d'un gabarit : ?key=…&level=0). | |
| 2 | +import { NextRequest, NextResponse } from "next/server"; | |
| 3 | +import { HttpError, handle, requireUser } from "@/lib/metre/auth"; | |
| 4 | +import { TEMPLATE_KINDS, buildTemplate, listTemplates, materializeLevel } from "@/lib/metre/templates"; | |
| 5 | +import { renderPlanSvg } from "@/lib/metre/plan-render"; | |
| 6 | + | |
| 7 | +export const runtime = "nodejs"; | |
| 8 | + | |
| 9 | +export async function GET(req: NextRequest) { | |
| 10 | + return handle(() => { | |
| 11 | + requireUser(req, { limit: { key: "templates", max: 300, windowMs: 60_000 } }); | |
| 12 | + const key = req.nextUrl.searchParams.get("key"); | |
| 13 | + if (!key) return NextResponse.json({ kinds: TEMPLATE_KINDS, templates: listTemplates().map((t) => ({ ...t, levels: buildTemplate(t.key)!.levels.map((l) => l.level) })) }); | |
| 14 | + const t = buildTemplate(key); if (!t) throw new HttpError(404, "Gabarit inconnu."); | |
| 15 | + const li = Math.min(t.levels.length - 1, Math.max(0, Number(req.nextUrl.searchParams.get("level") ?? Math.min(1, t.levels.length - 1)))); | |
| 16 | + const lv = t.levels[li]; const ppm = 24; | |
| 17 | + const objects = materializeLevel(lv, ppm).map((o, i) => ({ ...o, id: `p${i}` })); | |
| 18 | + // les ouvertures référencent les murs par identifiant temporaire : déjà cohérent dans materializeLevel | |
| 19 | + const svg = renderPlanSvg(objects, { widthPx: Math.round(lv.widthM * ppm), heightPx: Math.round(lv.heightM * ppm), pxPerMeter: ppm, imperial: false, grid: false, showLabels: true, showDims: false, title: null }); | |
| 20 | + return new NextResponse(svg, { headers: { "Content-Type": "image/svg+xml", "Cache-Control": "private, max-age=3600" } }); | |
| 21 | + }); | |
| 22 | +} | |
modified
src/components/metre/EstimateEditor.tsx
+6 −2
@@ -19,6 +19,7 @@ export default function EstimateEditor({ d, fr, lang, canWrite, reload }: { d: W | ||
| 19 | 19 | const [err, setErr] = useState<string | null>(null); |
| 20 | 20 | const [saving, setSaving] = useState(false); |
| 21 | 21 | const [hideCosts, setHideCosts] = useState(false); |
| 22 | + const [qsBusy, setQsBusy] = useState(false); const [qsInfo, setQsInfo] = useState<{ summary: Record<string, number>; warnings: string[]; lines: number } | null>(null); | |
| 22 | 23 | const timer = useRef<ReturnType<typeof setTimeout> | null>(null); |
| 23 | 24 | const load = useCallback((id: string) => api<Loaded>(`/estimates/${id}`).then(setData).catch((e) => setErr(e.message)), []); |
| 24 | 25 | useEffect(() => { if (estId) void load(estId); }, [estId, load]); |
@@ -33,7 +34,8 @@ export default function EstimateEditor({ d, fr, lang, canWrite, reload }: { d: W | ||
| 33 | 34 | const create = async () => { try { const r = await api<{ estimate: { id: string } }>(`/projects/${d.project.id}/estimates`, { method: "POST", json: { name: `${fr ? "Estimation" : "Estimate"} ${d.estimates.length + 1}` } }); await reload(); setEstId(r.estimate.id); } catch (e) { setErr((e as Error).message); } }; |
| 34 | 35 | const freeze = async () => { if (!data) return; const label = prompt(fr ? "Libellé de la révision" : "Revision label", `${fr ? "Révision" : "Revision"} ${(data.versions?.length ?? 0) + 1}`); if (label == null) return; const scenario = prompt(fr ? "Scénario (facultatif)" : "Scenario (optional)", "") || null; try { await api(`/estimates/${data.estimate.id}/freeze`, { method: "POST", json: { label, scenario } }); await reload(); await load(data.estimate.id); } catch (e) { setErr((e as Error).message); } }; |
| 35 | 36 | |
| 36 | − if (!d.estimates.length) return <Block num="01" title={fr ? "Estimation" : "Estimate"}><p className="text-[13.5px] text-ink-2">{fr ? "Aucune estimation pour ce projet." : "No estimate for this project yet."}</p>{canWrite && <Btn tone="primary" onClick={() => void create()}>{fr ? "+ Créer une estimation" : "+ Create an estimate"}</Btn>}<Err msg={err} /></Block>; | |
| 37 | + const qs = async () => { setQsBusy(true); setErr(null); try { const r = await api<{ estimate: { id: string }; summary: Record<string, number>; warnings: string[]; lines: number }>(`/projects/${d.project.id}/quantity-survey`, { method: "POST", json: { estimateId: estId } }); setQsInfo(r); await reload(); setEstId(r.estimate.id); await load(r.estimate.id); } catch (e) { setErr((e as Error).message); } finally { setQsBusy(false); } }; | |
| 38 | + if (!d.estimates.length) return <Block num="01" title={fr ? "Estimation" : "Estimate"}><p className="text-[13.5px] text-ink-2">{fr ? "Aucune estimation pour ce projet." : "No estimate for this project yet."}</p><div className="flex flex-wrap gap-2">{canWrite && <Btn tone="primary" onClick={() => void create()}>{fr ? "+ Créer une estimation" : "+ Create an estimate"}</Btn>}{canWrite && <Btn tone="accent" disabled={qsBusy} onClick={() => void qs()}>{qsBusy ? "…" : fr ? "⚙ Relevé des quantités depuis les plans" : "⚙ Quantity survey from plans"}</Btn>}</div><Err msg={err} /></Block>; | |
| 37 | 39 | if (!data) return <div className="vp-mono text-[11px] uppercase text-ink-3">{fr ? "Chargement…" : "Loading…"}<Err msg={err} /></div>; |
| 38 | 40 | const e = data.estimate, r = data.result, p = e.params; |
| 39 | 41 | const setLine = (id: string, patchL: Partial<EstimateLineInput>) => patch({ lines: e.lines.map((l) => (l.id === id ? { ...l, ...patchL } : l)) }); |
@@ -49,7 +51,7 @@ export default function EstimateEditor({ d, fr, lang, canWrite, reload }: { d: W | ||
| 49 | 51 | {e.lines.map((l) => { const rr = res(l.id); const m = l.measurementId ? d.measurements.find((x) => x.id === l.measurementId) : null; return ( |
| 50 | 52 | <tr key={l.id} className={l.excluded ? "opacity-50" : ""}> |
| 51 | 53 | <td><Input value={l.wbs} disabled={!canWrite} onChange={(ev) => setLine(l.id, { wbs: ev.target.value })} className="vp-mono !min-h-0 !py-1 w-[64px] text-[12px]" /></td> |
| 52 | − <td><Input value={l.description} disabled={!canWrite} onChange={(ev) => setLine(l.id, { description: ev.target.value })} className="!min-h-0 !py-1 min-w-[160px] text-[13px]" />{rr?.warnings.length ? <div className="text-[10.5px] text-[var(--amber)]">{rr.warnings.join(" · ")}</div> : null}</td> | |
| 54 | + <td>{l.origin === "qs" && <span className="vp-mono text-[9px] uppercase text-accent" title={fr ? "relevé des quantités" : "quantity survey"}>qs </span>}<Input value={l.description} disabled={!canWrite} onChange={(ev) => setLine(l.id, { description: ev.target.value })} className="!min-h-0 !py-1 min-w-[160px] text-[13px]" />{rr?.warnings.length ? <div className="text-[10.5px] text-[var(--amber)]">{rr.warnings.join(" · ")}</div> : null}</td> | |
| 53 | 55 | <td className="whitespace-nowrap">{m ? <span className="vp-mono text-[12px]">{num(m.value, lang, 3)} {unitLabel(m.unit, fr)}{m.stale && " ⚠"}</span> : <span className="flex items-center gap-1"><Input type="number" step="0.001" min={0} value={l.quantity} disabled={!canWrite} onChange={(ev) => setLine(l.id, { quantity: Number(ev.target.value) })} className="vp-mono !min-h-0 !py-1 w-[90px] text-[12px]" /><Select value={l.unit} disabled={!canWrite} onChange={(ev) => setLine(l.id, { unit: ev.target.value })} className="!min-h-0 !py-1 w-[72px] text-[11px]">{["m", "ft", "m2", "ft2", "m3", "ft3", "yd3", "unit", "h", "lump"].map((u) => <option key={u} value={u}>{unitLabel(u, fr)}</option>)}</Select></span>}</td> |
| 54 | 56 | <td><Select value={l.measurementId ?? ""} disabled={!canWrite} onChange={(ev) => { const mm = d.measurements.find((x) => x.id === ev.target.value); setLine(l.id, { measurementId: ev.target.value || null, ...(mm ? { quantity: mm.value, unit: mm.unit, description: l.description || mm.name } : {}) }); }} className="!min-h-0 !py-1 w-[150px] text-[11px]"><option value="">{fr ? "— saisie —" : "— manual —"}</option>{d.measurements.map((x) => <option key={x.id} value={x.id}>{x.name} ({num(x.value, lang, 2)} {x.unit})</option>)}</Select></td> |
| 55 | 57 | <td><Input type="number" step="0.01" min={0.0001} value={l.adjustmentFactor} disabled={!canWrite} title={fr ? "facteur d'ajustement explicite (ex. 1,05 = +5 %)" : "explicit adjustment factor (e.g. 1.05 = +5 %)"} onChange={(ev) => setLine(l.id, { adjustmentFactor: Number(ev.target.value) || 1 })} className="vp-mono !min-h-0 !py-1 w-[62px] text-[12px]" /></td> |
@@ -58,6 +60,8 @@ export default function EstimateEditor({ d, fr, lang, canWrite, reload }: { d: W | ||
| 58 | 60 | <td className="whitespace-nowrap">{canWrite && <><button type="button" className="vp-mono text-[10px] uppercase text-ink-3 hover:text-accent" onClick={() => setLine(l.id, { excluded: !l.excluded })}>{l.excluded ? (fr ? "inclure" : "include") : (fr ? "exclure" : "exclude")}</button> <button type="button" className="vp-mono text-[10px] text-ink-3 hover:text-[var(--danger)]" onClick={() => patch({ lines: e.lines.filter((x) => x.id !== l.id) })}>✕</button></>}</td> |
| 59 | 61 | </tr>); })} |
| 60 | 62 | </tbody></table></div> |
| 63 | + {canWrite && <div className="mt-card !p-3"><div className="flex flex-wrap items-center gap-3"><Btn tone="accent" small disabled={qsBusy} onClick={() => void qs()}>{qsBusy ? "…" : fr ? "⚙ Générer le relevé des quantités (plans + fiche)" : "⚙ Generate the quantity survey (plans + sheet)"}</Btn><span className="text-[12px] text-ink-2">{fr ? "Méthode du relevé des quantités : excavation, fondations, structure, enveloppe, toiture, ouvertures, isolation, finitions, cuisine, bains, plomberie, CVCA, électricité, extérieur — chaque ligne garde sa formule, ses entrées et ses pages sources ; les lignes « qs » sont remplacées à chaque génération, vos lignes saisies sont conservées. Complétez la fiche technique (onglet Fiche) pour les éléments invisibles sur le plan." : "Quantity survey method: excavation, foundations, structure, envelope, roofing, openings, insulation, finishes, kitchen, baths, plumbing, HVAC, electrical, exterior — each line keeps its formula, inputs and source pages; “qs” lines are replaced on each run, your own lines are kept. Fill the technical sheet (Sheet tab) for items not visible on the plan."}</span></div> | |
| 64 | + {qsInfo && <p className="vp-mono mt-2 text-[10.5px] text-ink-2">{qsInfo.lines} {fr ? "lignes" : "lines"} · {Object.entries(qsInfo.summary).map(([k, v]) => `${k.replace(/_/g, " ")} ${v}`).join(" · ")}{qsInfo.warnings.length ? ` · ⚠ ${qsInfo.warnings.join(" ; ")}` : ""}</p>}</div>} | |
| 61 | 65 | {canWrite && <div className="flex flex-wrap gap-2"><Btn small onClick={() => addLine()}>{fr ? "+ Ligne (catalogue)" : "+ Line (catalogue)"}</Btn><Btn small onClick={() => addLine({ basis: "assumption", unitPrice: 0, installed: true, description: fr ? "Prix supposé" : "Assumed price" })}>{fr ? "+ Ligne hypothèse (prix saisi)" : "+ Assumption line (entered price)"}</Btn><Btn small onClick={() => { const missing = d.measurements.filter((m) => !e.lines.some((l) => l.measurementId === m.id)); patch({ lines: [...e.lines, ...missing.map((m, i) => ({ id: uuid(), wbs: "00", description: m.name, assemblyCode: null, measurementId: m.id, quantity: m.value, unit: m.unit, adjustmentFactor: 1, basis: "catalogue" as const, unitPrice: null, installed: false, assumptionNote: null, excluded: false, sortOrder: e.lines.length + i }))] }); }}>{fr ? "+ Toutes les mesures non liées" : "+ All unlinked measurements"}</Btn></div>} |
| 62 | 66 | <Err msg={err} /> |
| 63 | 67 | </Block> |
modified
src/components/metre/PlanStudio.tsx
+22 −8
@@ -54,10 +54,12 @@ export default function PlanStudio({ d, fr, canWrite, reload, initialCreate }: { | ||
| 54 | 54 | |
| 55 | 55 | function NewPage({ mode, setMode, d, fr, onDone }: { mode: "menu" | "blank" | "template" | "map" | "import"; setMode: (m: "menu" | "blank" | "template" | "map" | "import" | null) => void; d: WorkspaceData; fr: boolean; onDone: (pageId: string | null) => Promise<void> }) { |
| 56 | 56 | const [levels, setLevels] = useState<string[]>(["Site", "Sous-sol", "Rez-de-chaussée", "Étage", "Toit"]); |
| 57 | − const [templates, setTemplates] = useState<{ key: string; fr: string; en: string; widthM: number; heightM: number }[]>([]); | |
| 58 | − const [f, setF] = useState({ widthM: 24, heightM: 16, pxPerMeter: 50, level: "Rez-de-chaussée", template: "bungalow" }); | |
| 57 | + const [templates, setTemplates] = useState<{ key: string; kind: string; fr: string; en: string; footprint: string; storeys: number; units: number; areaM2: number; levels: string[] }[]>([]); | |
| 58 | + const [kinds, setKinds] = useState<{ key: string; fr: string; en: string }[]>([]); | |
| 59 | + const [kindFilter, setKindFilter] = useState<string>(""); | |
| 60 | + const [f, setF] = useState({ widthM: 24, heightM: 16, pxPerMeter: 50, level: "Rez-de-chaussée", template: "bungalow-c" }); | |
| 59 | 61 | const [busy, setBusy] = useState<string | null>(null); const [err, setErr] = useState<string | null>(null); |
| 60 | − useEffect(() => { api<{ levels: string[]; templates: typeof templates }>(`/projects/${d.project.id}/pages`).then((r) => { setLevels(r.levels); setTemplates(r.templates); }).catch(() => undefined); }, [d.project.id]); | |
| 62 | + useEffect(() => { api<{ levels: string[] }>(`/projects/${d.project.id}/pages`).then((r) => setLevels(r.levels)).catch(() => undefined); api<{ kinds: typeof kinds; templates: typeof templates }>("/templates").then((r) => { setKinds(r.kinds); setTemplates(r.templates); }).catch(() => undefined); }, [d.project.id]); | |
| 61 | 63 | const create = async (body: Record<string, unknown>) => { setBusy("…"); setErr(null); try { const r = await api<{ page: PlanPage }>(`/projects/${d.project.id}/pages`, { method: "POST", json: body }); await onDone(r.page.id); } catch (e) { setErr((e as Error).message); } finally { setBusy(null); } }; |
| 62 | 64 | const lvl = <label className="block"><span className="klabel">{fr ? "Niveau" : "Level"}</span><select value={f.level} onChange={(e) => setF({ ...f, level: e.target.value })} className="vp-input !min-h-0 text-[13px]">{levels.map((l) => <option key={l}>{l}</option>)}</select></label>; |
| 63 | 65 | if (mode === "menu") return ( |
@@ -72,7 +74,15 @@ function NewPage({ mode, setMode, d, fr, onDone }: { mode: "menu" | "blank" | "t | ||
| 72 | 74 | <div className="mt-card"> |
| 73 | 75 | <div className="flex items-center justify-between"><h2>{mode === "blank" ? (fr ? "Plan vierge" : "Blank plan") : mode === "template" ? (fr ? "Gabarit" : "Template") : mode === "map" ? (fr ? "Plan de site" : "Site plan") : (fr ? "Importer" : "Import")}</h2><button type="button" className="vp-mono text-[10.5px] uppercase text-ink-3" onClick={() => setMode("menu")}>← {fr ? "autres options" : "other options"}</button></div> |
| 74 | 76 | {mode === "blank" && (<div className="space-y-3"><div className="grid gap-3 sm:grid-cols-4"><label className="block"><span className="klabel">{fr ? "Largeur (m)" : "Width (m)"}</span><Input type="number" min={3} max={200} value={f.widthM} onChange={(e) => setF({ ...f, widthM: Number(e.target.value) })} className="!min-h-0" /></label><label className="block"><span className="klabel">{fr ? "Hauteur (m)" : "Height (m)"}</span><Input type="number" min={3} max={200} value={f.heightM} onChange={(e) => setF({ ...f, heightM: Number(e.target.value) })} className="!min-h-0" /></label><label className="block"><span className="klabel">{fr ? "Échelle (px/m)" : "Scale (px/m)"}</span><Input type="number" min={10} max={200} value={f.pxPerMeter} onChange={(e) => setF({ ...f, pxPerMeter: Number(e.target.value) })} className="!min-h-0" /></label>{lvl}</div><p className="text-[12px] text-ink-3">{fr ? "Page" : "Page"} {f.widthM * f.pxPerMeter} × {f.heightM * f.pxPerMeter} px · {fr ? "grille principale de 1 m, aimant de 10 cm." : "main grid 1 m, snap 10 cm."}</p><Btn tone="primary" disabled={!!busy} onClick={() => void create({ kind: "blank", ...f })}>{busy ?? (fr ? "Créer le plan" : "Create plan")}</Btn></div>)} |
| 75 | − {mode === "template" && (<div className="space-y-3"><div className="grid gap-3 sm:grid-cols-3"><label className="block sm:col-span-2"><span className="klabel">{fr ? "Gabarit" : "Template"}</span><select value={f.template} onChange={(e) => setF({ ...f, template: e.target.value })} className="vp-input !min-h-0 text-[13px]">{templates.map((t) => <option key={t.key} value={t.key}>{fr ? t.fr : t.en}</option>)}</select></label>{lvl}</div><Btn tone="primary" disabled={!!busy} onClick={() => void create({ kind: "template", template: f.template, level: f.level, pxPerMeter: 50 })}>{busy ?? (fr ? "Créer depuis le gabarit" : "Create from template")}</Btn></div>)} | |
| 77 | + {mode === "template" && (<div className="space-y-3"> | |
| 78 | + <div className="flex flex-wrap items-center gap-2"><button type="button" className={`chip ${!kindFilter ? "on" : ""} vp-mono text-[10.5px] rounded-full border px-2.5 py-1`} onClick={() => setKindFilter("")}>{fr ? "tous" : "all"} ({templates.length})</button>{kinds.map((k) => <button key={k.key} type="button" className={`vp-mono text-[10.5px] rounded-full border px-2.5 py-1 ${kindFilter === k.key ? "border-accent text-accent bg-[#e6f0f4]" : "border-[var(--line)] text-ink-2"}`} onClick={() => setKindFilter(k.key)}>{fr ? k.fr : k.en} ({templates.filter((t) => t.kind === k.key).length})</button>)}</div> | |
| 79 | + <div className="tpl-grid">{templates.filter((t) => !kindFilter || t.kind === kindFilter).map((t) => ( | |
| 80 | + <button key={t.key} type="button" className={`tpl-card ${f.template === t.key ? "sel" : ""}`} onClick={() => setF({ ...f, template: t.key })} onDoubleClick={() => void create({ kind: "template", template: t.key, pxPerMeter: 50 })}> | |
| 81 | + <object data={`/api/metre/templates?key=${t.key}`} type="image/svg+xml" className="thumb" aria-label={fr ? t.fr : t.en} /> | |
| 82 | + <b>{fr ? t.fr : t.en}</b><span>{t.footprint} · {t.storeys} {fr ? "niv." : "lvl"} · {t.units} {fr ? "log." : "unit"} · {Math.round(t.areaM2)} m² · {t.levels.join(" / ")}</span> | |
| 83 | + </button>))}</div> | |
| 84 | + <div className="flex flex-wrap items-center gap-3"><Btn tone="primary" disabled={!!busy} onClick={() => void create({ kind: "template", template: f.template, pxPerMeter: 50 })}>{busy ?? (fr ? `Créer tous les niveaux du gabarit « ${templates.find((t) => t.key === f.template)?.fr ?? f.template} »` : `Create all levels of “${templates.find((t) => t.key === f.template)?.en ?? f.template}”`)}</Btn><span className="text-[12px] text-ink-3">{fr ? "Les niveaux (sous-sol, rez-de-chaussée, étages) sont créés avec murs, cloisons, pièces, portes et fenêtres ; la fiche technique du projet est préremplie pour le relevé des quantités." : "Levels (basement, ground, upper) are created with walls, partitions, rooms, doors and windows; the project technical sheet is prefilled for the quantity survey."}</span></div> | |
| 85 | + </div>)} | |
| 76 | 86 | {mode === "map" && <MapPicker fr={fr} initial={{ lat: 45.4765, lng: -75.7013, q: [d.project.address, d.project.municipality].filter(Boolean).join(", ") || null, mamh: d.project.propertyId }} onCapture={async (v) => { await create({ kind: "map", ...v }); }} onCancel={() => setMode("menu")} />} |
| 77 | 87 | {mode === "import" && <Uploader projectId={d.project.id} fr={fr} onDone={() => onDone(null)} />} |
| 78 | 88 | <Err msg={err} /> |
@@ -120,6 +130,7 @@ function Editor({ page, fr, canWrite, projectId, measurements, reload, imperial, | ||
| 120 | 130 | const [status, setStatus] = useState(""); const [err, setErr] = useState<string | null>(null); |
| 121 | 131 | const [drag, setDrag] = useState<{ kind: "vertex" | "object" | "pan"; id?: string; idx?: number; start: Point; orig?: Point[]; view?: ViewT } | null>(null); |
| 122 | 132 | const [spaceDown, setSpaceDown] = useState(false); |
| 133 | + const [full, setFull] = useState(false); const [panel, setPanel] = useState(true); | |
| 123 | 134 | const undo = useRef<PlanObject[][]>([]); const redo = useRef<PlanObject[][]>([]); |
| 124 | 135 | const wrapRef = useRef<HTMLDivElement>(null); const svgRef = useRef<SVGSVGElement>(null); |
| 125 | 136 | const k = cal?.pxPerMeter ?? null; |
@@ -128,7 +139,8 @@ function Editor({ page, fr, canWrite, projectId, measurements, reload, imperial, | ||
| 128 | 139 | |
| 129 | 140 | useEffect(() => { api<{ objects: PlanObject[] }>(`/pages/${page.id}/objects`).then((r) => setObjects(r.objects)).catch((e) => setErr(e.message)); }, [page.id]); |
| 130 | 141 | const fit = useCallback(() => { const el = wrapRef.current; if (!el) return; const kk = Math.min((el.clientWidth - 20) / page.widthPx, (el.clientHeight - 20) / page.heightPx); setView({ k: kk, tx: (el.clientWidth - page.widthPx * kk) / 2, ty: (el.clientHeight - page.heightPx * kk) / 2 }); }, [page.widthPx, page.heightPx]); |
| 131 | − useEffect(() => { fit(); }, [fit]); | |
| 142 | + useEffect(() => { fit(); }, [fit, full, panel]); | |
| 143 | + useEffect(() => { if (!full) return; const h = (e: KeyboardEvent) => { if (e.key === "Escape") setFull(false); }; window.addEventListener("keydown", h); return () => window.removeEventListener("keydown", h); }, [full]); | |
| 132 | 144 | |
| 133 | 145 | /* ----------------------------------------------------- persistance + annuler */ |
| 134 | 146 | const persistCreate = (o: PlanObject) => api(`/pages/${page.id}/objects`, { method: "POST", json: o }).catch((e) => setErr((e as Error).message)); |
@@ -279,7 +291,7 @@ function Editor({ page, fr, canWrite, projectId, measurements, reload, imperial, | ||
| 279 | 291 | const mOf = (id: string) => measurements.filter((m) => m.objectId === id); |
| 280 | 292 | |
| 281 | 293 | return ( |
| 282 | − <div className="pl-wrap"> | |
| 294 | + <div className={`pl-wrap ${full ? "full" : ""} ${panel ? "" : "nopanel"}`}> | |
| 283 | 295 | <div className="pl-tools"> |
| 284 | 296 | {TOOLS.map((t, i) => (<span key={t.t} className="contents">{(i === 2 || i === 6 || i === 8 || i === 12 || i === 15) && <span className="sep" />}<button type="button" disabled={toolDisabled(t.t)} className={tool === t.t ? "active" : ""} title={`${fr ? t.fr : t.en} (${t.key})`} onClick={() => { setTool(t.t); setDraft(null); setTyped(""); }}>{t.ico}<small>{t.key}</small></button></span>))} |
| 285 | 297 | </div> |
@@ -296,6 +308,8 @@ function Editor({ page, fr, canWrite, projectId, measurements, reload, imperial, | ||
| 296 | 308 | <span className="mx-1 h-4 w-px bg-[#d9dee2]" /> |
| 297 | 309 | {layers.map((l) => <button key={l} type="button" className={`chip ${hidden.has(l) ? "" : "on"}`} onClick={() => setHidden((h) => { const n = new Set(h); if (n.has(l)) n.delete(l); else n.add(l); return n; })}>{l}</button>)} |
| 298 | 310 | <span className="ml-auto flex flex-wrap gap-1"> |
| 311 | + <button type="button" className={`chip ${full ? "on" : ""}`} onClick={() => setFull((v) => !v)} title={fr ? "Plein écran (Échap pour quitter)" : "Full screen (Esc to exit)"}>{full ? "⤡" : "⤢"} {fr ? "plein écran" : "full screen"}</button> | |
| 312 | + <button type="button" className={`chip ${panel ? "on" : ""}`} onClick={() => setPanel((v) => !v)}>{fr ? "panneau" : "panel"}</button> | |
| 299 | 313 | {canWrite && <Btn small tone="accent" disabled={!k} onClick={() => void measureAll()}>{fr ? "Mesurer tout → registre" : "Measure all → ledger"}</Btn>} |
| 300 | 314 | <a className="btn btn-ghost !min-h-0 !px-3 !py-1.5 text-[12px]" href={`/api/metre/pages/${page.id}/export?format=pdf`}>PDF</a><a className="btn btn-ghost !min-h-0 !px-3 !py-1.5 text-[12px]" href={`/api/metre/pages/${page.id}/export?format=png`}>PNG</a><a className="btn btn-ghost !min-h-0 !px-3 !py-1.5 text-[12px]" href={`/api/metre/pages/${page.id}/export?format=svg`}>SVG</a> |
| 301 | 315 | {canWrite && <><Btn small onClick={() => void pageAction("rename")} title={fr ? "Renommer" : "Rename"}>✎</Btn><Btn small onClick={() => void pageAction("duplicate")} title={fr ? "Dupliquer en nouveau niveau" : "Duplicate as new level"}>⧉</Btn><Btn small onClick={() => void pageAction("delete")} title={fr ? "Supprimer la page" : "Delete page"}>🗑</Btn></>} |
@@ -339,7 +353,7 @@ function Editor({ page, fr, canWrite, projectId, measurements, reload, imperial, | ||
| 339 | 353 | </div> |
| 340 | 354 | <Err msg={err} /> |
| 341 | 355 | </div> |
| 342 | − <aside className="pl-right"> | |
| 356 | + {panel && <aside className="pl-right"> | |
| 343 | 357 | {selected ? (<> |
| 344 | 358 | <h3>{fr ? KIND_FR[selected.kind]?.[0] : KIND_FR[selected.kind]?.[1]} — {selected.name}</h3> |
| 345 | 359 | <div className="sec space-y-2"> |
@@ -380,7 +394,7 @@ function Editor({ page, fr, canWrite, projectId, measurements, reload, imperial, | ||
| 380 | 394 | </>)} |
| 381 | 395 | <h3>{fr ? "Objets" : "Objects"} ({objects.length})</h3> |
| 382 | 396 | <div className="pl-list">{objects.map((o) => <button key={o.id} type="button" className={o.id === sel ? "sel" : ""} onClick={() => { setSel(o.id); setTool("select"); }}><span>{(fr ? KIND_FR[o.kind]?.[0] : KIND_FR[o.kind]?.[1]) ?? o.kind} · {o.name}</span><span className="vp-mono text-[10px] text-ink-3">{mOf(o.id).some((m) => m.verifiedAt) ? "✓" : mOf(o.id).length ? "•" : ""}</span></button>)}{!objects.length && <p className="px-3 py-2 text-[12px] text-ink-3">{fr ? "Dessinez des murs (W), posez des portes (D) et des fenêtres (N), nommez les pièces (R)." : "Draw walls (W), place doors (D) and windows (N), name rooms (R)."}</p>}</div> |
| 383 | − </aside> | |
| 397 | + </aside>} | |
| 384 | 398 | </div> |
| 385 | 399 | ); |
| 386 | 400 | } |
modified
src/components/metre/ProjectWorkspace.tsx
+50 −1
@@ -92,7 +92,8 @@ function ProjectTab({ d, fr, lang, canWrite, reload }: { d: WorkspaceData; fr: b | ||
| 92 | 92 | <p className="vp-mono text-[10px] uppercase tracking-[0.05em] text-ink-3">{fr ? "créé" : "created"} {dt(p.createdAt, lang)} · {fr ? "modifié" : "updated"} {dt(p.updatedAt, lang)}{p.propertyId && <> · <Link href={`/cout?property=${p.propertyId}`} className="text-accent">{fr ? "atelier méthode du coût (MAMH)" : "cost-approach workshop (MAMH)"}</Link></>}</p> |
| 93 | 93 | <Err msg={err} /> |
| 94 | 94 | </Block> |
| 95 | − <Block num="02" title={fr ? "Membres et partage" : "Members and sharing"}> | |
| 95 | + <SpecBlock p={p} fr={fr} canWrite={canWrite} reload={reload} /> | |
| 96 | + <Block num="03" title={fr ? "Membres et partage" : "Members and sharing"}> | |
| 96 | 97 | <ul className="divide-y divide-[var(--line)] text-[13.5px]">{d.members.map((m) => (<li key={m.userId} className="flex items-center justify-between py-2"><span>{m.displayName} <span className="vp-mono text-[10px] uppercase text-ink-3">{m.role}</span></span>{d.access === "owner" && <Btn small onClick={() => api(`/projects/${p.id}/members`, { method: "DELETE", json: { userId: m.userId } }).then(reload)}>✕</Btn>}</li>))}{!d.members.length && <li className="py-2 text-ink-2">{fr ? "Aucun membre ; seul le propriétaire (et les enseignants/admins) voient ce projet." : "No member; only the owner (and instructors/admins) can see this project."}</li>}</ul> |
| 97 | 98 | {d.access === "owner" && <div className="flex flex-wrap items-end gap-3"><Field label={fr ? "Nom d'affichage exact" : "Exact display name"}><Input value={member.displayName} onChange={(e) => setMember({ ...member, displayName: e.target.value })} /></Field><Field label={fr ? "Droit" : "Right"}><Select value={member.role} onChange={(e) => setMember({ ...member, role: e.target.value })}><option value="viewer">{fr ? "lecture" : "read"}</option><option value="editor">{fr ? "édition" : "edit"}</option></Select></Field><Btn small onClick={() => api(`/projects/${p.id}/members`, { method: "POST", json: member }).then(reload).catch((e) => setErr(e.message))}>{fr ? "Ajouter" : "Add"}</Btn></div>} |
| 98 | 99 | </Block> |
@@ -152,3 +153,51 @@ function CompareBlock({ versions, fr, lang }: { versions: WorkspaceData["version | ||
| 152 | 153 | </Block> |
| 153 | 154 | ); |
| 154 | 155 | } |
| 156 | + | |
| 157 | + | |
| 158 | +/* ---------------------------------------------- fiche technique pour le relevé des quantités */ | |
| 159 | +function SpecBlock({ p, fr, canWrite, reload }: { p: Project; fr: boolean; canWrite: boolean; reload: () => Promise<void> }) { | |
| 160 | + const base: Record<string, unknown> = { units: 1, basement: "unfinished", basementFinishedPct: 0, foundation: "poured_concrete", foundationWallHeightM: 2.44, roofType: "asphalt_shingle", roofGeometry: "gable", roofPitch: 6, siding: { vinyl: 0.7, brick: 0.3 }, windows: "pvc", heating: "electric_baseboard", hasAirConditioning: false, hasAirExchanger: true, quality: "standard", kitchenQuality: "standard", bathroomQuality: "standard", flooring: { hardwood: 0.6, ceramic: 0.25, vinyl_plank: 0.15 }, ceilingHeightM: 2.44, garage: "none", deckM2: 0, drivewayM2: 0, driveway: "none", fenceM: 0, landscapingM2: 0 }; | |
| 161 | + const [s, setS] = useState<Record<string, unknown>>({ ...base, ...(p.spec ?? {}) }); | |
| 162 | + const [err, setErr] = useState<string | null>(null); const [ok, setOk] = useState(false); | |
| 163 | + const set = (k: string, v: unknown) => setS((x) => ({ ...x, [k]: v })); | |
| 164 | + const mix = (k: string) => (s[k] ?? {}) as Record<string, number>; | |
| 165 | + const setMix = (k: string, kk: string, v: string) => set(k, { ...mix(k), [kk]: Number(v) / 100 || 0 }); | |
| 166 | + const save = async () => { setErr(null); try { await api(`/projects/${p.id}`, { method: "PATCH", json: { spec: s } }); setOk(true); await reload(); setTimeout(() => setOk(false), 2000); } catch (e) { setErr((e as Error).message); } }; | |
| 167 | + const sel = (k: string, opts: [string, string, string][]) => <Select value={String(s[k] ?? "")} disabled={!canWrite} onChange={(e) => set(k, e.target.value)}>{opts.map(([v, f, e]) => <option key={v} value={v}>{fr ? f : e}</option>)}</Select>; | |
| 168 | + const num = (k: string, step = 1, min = 0, max = 10000) => <Input type="number" step={step} min={min} max={max} value={Number(s[k] ?? 0)} disabled={!canWrite} onChange={(e) => set(k, Number(e.target.value))} className="vp-mono" />; | |
| 169 | + const Q: [string, string, string][] = [["economy", "économique", "economy"], ["standard", "standard", "standard"], ["superior", "supérieure", "superior"], ["prestige", "prestige", "prestige"]]; | |
| 170 | + return ( | |
| 171 | + <Block num="02" title={fr ? "Fiche technique — relevé des quantités (ce que le plan ne montre pas)" : "Technical sheet — quantity survey (what the plan does not show)"} aside={canWrite && <Btn small tone="primary" onClick={() => void save()}>{ok ? "✓" : fr ? "Enregistrer la fiche" : "Save sheet"}</Btn>}> | |
| 172 | + <div className="spec-grid"> | |
| 173 | + <Field label={fr ? "Logements" : "Dwelling units"}>{num("units", 1, 1, 60)}</Field> | |
| 174 | + <Field label={fr ? "Sous-sol" : "Basement"}>{sel("basement", [["none", "aucun (dalle)", "none (slab)"], ["crawl", "vide sanitaire", "crawl space"], ["unfinished", "non fini", "unfinished"], ["finished", "fini", "finished"]])}</Field> | |
| 175 | + <Field label={fr ? "Part finie du sous-sol" : "Finished share of basement"}>{num("basementFinishedPct", 0.1, 0, 1)}</Field> | |
| 176 | + <Field label={fr ? "Fondation" : "Foundation"}>{sel("foundation", [["poured_concrete", "béton coulé", "poured concrete"], ["concrete_block", "blocs de béton", "concrete block"], ["slab_on_grade", "dalle sur sol", "slab on grade"], ["piers", "pieux", "piers"]])}</Field> | |
| 177 | + <Field label={fr ? "Hauteur des murs de fondation (m)" : "Foundation wall height (m)"}>{num("foundationWallHeightM", 0.1, 0.3, 4)}</Field> | |
| 178 | + <Field label={fr ? "Toiture" : "Roofing"}>{sel("roofType", [["asphalt_shingle", "bardeau d'asphalte", "asphalt shingle"], ["metal", "tôle", "metal"], ["membrane", "membrane", "membrane"]])}</Field> | |
| 179 | + <Field label={fr ? "Géométrie du toit" : "Roof geometry"}>{sel("roofGeometry", [["gable", "à deux versants", "gable"], ["hip", "à quatre versants", "hip"], ["flat", "plat", "flat"]])}</Field> | |
| 180 | + <Field label={fr ? "Pente (po / 12)" : "Pitch (in / 12)"}>{num("roofPitch", 1, 0, 18)}</Field> | |
| 181 | + <Field label={fr ? "Fenêtres" : "Windows"}>{sel("windows", [["pvc", "PVC", "PVC"], ["hybrid", "hybride", "hybrid"], ["aluminum", "aluminium", "aluminum"], ["wood", "bois", "wood"]])}</Field> | |
| 182 | + <Field label={fr ? "Chauffage" : "Heating"}>{sel("heating", [["electric_baseboard", "plinthes électriques", "electric baseboards"], ["heat_pump", "thermopompe centrale", "central heat pump"], ["furnace_electric", "fournaise électrique", "electric furnace"], ["furnace_gas", "fournaise au gaz", "gas furnace"]])}</Field> | |
| 183 | + <Field label={fr ? "Climatisation / échangeur d'air" : "A/C / air exchanger"}><div className="flex gap-3 text-[13px]"><label className="flex items-center gap-1"><input type="checkbox" checked={!!s.hasAirConditioning} disabled={!canWrite} onChange={(e) => set("hasAirConditioning", e.target.checked)} /> {fr ? "clim." : "A/C"}</label><label className="flex items-center gap-1"><input type="checkbox" checked={!!s.hasAirExchanger} disabled={!canWrite} onChange={(e) => set("hasAirExchanger", e.target.checked)} /> VRC</label></div></Field> | |
| 184 | + <Field label={fr ? "Qualité générale" : "Overall quality"}>{sel("quality", Q)}</Field> | |
| 185 | + <Field label={fr ? "Qualité cuisine" : "Kitchen quality"}>{sel("kitchenQuality", Q)}</Field> | |
| 186 | + <Field label={fr ? "Qualité salles de bain" : "Bathroom quality"}>{sel("bathroomQuality", Q)}</Field> | |
| 187 | + <Field label={fr ? "Garage" : "Garage"}>{sel("garage", [["none", "aucun", "none"], ["attached", "attenant", "attached"], ["detached", "détaché", "detached"], ["integrated", "intégré", "integrated"]])}</Field> | |
| 188 | + <Field label={fr ? "Hauteur de plafond (m)" : "Ceiling height (m)"}>{num("ceilingHeightM", 0.01, 2, 6)}</Field> | |
| 189 | + <Field label={fr ? "Entrée" : "Driveway"}>{sel("driveway", [["none", "aucune", "none"], ["asphalt", "asphalte", "asphalt"], ["pavers", "pavés", "pavers"], ["gravel", "gravier", "gravel"]])}</Field> | |
| 190 | + <Field label={fr ? "Entrée (m²) si pas de plan de site" : "Driveway (m²) if no site plan"}>{num("drivewayM2")}</Field> | |
| 191 | + <Field label={fr ? "Terrasse (m²) si pas de plan de site" : "Deck (m²) if no site plan"}>{num("deckM2")}</Field> | |
| 192 | + <Field label={fr ? "Clôture (m)" : "Fence (m)"}>{num("fenceM")}</Field> | |
| 193 | + <Field label={fr ? "Aménagement paysager (m²)" : "Landscaping (m²)"}>{num("landscapingM2")}</Field> | |
| 194 | + </div> | |
| 195 | + <div className="grid gap-4 sm:grid-cols-2"> | |
| 196 | + <div><span className="klabel">{fr ? "Revêtements extérieurs (parts en %)" : "Exterior cladding (shares in %)"}</span><div className="mt-1 grid grid-cols-3 gap-2 text-[12.5px]">{[["vinyl", "vinyle", "vinyl"], ["brick", "brique", "brick"], ["fiber_cement", "fibrociment", "fiber cement"], ["wood", "bois", "wood"], ["stone", "pierre", "stone"], ["steel", "acier", "steel"]].map(([kk, f, e]) => <label key={kk} className="flex items-center justify-between gap-1">{fr ? f : e}<Input type="number" min={0} max={100} value={Math.round((mix("siding")[kk] ?? 0) * 100)} disabled={!canWrite} onChange={(ev) => setMix("siding", kk, ev.target.value)} className="vp-mono !min-h-0 w-[64px] !py-1 text-right text-[12px]" /></label>)}</div></div> | |
| 197 | + <div><span className="klabel">{fr ? "Planchers des pièces sèches (parts en %)" : "Dry-room flooring (shares in %)"}</span><div className="mt-1 grid grid-cols-3 gap-2 text-[12.5px]">{[["hardwood", "bois franc", "hardwood"], ["engineered", "ingénierie", "engineered"], ["vinyl_plank", "vinyle", "vinyl plank"], ["laminate", "flottant", "laminate"], ["carpet", "tapis", "carpet"]].map(([kk, f, e]) => <label key={kk} className="flex items-center justify-between gap-1">{fr ? f : e}<Input type="number" min={0} max={100} value={Math.round((mix("flooring")[kk] ?? 0) * 100)} disabled={!canWrite} onChange={(ev) => setMix("flooring", kk, ev.target.value)} className="vp-mono !min-h-0 w-[64px] !py-1 text-right text-[12px]" /></label>)}</div></div> | |
| 198 | + </div> | |
| 199 | + <p className="text-[12px] text-ink-3">{fr ? "Les cuisines, salles de bain, chambres, garage, escaliers, portes et fenêtres sont comptés sur les plans (usages des pièces, ouvertures) ; la fiche complète ce que le plan ne montre pas. Les parts sont normalisées à 100 % par le moteur." : "Kitchens, bathrooms, bedrooms, garage, stairs, doors and windows are counted on the plans (room uses, openings); the sheet completes what the plan does not show. Shares are normalised to 100 % by the engine."}</p> | |
| 200 | + <Err msg={err} /> | |
| 201 | + </Block> | |
| 202 | + ); | |
| 203 | +} | |
modified
src/components/metre/metre.css
+12 −1
@@ -38,7 +38,10 @@ body.metre-app { background: #eef1f3; } | ||
| 38 | 38 | .mt-action span { font-size: 12px; color: #4b5258; } |
| 39 | 39 | |
| 40 | 40 | /* ----- éditeur de plan ----- */ |
| 41 | −.pl-wrap { display: grid; grid-template-columns: 56px minmax(0, 1fr) 300px; gap: 0; border: 1px solid #d9dee2; border-radius: 10px; overflow: hidden; background: #fff; height: calc(100vh - 150px); min-height: 560px; } | |
| 41 | +.pl-wrap { display: grid; grid-template-columns: 56px minmax(0, 1fr) 300px; gap: 0; border: 1px solid #d9dee2; border-radius: 10px; overflow: hidden; background: #fff; height: calc(100vh - 230px); min-height: 640px; } | |
| 42 | +.pl-wrap.nopanel { grid-template-columns: 56px minmax(0, 1fr); } | |
| 43 | +.pl-wrap.full { position: fixed; inset: 0; z-index: 1000; height: 100vh; min-height: 0; border-radius: 0; border: 0; } | |
| 44 | +.pl-wrap.full .pl-canvas { background: #dfe3e6; } | |
| 42 | 45 | @media (max-width: 1023px) { .pl-wrap { grid-template-columns: 56px minmax(0, 1fr); } .pl-right { display: none; } } |
| 43 | 46 | .pl-tools { background: #1f2326; display: flex; flex-direction: column; align-items: center; gap: 4px; padding: 8px 4px; overflow-y: auto; } |
| 44 | 47 | .pl-tools button { width: 44px; height: 44px; border-radius: 8px; color: #c9d1d4; display: flex; flex-direction: column; align-items: center; justify-content: center; font-size: 15px; line-height: 1; } |
@@ -71,3 +74,11 @@ body.metre-app { background: #eef1f3; } | ||
| 71 | 74 | .pl-levels button { padding: 5px 10px; border-radius: 6px; font-size: 12px; border: 1px solid #d9dee2; background: #fff; color: #4b5258; } |
| 72 | 75 | .pl-levels button.active { background: #16181b; color: #fff; border-color: #16181b; } |
| 73 | 76 | .pl-new { display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: 10px; } |
| 77 | +.tpl-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 12px; max-height: 60vh; overflow-y: auto; padding: 2px; } | |
| 78 | +.tpl-card { border: 1px solid #d9dee2; border-radius: 10px; background: #fff; overflow: hidden; text-align: left; display: flex; flex-direction: column; transition: border-color .15s, box-shadow .15s; } | |
| 79 | +.tpl-card:hover { border-color: #006085; box-shadow: 0 2px 10px rgba(0,96,133,.12); } | |
| 80 | +.tpl-card.sel { border-color: #006085; box-shadow: 0 0 0 2px #00608533; } | |
| 81 | +.tpl-card img, .tpl-card .thumb { width: 100%; height: 150px; object-fit: contain; background: #f6f8f9; border-bottom: 1px solid #eef1f3; } | |
| 82 | +.tpl-card b { font-size: 13px; padding: 8px 10px 2px; color: #16181b; } | |
| 83 | +.tpl-card span { font-size: 11px; color: #4b5258; padding: 0 10px 10px; font-family: var(--font-jetbrains), ui-monospace, monospace; } | |
| 84 | +.spec-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 12px; } | |
modified
src/lib/metre/db.ts
+3 −1
@@ -11,7 +11,7 @@ import Database from "better-sqlite3"; | ||
| 11 | 11 | import fs from "fs"; |
| 12 | 12 | import path from "path"; |
| 13 | 13 | |
| 14 | −export const METRE_DB_SCHEMA_VERSION = 3; | |
| 14 | +export const METRE_DB_SCHEMA_VERSION = 4; | |
| 15 | 15 | |
| 16 | 16 | let db: Database.Database | null = null; |
| 17 | 17 | |
@@ -102,6 +102,7 @@ CREATE TABLE IF NOT EXISTS metre_projects ( | ||
| 102 | 102 | notes TEXT, |
| 103 | 103 | is_template INTEGER NOT NULL DEFAULT 0, |
| 104 | 104 | locked INTEGER NOT NULL DEFAULT 0, |
| 105 | + spec_json TEXT, | |
| 105 | 106 | course TEXT, |
| 106 | 107 | deleted_at TEXT, |
| 107 | 108 | created_at TEXT NOT NULL DEFAULT (datetime('now')), |
@@ -467,6 +468,7 @@ function migrate(d: Database.Database): void { | ||
| 467 | 468 | if (cur < 3) { |
| 468 | 469 | for (const sql of ["ALTER TABLE metre_projects ADD COLUMN locked INTEGER NOT NULL DEFAULT 0", "ALTER TABLE metre_plan_pages ADD COLUMN source TEXT NOT NULL DEFAULT 'import'", "ALTER TABLE metre_plan_pages ADD COLUMN level TEXT", "ALTER TABLE metre_plan_pages ADD COLUMN meta_json TEXT"]) { try { d.exec(sql); } catch { /* déjà présent */ } } |
| 469 | 470 | } |
| 471 | + if (cur < 4) { try { d.exec("ALTER TABLE metre_projects ADD COLUMN spec_json TEXT"); } catch { /* déjà présent */ } } | |
| 470 | 472 | d.prepare("INSERT INTO metre_meta(key,value) VALUES('schema_version',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value").run(String(METRE_DB_SCHEMA_VERSION)); |
| 471 | 473 | seedSources(d); |
| 472 | 474 | } |
modified
src/lib/metre/estimates.ts
+37 −0
@@ -241,6 +241,7 @@ export function sanitizeLines(raw: unknown, d: Database.Database, projectId: str | ||
| 241 | 241 | id: typeof r.id === "string" && /^[0-9a-f-]{36}$/i.test(r.id) ? r.id : randomUUID(), wbs: String(r.wbs ?? "00").slice(0, 60), description: String(r.description ?? "").slice(0, 200), |
| 242 | 242 | assemblyCode: typeof r.assemblyCode === "string" && r.assemblyCode ? r.assemblyCode.toUpperCase().slice(0, 40) : null, measurementId, quantity, unit, adjustmentFactor: num("adjustmentFactor", 1, 0.0001, 1000), |
| 243 | 243 | basis, unitPrice: r.unitPrice == null || r.unitPrice === "" ? null : num("unitPrice", 0, 0, 1e8), installed: !!r.installed, assumptionNote: typeof r.assumptionNote === "string" ? r.assumptionNote.slice(0, 300) : null, excluded: !!r.excluded, sortOrder: num("sortOrder", i, 0, 1e6), |
| 244 | + ...(r.origin === "qs" ? { origin: "qs" as const } : {}), | |
| 244 | 245 | }; |
| 245 | 246 | }); |
| 246 | 247 | } |
@@ -366,3 +367,39 @@ export function saveValuationCase(ctx: Ctx, projectId: string, raw: Record<strin | ||
| 366 | 367 | export function deleteValuationCase(ctx: Ctx, id: string): void { |
| 367 | 368 | ctx.db.prepare("UPDATE metre_valuation_cases SET deleted_at=? WHERE id=?").run(nowIso(), id); |
| 368 | 369 | } |
| 370 | + | |
| 371 | +/* ------------------------------------------------ relevé des quantités (QS) */ | |
| 372 | + | |
| 373 | +/** | |
| 374 | + * Génère le relevé des quantités depuis tous les plans calibrés du projet et sa fiche technique : | |
| 375 | + * une mesure « dérivée des plans » par ligne (formule, entrées, pages sources) + les lignes d'estimation | |
| 376 | + * correspondantes (origine « qs », remplacées à chaque génération ; les lignes saisies sont conservées). | |
| 377 | + */ | |
| 378 | +export async function generateQuantitySurvey(ctx: Ctx, projectId: string, estimateId: string | null): Promise<{ estimate: EstimateRow; result: EstimateResult; qs: import("./quantity-survey").QsResult }> { | |
| 379 | + const { quantitySurvey } = await import("./quantity-survey"); | |
| 380 | + const { defaultSpec } = await import("./templates"); | |
| 381 | + const { listPlanFiles, listObjects, createManualMeasurement, listMeasurements, deleteMeasurement } = await import("./plans"); | |
| 382 | + const { getProject } = await import("./projects"); | |
| 383 | + const d = ctx.db; | |
| 384 | + const project = getProject(d, projectId); if (!project) throw new HttpError(404, "Projet introuvable."); | |
| 385 | + const spec = defaultSpec((project.spec ?? {}) as Parameters<typeof defaultSpec>[0]); | |
| 386 | + const levels = listPlanFiles(d, projectId).flatMap((f) => f.pages.map((pg) => ({ pageId: pg.id, level: pg.level ?? pg.label ?? "Rez-de-chaussée", objects: listObjects(d, pg.id), pxPerMeter: pg.calibration?.pxPerMeter ?? null }))); | |
| 387 | + if (!levels.length) throw new HttpError(400, "Aucun plan : dessinez ou importez au moins un niveau calibré."); | |
| 388 | + const available = new Set(loadAssemblies(getCostDb()).keys()); | |
| 389 | + const qs = quantitySurvey(levels, spec, available); | |
| 390 | + if (!qs.lines.length) throw new HttpError(400, `Relevé vide : ${qs.warnings.join(" ; ")}`); | |
| 391 | + let e = estimateId ? getEstimate(d, estimateId) : listEstimates(d, projectId)[0] ?? null; | |
| 392 | + if (!e) e = createEstimate(ctx, projectId, "Relevé des quantités", project.regionCode, project.valuationDate); | |
| 393 | + // retirer l'ancien relevé (lignes « qs » + mesures « QS · ») | |
| 394 | + for (const m of listMeasurements(d, projectId)) if (m.name.startsWith("QS · ")) deleteMeasurement(ctx, m.id); | |
| 395 | + const kept = e.lines.filter((l) => l.origin !== "qs"); | |
| 396 | + const lines: EstimateLineInput[] = [...kept]; | |
| 397 | + for (const q of qs.lines) { | |
| 398 | + const m = createManualMeasurement(ctx, projectId, { name: `QS · ${q.description}`, value: q.quantity, unit: q.unit, confidence: q.origin === "plan" ? "estimated" : "assumed", note: `${q.formula} — ${Object.entries(q.inputs).map(([k, v]) => `${k}=${v}`).join(", ")}${q.pageIds.length ? ` — pages ${q.pageIds.map((x) => x.slice(0, 8)).join(",")}` : ""} (${qs.version})` }); | |
| 399 | + lines.push({ id: randomUUID(), wbs: q.wbs, description: q.description, assemblyCode: q.assemblyCode, measurementId: m.id, quantity: q.quantity, unit: q.unit, adjustmentFactor: 1, basis: "catalogue", unitPrice: null, installed: false, assumptionNote: null, excluded: false, sortOrder: lines.length, origin: "qs" }); | |
| 400 | + } | |
| 401 | + const upd = updateEstimate(ctx, e.id, { lines }); | |
| 402 | + audit(d, { userId: ctx.user.id, role: ctx.user.role, action: "estimate.quantity_survey", entityType: "estimate", entityId: e.id, projectId, details: { lines: qs.lines.length, warnings: qs.warnings, summary: qs.summary }, ip: ctx.ip }); | |
| 403 | + const { result } = computeEstimateRow(d, upd); | |
| 404 | + return { estimate: upd, result, qs }; | |
| 405 | +} | |
modified
src/lib/metre/plans.ts
+32 −59
@@ -196,67 +196,40 @@ export async function createBlankPage(ctx: Ctx, projectId: string, p: { widthM?: | ||
| 196 | 196 | return { page: getPage(ctx.db, pid)!, calibration: cal }; |
| 197 | 197 | } |
| 198 | 198 | |
| 199 | −/** Gabarits de départ (murs extérieurs tracés, pièces nommées) — quantités reproductibles. */ | |
| 200 | −export const TEMPLATES: Record<string, { fr: string; en: string; widthM: number; heightM: number; build: (ppm: number, ox: number, oy: number) => { kind: ObjectKind; name: string; points: Point[]; closed: boolean; props: ObjectProps }[] }> = { | |
| 201 | − bungalow: { fr: "Bungalow 12,2 × 9,1 m (1 étage)", en: "Bungalow 12.2 × 9.1 m (1 storey)", widthM: 20, heightM: 15, build: (k, ox, oy) => { | |
| 202 | − const W = 12.2 * k, H = 9.1 * k; const o = { x: ox, y: oy }; | |
| 203 | − const rect = (x: number, y: number, w: number, h: number): Point[] => [{ x: o.x + x * k, y: o.y + y * k }, { x: o.x + (x + w) * k, y: o.y + y * k }, { x: o.x + (x + w) * k, y: o.y + (y + h) * k }, { x: o.x + x * k, y: o.y + (y + h) * k }]; | |
| 204 | − return [ | |
| 205 | − { kind: "wall", name: "Murs extérieurs", points: [{ x: o.x, y: o.y }, { x: o.x + W, y: o.y }, { x: o.x + W, y: o.y + H }, { x: o.x, y: o.y + H }], closed: true, props: { thickness: 0.3, height: 2.44, exterior: true } }, | |
| 206 | − { kind: "wall", name: "Cloison séjour / chambres", points: [{ x: o.x + 6.6 * k, y: o.y }, { x: o.x + 6.6 * k, y: o.y + H }], closed: false, props: { thickness: 0.12, height: 2.44 } }, | |
| 207 | − { kind: "wall", name: "Cloison chambres", points: [{ x: o.x + 6.6 * k, y: o.y + 4.5 * k }, { x: o.x + W, y: o.y + 4.5 * k }], closed: false, props: { thickness: 0.12, height: 2.44 } }, | |
| 208 | − { kind: "wall", name: "Cloison salle de bain", points: [{ x: o.x + 9.4 * k, y: o.y + 4.5 * k }, { x: o.x + 9.4 * k, y: o.y + H }], closed: false, props: { thickness: 0.12, height: 2.44 } }, | |
| 209 | − { kind: "room", name: "Séjour / cuisine", points: rect(0.15, 0.15, 6.39, 8.8), closed: true, props: { usage: "séjour" } }, | |
| 210 | − { kind: "room", name: "Chambre 1", points: rect(6.66, 0.15, 5.39, 4.29), closed: true, props: { usage: "chambre" } }, | |
| 211 | − { kind: "room", name: "Chambre 2", points: rect(6.66, 4.56, 2.68, 4.39), closed: true, props: { usage: "chambre" } }, | |
| 212 | − { kind: "room", name: "Salle de bain", points: rect(9.46, 4.56, 2.59, 4.39), closed: true, props: { usage: "salle de bain" } }, | |
| 213 | − { kind: "footprint", name: "Empreinte", points: [{ x: o.x - 0.15 * k, y: o.y - 0.15 * k }, { x: o.x + W + 0.15 * k, y: o.y - 0.15 * k }, { x: o.x + W + 0.15 * k, y: o.y + H + 0.15 * k }, { x: o.x - 0.15 * k, y: o.y + H + 0.15 * k }], closed: true, props: {} }, | |
| 214 | − ]; | |
| 215 | − } }, | |
| 216 | − cottage: { fr: "Cottage 2 étages 12,2 × 8,5 m (un niveau)", en: "Two-storey cottage 12.2 × 8.5 m (one level)", widthM: 20, heightM: 14, build: (k, ox, oy) => { | |
| 217 | − const W = 12.2 * k, H = 8.5 * k; const o = { x: ox, y: oy }; | |
| 218 | − const rect = (x: number, y: number, w: number, h: number): Point[] => [{ x: o.x + x * k, y: o.y + y * k }, { x: o.x + (x + w) * k, y: o.y + y * k }, { x: o.x + (x + w) * k, y: o.y + (y + h) * k }, { x: o.x + x * k, y: o.y + (y + h) * k }]; | |
| 219 | − return [ | |
| 220 | − { kind: "wall", name: "Murs extérieurs", points: [{ x: o.x, y: o.y }, { x: o.x + W, y: o.y }, { x: o.x + W, y: o.y + H }, { x: o.x, y: o.y + H }], closed: true, props: { thickness: 0.3, height: 2.6, exterior: true } }, | |
| 221 | − { kind: "wall", name: "Cloison centrale", points: [{ x: o.x + 5.5 * k, y: o.y }, { x: o.x + 5.5 * k, y: o.y + H }], closed: false, props: { thickness: 0.12, height: 2.6 } }, | |
| 222 | − { kind: "room", name: "Salon", points: rect(0.15, 0.15, 5.2, 8.2), closed: true, props: { usage: "séjour" } }, | |
| 223 | − { kind: "room", name: "Cuisine / salle à manger", points: rect(5.65, 0.15, 6.4, 8.2), closed: true, props: { usage: "cuisine" } }, | |
| 224 | − { kind: "stairs", name: "Escalier", points: rect(5.65, 0.15, 1.0, 3.6), closed: true, props: {} }, | |
| 225 | − { kind: "footprint", name: "Empreinte", points: [{ x: o.x - 0.15 * k, y: o.y - 0.15 * k }, { x: o.x + W + 0.15 * k, y: o.y - 0.15 * k }, { x: o.x + W + 0.15 * k, y: o.y + H + 0.15 * k }, { x: o.x - 0.15 * k, y: o.y + H + 0.15 * k }], closed: true, props: {} }, | |
| 226 | − ]; | |
| 227 | − } }, | |
| 228 | − garage: { fr: "Garage détaché 6,7 × 6,7 m", en: "Detached garage 6.7 × 6.7 m", widthM: 12, heightM: 12, build: (k, ox, oy) => { | |
| 229 | − const W = 6.7 * k, H = 6.7 * k; const o = { x: ox, y: oy }; | |
| 230 | − return [ | |
| 231 | − { kind: "wall", name: "Murs du garage", points: [{ x: o.x, y: o.y }, { x: o.x + W, y: o.y }, { x: o.x + W, y: o.y + H }, { x: o.x, y: o.y + H }], closed: true, props: { thickness: 0.2, height: 3.0, exterior: true } }, | |
| 232 | − { kind: "slab", name: "Dalle 125 mm", points: [{ x: o.x, y: o.y }, { x: o.x + W, y: o.y }, { x: o.x + W, y: o.y + H }, { x: o.x, y: o.y + H }], closed: true, props: { thickness: 0.125 } }, | |
| 233 | − { kind: "footprint", name: "Empreinte", points: [{ x: o.x - 0.1 * k, y: o.y - 0.1 * k }, { x: o.x + W + 0.1 * k, y: o.y - 0.1 * k }, { x: o.x + W + 0.1 * k, y: o.y + H + 0.1 * k }, { x: o.x - 0.1 * k, y: o.y + H + 0.1 * k }], closed: true, props: {} }, | |
| 234 | − ]; | |
| 235 | − } }, | |
| 236 | −}; | |
| 237 | − | |
| 238 | −export async function createTemplatePage(ctx: Ctx, projectId: string, p: { template: string; level?: string | null; pxPerMeter?: number }): Promise<{ page: PlanPage; objects: number }> { | |
| 239 | − const t = TEMPLATES[p.template]; | |
| 240 | − if (!t) throw new HttpError(400, `Gabarit inconnu : ${Object.keys(TEMPLATES).join(", ")}`); | |
| 199 | +/** Crée tous les niveaux d'un gabarit généré (templates.ts) dans le projet, pose la fiche technique par défaut du gabarit. */ | |
| 200 | +export async function createTemplateProject(ctx: Ctx, projectId: string, p: { template: string; pxPerMeter?: number }): Promise<{ pages: PlanPage[]; objects: number; template: string }> { | |
| 201 | + const { buildTemplate, materializeLevel, defaultSpec } = await import("./templates"); | |
| 202 | + const t = buildTemplate(p.template); | |
| 203 | + if (!t) throw new HttpError(400, "Gabarit inconnu."); | |
| 241 | 204 | const ppm = Math.min(120, Math.max(20, Number(p.pxPerMeter) || 50)); |
| 242 | − const { page } = await createBlankPage(ctx, projectId, { widthM: t.widthM, heightM: t.heightM, pxPerMeter: ppm, label: p.level ?? "Rez-de-chaussée", level: p.level ?? "Rez-de-chaussée" }); | |
| 243 | − ctx.db.prepare("UPDATE metre_plan_pages SET source='template', meta_json=json_set(COALESCE(meta_json,'{}'),'$.template',?) WHERE id=?").run(p.template, page.id); | |
| 244 | − const objs = t.build(ppm, 3 * ppm, 2.5 * ppm); | |
| 245 | − let n = 0; | |
| 246 | − const wallIds: string[] = []; | |
| 247 | − for (const o of objs) { const created = createObject(ctx, page.id, o as unknown as Record<string, unknown>); n++; if (o.kind === "wall" && o.props.exterior) wallIds.push(created.id); } | |
| 248 | − // portes et fenêtres de départ sur le mur extérieur | |
| 249 | − if (wallIds[0]) { | |
| 250 | − const w = getObject(ctx.db, wallIds[0])!; | |
| 251 | − const along = (t0: number, segIdx: number): Point => { const a = w.points[segIdx], b = w.points[(segIdx + 1) % w.points.length]; return { x: a.x + (b.x - a.x) * t0, y: a.y + (b.y - a.y) * t0 }; }; | |
| 252 | − const items: { name: string; p: Point; kind: "door" | "window"; wdt: number; hgt: number; swing?: number }[] = [ | |
| 253 | − { name: "Porte d'entrée", p: along(0.2, 0), kind: "door", wdt: 0.9, hgt: 2.1, swing: 1 }, { name: "Fenêtre séjour", p: along(0.55, 0), kind: "window", wdt: 1.8, hgt: 1.5 }, { name: "Fenêtre", p: along(0.5, 1), kind: "window", wdt: 1.2, hgt: 1.2 }, | |
| 254 | − { name: "Fenêtre", p: along(0.3, 2), kind: "window", wdt: 1.2, hgt: 1.2 }, { name: "Porte-patio", p: along(0.7, 2), kind: "door", wdt: 1.8, hgt: 2.1, swing: -1 }, { name: "Fenêtre", p: along(0.5, 3), kind: "window", wdt: 1.2, hgt: 1.2 }, | |
| 255 | − ]; | |
| 256 | − for (const it of items) { createObject(ctx, page.id, { kind: "opening", name: it.name, points: [it.p], props: { openingKind: it.kind, openingWidth: it.wdt, openingHeight: it.hgt, deductFrom: w.id, swing: it.swing ?? 1 } }); n++; } | |
| 205 | + const pages: PlanPage[] = []; let n = 0; | |
| 206 | + for (const lv of t.levels) { | |
| 207 | + const { page } = await createBlankPage(ctx, projectId, { widthM: lv.widthM, heightM: lv.heightM, pxPerMeter: ppm, label: lv.level, level: lv.level }); | |
| 208 | + ctx.db.prepare("UPDATE metre_plan_pages SET source='template', meta_json=json_set(COALESCE(meta_json,'{}'),'$.template',?) WHERE id=?").run(t.key, page.id); | |
| 209 | + const idMap = new Map<string, string>(); | |
| 210 | + const objs = materializeLevel(lv, ppm); | |
| 211 | + for (const o of objs.filter((x) => x.kind !== "opening")) { const c = createObject(ctx, page.id, { ...o, id: undefined }); idMap.set(o.id, c.id); n++; } | |
| 212 | + for (const o of objs.filter((x) => x.kind === "opening")) { createObject(ctx, page.id, { ...o, id: undefined, props: { ...o.props, deductFrom: o.props.deductFrom ? idMap.get(o.props.deductFrom) ?? null : null } }); n++; } | |
| 213 | + pages.push(getPage(ctx.db, page.id)!); | |
| 257 | 214 | } |
| 258 | − audit(ctx.db, { userId: ctx.user.id, role: ctx.user.role, action: "plan.template", entityType: "page", entityId: page.id, projectId, details: { template: p.template, objects: n }, ip: ctx.ip }); | |
| 259 | − return { page: getPage(ctx.db, page.id)!, objects: n }; | |
| 215 | + const spec = defaultSpec(t.spec); | |
| 216 | + ctx.db.prepare("UPDATE metre_projects SET spec_json=?, stories=?, building_kind=?, updated_at=? WHERE id=?").run(JSON.stringify(spec), t.storeys, t.kind === "plex" ? "plex" : t.kind === "row" ? "row" : t.kind === "garage" ? "other" : t.kind === "condo" ? "condo" : t.kind === "chalet" ? "chalet" : "detached", nowIso(), projectId); | |
| 217 | + audit(ctx.db, { userId: ctx.user.id, role: ctx.user.role, action: "plan.template", entityType: "project", entityId: projectId, projectId, details: { template: t.key, levels: pages.length, objects: n }, ip: ctx.ip }); | |
| 218 | + return { pages, objects: n, template: t.key }; | |
| 219 | +} | |
| 220 | + | |
| 221 | +/** Copie une page (image, calibration, objets) vers un autre projet (duplication de projet). */ | |
| 222 | +export function copyPageTo(ctx: Ctx, pageId: string, targetProjectId: string): string | null { | |
| 223 | + const cur = getPage(ctx.db, pageId); if (!cur) return null; | |
| 224 | + const f = virtualFile(ctx, targetProjectId, `${cur.label ?? "page"}.plan`, "application/x-metre-plan", Buffer.from(JSON.stringify({ kind: "copy", from: pageId }))); | |
| 225 | + const imgPath = path.join(f.dir, "page-1.png"); | |
| 226 | + fs.copyFileSync(cur.imagePath, imgPath); | |
| 227 | + const pid = insertPage(ctx, f.id, targetProjectId, { widthPx: cur.widthPx, heightPx: cur.heightPx, imagePath: imgPath, label: cur.label ?? "page", source: cur.source ?? "blank", level: cur.level ?? null, meta: cur.meta }); | |
| 228 | + if (cur.calibration) { const c = calibratePage(ctx, pid, { p1: cur.calibration.p1, p2: cur.calibration.p2, realLength: cur.calibration.realLength, unit: cur.calibration.unit }); ctx.db.prepare("UPDATE metre_calibrations SET uncertainty_pct=? WHERE id=?").run(cur.calibration.uncertaintyPct, c.id); } | |
| 229 | + const idMap = new Map<string, string>(); const objs = listObjects(ctx.db, pageId); | |
| 230 | + for (const o of objs.filter((x) => x.kind !== "opening")) { const c = createObject(ctx, pid, { ...o, id: undefined, props: { ...o.props } }); idMap.set(o.id, c.id); } | |
| 231 | + for (const o of objs.filter((x) => x.kind === "opening")) createObject(ctx, pid, { ...o, id: undefined, props: { ...o.props, deductFrom: o.props.deductFrom ? idMap.get(o.props.deductFrom) ?? null : null } }); | |
| 232 | + return pid; | |
| 260 | 233 | } |
| 261 | 234 | |
| 262 | 235 | /** |
modified
src/lib/metre/projects.ts
+32 −10
@@ -3,16 +3,34 @@ import { randomUUID } from "crypto"; | ||
| 3 | 3 | import type Database from "better-sqlite3"; |
| 4 | 4 | import { audit, nowIso } from "./db"; |
| 5 | 5 | import { HttpError, projectAccess, type Ctx } from "./auth"; |
| 6 | +import { copyPageTo } from "./plans"; | |
| 6 | 7 | import type { BuildingKind, MetreUser, Project, UnitSystem } from "./types"; |
| 7 | 8 | import { getUnit } from "../db"; |
| 8 | 9 | import { resolveLocation } from "../cost/catalog"; |
| 9 | 10 | import { sqftFromM2 } from "../cost/units"; |
| 11 | +import { defaultSpec, type BuildingSpecQS } from "./templates"; | |
| 12 | + | |
| 13 | +/** Fiche technique validée (bornes, listes) — fusionnée avec les défauts. */ | |
| 14 | +export function sanitizeSpec(raw: Record<string, unknown>, base: Record<string, unknown> | null): Record<string, unknown> { | |
| 15 | + const d = defaultSpec((base ?? {}) as Partial<BuildingSpecQS>); | |
| 16 | + const pick = <T extends string>(k: string, allowed: T[], def: T): T => (allowed.includes(raw[k] as T) ? (raw[k] as T) : (d as unknown as Record<string, T>)[k] ?? def); | |
| 17 | + const num = (k: string, lo: number, hi: number, def: number) => { const v = raw[k]; if (v == null || v === "") return (d as unknown as Record<string, number>)[k] ?? def; const n = Number(v); if (!Number.isFinite(n)) throw new HttpError(400, `Fiche : ${k} invalide.`); return Math.min(hi, Math.max(lo, n)); }; | |
| 18 | + const mix = (k: string) => { const v = raw[k]; if (!v || typeof v !== "object") return (d as unknown as Record<string, unknown>)[k]; return Object.fromEntries(Object.entries(v as Record<string, unknown>).filter(([, x]) => typeof x === "number" && x > 0).map(([kk, x]) => [kk.slice(0, 20), Math.min(1, Number(x))])); }; | |
| 19 | + const out: BuildingSpecQS = { | |
| 20 | + units: num("units", 1, 60, 1), basement: pick("basement", ["none", "crawl", "unfinished", "finished"], "unfinished"), basementFinishedPct: num("basementFinishedPct", 0, 1, 0), foundation: pick("foundation", ["poured_concrete", "concrete_block", "slab_on_grade", "piers"], "poured_concrete"), foundationWallHeightM: num("foundationWallHeightM", 0.3, 4, 2.44), | |
| 21 | + roofType: pick("roofType", ["asphalt_shingle", "metal", "membrane"], "asphalt_shingle"), roofGeometry: pick("roofGeometry", ["gable", "hip", "flat"], "gable"), roofPitch: num("roofPitch", 0, 18, 6), siding: mix("siding") as BuildingSpecQS["siding"], windows: pick("windows", ["pvc", "hybrid", "aluminum", "wood"], "pvc"), | |
| 22 | + heating: pick("heating", ["electric_baseboard", "heat_pump", "furnace_electric", "furnace_gas"], "electric_baseboard"), hasAirConditioning: typeof raw.hasAirConditioning === "boolean" ? raw.hasAirConditioning : d.hasAirConditioning, hasAirExchanger: typeof raw.hasAirExchanger === "boolean" ? raw.hasAirExchanger : d.hasAirExchanger, | |
| 23 | + quality: pick("quality", ["economy", "standard", "superior", "prestige"], "standard"), kitchenQuality: pick("kitchenQuality", ["economy", "standard", "superior", "prestige"], "standard"), bathroomQuality: pick("bathroomQuality", ["economy", "standard", "superior", "prestige"], "standard"), flooring: mix("flooring") as BuildingSpecQS["flooring"], | |
| 24 | + ceilingHeightM: num("ceilingHeightM", 2, 6, 2.44), garage: pick("garage", ["none", "attached", "detached", "integrated"], "none"), deckM2: num("deckM2", 0, 2000, 0), drivewayM2: num("drivewayM2", 0, 5000, 0), driveway: pick("driveway", ["asphalt", "pavers", "gravel", "none"], "none"), fenceM: num("fenceM", 0, 2000, 0), landscapingM2: num("landscapingM2", 0, 50000, 0), notes: typeof raw.notes === "string" ? raw.notes.slice(0, 1000) : d.notes, | |
| 25 | + }; | |
| 26 | + return out as unknown as Record<string, unknown>; | |
| 27 | +} | |
| 10 | 28 | |
| 11 | 29 | const KINDS: BuildingKind[] = ["detached", "semi_detached", "row", "plex", "condo", "chalet", "commercial", "other"]; |
| 12 | 30 | |
| 13 | −interface Row { id: string; owner_id: string; name: string; address: string | null; municipality: string | null; region_code: string | null; property_id: string | null; valuation_date: string; building_kind: BuildingKind; stories: number; unit_system: UnitSystem; notes: string | null; is_template: number; locked: number; course: string | null; created_at: string; updated_at: string } | |
| 31 | +interface Row { id: string; owner_id: string; name: string; address: string | null; municipality: string | null; region_code: string | null; property_id: string | null; valuation_date: string; building_kind: BuildingKind; stories: number; unit_system: UnitSystem; notes: string | null; is_template: number; locked: number; spec_json: string | null; course: string | null; created_at: string; updated_at: string } | |
| 14 | 32 | |
| 15 | −const map = (r: Row): Project => ({ id: r.id, ownerId: r.owner_id, name: r.name, address: r.address, municipality: r.municipality, regionCode: r.region_code, propertyId: r.property_id, valuationDate: r.valuation_date, buildingKind: r.building_kind, stories: r.stories, unitSystem: r.unit_system, notes: r.notes, isTemplate: !!r.is_template, locked: !!r.locked, createdAt: r.created_at, updatedAt: r.updated_at }); | |
| 33 | +const map = (r: Row): Project => ({ id: r.id, ownerId: r.owner_id, name: r.name, address: r.address, municipality: r.municipality, regionCode: r.region_code, propertyId: r.property_id, valuationDate: r.valuation_date, buildingKind: r.building_kind, stories: r.stories, unitSystem: r.unit_system, notes: r.notes, isTemplate: !!r.is_template, locked: !!r.locked, spec: r.spec_json ? (JSON.parse(r.spec_json) as Record<string, unknown>) : null, createdAt: r.created_at, updatedAt: r.updated_at }); | |
| 16 | 34 | |
| 17 | 35 | export function getProject(d: Database.Database, id: string): Project | null { |
| 18 | 36 | const r = d.prepare("SELECT * FROM metre_projects WHERE id=? AND deleted_at IS NULL").get(id) as Row | undefined; |
@@ -30,7 +48,7 @@ export function sanitizeProject(raw: Record<string, unknown>, base?: Project): O | ||
| 30 | 48 | return { |
| 31 | 49 | name, address: s("address", 200), municipality: s("municipality", 120), regionCode: s("regionCode", 20), propertyId: s("propertyId", 40), valuationDate: vd, |
| 32 | 50 | buildingKind: KINDS.includes(kind as BuildingKind) ? (kind as BuildingKind) : "detached", stories: Number.isFinite(stories) ? Math.min(60, Math.max(1, stories)) : 1, |
| 33 | − unitSystem: (raw.unitSystem ?? base?.unitSystem) === "imperial" ? "imperial" : "metric", notes: s("notes", 4000), isTemplate: typeof raw.isTemplate === "boolean" ? raw.isTemplate : base?.isTemplate ?? false, locked: base?.locked ?? false, | |
| 51 | + unitSystem: (raw.unitSystem ?? base?.unitSystem) === "imperial" ? "imperial" : "metric", notes: s("notes", 4000), isTemplate: typeof raw.isTemplate === "boolean" ? raw.isTemplate : base?.isTemplate ?? false, locked: base?.locked ?? false, spec: raw.spec && typeof raw.spec === "object" ? sanitizeSpec(raw.spec as Record<string, unknown>, base?.spec ?? null) : base?.spec ?? null, | |
| 34 | 52 | }; |
| 35 | 53 | } |
| 36 | 54 | |
@@ -39,8 +57,8 @@ export function createProject(ctx: Ctx, raw: Record<string, unknown>): Project { | ||
| 39 | 57 | if (p.isTemplate && ctx.user.role === "student") throw new HttpError(403, "Seuls les enseignants créent des gabarits."); |
| 40 | 58 | const id = randomUUID(); |
| 41 | 59 | const course = (ctx.db.prepare("SELECT course FROM metre_users WHERE id=?").get(ctx.user.id) as { course: string | null } | undefined)?.course ?? null; |
| 42 | − ctx.db.prepare("INSERT INTO metre_projects(id,owner_id,name,address,municipality,region_code,property_id,valuation_date,building_kind,stories,unit_system,notes,is_template,course) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)") | |
| 43 | − .run(id, ctx.user.id, p.name, p.address, p.municipality, p.regionCode, p.propertyId, p.valuationDate, p.buildingKind, p.stories, p.unitSystem, p.notes, p.isTemplate ? 1 : 0, course); | |
| 60 | + ctx.db.prepare("INSERT INTO metre_projects(id,owner_id,name,address,municipality,region_code,property_id,valuation_date,building_kind,stories,unit_system,notes,is_template,course,spec_json) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)") | |
| 61 | + .run(id, ctx.user.id, p.name, p.address, p.municipality, p.regionCode, p.propertyId, p.valuationDate, p.buildingKind, p.stories, p.unitSystem, p.notes, p.isTemplate ? 1 : 0, course, p.spec ? JSON.stringify(p.spec) : null); | |
| 44 | 62 | audit(ctx.db, { userId: ctx.user.id, role: ctx.user.role, action: "project.create", entityType: "project", entityId: id, projectId: id, ip: ctx.ip }); |
| 45 | 63 | return getProject(ctx.db, id)!; |
| 46 | 64 | } |
@@ -50,8 +68,8 @@ export function updateProject(ctx: Ctx, id: string, raw: Record<string, unknown> | ||
| 50 | 68 | if (!base) throw new HttpError(404, "Projet introuvable."); |
| 51 | 69 | const p = sanitizeProject({ ...(base as unknown as Record<string, unknown>), ...raw }, base); |
| 52 | 70 | if (p.isTemplate !== base.isTemplate && ctx.user.role === "student") throw new HttpError(403, "Seuls les enseignants gèrent les gabarits."); |
| 53 | − ctx.db.prepare("UPDATE metre_projects SET name=?,address=?,municipality=?,region_code=?,property_id=?,valuation_date=?,building_kind=?,stories=?,unit_system=?,notes=?,is_template=?,updated_at=? WHERE id=?") | |
| 54 | − .run(p.name, p.address, p.municipality, p.regionCode, p.propertyId, p.valuationDate, p.buildingKind, p.stories, p.unitSystem, p.notes, p.isTemplate ? 1 : 0, nowIso(), id); | |
| 71 | + ctx.db.prepare("UPDATE metre_projects SET name=?,address=?,municipality=?,region_code=?,property_id=?,valuation_date=?,building_kind=?,stories=?,unit_system=?,notes=?,is_template=?,spec_json=?,updated_at=? WHERE id=?") | |
| 72 | + .run(p.name, p.address, p.municipality, p.regionCode, p.propertyId, p.valuationDate, p.buildingKind, p.stories, p.unitSystem, p.notes, p.isTemplate ? 1 : 0, p.spec ? JSON.stringify(p.spec) : null, nowIso(), id); | |
| 55 | 73 | audit(ctx.db, { userId: ctx.user.id, role: ctx.user.role, action: "project.update", entityType: "project", entityId: id, projectId: id, details: Object.keys(raw), ip: ctx.ip }); |
| 56 | 74 | return getProject(ctx.db, id)!; |
| 57 | 75 | } |
@@ -76,9 +94,12 @@ export function duplicateProject(ctx: Ctx, sourceId: string, name: string | null | ||
| 76 | 94 | if (!src) throw new HttpError(404, "Projet introuvable."); |
| 77 | 95 | const id = randomUUID(); |
| 78 | 96 | const d = ctx.db; |
| 97 | + const pagesToCopy: string[] = []; | |
| 79 | 98 | const tx = d.transaction(() => { |
| 80 | − d.prepare("INSERT INTO metre_projects(id,owner_id,name,address,municipality,region_code,property_id,valuation_date,building_kind,stories,unit_system,notes,is_template,course) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,0,NULL)") | |
| 81 | − .run(id, ctx.user.id, (name ?? `${src.name} — copie`).slice(0, 120), src.address, src.municipality, src.regionCode, src.propertyId, src.valuationDate, src.buildingKind, src.stories, src.unitSystem, src.notes); | |
| 99 | + d.prepare("INSERT INTO metre_projects(id,owner_id,name,address,municipality,region_code,property_id,valuation_date,building_kind,stories,unit_system,notes,is_template,course,spec_json) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,0,NULL,?)") | |
| 100 | + .run(id, ctx.user.id, (name ?? `${src.name} — copie`).slice(0, 120), src.address, src.municipality, src.regionCode, src.propertyId, src.valuationDate, src.buildingKind, src.stories, src.unitSystem, src.notes, src.spec ? JSON.stringify(src.spec) : null); | |
| 101 | + // plans copiés (pages, calibration, objets) | |
| 102 | + for (const f of d.prepare("SELECT id FROM metre_plan_files WHERE project_id=?").all(sourceId) as { id: string }[]) for (const pg of d.prepare("SELECT id FROM metre_plan_pages WHERE file_id=?").all(f.id) as { id: string }[]) pagesToCopy.push(pg.id); | |
| 82 | 103 | const mmap = new Map<string, string>(); |
| 83 | 104 | for (const m of d.prepare("SELECT * FROM metre_measurements WHERE project_id=? AND deleted_at IS NULL AND object_id IS NULL").all(sourceId) as Record<string, unknown>[]) { |
| 84 | 105 | const nid = randomUUID(); mmap.set(String(m.id), nid); |
@@ -94,7 +115,8 @@ export function duplicateProject(ctx: Ctx, sourceId: string, name: string | null | ||
| 94 | 115 | } |
| 95 | 116 | }); |
| 96 | 117 | tx(); |
| 97 | − audit(ctx.db, { userId: ctx.user.id, role: ctx.user.role, action: "project.duplicate", entityType: "project", entityId: id, projectId: id, details: { from: sourceId }, ip: ctx.ip }); | |
| 118 | + for (const pg of pagesToCopy) copyPageTo(ctx, pg, id); | |
| 119 | + audit(ctx.db, { userId: ctx.user.id, role: ctx.user.role, action: "project.duplicate", entityType: "project", entityId: id, projectId: id, details: { from: sourceId, pages: pagesToCopy.length }, ip: ctx.ip }); | |
| 98 | 120 | return getProject(ctx.db, id)!; |
| 99 | 121 | } |
| 100 | 122 | |
added
src/lib/metre/qs.test.ts
+97 −0
@@ -0,0 +1,97 @@ | ||
| 1 | +// UQO Éval — Métré : tests des gabarits générés (cohérence géométrique de chacun) et du relevé des quantités (QS) — purs, puis service sur base isolée. | |
| 2 | +import { afterAll, beforeAll, describe, expect, it } from "vitest"; | |
| 3 | +import fs from "fs"; | |
| 4 | +import os from "os"; | |
| 5 | +import path from "path"; | |
| 6 | +import { buildTemplate, defaultSpec, listTemplates, materializeLevel } from "./templates"; | |
| 7 | +import { quantitySurvey, type LevelInput } from "./quantity-survey"; | |
| 8 | +import { planQuantities, projectOnPolyline } from "./plan-geometry"; | |
| 9 | +import { polygonAreaPx } from "./geometry"; | |
| 10 | + | |
| 11 | +describe("gabarits générés", () => { | |
| 12 | + const all = listTemplates(); | |
| 13 | + it("au moins 30 gabarits, toutes familles présentes", () => { | |
| 14 | + expect(all.length).toBeGreaterThanOrEqual(30); | |
| 15 | + for (const kind of ["bungalow", "cottage", "plex", "row", "chalet", "mobile", "condo", "garage"]) expect(all.some((t) => t.kind === kind), kind).toBe(true); | |
| 16 | + expect(all.filter((t) => t.kind === "plex").length).toBeGreaterThanOrEqual(8); | |
| 17 | + }); | |
| 18 | + for (const t of all) it(`${t.key} : niveaux cohérents, ouvertures sur les murs, pièces dans l'enveloppe`, () => { | |
| 19 | + const full = buildTemplate(t.key)!; | |
| 20 | + expect(full.levels.length).toBe(t.storeys + (t.spec.basement && t.spec.basement !== "none" && t.kind !== "garage" ? 1 : 0)); | |
| 21 | + for (const lv of full.levels) { | |
| 22 | + const objs = materializeLevel(lv, 50); | |
| 23 | + const walls = objs.filter((o) => o.kind === "wall"), rooms = objs.filter((o) => o.kind === "room"), openings = objs.filter((o) => o.kind === "opening"), fp = objs.find((o) => o.kind === "footprint")!; | |
| 24 | + expect(walls.length).toBeGreaterThan(0); expect(fp).toBeTruthy(); | |
| 25 | + const q = planQuantities(objs, 50); | |
| 26 | + // chaque ouverture est à moins de 1 cm de son mur | |
| 27 | + for (const o of openings) { const w = objs.find((x) => x.id === o.props.deductFrom)!; expect(w, `${t.key}/${lv.level}/${o.name}`).toBeTruthy(); const pr = projectOnPolyline(o.points[0], w.points, w.closed)!; expect(pr.dist).toBeLessThan(0.5); } | |
| 28 | + // chaque pièce est dans l'empreinte (centroïde) et les aires sont plausibles | |
| 29 | + const fpArea = polygonAreaPx(fp.points) / 2500; | |
| 30 | + for (const r of q.rooms) { expect(r.areaM2, `${t.key}/${lv.level}/${r.name}`).toBeGreaterThan(1); expect(r.areaM2).toBeLessThan(fpArea + 1); } | |
| 31 | + if (!lv.level.includes("Sous-sol") && t.kind !== "garage") { expect(rooms.length, `${t.key}/${lv.level}`).toBeGreaterThanOrEqual(2); expect(q.totals.windows + q.totals.doors).toBeGreaterThanOrEqual(3); } | |
| 32 | + expect(q.totals.wallLengthM).toBeGreaterThan(10); | |
| 33 | + } | |
| 34 | + }); | |
| 35 | +}); | |
| 36 | + | |
| 37 | +describe("relevé des quantités (pur)", () => { | |
| 38 | + const t = buildTemplate("bungalow-c")!; | |
| 39 | + const levels: LevelInput[] = t.levels.map((lv, i) => ({ pageId: `p${i}`, level: lv.level, objects: materializeLevel(lv, 50).map((o, j) => ({ ...o, id: `${i}-${j}`, props: { ...o.props, deductFrom: o.props.deductFrom ? `${i}-${Number(o.props.deductFrom.slice(4)) - 1}` : o.props.deductFrom } })), pxPerMeter: 50 })); | |
| 40 | + const spec = defaultSpec(t.spec); | |
| 41 | + it("produit un relevé complet et cohérent pour un bungalow 12,2 × 9,1 m avec sous-sol", () => { | |
| 42 | + const r = quantitySurvey(levels, spec); | |
| 43 | + const codes = new Set(r.lines.map((l) => l.assemblyCode)); | |
| 44 | + for (const c of ["SITE-EXCAV-BASEMENT", "FND-FOOTING", "FND-WALL-POURED-8IN", "FND-SLAB-BASEMENT-4IN", "STR-FLOOR-IJOIST", "STR-WALL-2X6-EXT", "STR-WALL-2X4-INT", "STR-ROOF-TRUSS-OSB", "ENV-INSUL-WALL-R24", "ENV-INSUL-ATTIC-R50", "ENV-SIDING-VINYL", "ENV-SIDING-BRICK", "ROOF-ASPHALT-ARCH", "OPN-WINDOW-PVC-STD", "OPN-DOOR-EXT-STEEL", "OPN-DOOR-PATIO-6FT", "OPN-DOOR-CLOSET-BIFOLD", "INT-DRYWALL-1/2-FINISHED", "INT-DRYWALL-5/8-CEILING", "INT-PAINT-2-COATS", "INT-FLOOR-CERAMIC", "INT-FLOOR-HARDWOOD", "INT-TRIM-BASEBOARD", "KIT-STANDARD", "BATH-STANDARD", "MEC-PLUMBING-ROUGH-IN", "MEC-WATER-HEATER-60", "MEC-BASEBOARD-ELECTRIC", "MEC-HRV", "ELE-SERVICE-200A", "ELE-ROUGH-WIRING", "ELE-LIGHTING-STD", "EXT-STEPS-PRECAST"]) expect(codes.has(c), c).toBe(true); | |
| 45 | + expect(r.summary.empreinte_m2).toBeCloseTo(12.5 * 9.4, 0); // empreinte hors tout (murs 0,3 m) | |
| 46 | + expect(r.summary.perimetre_m).toBeCloseTo((12.2 + 9.1) * 2, 1); | |
| 47 | + expect(r.summary.chambres).toBe(3); expect(r.summary.salles_de_bain).toBe(1); expect(r.summary.cuisines).toBe(1); | |
| 48 | + const win = r.lines.find((l) => l.assemblyCode === "OPN-WINDOW-PVC-STD")!; expect(win.origin).toBe("plan"); expect(win.quantity).toBeGreaterThanOrEqual(5); | |
| 49 | + const ext = r.lines.find((l) => l.assemblyCode === "STR-WALL-2X6-EXT")!; expect(ext.quantity).toBeGreaterThan((12.2 + 9.1) * 2 * 2.44); | |
| 50 | + const fnd = r.lines.find((l) => l.assemblyCode === "FND-WALL-POURED-8IN")!; expect(fnd.quantity).toBeCloseTo((12.2 + 9.1) * 2 * 2.44, 0); | |
| 51 | + expect(r.lines.every((l) => l.quantity > 0 && l.formula && l.unit)).toBe(true); | |
| 52 | + expect(r.warnings).toHaveLength(0); | |
| 53 | + }); | |
| 54 | + it("respecte la fiche : dalle sans sous-sol, thermopompe, toit plat membrane, catalogue filtré", () => { | |
| 55 | + const r = quantitySurvey(levels.filter((l) => !l.level.includes("Sous-sol")), defaultSpec({ ...t.spec, basement: "none", foundation: "slab_on_grade", foundationWallHeightM: 1.2, heating: "heat_pump", hasAirConditioning: true, roofGeometry: "flat", roofType: "membrane", roofPitch: 0 }), new Set(["FND-SLAB-ON-GRADE-INSULATED", "MEC-HEATPUMP-CENTRAL", "ROOF-MEMBRANE-ELASTO", "STR-ROOF-TRUSS-OSB"])); | |
| 56 | + const codes = r.lines.map((l) => l.assemblyCode); | |
| 57 | + expect(codes).toContain("FND-SLAB-ON-GRADE-INSULATED"); expect(codes).toContain("MEC-HEATPUMP-CENTRAL"); expect(codes).toContain("ROOF-MEMBRANE-ELASTO"); | |
| 58 | + expect(codes).not.toContain("FND-SLAB-BASEMENT-4IN"); expect(codes).not.toContain("SITE-EXCAV-BASEMENT"); | |
| 59 | + expect(r.warnings.some((w) => w.includes("absent du catalogue"))).toBe(true); // les autres codes sont filtrés et signalés | |
| 60 | + const roof = r.lines.find((l) => l.assemblyCode === "STR-ROOF-TRUSS-OSB")!; expect(roof.inputs.facteur_pente).toBe(1); | |
| 61 | + }); | |
| 62 | + it("sans niveau calibré : relevé impossible, avertissement explicite", () => { | |
| 63 | + const r = quantitySurvey(levels.map((l) => ({ ...l, pxPerMeter: null })), spec); | |
| 64 | + expect(r.lines).toHaveLength(0); expect(r.warnings.join(" ")).toMatch(/non calibrée|impossible/); | |
| 65 | + }); | |
| 66 | +}); | |
| 67 | + | |
| 68 | +describe("service : gabarit multi-niveaux → relevé → estimation", () => { | |
| 69 | + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "metre-qs-")); | |
| 70 | + process.env.METRE_DB = path.join(tmp, "metre.db"); process.env.METRE_FILES_DIR = path.join(tmp, "files"); process.env.COST_DB = path.join(tmp, "cost.db"); | |
| 71 | + afterAll(() => { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* ignore */ } }); | |
| 72 | + let ctx: import("./auth").Ctx; | |
| 73 | + beforeAll(async () => { const db = await import("./db"); const auth = await import("./auth"); const d = db.getMetreDb(); ctx = { db: d, user: auth.anonymousLogin(d, { ip: null, userAgent: null }).user, ip: null }; }); | |
| 74 | + it("crée les niveaux du cottage, pose la fiche, génère le relevé et chiffre l'estimation", async () => { | |
| 75 | + const projects = await import("./projects"); const plans = await import("./plans"); const estimates = await import("./estimates"); | |
| 76 | + const p = projects.createProject(ctx, { name: "QS cottage", regionCode: "QC-MTL", valuationDate: "2026-10-28" }); | |
| 77 | + const r = await plans.createTemplateProject(ctx, p.id, { template: "cottage-c" }); | |
| 78 | + expect(r.pages.map((x) => x.level)).toEqual(["Sous-sol", "Rez-de-chaussée", "Étage"]); | |
| 79 | + expect(r.objects).toBeGreaterThan(30); | |
| 80 | + const prj = projects.getProject(ctx.db, p.id)!; expect(prj.spec?.basement).toBe("unfinished"); expect(prj.stories).toBe(2); | |
| 81 | + const qs = await estimates.generateQuantitySurvey(ctx, p.id, null); | |
| 82 | + expect(qs.qs.lines.length).toBeGreaterThan(35); | |
| 83 | + expect(qs.estimate.lines.filter((l) => l.origin === "qs").length).toBe(qs.qs.lines.length); | |
| 84 | + expect(qs.result.coverage.linesPriced).toBeGreaterThan(30); // chiffrées avec les prix de référence du catalogue seedé | |
| 85 | + expect(qs.result.totals.total).toBeGreaterThan(100000); | |
| 86 | + expect(plans.listMeasurements(ctx.db, p.id).filter((m) => m.name.startsWith("QS · ")).length).toBe(qs.qs.lines.length); | |
| 87 | + // regénération : les lignes qs sont remplacées, pas dupliquées ; une ligne manuelle est conservée | |
| 88 | + estimates.updateEstimate(ctx, qs.estimate.id, { lines: [...qs.estimate.lines, { wbs: "99", description: "manuelle", basis: "assumption", unitPrice: 1, quantity: 1, unit: "unit", installed: true }] }); | |
| 89 | + const again = await estimates.generateQuantitySurvey(ctx, p.id, qs.estimate.id); | |
| 90 | + expect(again.estimate.lines.filter((l) => l.origin === "qs").length).toBe(again.qs.lines.length); | |
| 91 | + expect(again.estimate.lines.some((l) => l.description === "manuelle")).toBe(true); | |
| 92 | + expect(plans.listMeasurements(ctx.db, p.id).filter((m) => m.name.startsWith("QS · ")).length).toBe(again.qs.lines.length); | |
| 93 | + // duplication d'un projet : les plans suivent | |
| 94 | + const copy = projects.duplicateProject(ctx, p.id, "copie"); | |
| 95 | + expect(plans.listPlanFiles(ctx.db, copy.id).flatMap((f) => f.pages).length).toBe(3); | |
| 96 | + }); | |
| 97 | +}); | |
added
src/lib/metre/quantity-survey.ts
+190 −0
@@ -0,0 +1,190 @@ | ||
| 1 | +// UQO Éval — Métré : méthode du relevé des quantités (quantity survey) — des plans (par niveau) + de la fiche technique vers TOUTES les | |
| 2 | +// quantités d'assemblages du catalogue : excavation, fondations, structure, enveloppe, toiture, ouvertures, isolation, finitions, cuisine, bains, | |
| 3 | +// plomberie, CVCA, électricité, garage, extérieur. Module PUR, déterministe ; chaque ligne garde sa formule, ses entrées et son origine. | |
| 4 | +import type { PlanObject } from "./types"; | |
| 5 | +import { planQuantities, type PlanQuantities } from "./plan-geometry"; | |
| 6 | +import { polygonAreaPx, polylineLengthPx, roofSlopeFactor } from "./geometry"; | |
| 7 | +import type { BuildingSpecQS } from "./templates"; | |
| 8 | + | |
| 9 | +export const QS_VERSION = "metre-qs-1.0"; | |
| 10 | + | |
| 11 | +export interface LevelInput { pageId: string; level: string; objects: PlanObject[]; pxPerMeter: number | null } | |
| 12 | +export interface QsLine { wbs: string; assemblyCode: string; description: string; quantity: number; unit: "m" | "m2" | "m3" | "unit" | "lump"; formula: string; inputs: Record<string, number | string>; origin: "plan" | "spec" | "derived"; pageIds: string[] } | |
| 13 | +export interface QsResult { lines: QsLine[]; warnings: string[]; summary: Record<string, number>; version: string } | |
| 14 | + | |
| 15 | +const M2_FT2 = 10.7639; | |
| 16 | +const r2 = (x: number) => Math.round(x * 100) / 100; | |
| 17 | + | |
| 18 | +interface LevelQ { level: string; pageId: string; q: PlanQuantities; footprintM2: number; perimeterM: number; rooms: { name: string; usage: string; areaM2: number; perimeterM: number }[]; extNetM2: number; extGrossM2: number; intGrossM2: number; extDoors: number; intDoors: number; windows: number; patioDoors: number; garageDoors: { w: number }[]; stairs: number; garageM2: number; isSite: boolean; isBasement: boolean } | |
| 19 | + | |
| 20 | +function analyse(l: LevelInput): LevelQ | null { | |
| 21 | + if (!l.pxPerMeter) return null; | |
| 22 | + const k = l.pxPerMeter, q = planQuantities(l.objects, k); | |
| 23 | + const byId = new Map(l.objects.map((o) => [o.id, o])); | |
| 24 | + const fps = l.objects.filter((o) => o.kind === "footprint" && o.points.length >= 3); | |
| 25 | + const extLoop = l.objects.find((o) => o.kind === "wall" && o.closed && o.props.exterior); | |
| 26 | + const footprintM2 = fps.length ? fps.reduce((s, f) => s + polygonAreaPx(f.points) / (k * k), 0) : extLoop ? polygonAreaPx(extLoop.points) / (k * k) : q.totals.floorAreaM2; | |
| 27 | + const perimeterM = extLoop ? polylineLengthPx(extLoop.points, true) / k : fps[0] ? polylineLengthPx(fps[0].points, true) / k : 4 * Math.sqrt(Math.max(0, footprintM2)); | |
| 28 | + const rooms = q.rooms.map((r) => ({ name: r.name, usage: (byId.get(r.id)?.props.usage ?? "").toLowerCase(), areaM2: r.areaM2, perimeterM: r.perimeterM })); | |
| 29 | + const ext = q.walls.filter((w) => w.exterior), int = q.walls.filter((w) => !w.exterior); | |
| 30 | + let extDoors = 0, intDoors = 0, windows = 0, patioDoors = 0; const garageDoors: { w: number }[] = []; | |
| 31 | + for (const o of q.openings) { | |
| 32 | + const wall = o.wallId ? byId.get(o.wallId) : null; const onExt = !!wall?.props.exterior; | |
| 33 | + if (o.kind === "window") windows++; | |
| 34 | + else if (o.kind === "door") { if (/garage/i.test(byId.get(o.id)?.name ?? "") || o.widthM >= 2.4) garageDoors.push({ w: o.widthM }); else if (onExt) { if (o.widthM >= 1.5) patioDoors++; else extDoors++; } else intDoors++; } | |
| 35 | + } | |
| 36 | + const lvl = l.level.toLowerCase(); | |
| 37 | + return { level: l.level, pageId: l.pageId, q, footprintM2, perimeterM, rooms, extNetM2: ext.reduce((s, w) => s + w.netAreaM2, 0), extGrossM2: ext.reduce((s, w) => s + w.grossAreaM2, 0), intGrossM2: int.reduce((s, w) => s + w.grossAreaM2, 0), extDoors, intDoors, windows, patioDoors, garageDoors, stairs: l.objects.filter((o) => o.kind === "stairs").length, garageM2: rooms.filter((r) => r.usage === "garage").reduce((s, r) => s + r.areaM2, 0), isSite: lvl === "site" || lvl.startsWith("terrain"), isBasement: lvl.includes("sous-sol") || lvl.includes("basement") }; | |
| 38 | +} | |
| 39 | + | |
| 40 | +export function quantitySurvey(levels: LevelInput[], spec: BuildingSpecQS, available: Set<string> | null = null): QsResult { | |
| 41 | + const warnings: string[] = []; const lines: QsLine[] = []; | |
| 42 | + const Ls = levels.map(analyse).filter((x): x is LevelQ => !!x); | |
| 43 | + if (levels.some((l) => !l.pxPerMeter)) warnings.push("page(s) non calibrée(s) ignorée(s)"); | |
| 44 | + const above = Ls.filter((l) => !l.isSite && !l.isBasement), basement = Ls.find((l) => l.isBasement) ?? null, site = Ls.find((l) => l.isSite) ?? null; | |
| 45 | + if (!above.length) { warnings.push("aucun niveau hors sol calibré : relevé impossible"); return { lines, warnings, summary: {}, version: QS_VERSION }; } | |
| 46 | + const ground = above[0], top = above[above.length - 1]; | |
| 47 | + const add = (wbs: string, code: string, description: string, quantity: number, unit: QsLine["unit"], formula: string, inputs: Record<string, number | string>, origin: QsLine["origin"], pageIds: string[] = []) => { | |
| 48 | + if (!(quantity > 0)) return; | |
| 49 | + if (available && !available.has(code)) { warnings.push(`assemblage absent du catalogue : ${code}`); return; } | |
| 50 | + lines.push({ wbs, assemblyCode: code, description, quantity: r2(quantity), unit, formula, inputs, origin, pageIds }); | |
| 51 | + }; | |
| 52 | + const allPages = above.map((l) => l.pageId); | |
| 53 | + const F = ground.footprintM2, P = ground.perimeterM, units = Math.max(1, spec.units || 1); | |
| 54 | + const livable = above.reduce((s, l) => s + (l.rooms.filter((r) => r.usage !== "garage").reduce((a, r) => a + r.areaM2, 0) || l.footprintM2), 0); | |
| 55 | + const basementArea = basement?.footprintM2 ?? (spec.basement !== "none" && spec.basement !== "crawl" ? F : 0); | |
| 56 | + const finishedBsm = spec.basement === "finished" ? basementArea * (spec.basementFinishedPct || 0.8) : 0; | |
| 57 | + const heated = livable + finishedBsm; | |
| 58 | + const kitchens = Math.max(units, above.reduce((s, l) => s + l.rooms.filter((r) => r.usage.includes("cuisine")).length, 0)); | |
| 59 | + const baths = above.reduce((s, l) => s + l.rooms.filter((r) => r.usage === "salle de bain").length, 0) + (basement ? basement.rooms.filter((r) => r.usage === "salle de bain").length : 0); | |
| 60 | + const powders = above.reduce((s, l) => s + l.rooms.filter((r) => r.usage === "salle d'eau").length, 0); | |
| 61 | + const bedrooms = above.reduce((s, l) => s + l.rooms.filter((r) => r.usage === "chambre").length, 0); | |
| 62 | + const windows = above.reduce((s, l) => s + l.windows, 0), extDoors = above.reduce((s, l) => s + l.extDoors, 0), patio = above.reduce((s, l) => s + l.patioDoors, 0), intDoors = above.reduce((s, l) => s + l.intDoors, 0); | |
| 63 | + const garageM2 = above.reduce((s, l) => s + l.garageM2, 0); | |
| 64 | + const qual = spec.quality; | |
| 65 | + | |
| 66 | + /* 1. Excavation et fondations (depuis le périmètre et l'empreinte du rez-de-chaussée) */ | |
| 67 | + const hasBsm = spec.basement === "unfinished" || spec.basement === "finished"; | |
| 68 | + const depth = hasBsm ? 2.6 : spec.basement === "crawl" ? 1.5 : spec.foundation === "piers" ? 0 : 1.4; | |
| 69 | + if (spec.foundation === "piers") add("31", "FND-PIER", "Pieux (grille 2,4 × 2,4 m)", Math.ceil(F / 5.9), "unit", "empreinte ÷ 5,9 m²", { empreinte_m2: r2(F) }, "derived", [ground.pageId]); | |
| 70 | + else { | |
| 71 | + if (depth > 0) { | |
| 72 | + add("31", "SITE-EXCAV-BASEMENT", "Excavation", (F + 0.9 * P) * depth, "m3", "(empreinte + 0,9 m × périmètre) × profondeur", { empreinte_m2: r2(F), perimetre_m: r2(P), profondeur_m: depth }, "derived", [ground.pageId]); | |
| 73 | + add("31", "SITE-BACKFILL", "Remblai", P * 0.9 * Math.max(0, depth - 0.3), "m3", "périmètre × 0,9 m × (profondeur − 0,3)", { perimetre_m: r2(P), profondeur_m: depth }, "derived", [ground.pageId]); | |
| 74 | + add("31", "SITE-DRAIN-FRENCH", "Drain français", P, "m", "périmètre de fondation", { perimetre_m: r2(P) }, "plan", [ground.pageId]); | |
| 75 | + } | |
| 76 | + add("33", "SITE-SERVICE-CONNECTIONS", "Branchements (aqueduc, égout, électricité)", 1, "lump", "1 par bâtiment", {}, "spec"); | |
| 77 | + add("03", "FND-FOOTING", "Semelles filantes", P + (garageM2 ? 4 * Math.sqrt(garageM2) * 0.5 : 0), "m", "périmètre (+ ½ périmètre du garage)", { perimetre_m: r2(P), garage_m2: r2(garageM2) }, "plan", [ground.pageId]); | |
| 78 | + const wh = spec.foundationWallHeightM || (hasBsm ? 2.44 : 1.2); | |
| 79 | + add("03", spec.foundation === "concrete_block" ? "FND-WALL-BLOCK-8IN" : "FND-WALL-POURED-8IN", `Murs de fondation (h ${wh} m)`, P * wh, "m2", "périmètre × hauteur de mur", { perimetre_m: r2(P), hauteur_m: wh }, "derived", [ground.pageId]); | |
| 80 | + add("07", "FND-DAMPPROOF-MEMBRANE", "Membrane d'étanchéité des fondations", P * Math.max(0, wh - 0.3), "m2", "périmètre × (hauteur − 0,3 m hors sol)", { perimetre_m: r2(P), hauteur_m: wh }, "derived", [ground.pageId]); | |
| 81 | + if (hasBsm) { add("03", "FND-SLAB-BASEMENT-4IN", "Dalle de sous-sol 100 mm", basementArea, "m2", "aire du sous-sol", { aire_m2: r2(basementArea) }, basement ? "plan" : "derived", basement ? [basement.pageId] : [ground.pageId]); add("08", "OPN-WINDOW-BASEMENT", "Fenêtres de sous-sol", basement ? Math.max(basement.windows, 2) : Math.max(2, Math.round(P / 7.6)), "unit", basement?.windows ? "comptées sur le plan du sous-sol" : "périmètre ÷ 7,6 m (min. 2)", { perimetre_m: r2(P) }, basement?.windows ? "plan" : "derived", basement ? [basement.pageId] : []); } | |
| 82 | + else if (spec.basement === "none") add("03", "FND-SLAB-ON-GRADE-INSULATED", "Dalle sur sol isolée", F, "m2", "empreinte", { empreinte_m2: r2(F) }, "plan", [ground.pageId]); | |
| 83 | + } | |
| 84 | + | |
| 85 | + /* 2. Structure (depuis les plans : murs mesurés, planchers = aires de pièces) */ | |
| 86 | + const floorsM2 = above.reduce((s, l, i) => s + (i === 0 && spec.basement === "none" && spec.foundation !== "piers" ? 0 : (l.rooms.reduce((a, r) => a + r.areaM2, 0) || l.footprintM2)), 0); | |
| 87 | + add("06", "STR-FLOOR-IJOIST", "Planchers portés (solives)", floorsM2, "m2", spec.basement === "none" ? "Σ aires des niveaux sauf rez-de-chaussée sur dalle" : "Σ aires de plancher des niveaux", { aire_m2: r2(floorsM2) }, "plan", allPages); | |
| 88 | + const roofF = roofSlopeFactor(spec.roofGeometry === "flat" ? 0 : spec.roofPitch); | |
| 89 | + const gable = spec.roofGeometry === "gable" ? 2 * 0.5 * Math.sqrt(F / 1.4) * (Math.sqrt(F / 1.4) / 2) * (spec.roofPitch / 12) : 0; // 2 pignons triangulaires | |
| 90 | + add("06", "STR-WALL-2X6-EXT", "Murs extérieurs ossature 2×6", above.reduce((s, l) => s + l.extGrossM2, 0) + gable, "m2", "Σ murs extérieurs bruts mesurés + pignons", { murs_bruts_m2: r2(above.reduce((s, l) => s + l.extGrossM2, 0)), pignons_m2: r2(gable) }, "plan", allPages); | |
| 91 | + add("06", "STR-WALL-2X4-INT", "Cloisons ossature 2×4", above.reduce((s, l) => s + l.intGrossM2, 0), "m2", "Σ cloisons mesurées (longueur × hauteur)", { cloisons_m2: r2(above.reduce((s, l) => s + l.intGrossM2, 0)) }, "plan", allPages); | |
| 92 | + const L = Math.sqrt(F * 1.4); | |
| 93 | + add("06", "STR-BEAM-LVL", "Poutre centrale LVL", L, "m", "longueur du bâtiment ≈ √(empreinte × 1,4)", { empreinte_m2: r2(F) }, "derived", [ground.pageId]); | |
| 94 | + add("05", "STR-COLUMN-STEEL", "Colonnes d'acier", Math.max(2, Math.round(L / 3)), "unit", "longueur ÷ 3 m", { longueur_m: r2(L) }, "derived"); | |
| 95 | + const roofM2 = (top.footprintM2 + garageM2) * roofF * 1.1; | |
| 96 | + add("06", "STR-ROOF-TRUSS-OSB", "Fermes de toit + support OSB", roofM2, "m2", "(empreinte du dernier niveau + garage) × facteur de pente × 1,10 débords", { empreinte_m2: r2(top.footprintM2), facteur_pente: r2(roofF), pente: spec.roofPitch }, "derived", [top.pageId]); | |
| 97 | + add("09", "STR-STAIRS-INTERIOR", "Escaliers intérieurs", Math.max(above.reduce((s, l) => s + l.stairs, 0), above.length - 1 + (hasBsm ? 1 : 0)), "unit", "escaliers dessinés (min. niveaux − 1 + sous-sol)", { dessines: above.reduce((s, l) => s + l.stairs, 0) }, "plan", allPages); | |
| 98 | + | |
| 99 | + /* 3. Enveloppe, isolation, toiture */ | |
| 100 | + const extNet = above.reduce((s, l) => s + l.extNetM2, 0); | |
| 101 | + add("07", "ENV-INSUL-WALL-R24", "Isolation des murs extérieurs R-24", extNet, "m2", "Σ murs extérieurs nets (ouvertures déduites)", { net_m2: r2(extNet) }, "plan", allPages); | |
| 102 | + add("07", "ENV-INSUL-ATTIC-R50", "Isolation du comble R-50", top.footprintM2, "m2", "empreinte du dernier niveau", { empreinte_m2: r2(top.footprintM2) }, "plan", [top.pageId]); | |
| 103 | + if (hasBsm) add("07", "ENV-INSUL-BASEMENT-R20", "Isolation intérieure des murs de sous-sol R-20", P * 2.44, "m2", "périmètre × 2,44 m", { perimetre_m: r2(P) }, "derived"); | |
| 104 | + const mix = normalize(spec.siding); const SID: Record<string, string> = { vinyl: "ENV-SIDING-VINYL", brick: "ENV-SIDING-BRICK", fiber_cement: "ENV-SIDING-FIBERCEMENT", wood: "ENV-SIDING-WOOD", stone: "ENV-SIDING-STONE", steel: "ENV-SIDING-STEEL" }; | |
| 105 | + for (const [kk, share] of Object.entries(mix)) add("07", SID[kk], `Revêtement ${kk} (${Math.round(share * 100)} %)`, extNet * share, "m2", "murs extérieurs nets × part", { net_m2: r2(extNet), part: share }, "spec", allPages); | |
| 106 | + add("07", "ENV-SOFFIT-FASCIA", "Soffites et bordures de toit", P * 1.1, "m", "périmètre × 1,10", { perimetre_m: r2(P) }, "derived"); | |
| 107 | + add("07", "EXT-GUTTERS", "Gouttières", P * 0.6, "m", "périmètre × 60 %", { perimetre_m: r2(P) }, "derived"); | |
| 108 | + const roofCode = spec.roofGeometry === "flat" ? (spec.roofType === "membrane" ? "ROOF-MEMBRANE-ELASTO" : "ROOF-EPDM") : spec.roofType === "metal" ? "ROOF-METAL-STANDING" : spec.roofType === "membrane" ? "ROOF-MEMBRANE-ELASTO" : "ROOF-ASPHALT-ARCH"; | |
| 109 | + add("07", roofCode, "Couverture", roofM2, "m2", "surface de toit", { toit_m2: r2(roofM2) }, "derived", [top.pageId]); | |
| 110 | + | |
| 111 | + /* 4. Ouvertures (comptées sur les plans ; dérivées si absentes) */ | |
| 112 | + const WIN: Record<string, string> = { pvc: "OPN-WINDOW-PVC-STD", hybrid: "OPN-WINDOW-HYBRID-STD", aluminum: "OPN-WINDOW-ALU-STD", wood: "OPN-WINDOW-WOOD-STD" }; | |
| 113 | + add("08", WIN[spec.windows] ?? "OPN-WINDOW-PVC-STD", "Fenêtres", windows || Math.round(livable * 0.118), "unit", windows ? "comptées sur les plans" : "aire habitable × 0,118 fenêtre/m²", { comptees: windows }, windows ? "plan" : "derived", allPages); | |
| 114 | + add("08", qual === "superior" || qual === "prestige" ? "OPN-DOOR-EXT-FIBERGLASS" : "OPN-DOOR-EXT-STEEL", "Portes extérieures", extDoors || units, "unit", extDoors ? "comptées sur les plans" : "1 par logement", { comptees: extDoors }, extDoors ? "plan" : "derived", allPages); | |
| 115 | + add("08", "OPN-DOOR-PATIO-6FT", "Portes-patio", patio, "unit", "comptées sur les plans (portes ext. ≥ 1,5 m)", { comptees: patio }, "plan", allPages); | |
| 116 | + const intD = intDoors || bedrooms + baths + powders + units; | |
| 117 | + add("08", qual === "superior" || qual === "prestige" ? "OPN-DOOR-INT-SOLID" : "OPN-DOOR-INT-HOLLOW", "Portes intérieures", intD, "unit", intDoors ? "comptées sur les plans" : "chambres + salles de bain + salles d'eau + 1 par logement", { comptees: intDoors, chambres: bedrooms }, intDoors ? "plan" : "derived", allPages); | |
| 118 | + add("08", "OPN-DOOR-CLOSET-BIFOLD", "Portes de garde-robe", Math.max(bedrooms, Math.round(livable / 32)), "unit", "1 par chambre (min. aire ÷ 32 m²)", { chambres: bedrooms }, "derived"); | |
| 119 | + for (const g of above.flatMap((l) => l.garageDoors)) add("08", g.w >= 4 ? "OPN-DOOR-GARAGE-16X7" : "OPN-DOOR-GARAGE-9X7", "Porte de garage", 1, "unit", "comptée sur le plan", { largeur_m: g.w }, "plan"); | |
| 120 | + | |
| 121 | + /* 5. Garage */ | |
| 122 | + if (garageM2 > 0) { add("03", "FND-SLAB-GARAGE-5IN", "Dalle de garage 125 mm", garageM2, "m2", "aire du garage (pièce « garage »)", { garage_m2: r2(garageM2) }, "plan"); add("09", "INT-DRYWALL-5/8-CEILING", "Plafond de garage (gypse 5/8 coupe-feu)", garageM2, "m2", "aire du garage", { garage_m2: r2(garageM2) }, "plan"); add("26", "ELE-LIGHTING-STD", "Luminaires de garage", 2, "unit", "2 par garage", {}, "derived"); } | |
| 123 | + | |
| 124 | + /* 6. Finitions intérieures (depuis les pièces : aires, périmètres, usages) */ | |
| 125 | + const roomsAll = above.flatMap((l) => l.rooms.filter((r) => r.usage !== "garage")); | |
| 126 | + const ceilings = roomsAll.reduce((s, r) => s + r.areaM2, 0) + finishedBsm; | |
| 127 | + const partitions2 = above.reduce((s, l) => s + l.intGrossM2, 0) * 2; | |
| 128 | + const drywallWalls = extNet + partitions2 + (finishedBsm ? P * 2.44 * (finishedBsm / Math.max(1, basementArea)) : 0); | |
| 129 | + add("09", "INT-DRYWALL-1/2-FINISHED", "Gypse ½ po murs (face intérieure des murs ext. + 2 faces des cloisons)", drywallWalls, "m2", "murs ext. nets + 2 × cloisons + sous-sol fini", { ext_net_m2: r2(extNet), cloisons_x2_m2: r2(partitions2) }, "plan", allPages); | |
| 130 | + add("09", "INT-DRYWALL-5/8-CEILING", "Gypse ⅝ po plafonds", ceilings, "m2", "Σ aires des pièces (+ sous-sol fini)", { pieces_m2: r2(ceilings) }, "plan", allPages); | |
| 131 | + add("09", "INT-PAINT-2-COATS", "Peinture 2 couches (murs + plafonds)", drywallWalls + ceilings, "m2", "gypse murs + plafonds", {}, "derived"); | |
| 132 | + const wet = roomsAll.filter((r) => r.usage.includes("salle de bain") || r.usage.includes("salle d'eau") || r.usage.includes("cuisine") || r.usage.includes("buanderie")).reduce((s, r) => s + r.areaM2, 0); | |
| 133 | + const dry = Math.max(0, roomsAll.reduce((s, r) => s + r.areaM2, 0) - wet); | |
| 134 | + add("09", "INT-FLOOR-CERAMIC", "Céramique (cuisine, salles de bain, salles d'eau)", wet, "m2", "aires des pièces d'eau et cuisine", { pieces_eau_m2: r2(wet) }, "plan", allPages); | |
| 135 | + const fl = normalize(Object.fromEntries(Object.entries(spec.flooring).filter(([k]) => k !== "ceramic"))); const FLR: Record<string, string> = { hardwood: "INT-FLOOR-HARDWOOD", engineered: "INT-FLOOR-ENGINEERED", vinyl_plank: "INT-FLOOR-VINYL-PLANK", laminate: "INT-FLOOR-LAMINATE", carpet: "INT-FLOOR-CARPET" }; | |
| 136 | + for (const [kk, share] of Object.entries(fl)) if (FLR[kk]) add("09", FLR[kk], `Plancher ${kk} (${Math.round(share * 100)} %)`, dry * share, "m2", "aires des pièces sèches × part", { pieces_seches_m2: r2(dry), part: share }, "spec", allPages); | |
| 137 | + if (finishedBsm) { add("09", "BSM-SUBFLOOR-PANELS", "Sous-plancher du sous-sol fini", finishedBsm, "m2", "aire finie", {}, "spec"); add("09", "INT-FLOOR-VINYL-PLANK", "Plancher du sous-sol fini (vinyle)", finishedBsm, "m2", "aire finie", {}, "spec"); } | |
| 138 | + add("09", "INT-TRIM-BASEBOARD", "Plinthes", roomsAll.reduce((s, r) => s + r.perimeterM, 0), "m", "Σ périmètres des pièces", {}, "plan", allPages); | |
| 139 | + add("09", "INT-CLOSET-SHELVING", "Tablettes de garde-robe", Math.max(2.4, bedrooms * 2.4), "m", "2,4 m par chambre", { chambres: bedrooms }, "derived"); | |
| 140 | + | |
| 141 | + /* 7. Cuisine et salles de bain */ | |
| 142 | + const KIT: Record<string, string> = { economy: "KIT-ECONOMY", standard: "KIT-STANDARD", superior: "KIT-SUPERIOR", prestige: "KIT-PRESTIGE" }; | |
| 143 | + const kitchenRooms = above.flatMap((l) => l.rooms.filter((r) => r.usage.includes("cuisine"))); | |
| 144 | + const kitLin = kitchenRooms.length ? kitchenRooms.reduce((s, r) => s + Math.min(9, Math.max(3.6, r.perimeterM * 0.45)), 0) : kitchens * 5.5; | |
| 145 | + add("11", KIT[spec.kitchenQuality], "Armoires et comptoirs de cuisine", kitLin, "m", kitchenRooms.length ? "45 % du périmètre de chaque cuisine (3,6–9 m)" : "5,5 m par logement", { cuisines: kitchens }, kitchenRooms.length ? "plan" : "derived"); | |
| 146 | + const BATH: Record<string, string> = { economy: "BATH-ECONOMY", standard: "BATH-STANDARD", superior: "BATH-SUPERIOR", prestige: "BATH-SUPERIOR" }; | |
| 147 | + add("22", BATH[spec.bathroomQuality], "Salles de bain complètes", Math.max(baths, units), "unit", baths ? "pièces « salle de bain » comptées" : "1 par logement", { comptees: baths }, baths ? "plan" : "derived"); | |
| 148 | + add("22", "BATH-POWDER", "Salles d'eau", powders, "unit", "pièces « salle d'eau » comptées", {}, "plan"); | |
| 149 | + | |
| 150 | + /* 8. Plomberie, CVCA, électricité */ | |
| 151 | + const fixtures = kitchens * 2 + Math.max(baths, units) * 3 + powders * 2 + units * 2; | |
| 152 | + add("22", "MEC-PLUMBING-ROUGH-IN", "Plomberie brute (appareils)", fixtures, "unit", "2/cuisine + 3/salle de bain + 2/salle d'eau + 2/logement", { cuisines: kitchens, sdb: Math.max(baths, units), salles_eau: powders, logements: units }, "derived"); | |
| 153 | + add("22", "MEC-WATER-HEATER-60", "Chauffe-eau 60 gal", units, "unit", "1 par logement", { logements: units }, "spec"); | |
| 154 | + add("22", "MEC-DRAINAGE-BASE", "Drainage et évents", 1, "lump", "1 par bâtiment", {}, "spec"); | |
| 155 | + switch (spec.heating) { | |
| 156 | + case "heat_pump": add("23", "MEC-HEATPUMP-CENTRAL", "Thermopompe centrale", units, "unit", "1 par logement", {}, "spec"); add("23", "MEC-FURNACE-ELECTRIC", "Fournaise électrique d'appoint", units, "unit", "1 par logement", {}, "spec"); add("23", "MEC-DUCTWORK", "Conduits", heated, "m2", "aire chauffée", { aire_m2: r2(heated) }, "derived"); break; | |
| 157 | + case "furnace_electric": add("23", "MEC-FURNACE-ELECTRIC", "Fournaise électrique", units, "unit", "1 par logement", {}, "spec"); add("23", "MEC-DUCTWORK", "Conduits", heated, "m2", "aire chauffée", { aire_m2: r2(heated) }, "derived"); if (spec.hasAirConditioning) add("23", "MEC-CENTRAL-AC", "Climatisation centrale", units, "unit", "1 par logement", {}, "spec"); break; | |
| 158 | + case "furnace_gas": add("23", "MEC-FURNACE-GAS", "Fournaise au gaz", units, "unit", "1 par logement", {}, "spec"); add("23", "MEC-DUCTWORK", "Conduits", heated, "m2", "aire chauffée", { aire_m2: r2(heated) }, "derived"); if (spec.hasAirConditioning) add("23", "MEC-CENTRAL-AC", "Climatisation centrale", units, "unit", "1 par logement", {}, "spec"); break; | |
| 159 | + default: add("23", "MEC-BASEBOARD-ELECTRIC", "Plinthes électriques 1 500 W", Math.ceil(heated / 14), "unit", "aire chauffée ÷ 14 m²", { aire_m2: r2(heated) }, "derived"); if (spec.hasAirConditioning) add("23", "MEC-HEATPUMP-WALL", "Thermopompe murale", units, "unit", "1 par logement", {}, "spec"); | |
| 160 | + } | |
| 161 | + if (spec.hasAirExchanger) add("23", "MEC-HRV", "Échangeur d'air (VRC)", units, "unit", "1 par logement", {}, "spec"); | |
| 162 | + add("26", "ELE-SERVICE-200A", "Entrée électrique 200 A", units, "unit", "1 par logement", { logements: units }, "spec"); | |
| 163 | + add("26", "ELE-ROUGH-WIRING", "Filage brut", heated, "m2", "aire chauffée", { aire_m2: r2(heated) }, "derived"); | |
| 164 | + add("26", "ELE-LIGHTING-STD", "Luminaires", Math.round(heated / 6.5), "unit", "aire chauffée ÷ 6,5 m²", { aire_m2: r2(heated) }, "derived"); | |
| 165 | + add("26", "ELE-LIGHTING-EXT", "Luminaires extérieurs", 3 + (garageM2 ? 1 : 0), "unit", "3 + 1 si garage", {}, "derived"); | |
| 166 | + | |
| 167 | + /* 9. Extérieur (plan de site si présent, sinon fiche) */ | |
| 168 | + const siteRooms = site?.rooms ?? []; | |
| 169 | + const sum = (pred: (u: string) => boolean) => siteRooms.filter((r) => pred(r.usage)).reduce((s, r) => s + r.areaM2, 0); | |
| 170 | + const deck = sum((u) => u.includes("terrasse") || u.includes("deck") || u.includes("balcon")) || spec.deckM2; | |
| 171 | + const drive = sum((u) => u.includes("entrée") || u.includes("stationnement") || u.includes("driveway")) || spec.drivewayM2; | |
| 172 | + const lotM2 = site ? (site.q.footprintM2 ?? 0) : 0; const lotObj = site?.q ? site.q : null; void lotObj; | |
| 173 | + if (deck > 0) { add("32", "EXT-DECK-TREATED", "Terrasse en bois traité", deck, "m2", site ? "aire « terrasse » sur le plan de site" : "fiche", { terrasse_m2: r2(deck) }, site ? "plan" : "spec", site ? [site.pageId] : []); add("32", qual === "superior" || qual === "prestige" ? "EXT-DECK-RAILING-ALU" : "EXT-DECK-RAILING-WOOD", "Garde-corps de terrasse", 2.5 * Math.sqrt(deck), "m", "2,5 × √aire", {}, "derived"); } | |
| 174 | + if (drive > 0) add("32", spec.driveway === "pavers" ? "EXT-DRIVEWAY-PAVERS" : spec.driveway === "gravel" ? "EXT-DRIVEWAY-GRAVEL" : "EXT-DRIVEWAY-ASPHALT", "Entrée de stationnement", drive, "m2", site ? "aire « entrée » sur le plan de site" : "fiche", { entree_m2: r2(drive) }, site ? "plan" : "spec", site ? [site.pageId] : []); | |
| 175 | + if (spec.fenceM > 0) add("32", "EXT-FENCE-WOOD", "Clôture", spec.fenceM, "m", "fiche", {}, "spec"); | |
| 176 | + const landscaping = spec.landscapingM2 || (lotM2 > F ? Math.max(0, lotM2 - F - drive - deck) : 0); | |
| 177 | + if (landscaping > 0) add("32", "EXT-LANDSCAPING-SOD", "Aménagement paysager (tourbe)", landscaping, "m2", lotM2 ? "lot − empreinte − entrée − terrasse" : "fiche", { lot_m2: r2(lotM2) }, lotM2 ? "plan" : "spec", site ? [site.pageId] : []); | |
| 178 | + if (spec.foundation !== "piers") add("32", "EXT-STEPS-PRECAST", "Perron préfabriqué", 1, "unit", "1 par bâtiment", {}, "derived"); | |
| 179 | + | |
| 180 | + const summary = { empreinte_m2: r2(F), perimetre_m: r2(P), aire_habitable_m2: r2(livable), aire_chauffee_m2: r2(heated), murs_ext_nets_m2: r2(extNet), toit_m2: r2(roofM2), fenetres: windows, portes_ext: extDoors, portes_int: intD, chambres: bedrooms, salles_de_bain: Math.max(baths, units), cuisines: kitchens, logements: units, niveaux_hors_sol: above.length, garage_m2: r2(garageM2), lignes: lines.length }; | |
| 181 | + return { lines, warnings, summary, version: QS_VERSION }; | |
| 182 | +} | |
| 183 | + | |
| 184 | +function normalize(m: Record<string, number | undefined>): Record<string, number> { | |
| 185 | + const e = Object.entries(m).filter((x): x is [string, number] => typeof x[1] === "number" && x[1] > 0); | |
| 186 | + const t = e.reduce((s, [, v]) => s + v, 0); if (!t) return {}; | |
| 187 | + return Object.fromEntries(e.map(([k, v]) => [k, v / t])); | |
| 188 | +} | |
| 189 | + | |
| 190 | +export const M2_TO_FT2 = M2_FT2; | |
modified
src/lib/metre/report.ts
+2 −0
@@ -4,6 +4,7 @@ import path from "path"; | ||
| 4 | 4 | import type { EstimateVersion, Measurement, Project, ValuationResult } from "./types"; |
| 5 | 5 | import { unitLabel } from "./units"; |
| 6 | 6 | import type { ValuationCase } from "./estimates"; |
| 7 | +import { disableLigatures } from "../export-generic"; | |
| 7 | 8 | |
| 8 | 9 | const INK = "#16181b", INK2 = "#4b5258", INK3 = "#8b928c", PAPER = "#f4f5f3", SURFACE2 = "#f9fafa", BLUE = "#005F85", TURQ = "#2BCCAE", TURQ_SOFT = "#d9f4ee", AMBER_SOFT = "#fdf3e2", LINE = "#dedcd4"; |
| 9 | 10 | const W = 612, H = 792, M = 44, BOTTOM = H - 62; |
@@ -43,6 +44,7 @@ export async function buildMetreReport(input: ReportInput): Promise<Buffer> { | ||
| 43 | 44 | const t = (a: string, b: string) => (fr ? a : b); |
| 44 | 45 | const doc = new PDFDocument({ size: "LETTER", margin: 0, autoFirstPage: false, bufferPages: true, info: { Title: `${t("Métré estimatif", "Quantity takeoff estimate")} — ${p.name}`, Author: "UQO Éval", Subject: "IMM1033" } }); |
| 45 | 46 | doc.registerFont("Inter", F("Inter-Regular.ttf")); doc.registerFont("SG-Bold", F("SpaceGrotesk-Bold.ttf")); doc.registerFont("SG-Med", F("SpaceGrotesk-Medium.ttf")); doc.registerFont("JB-Reg", F("JetBrainsMono-Regular.ttf")); doc.registerFont("JB-Bold", F("JetBrainsMono-Bold.ttf")); |
| 47 | + disableLigatures(doc); | |
| 46 | 48 | const chunks: Buffer[] = []; |
| 47 | 49 | doc.on("data", (c: Buffer) => chunks.push(c)); |
| 48 | 50 | const done = new Promise<Buffer>((res) => doc.on("end", () => res(Buffer.concat(chunks)))); |
added
src/lib/metre/templates.ts
+232 −0
@@ -0,0 +1,232 @@ | ||
| 1 | +// UQO Éval — Métré : générateur paramétrique de gabarits de plans (bungalows, maisons 2 étages, plex, rangée, chalets, garages) + fiche technique | |
| 2 | +// par défaut pour la méthode du relevé des quantités. Module PUR : produit des objets de plan (m) par niveau ; le service les convertit en pixels. | |
| 3 | +import type { ObjectKind, ObjectProps, Point } from "./types"; | |
| 4 | + | |
| 5 | +/* ------------------------------------------------------------- fiche QS */ | |
| 6 | + | |
| 7 | +export type BasementKind = "none" | "crawl" | "unfinished" | "finished"; | |
| 8 | +export type FoundationKind = "poured_concrete" | "concrete_block" | "slab_on_grade" | "piers"; | |
| 9 | +export type RoofKind = "asphalt_shingle" | "metal" | "membrane"; | |
| 10 | +export type RoofGeom = "gable" | "hip" | "flat"; | |
| 11 | +export type HeatingKind = "electric_baseboard" | "heat_pump" | "furnace_electric" | "furnace_gas"; | |
| 12 | +export type Quality = "economy" | "standard" | "superior" | "prestige"; | |
| 13 | + | |
| 14 | +/** Fiche technique d'un projet — tout ce que le plan ne montre pas mais que le relevé des quantités exige. */ | |
| 15 | +export interface BuildingSpecQS { | |
| 16 | + units: number; // logements | |
| 17 | + basement: BasementKind; | |
| 18 | + basementFinishedPct: number; // 0-1 | |
| 19 | + foundation: FoundationKind; | |
| 20 | + foundationWallHeightM: number; // 2.44 sous-sol, 1.2 vide sanitaire / mur hors gel | |
| 21 | + roofType: RoofKind; | |
| 22 | + roofGeometry: RoofGeom; | |
| 23 | + roofPitch: number; // po/12 | |
| 24 | + siding: Partial<Record<"vinyl" | "brick" | "fiber_cement" | "wood" | "stone" | "steel", number>>; | |
| 25 | + windows: "pvc" | "hybrid" | "aluminum" | "wood"; | |
| 26 | + heating: HeatingKind; | |
| 27 | + hasAirConditioning: boolean; | |
| 28 | + hasAirExchanger: boolean; | |
| 29 | + quality: Quality; | |
| 30 | + kitchenQuality: Quality; | |
| 31 | + bathroomQuality: Quality; | |
| 32 | + flooring: Partial<Record<"hardwood" | "engineered" | "vinyl_plank" | "ceramic" | "laminate" | "carpet", number>>; | |
| 33 | + ceilingHeightM: number; | |
| 34 | + garage: "none" | "attached" | "detached" | "integrated"; | |
| 35 | + deckM2: number; | |
| 36 | + drivewayM2: number; | |
| 37 | + driveway: "asphalt" | "pavers" | "gravel" | "none"; | |
| 38 | + fenceM: number; | |
| 39 | + landscapingM2: number; | |
| 40 | + notes: string | null; | |
| 41 | +} | |
| 42 | + | |
| 43 | +export function defaultSpec(p: Partial<BuildingSpecQS> = {}): BuildingSpecQS { | |
| 44 | + return { | |
| 45 | + units: 1, basement: "unfinished", basementFinishedPct: 0, foundation: "poured_concrete", foundationWallHeightM: 2.44, roofType: "asphalt_shingle", roofGeometry: "gable", roofPitch: 6, | |
| 46 | + siding: { vinyl: 0.7, brick: 0.3 }, windows: "pvc", heating: "electric_baseboard", hasAirConditioning: false, hasAirExchanger: true, quality: "standard", kitchenQuality: "standard", bathroomQuality: "standard", | |
| 47 | + flooring: { hardwood: 0.6, ceramic: 0.25, vinyl_plank: 0.15 }, ceilingHeightM: 2.44, garage: "none", deckM2: 0, drivewayM2: 0, driveway: "none", fenceM: 0, landscapingM2: 0, notes: null, ...p, | |
| 48 | + }; | |
| 49 | +} | |
| 50 | + | |
| 51 | +/* -------------------------------------------------------- objets générés */ | |
| 52 | + | |
| 53 | +export interface GenObject { kind: ObjectKind; name: string; points: Point[]; closed: boolean; props: ObjectProps; openings?: GenOpening[] } | |
| 54 | +export interface GenOpening { name: string; kind: "door" | "window" | "opening"; at: Point; widthM: number; heightM: number; swing?: number } | |
| 55 | +export interface GenLevel { level: string; widthM: number; heightM: number; objects: GenObject[] } | |
| 56 | +export interface Template { key: string; kind: "bungalow" | "cottage" | "plex" | "row" | "chalet" | "garage" | "condo" | "mobile"; fr: string; en: string; footprint: string; storeys: number; units: number; areaM2: number; spec: Partial<BuildingSpecQS>; levels: GenLevel[] } | |
| 57 | + | |
| 58 | +const T_EXT = 0.3, T_INT = 0.12, H = 2.44; | |
| 59 | +const rect = (x: number, y: number, w: number, h: number): Point[] => [{ x, y }, { x: x + w, y }, { x: x + w, y: y + h }, { x, y: y + h }]; | |
| 60 | +const r2 = (v: number) => Math.round(v * 100) / 100; | |
| 61 | + | |
| 62 | +/** Programme d'une bande de pièces : [usage, nom, fraction de la largeur]. */ | |
| 63 | +type Band = [string, string, number][]; | |
| 64 | +interface UnitProgram { front: Band; back: Band; frontDepth: number; stairs?: boolean; entrance?: "front" | "side" } | |
| 65 | + | |
| 66 | +const PROGRAMS: Record<string, UnitProgram> = { | |
| 67 | + bungalow2: { front: [["séjour", "Séjour", 0.55], ["cuisine", "Cuisine / salle à manger", 0.45]], back: [["chambre", "Chambre principale", 0.38], ["chambre", "Chambre 2", 0.3], ["salle de bain", "Salle de bain", 0.17], ["hall", "Hall / escalier", 0.15]], frontDepth: 0.5, stairs: true }, | |
| 68 | + bungalow3: { front: [["séjour", "Séjour", 0.45], ["cuisine", "Cuisine / salle à manger", 0.4], ["hall", "Hall / escalier", 0.15]], back: [["chambre", "Chambre principale", 0.34], ["chambre", "Chambre 2", 0.25], ["chambre", "Chambre 3", 0.23], ["salle de bain", "Salle de bain", 0.18]], frontDepth: 0.5, stairs: true }, | |
| 69 | + cottageRdc: { front: [["séjour", "Salon", 0.5], ["salle à manger", "Salle à manger", 0.3], ["hall", "Hall / escalier", 0.2]], back: [["cuisine", "Cuisine", 0.45], ["bureau", "Bureau / salle familiale", 0.35], ["salle d'eau", "Salle d'eau", 0.2]], frontDepth: 0.5, stairs: true }, | |
| 70 | + cottageEtage: { front: [["chambre", "Chambre principale", 0.45], ["salle de bain", "Salle de bain", 0.25], ["hall", "Hall / escalier", 0.3]], back: [["chambre", "Chambre 2", 0.37], ["chambre", "Chambre 3", 0.33], ["chambre", "Chambre 4", 0.3]], frontDepth: 0.5, stairs: true }, | |
| 71 | + cottageEtage3: { front: [["chambre", "Chambre principale", 0.5], ["salle de bain", "Salle de bain", 0.25], ["hall", "Hall / escalier", 0.25]], back: [["chambre", "Chambre 2", 0.5], ["chambre", "Chambre 3", 0.5]], frontDepth: 0.5, stairs: true }, | |
| 72 | + apartment: { front: [["séjour", "Séjour", 0.5], ["cuisine", "Cuisine / salle à manger", 0.5]], back: [["chambre", "Chambre 1", 0.4], ["chambre", "Chambre 2", 0.35], ["salle de bain", "Salle de bain", 0.25]], frontDepth: 0.5 }, | |
| 73 | + apartment1: { front: [["séjour", "Séjour / cuisine", 1]], back: [["chambre", "Chambre", 0.6], ["salle de bain", "Salle de bain", 0.4]], frontDepth: 0.5 }, | |
| 74 | + rowRdc: { front: [["séjour", "Salon", 0.65], ["hall", "Hall / escalier", 0.35]], back: [["cuisine", "Cuisine / salle à manger", 0.7], ["salle d'eau", "Salle d'eau", 0.3]], frontDepth: 0.5, stairs: true }, | |
| 75 | + rowEtage: { front: [["chambre", "Chambre principale", 0.65], ["hall", "Hall / escalier", 0.35]], back: [["chambre", "Chambre 2", 0.55], ["salle de bain", "Salle de bain", 0.45]], frontDepth: 0.5, stairs: true }, | |
| 76 | + chalet: { front: [["séjour", "Séjour / cuisine", 1]], back: [["chambre", "Chambre 1", 0.4], ["chambre", "Chambre 2", 0.35], ["salle de bain", "Salle de bain", 0.25]], frontDepth: 0.55 }, | |
| 77 | + mobile: { front: [["séjour", "Séjour / cuisine", 0.45], ["chambre", "Chambre 2", 0.2], ["salle de bain", "Salle de bain", 0.12], ["chambre", "Chambre principale", 0.23]], back: [], frontDepth: 1 }, | |
| 78 | +}; | |
| 79 | + | |
| 80 | +/** Dispose un logement rectangulaire (x,y,W,D en m) : cloisons, pièces, ouvertures sur les murs extérieurs exposés. */ | |
| 81 | +function layoutUnit(x: number, y: number, W: number, D: number, prog: UnitProgram, exposed: { front: boolean; back: boolean; left: boolean; right: boolean }, unitLabel: string, out: GenObject[], openings: GenOpening[]): void { | |
| 82 | + const inner = (ox: number, oy: number, w: number, h: number): Point[] => rect(ox + 0.08, oy + 0.08, w - 0.16, h - 0.16); | |
| 83 | + const frontD = prog.back.length ? D * prog.frontDepth : D; | |
| 84 | + const prefix = unitLabel ? `${unitLabel} — ` : ""; | |
| 85 | + if (prog.back.length) out.push({ kind: "wall", name: `${prefix}Cloison centrale`, points: [{ x, y: y + frontD }, { x: x + W, y: y + frontD }], closed: false, props: { thickness: T_INT, height: H } }); | |
| 86 | + const band = (rooms: Band, by: number, bh: number, isFront: boolean) => { | |
| 87 | + let cx = x; | |
| 88 | + rooms.forEach(([usage, name, frac], i) => { | |
| 89 | + const w = W * frac; | |
| 90 | + out.push({ kind: "room", name: `${prefix}${name}`, points: inner(cx, by, w, bh), closed: true, props: { usage, height: H } }); | |
| 91 | + if (i < rooms.length - 1) out.push({ kind: "wall", name: `${prefix}Cloison ${name}`, points: [{ x: cx + w, y: by }, { x: cx + w, y: by + bh }], closed: false, props: { thickness: T_INT, height: H } }); | |
| 92 | + // fenêtre sur la face exposée (avant ou arrière) | |
| 93 | + const edgeY = isFront ? y : y + D; const exposedEdge = isFront ? exposed.front : exposed.back; | |
| 94 | + if (exposedEdge && usage !== "hall" && usage !== "salle d'eau") openings.push({ name: `Fenêtre ${name}`, kind: "window", at: { x: cx + w / 2, y: edgeY }, widthM: usage === "séjour" || usage === "cuisine" ? 1.8 : usage === "salle de bain" ? 0.9 : 1.2, heightM: usage === "salle de bain" ? 0.9 : 1.3 }); | |
| 95 | + if (usage === "hall" && prog.stairs) out.push({ kind: "stairs", name: `${prefix}Escalier`, points: rect(cx + 0.3, by + 0.3, Math.min(1.0, w - 0.6), Math.min(3.6, bh - 0.6)), closed: true, props: {} }); | |
| 96 | + cx += w; | |
| 97 | + }); | |
| 98 | + }; | |
| 99 | + band(prog.front, y, frontD, true); | |
| 100 | + if (prog.back.length) band(prog.back, y + frontD, D - frontD, false); | |
| 101 | + // porte d'entrée (face avant, premier tiers) et porte-patio (face arrière, côté cuisine) si exposées | |
| 102 | + if (exposed.front) openings.push({ name: `${prefix}Porte d'entrée`, kind: "door", at: { x: x + W * 0.12, y }, widthM: 0.9, heightM: 2.1, swing: 1 }); | |
| 103 | + else if (exposed.left) openings.push({ name: `${prefix}Porte d'entrée`, kind: "door", at: { x, y: y + 1.2 }, widthM: 0.9, heightM: 2.1, swing: 1 }); | |
| 104 | + if (exposed.back && prog.back.length) openings.push({ name: `${prefix}Porte-patio`, kind: "door", at: { x: x + W * 0.78, y: y + D }, widthM: 1.8, heightM: 2.1, swing: -1 }); | |
| 105 | + // fenêtres latérales (une par face exposée) | |
| 106 | + if (exposed.left) openings.push({ name: "Fenêtre latérale", kind: "window", at: { x, y: y + D * 0.3 }, widthM: 1.2, heightM: 1.3 }); | |
| 107 | + if (exposed.right) openings.push({ name: "Fenêtre latérale", kind: "window", at: { x: x + W, y: y + D * 0.7 }, widthM: 1.2, heightM: 1.3 }); | |
| 108 | +} | |
| 109 | + | |
| 110 | +/** Niveau complet : enveloppe + logements côte à côte (nUnitsAcross) + garage attenant facultatif. */ | |
| 111 | +function level(name: string, W: number, D: number, programs: UnitProgram[], opts: { garage?: { w: number; d: number } | null; unitLabels?: string[]; basement?: boolean } = {}): GenLevel { | |
| 112 | + const margin = 2.5; const objects: GenObject[] = []; const openings: GenOpening[] = []; | |
| 113 | + const x0 = margin, y0 = margin; | |
| 114 | + objects.push({ kind: "wall", name: "Murs extérieurs", points: rect(x0, y0, W, D), closed: true, props: { thickness: T_EXT, height: H, exterior: true } }); | |
| 115 | + objects.push({ kind: "footprint", name: "Empreinte", points: rect(x0 - T_EXT / 2, y0 - T_EXT / 2, W + T_EXT, D + T_EXT), closed: true, props: {} }); | |
| 116 | + if (opts.basement) { | |
| 117 | + objects.push({ kind: "room", name: "Sous-sol", points: rect(x0 + 0.1, y0 + 0.1, W - 0.2, D - 0.2), closed: true, props: { usage: "sous-sol", height: 2.3 } }); | |
| 118 | + objects.push({ kind: "wall", name: "Cloison salle mécanique", points: [{ x: x0 + 3, y: y0 }, { x: x0 + 3, y: y0 + 3 }, { x: x0, y: y0 + 3 }], closed: false, props: { thickness: T_INT, height: 2.3 } }); | |
| 119 | + objects.push({ kind: "room", name: "Salle mécanique", points: rect(x0 + 0.1, y0 + 0.1, 2.8, 2.8), closed: true, props: { usage: "salle mécanique", height: 2.3 } }); | |
| 120 | + objects.push({ kind: "stairs", name: "Escalier", points: rect(x0 + W - 1.3, y0 + 0.3, 1.0, 3.6), closed: true, props: {} }); | |
| 121 | + openings.push({ name: "Fenêtre de sous-sol", kind: "window", at: { x: x0 + W * 0.3, y: y0 + D }, widthM: 0.8, heightM: 0.5 }, { name: "Fenêtre de sous-sol", kind: "window", at: { x: x0 + W * 0.7, y: y0 + D }, widthM: 0.8, heightM: 0.5 }, { name: "Fenêtre de sous-sol", kind: "window", at: { x: x0, y: y0 + D * 0.5 }, widthM: 0.8, heightM: 0.5 }); | |
| 122 | + } else { | |
| 123 | + const n = programs.length; const uw = W / n; | |
| 124 | + programs.forEach((p, i) => { | |
| 125 | + if (i > 0) objects.push({ kind: "wall", name: `Mur mitoyen ${i}`, points: [{ x: x0 + uw * i, y: y0 }, { x: x0 + uw * i, y: y0 + D }], closed: false, props: { thickness: 0.25, height: H } }); | |
| 126 | + layoutUnit(x0 + uw * i, y0, uw, D, p, { front: true, back: true, left: i === 0, right: i === n - 1 }, opts.unitLabels?.[i] ?? "", objects, openings); | |
| 127 | + }); | |
| 128 | + } | |
| 129 | + if (opts.garage) { | |
| 130 | + const g = opts.garage; const gx = x0 + W, gy = y0 + D - g.d; | |
| 131 | + objects.push({ kind: "wall", name: "Murs du garage", points: [{ x: gx, y: gy }, { x: gx + g.w, y: gy }, { x: gx + g.w, y: gy + g.d }, { x: gx, y: gy + g.d }], closed: false, props: { thickness: T_EXT, height: 3.0, exterior: true }, openings: [{ name: "Porte de garage", kind: "door", at: { x: gx + g.w / 2, y: gy + g.d }, widthM: g.w >= 6 ? 4.9 : 2.7, heightM: 2.1, swing: -2 }, { name: "Fenêtre garage", kind: "window", at: { x: gx + g.w, y: gy + g.d / 2 }, widthM: 0.9, heightM: 0.8 }] }); | |
| 132 | + objects.push({ kind: "room", name: "Garage", points: rect(gx + 0.1, gy + 0.1, g.w - 0.2, g.d - 0.2), closed: true, props: { usage: "garage", height: 3.0 } }); | |
| 133 | + objects.push({ kind: "footprint", name: "Empreinte garage", points: rect(gx, gy - T_EXT / 2, g.w + T_EXT / 2, g.d + T_EXT), closed: true, props: {} }); | |
| 134 | + } | |
| 135 | + const ext = objects[0]; ext.openings = openings; | |
| 136 | + return { level: name, widthM: r2(W + 2 * margin + (opts.garage?.w ?? 0)), heightM: r2(D + 2 * margin), objects }; | |
| 137 | +} | |
| 138 | + | |
| 139 | +/* ------------------------------------------------------------ catalogue */ | |
| 140 | + | |
| 141 | +interface Def { key: string; kind: Template["kind"]; fr: string; en: string; W: number; D: number; storeys: number; units?: number; basement?: BasementKind; garage?: { w: number; d: number } | null; programs: (lvl: number) => UnitProgram[]; labels?: (lvl: number) => string[]; spec?: Partial<BuildingSpecQS>; levelNames?: string[] } | |
| 142 | + | |
| 143 | +const B = (key: string, fr: string, en: string, W: number, D: number, prog: string, extra: Partial<Def> = {}): Def => ({ key, kind: "bungalow", fr, en, W, D, storeys: 1, basement: "unfinished", garage: null, programs: () => [PROGRAMS[prog]], ...extra }); | |
| 144 | +const C = (key: string, fr: string, en: string, W: number, D: number, upper = "cottageEtage", extra: Partial<Def> = {}): Def => ({ key, kind: "cottage", fr, en, W, D, storeys: 2, basement: "unfinished", garage: null, programs: (l) => [l === 0 ? PROGRAMS.cottageRdc : PROGRAMS[upper]], ...extra }); | |
| 145 | +const P = (key: string, fr: string, en: string, W: number, D: number, storeys: number, across: number, prog = "apartment", extra: Partial<Def> = {}): Def => ({ key, kind: "plex", fr, en, W, D, storeys, units: storeys * across, basement: "unfinished", garage: null, programs: () => Array.from({ length: across }, () => PROGRAMS[prog]), labels: (l) => Array.from({ length: across }, (_, i) => `Logement ${l * across + i + 1}`), spec: { units: storeys * across, siding: { brick: 0.8, vinyl: 0.2 }, roofGeometry: "flat", roofType: "membrane", roofPitch: 0, heating: "electric_baseboard" }, ...extra }); | |
| 146 | + | |
| 147 | +const DEFS: Def[] = [ | |
| 148 | + B("bungalow-a", "Bungalow 2 chambres 10,4 × 8,5 m", "Bungalow 2 bedrooms 10.4 × 8.5 m", 10.4, 8.5, "bungalow2"), | |
| 149 | + B("bungalow-b", "Bungalow 2 chambres 11,6 × 9,1 m", "Bungalow 2 bedrooms 11.6 × 9.1 m", 11.6, 9.1, "bungalow2"), | |
| 150 | + B("bungalow-c", "Bungalow 3 chambres 12,2 × 9,1 m", "Bungalow 3 bedrooms 12.2 × 9.1 m", 12.2, 9.1, "bungalow3"), | |
| 151 | + B("bungalow-d", "Bungalow 3 chambres 13,4 × 9,8 m", "Bungalow 3 bedrooms 13.4 × 9.8 m", 13.4, 9.8, "bungalow3"), | |
| 152 | + B("bungalow-e", "Bungalow 3 chambres 14,6 × 10,4 m", "Bungalow 3 bedrooms 14.6 × 10.4 m", 14.6, 10.4, "bungalow3", { spec: { quality: "superior", kitchenQuality: "superior" } }), | |
| 153 | + B("bungalow-garage", "Bungalow 3 ch. 12,2 × 9,1 m + garage attenant", "Bungalow 3 bdr 12.2 × 9.1 m + attached garage", 12.2, 9.1, "bungalow3", { garage: { w: 4.3, d: 6.7 }, spec: { garage: "attached" } }), | |
| 154 | + B("bungalow-garage-2", "Bungalow 3 ch. 13,4 × 9,8 m + garage double", "Bungalow 3 bdr 13.4 × 9.8 m + double garage", 13.4, 9.8, "bungalow3", { garage: { w: 6.7, d: 6.7 }, spec: { garage: "attached", quality: "superior" } }), | |
| 155 | + B("bungalow-fini", "Bungalow 3 ch. 12,2 × 9,1 m, sous-sol fini", "Bungalow 3 bdr 12.2 × 9.1 m, finished basement", 12.2, 9.1, "bungalow3", { basement: "finished", spec: { basement: "finished", basementFinishedPct: 0.8 } }), | |
| 156 | + B("bungalow-dalle", "Bungalow 2 ch. 11,6 × 9,1 m sur dalle (sans sous-sol)", "Bungalow 2 bdr 11.6 × 9.1 m on slab (no basement)", 11.6, 9.1, "bungalow2", { basement: "none", spec: { basement: "none", foundation: "slab_on_grade", foundationWallHeightM: 1.2 } }), | |
| 157 | + B("bungalow-vs", "Bungalow 2 ch. 10,4 × 8,5 m sur vide sanitaire", "Bungalow 2 bdr 10.4 × 8.5 m on crawl space", 10.4, 8.5, "bungalow2", { basement: "crawl", spec: { basement: "crawl", foundationWallHeightM: 1.2 } }), | |
| 158 | + C("cottage-a", "Maison 2 étages 9,1 × 7,9 m (3 chambres)", "Two-storey 9.1 × 7.9 m (3 bedrooms)", 9.1, 7.9, "cottageEtage3"), | |
| 159 | + C("cottage-b", "Maison 2 étages 10,4 × 8,5 m (3 chambres)", "Two-storey 10.4 × 8.5 m (3 bedrooms)", 10.4, 8.5, "cottageEtage3"), | |
| 160 | + C("cottage-c", "Maison 2 étages 11,6 × 9,1 m (4 chambres)", "Two-storey 11.6 × 9.1 m (4 bedrooms)", 11.6, 9.1), | |
| 161 | + C("cottage-d", "Maison 2 étages 12,2 × 9,8 m (4 chambres)", "Two-storey 12.2 × 9.8 m (4 bedrooms)", 12.2, 9.8, "cottageEtage", { spec: { quality: "superior", kitchenQuality: "superior", bathroomQuality: "superior", siding: { brick: 0.6, fiber_cement: 0.4 } } }), | |
| 162 | + C("cottage-garage", "Maison 2 étages 11,6 × 9,1 m + garage attenant", "Two-storey 11.6 × 9.1 m + attached garage", 11.6, 9.1, "cottageEtage", { garage: { w: 4.3, d: 6.7 }, spec: { garage: "attached" } }), | |
| 163 | + C("cottage-garage-2", "Maison 2 étages 12,2 × 9,8 m + garage double", "Two-storey 12.2 × 9.8 m + double garage", 12.2, 9.8, "cottageEtage", { garage: { w: 6.7, d: 7.3 }, spec: { garage: "attached", quality: "superior", heating: "heat_pump", hasAirConditioning: true } }), | |
| 164 | + C("cottage-fini", "Maison 2 étages 11,6 × 9,1 m, sous-sol fini", "Two-storey 11.6 × 9.1 m, finished basement", 11.6, 9.1, "cottageEtage", { basement: "finished", spec: { basement: "finished", basementFinishedPct: 0.8 } }), | |
| 165 | + C("cottage-prestige", "Maison 2 étages 13,4 × 10,4 m, prestige, garage double", "Two-storey 13.4 × 10.4 m, prestige, double garage", 13.4, 10.4, "cottageEtage", { garage: { w: 6.7, d: 7.3 }, spec: { garage: "attached", quality: "prestige", kitchenQuality: "prestige", bathroomQuality: "prestige", siding: { brick: 0.5, stone: 0.3, fiber_cement: 0.2 }, windows: "hybrid", heating: "heat_pump", hasAirConditioning: true } }), | |
| 166 | + P("duplex", "Duplex superposé 11,6 × 10,4 m (2 logements)", "Stacked duplex 11.6 × 10.4 m (2 units)", 11.6, 10.4, 2, 1), | |
| 167 | + P("duplex-b", "Duplex superposé 12,8 × 11 m (2 logements)", "Stacked duplex 12.8 × 11 m (2 units)", 12.8, 11, 2, 1), | |
| 168 | + P("triplex", "Triplex 11,6 × 10,4 m (3 logements)", "Triplex 11.6 × 10.4 m (3 units)", 11.6, 10.4, 3, 1), | |
| 169 | + P("triplex-b", "Triplex 12,8 × 11 m (3 logements)", "Triplex 12.8 × 11 m (3 units)", 12.8, 11, 3, 1), | |
| 170 | + P("quadruplex", "Quadruplex 2 étages 18,3 × 10,4 m (4 logements)", "Fourplex 2 storeys 18.3 × 10.4 m (4 units)", 18.3, 10.4, 2, 2), | |
| 171 | + P("quadruplex-b", "Quadruplex 2 étages 20,1 × 11 m (4 logements)", "Fourplex 2 storeys 20.1 × 11 m (4 units)", 20.1, 11, 2, 2), | |
| 172 | + P("sixplex", "Sixplex 3 étages 18,3 × 10,4 m (6 logements)", "Sixplex 3 storeys 18.3 × 10.4 m (6 units)", 18.3, 10.4, 3, 2), | |
| 173 | + P("sixplex-b", "Sixplex 3 étages 20,1 × 11 m (6 logements)", "Sixplex 3 storeys 20.1 × 11 m (6 units)", 20.1, 11, 3, 2), | |
| 174 | + P("huitplex", "8 logements 3 étages 24,4 × 11 m (3 + 3 + 2)", "8 units 3 storeys 24.4 × 11 m", 24.4, 11, 3, 3, "apartment", { units: 9, spec: { units: 9 } }), | |
| 175 | + P("studio-plex", "Triplex de studios 9,1 × 9,1 m (3 logements 1 ch.)", "Triplex of one-bedroom units 9.1 × 9.1 m", 9.1, 9.1, 3, 1, "apartment1"), | |
| 176 | + { key: "rangee", kind: "row", fr: "Maison en rangée 6,1 × 11 m (2 étages)", en: "Row house 6.1 × 11 m (2 storeys)", W: 6.1, D: 11, storeys: 2, basement: "unfinished", garage: null, programs: (l) => [l === 0 ? PROGRAMS.rowRdc : PROGRAMS.rowEtage], spec: { siding: { brick: 0.9, vinyl: 0.1 } } }, | |
| 177 | + { key: "rangee-b", kind: "row", fr: "Maison en rangée 7,3 × 12,2 m (2 étages, sous-sol fini)", en: "Row house 7.3 × 12.2 m (2 storeys, finished basement)", W: 7.3, D: 12.2, storeys: 2, basement: "finished", garage: null, programs: (l) => [l === 0 ? PROGRAMS.rowRdc : PROGRAMS.rowEtage], spec: { basement: "finished", basementFinishedPct: 0.8, siding: { brick: 0.9, vinyl: 0.1 } } }, | |
| 178 | + { key: "jumele", kind: "cottage", fr: "Jumelé 2 étages 7,9 × 10,4 m (une unité)", en: "Semi-detached 2 storeys 7.9 × 10.4 m (one unit)", W: 7.9, D: 10.4, storeys: 2, basement: "unfinished", garage: null, programs: (l) => [l === 0 ? PROGRAMS.rowRdc : PROGRAMS.rowEtage], spec: { siding: { brick: 0.5, vinyl: 0.5 } } }, | |
| 179 | + { key: "chalet", kind: "chalet", fr: "Chalet 8,5 × 7,3 m sur pieux", "en": "Cottage cabin 8.5 × 7.3 m on piers", W: 8.5, D: 7.3, storeys: 1, basement: "none", garage: null, programs: () => [PROGRAMS.chalet], spec: { basement: "none", foundation: "piers", roofType: "metal", roofPitch: 8, siding: { wood: 1 }, heating: "electric_baseboard", hasAirExchanger: false } }, | |
| 180 | + { key: "chalet-b", kind: "chalet", fr: "Chalet 4 saisons 10,4 × 8,5 m, vide sanitaire", en: "Four-season cottage 10.4 × 8.5 m, crawl space", W: 10.4, D: 8.5, storeys: 1, basement: "crawl", garage: null, programs: () => [PROGRAMS.bungalow2], spec: { basement: "crawl", foundationWallHeightM: 1.2, roofType: "metal", roofPitch: 8, siding: { wood: 0.7, stone: 0.3 }, heating: "heat_pump" } }, | |
| 181 | + { key: "mobile", kind: "mobile", fr: "Maison mobile 4,3 × 18,3 m", en: "Mobile home 4.3 × 18.3 m", W: 18.3, D: 4.3, storeys: 1, basement: "none", garage: null, programs: () => [PROGRAMS.mobile], spec: { basement: "none", foundation: "piers", roofType: "metal", roofGeometry: "flat", roofPitch: 2, siding: { steel: 1 }, hasAirExchanger: false, quality: "economy", kitchenQuality: "economy", bathroomQuality: "economy" } }, | |
| 182 | + { key: "condo", kind: "condo", fr: "Unité de copropriété 10,4 × 8,5 m (intérieur seulement)", en: "Condo unit 10.4 × 8.5 m (interior only)", W: 10.4, D: 8.5, storeys: 1, basement: "none", garage: null, programs: () => [PROGRAMS.apartment], spec: { basement: "none", foundation: "slab_on_grade", roofGeometry: "flat", roofType: "membrane", roofPitch: 0, siding: { brick: 1 }, heating: "electric_baseboard" } }, | |
| 183 | + { key: "garage-1", kind: "garage", fr: "Garage détaché simple 4,3 × 6,7 m", en: "Detached single garage 4.3 × 6.7 m", W: 4.3, D: 6.7, storeys: 1, basement: "none", garage: null, programs: () => [], spec: { basement: "none", foundation: "slab_on_grade", foundationWallHeightM: 1.2, siding: { vinyl: 1 }, heating: "electric_baseboard", hasAirExchanger: false, garage: "detached" } }, | |
| 184 | + { key: "garage-2", kind: "garage", fr: "Garage détaché double 6,7 × 6,7 m", en: "Detached double garage 6.7 × 6.7 m", W: 6.7, D: 6.7, storeys: 1, basement: "none", garage: null, programs: () => [], spec: { basement: "none", foundation: "slab_on_grade", foundationWallHeightM: 1.2, siding: { vinyl: 1 }, heating: "electric_baseboard", hasAirExchanger: false, garage: "detached" } }, | |
| 185 | + { key: "garage-3", kind: "garage", fr: "Garage détaché 7,3 × 9,1 m avec atelier", en: "Detached garage 7.3 × 9.1 m with workshop", W: 7.3, D: 9.1, storeys: 1, basement: "none", garage: null, programs: () => [], spec: { basement: "none", foundation: "slab_on_grade", foundationWallHeightM: 1.2, siding: { steel: 1 }, heating: "electric_baseboard", hasAirExchanger: false, garage: "detached" } }, | |
| 186 | +]; | |
| 187 | + | |
| 188 | +const LEVEL_NAMES = ["Rez-de-chaussée", "Étage", "2e étage", "3e étage"]; | |
| 189 | + | |
| 190 | +export function listTemplates(): Omit<Template, "levels">[] { return DEFS.map((d) => { const t = buildTemplate(d.key)!; const { levels, ...rest } = t; void levels; return rest; }); } | |
| 191 | + | |
| 192 | +export function buildTemplate(key: string): Template | null { | |
| 193 | + const d = DEFS.find((x) => x.key === key); if (!d) return null; | |
| 194 | + const levels: GenLevel[] = []; | |
| 195 | + if (d.kind === "garage") { | |
| 196 | + const margin = 2; const objs: GenObject[] = [ | |
| 197 | + { kind: "wall", name: "Murs du garage", points: rect(margin, margin, d.W, d.D), closed: true, props: { thickness: 0.2, height: 3.0, exterior: true }, openings: [{ name: "Porte de garage", kind: "door", at: { x: margin + d.W / 2, y: margin }, widthM: d.W >= 6 ? 4.9 : 2.7, heightM: 2.1, swing: 1 }, { name: "Porte de service", kind: "door", at: { x: margin + d.W, y: margin + d.D * 0.7 }, widthM: 0.9, heightM: 2.1 }, { name: "Fenêtre", kind: "window", at: { x: margin + d.W * 0.5, y: margin + d.D }, widthM: 1.0, heightM: 0.8 }] }, | |
| 198 | + { kind: "room", name: "Garage", points: rect(margin + 0.1, margin + 0.1, d.W - 0.2, d.D - 0.2), closed: true, props: { usage: "garage", height: 3.0 } }, | |
| 199 | + { kind: "slab", name: "Dalle 125 mm", points: rect(margin, margin, d.W, d.D), closed: true, props: { thickness: 0.125 } }, | |
| 200 | + { kind: "footprint", name: "Empreinte", points: rect(margin - 0.1, margin - 0.1, d.W + 0.2, d.D + 0.2), closed: true, props: {} }, | |
| 201 | + ]; | |
| 202 | + levels.push({ level: "Rez-de-chaussée", widthM: d.W + 2 * margin, heightM: d.D + 2 * margin, objects: objs }); | |
| 203 | + } else { | |
| 204 | + if (d.basement && d.basement !== "none") levels.push(level("Sous-sol", d.W, d.D, [], { basement: true })); | |
| 205 | + for (let l = 0; l < d.storeys; l++) levels.push(level(d.levelNames?.[l] ?? LEVEL_NAMES[l], d.W, d.D, d.programs(l), { garage: l === 0 ? d.garage ?? null : null, unitLabels: d.labels?.(l) })); | |
| 206 | + } | |
| 207 | + const units = d.units ?? 1; | |
| 208 | + const areaM2 = r2(d.W * d.D * d.storeys); | |
| 209 | + const spec: Partial<BuildingSpecQS> = { units, basement: d.basement ?? "none", ...(d.spec ?? {}) }; | |
| 210 | + return { key: d.key, kind: d.kind, fr: d.fr, en: d.en, footprint: `${d.W} × ${d.D} m`, storeys: d.storeys, units, areaM2, spec, levels }; | |
| 211 | +} | |
| 212 | + | |
| 213 | +export const TEMPLATE_KINDS: { key: Template["kind"]; fr: string; en: string }[] = [ | |
| 214 | + { key: "bungalow", fr: "Bungalows", en: "Bungalows" }, { key: "cottage", fr: "Maisons 2 étages / jumelés", en: "Two-storey / semi-detached" }, { key: "plex", fr: "Plex (2 à 9 logements)", en: "Plexes (2 to 9 units)" }, | |
| 215 | + { key: "row", fr: "Maisons en rangée", en: "Row houses" }, { key: "chalet", fr: "Chalets", en: "Cottages" }, { key: "mobile", fr: "Maisons mobiles", en: "Mobile homes" }, { key: "condo", fr: "Copropriétés", en: "Condos" }, { key: "garage", fr: "Garages détachés", en: "Detached garages" }, | |
| 216 | +]; | |
| 217 | + | |
| 218 | +/* ------------------------------------------------- matérialisation (px) */ | |
| 219 | + | |
| 220 | +import type { PlanObject } from "./types"; | |
| 221 | + | |
| 222 | +/** Convertit un niveau généré (mètres) en objets de plan (pixels) avec identifiants temporaires ; les ouvertures référencent leur mur. */ | |
| 223 | +export function materializeLevel(lv: GenLevel, pxPerMeter: number): PlanObject[] { | |
| 224 | + const k = pxPerMeter; let n = 0; const out: PlanObject[] = []; | |
| 225 | + const px = (p: Point): Point => ({ x: Math.round(p.x * k * 100) / 100, y: Math.round(p.y * k * 100) / 100 }); | |
| 226 | + for (const o of lv.objects) { | |
| 227 | + const id = `tmp-${++n}`; | |
| 228 | + out.push({ id, pageId: "", kind: o.kind, layer: o.kind === "wall" ? "murs" : o.kind === "room" ? "pièces" : o.kind === "footprint" || o.kind === "lot" ? "site" : o.kind === "slab" ? "fondations" : "default", name: o.name, points: o.points.map(px), closed: o.closed, props: { ...o.props }, createdAt: "", updatedAt: "" }); | |
| 229 | + for (const op of o.openings ?? []) out.push({ id: `tmp-${++n}`, pageId: "", kind: "opening", layer: "ouvertures", name: op.name, points: [px(op.at)], closed: false, props: { openingKind: op.kind, openingWidth: op.widthM, openingHeight: op.heightM, count: 1, deductFrom: id, swing: op.swing ?? 1 }, createdAt: "", updatedAt: "" }); | |
| 230 | + } | |
| 231 | + return out; | |
| 232 | +} | |
modified
src/lib/metre/types.ts
+4 −0
@@ -52,6 +52,8 @@ export interface Project { | ||
| 52 | 52 | isTemplate: boolean; |
| 53 | 53 | /** verrouillé par son créateur : lecture seule pour les autres (plateforme ouverte) */ |
| 54 | 54 | locked: boolean; |
| 55 | + /** fiche technique pour le relevé des quantités (null = défauts) */ | |
| 56 | + spec: Record<string, unknown> | null; | |
| 55 | 57 | createdAt: string; |
| 56 | 58 | updatedAt: string; |
| 57 | 59 | } |
@@ -273,6 +275,8 @@ export interface EstimateLineInput { | ||
| 273 | 275 | assumptionNote: string | null; |
| 274 | 276 | excluded: boolean; |
| 275 | 277 | sortOrder: number; |
| 278 | + /** origine : relevé des quantités automatique (remplacé à chaque génération) ou saisie */ | |
| 279 | + origin?: "qs" | "manual"; | |
| 276 | 280 | } |
| 277 | 281 | |
| 278 | 282 | export interface EstimateParams { |
| 279 | 283 | |