using System; using System.Collections.Generic; using UnityEngine; /// /// Vérification des NORMES d'aménagement, déclenchée à chaque ajout de caisson ou d'objet dans la /// scène (voir HitObject.AddObject / AddObjectOnBox). Une violation affiche un avertissement. /// /// AJOUTER UNE NORME : /// 1. écrire une méthode statique static Violation MaNorme() qui renvoie null si tout va bien, /// ou une Violation décrivant le problème ; /// 2. l'inscrire dans le tableau RULES. /// Rien d'autre à toucher — le déclenchement, le log et l'affichage sont communs. /// /// Les règles lisent la scène entière : pas besoin de savoir QUEL objet vient d'être ajouté, et le /// résultat est le même si l'utilisateur déplace un objet existant au lieu d'en poser un nouveau. /// public static class NormCheck { // ─── Seuils, en pouces ─────────────────────────────────────────────────── /// /// Distance minimale DE CENTRE À CENTRE entre une plaque de cuisson et un garde-manger (T1). /// public const float STOVE_TO_TALL_MIN = 36f; /// /// Dégagement vertical minimal entre la SURFACE DE COMPTOIR (plan de cuisson) et le dessous /// de la hotte. /// public const float HOOD_ABOVE_STOVE_MIN = 24f; /// /// Hauteur du plan de cuisson au-dessus du plancher, pour un appareil posé AU SOL. /// const float STOVE_TOP_HEIGHT = 36f; /// /// Manque de largeur toléré entre la hotte et le foyer. La hotte doit couvrir au moins la largeur /// de la surface de cuisson ; plus large est admis. /// public const float HOOD_WIDTH_TOLERANCE = 0.5f; /// /// Décalage latéral maximal entre l'axe de la hotte et celui de la surface de cuisson. /// public const float HOOD_ALIGN_MAX = 3f; /// Dégagement libre exigé de chaque CÔTÉ d'un tabouret. public const float STOOL_SIDE_MIN = 6f; /// Dégagement libre exigé DERRIÈRE un tabouret, pour s'asseoir et passer. public const float STOOL_BACK_MIN = 24f; /// Dégagement libre exigé de chaque CÔTÉ d'une surface de cuisson. public const float COOKTOP_SIDE_MIN = 12f; /// /// Hauteur, au-dessus du plan de cuisson, où l'on sonde les obstacles latéraux. Assez haut pour /// ignorer les caissons de base voisins, assez bas pour ignorer les caissons muraux. /// const float COOKTOP_PROBE_ABOVE = 4f; /// Portée du sondage des murs : au-delà, aucun dégagement n'est plus contraignant. const float WALL_PROBE_RANGE = 120f; /// /// Écart minimal entre deux façades de caissons qui se font face, pour que leurs portes ouvrent /// sans se heurter. /// public const float CABINET_FACING_MIN = 30f; // ─── Triangle d'activité (frigo / évier / cuisson) ─────────────────────── /// Longueur minimale d'un côté du triangle : 4 pi. public const float TRIANGLE_LEG_MIN = 48f; /// Longueur maximale d'un côté du triangle : 9 pi. public const float TRIANGLE_LEG_MAX = 108f; /// Périmètre minimal du triangle : 13 pi. public const float TRIANGLE_TOTAL_MIN = 156f; /// Périmètre maximal du triangle : 26 pi. public const float TRIANGLE_TOTAL_MAX = 312f; /// /// Produit scalaire en deçà duquel deux axes avant sont tenus pour opposés : -1 = exactement 180°, /// -0.9 laisse environ 25° de jeu pour les murs qui ne sont pas rigoureusement parallèles. /// const float FACING_DOT = -0.9f; // ─── Résultat d'une règle ──────────────────────────────────────────────── public class Violation { public string Code; // identifiant court, pour le log public string ObjectName; // objet fautif dans la scène public string RelatedName; // second objet en cause (obstacle, vis-à-vis) — peut être vide public string[] Also; // objets en cause au-delà des deux premiers (triangle d'activité) public string[] Text; // [EN, FR, ES] — même ordre que _G.L (1, 2, 3) /// La violation met-elle en cause cet objet, à quelque titre que ce soit ? public bool Involves(string name) { if (string.IsNullOrEmpty(name)) return false; if (ObjectName == name || RelatedName == name) return true; if (Also != null) foreach (string n in Also) if (n == name) return true; return false; } public string Message => Text != null && Text.Length >= 3 ? Text[Mathf.Clamp(_G.L, 1, 3) - 1] : Code; } // ─── Registre des normes ───────────────────────────────────────────────── delegate Violation Rule(); static readonly Rule[] RULES = { StoveNearTallCabinet, HoodClearanceAboveStove, HoodWidthMatchesCooktop, HoodAlignedWithCooktop, StoolSeatingClearance, CooktopSideClearance, // AVANT FrontClearances : le passage de 36" devant un caisson est plus large que les 30" // entre deux façades, il masquerait sinon toujours la collision de portes, qui est le // diagnostic le plus précis. FacingCabinetDoors, FrontClearances, // En dernier : c'est un conseil d'ergonomie, pas un défaut de dégagement. Les collisions et // les passages bloqués doivent s'exprimer d'abord. WorkTriangle, }; // ─── Points d'entrée ───────────────────────────────────────────────────── /// Toutes les violations présentes dans la scène. public static List Check() { var found = new List(); foreach (Rule rule in RULES) { // Une norme en échec ne doit jamais empêcher l'ajout de l'objet ni bloquer les autres. try { Violation v = rule(); if (v != null) found.Add(v); } catch (Exception e) { Debug.LogError("[NormCheck] règle en échec : " + e); } } return found; } // IDENTITÉ de la violation au bandeau ("code|objet"), et NON son texte : les messages contiennent // une mesure qui change à chaque image pendant un déplacement. Se fier au texte faisait réapparaître // le bandeau en continu — il reprenait le pointeur et le renvoi par le X ne tenait pas. static string _shownKey = ""; static bool _hidden; // bandeau déjà masqué ? évite un GameObject.Find par image en glissement /// /// Vérifie la scène : affiche le bandeau d'avertissement s'il y a une violation, le masque sinon. /// Retourne true si tout est conforme. N'empêche jamais l'ajout : c'est un avertissement. /// Appelable aussi souvent qu'on veut — le bandeau reflète simplement l'état courant. /// public static bool CheckAndWarn() { // Vérification désactivée dans les préférences : rien n'est évalué, et un avertissement // encore affiché disparaît. if (!Get.PreferenceBool("NORMES")) { HideBanner(); return true; } // Silence en mode IA : la génération pose et repositionne les objets un par un, chaque étape // intermédiaire porterait sur une scène encore incomplète. _AI.On couvre toute la session du // panneau IA et retombe à sa fermeture (ClosePNL) — les normes reprennent ensuite. if (_AI.On) { HideBanner(); return true; } List found = Check(); if (found.Count == 0) { HideBanner(); return true; } Violation v = Pick(found); string key = v.Code + "|" + v.ObjectName; if (key != _shownKey) { // Violation NOUVELLE : on affiche. Debug.LogWarning($"[NormCheck] {v.Code} — {v.ObjectName} : {v.Message}"); _shownKey = key; _hidden = false; _M.Warning(v.Message); } else { // Même violation : on rafraîchit seulement la mesure, et uniquement si le bandeau est // encore à l'écran. S'il a été renvoyé par le X, il le reste. _M.WarningRefresh(v.Message); } return false; } /// /// Violation à afficher quand il y en a plusieurs : en priorité celle qui met en cause l'objet /// que l'utilisateur manipule — c'est de son geste qu'il attend un retour. À défaut, la première, /// c'est-à-dire la plus prioritaire dans l'ordre de RULES. /// static Violation Pick(List found) { string sel = SceneModeManager.SelectedName; foreach (Violation v in found) if (v.Involves(sel)) return v; return found[0]; } /// /// Contrôle au titre d'une action PONCTUELLE (ajout, pose SnapOn, redimensionnement) : réaffiche /// même si le même avertissement avait été renvoyé par le X — c'est une nouvelle action. /// public static bool CheckAndWarnNewAction() { ResetBanner(); return CheckAndWarn(); } /// /// Oublie l'avertissement renvoyé : le prochain contrôle réaffichera, même s'il s'agit de la même /// violation. À appeler au DÉBUT d'une action, jamais pendant — sinon le bandeau se rouvrirait à /// chaque image et reprendrait le pointeur. /// public static void ResetBanner() => _shownKey = ""; /// /// Masque le bandeau. Le premier appel passe toujours par _M : MESSAGEWARNING est actif par /// défaut dans la scène. Ensuite on ne retouche l'interface que si quelque chose est affiché — /// le contrôle tourne à chaque image pendant un glissement. /// static void HideBanner() { if (_hidden && _shownKey == "") return; _M.HideWarning(); _shownKey = ""; _hidden = true; } // ─── NORMES ────────────────────────────────────────────────────────────── /// /// Une surface de cuisson ne doit pas jouxter un garde-manger (caisson T1) : il faut un espace /// de pose d'au moins STOVE_TO_TALL_MIN. Seul le four encastré (BUIL) y échappe, puisqu'il est /// précisément conçu pour être logé dedans. /// static Violation StoveNearTallCabinet() { List tops = ObjectsWhere(IsCookingSurface); List talls = ObjectsWhere(o => o[2] == "Cabinet" && o[4] == "T1"); if (tops.Count == 0 || talls.Count == 0) return null; foreach (string[] s in tops) { GameObject sg = Get.o2("SCENE", s[0]); if (sg == null) continue; foreach (string[] t in talls) { GameObject tg = Get.o2("SCENE", t[0]); if (tg == null) continue; // Centre à centre, dans le plan du sol. On lit la position des transforms plutôt que // l'ADN : après un glisser-déposer, ADN[15]/[17] peut ne pas être encore réécrit. float d = HorizontalDistance(sg.transform.position, tg.transform.position); if (d >= STOVE_TO_TALL_MIN) continue; return new Violation { Code = "STOVE_NEAR_TALL", ObjectName = s[0], RelatedName = t[0], Text = new[] { $"Warning — cooktop too close to a pantry / tall cabinet ({d:0.#}\" centre to centre).\nMinimum required: {STOVE_TO_TALL_MIN:0}\".", $"Avertissement — plaque de cuisson trop près d'un garde-manger ({d:0.#} po de centre à centre).\nMinimum requis : {STOVE_TO_TALL_MIN:0} po.", $"Advertencia — placa de cocción demasiado cerca de una despensa ({d:0.#}\" de centro a centro).\nMínimo requerido: {STOVE_TO_TALL_MIN:0}\"." } }; } } return null; } /// /// La hotte doit être à au moins HOOD_ABOVE_STOVE_MIN au-dessus de la surface de cuisson. /// Ne concerne que les hottes effectivement AU-DESSUS d'un foyer (emprises au sol qui se recoupent). /// static Violation HoodClearanceAboveStove() { foreach (var (h, hg, s, sg) in HoodOverStovePairs()) { // Dégagement calculé sur la géométrie DÉCLARÉE (position + ADN[7]) et non sur les // englobants : ceux-ci portent le jeu de 0.25" par face des boîtes de la scène, qui // amputait le résultat d'un quart de pouce (23.8 au lieu de 24). float clearance = BottomY(hg, h) - CookingSurfaceY(sg, s); if (clearance >= HOOD_ABOVE_STOVE_MIN) continue; return new Violation { Code = "HOOD_LOW_OVER_STOVE", ObjectName = h[0], RelatedName = s[0], Text = new[] { $"Range hood height — insufficient clearance from stove ({clearance:0.#}\").\nMinimum required: {HOOD_ABOVE_STOVE_MIN:0}\".", $"Hauteur de la hotte — dégagement insuffisant au-dessus de la cuisinière ({clearance:0.#} po).\nMinimum requis : {HOOD_ABOVE_STOVE_MIN:0} po.", $"Altura de la campana — espacio insuficiente sobre la cocina ({clearance:0.#}\").\nMínimo requerido: {HOOD_ABOVE_STOVE_MIN:0}\"." } }; } return null; } /// /// La hotte doit couvrir au moins la largeur de la surface de cuisson : une hotte plus étroite /// ne capte pas les vapeurs sur toute la largeur des foyers. Plus large est admis. /// static Violation HoodWidthMatchesCooktop() { foreach (var (h, _, s, _) in HoodOverStovePairs()) { float hoodW = Width(h); float stoveW = Width(s); if (hoodW <= 0f || stoveW <= 0f) continue; // largeur non renseignée if (hoodW + HOOD_WIDTH_TOLERANCE >= stoveW) continue; return new Violation { Code = "HOOD_NARROWER_THAN_STOVE", ObjectName = h[0], RelatedName = s[0], Text = new[] { $"Range hood should match cooktop width — hood {hoodW:0.#}\", cooktop {stoveW:0.#}\".", $"La hotte doit couvrir la largeur de la surface de cuisson — hotte {hoodW:0.#} po, foyer {stoveW:0.#} po.", $"La campana debe cubrir el ancho de la placa de cocción — campana {hoodW:0.#}\", placa {stoveW:0.#}\"." } }; } return null; } /// /// La hotte doit être centrée sur la surface de cuisson : au-delà de HOOD_ALIGN_MAX de décalage /// latéral, une partie des foyers n'est plus couverte. /// static Violation HoodAlignedWithCooktop() { foreach (var (h, hg, s, sg) in HoodOverStovePairs()) { // Décalage mesuré le long de la LARGEUR de la hotte (son axe local droite) : c'est le // désalignement gauche-droite, insensible à la différence de profondeur des deux appareils. Vector3 delta = sg.transform.position - hg.transform.position; float offset = Mathf.Abs(Vector3.Dot(delta, hg.transform.right)); if (offset <= HOOD_ALIGN_MAX) continue; return new Violation { Code = "HOOD_OFF_CENTER", ObjectName = h[0], RelatedName = s[0], Text = new[] { $"Range hood is not centred on the cooktop ({offset:0.#}\" off).\nMaximum allowed: {HOOD_ALIGN_MAX:0}\".", $"La hotte n'est pas centrée sur la surface de cuisson ({offset:0.#} po de décalage).\nMaximum admis : {HOOD_ALIGN_MAX:0} po.", $"La campana no está centrada sobre la placa de cocción ({offset:0.#}\" de desfase).\nMáximo admitido: {HOOD_ALIGN_MAX:0}\"." } }; } return null; } // ─── Dégagements FRONTAUX, par type d'objet ────────────────────────────── // Un seul mécanisme de mesure, une ligne par type. {0} = mesuré, {1} = minimum exigé. class FrontSpec { public string Code; public Func Match; public float Min; public string[] Text; // [EN, FR, ES] } static readonly FrontSpec[] FRONT_SPECS = { new() { Code = "DISHWASHER_FRONT", Match = o => Prefix(o) == "dish", Min = 36f, Text = new[] { "Dishwasher door blocks circulation — only {0}\" clear in front.\nMinimum required: {1}\".", "La porte du lave-vaisselle bloque la circulation — seulement {0} po devant.\nMinimum requis : {1} po.", "La puerta del lavavajillas bloquea la circulación — solo {0}\" delante.\nMínimo requerido: {1}\"." }}, new() { Code = "FRIDGE_FRONT", Match = o => Prefix(o) == "frid", Min = 30f, Text = new[] { "Refrigerator doors may not fully open — only {0}\" clear in front.\nMinimum required: {1}\".", "Les portes du réfrigérateur risquent de ne pas ouvrir complètement — seulement {0} po devant.\nMinimum requis : {1} po.", "Las puertas del refrigerador podrían no abrir por completo — solo {0}\" delante.\nMínimo requerido: {1}\"." }}, new() { Code = "STOVE_FRONT", Match = IsRange, Min = 36f, Text = new[] { "Stove door blocks circulation — only {0}\" clear in front.\nMinimum required: {1}\".", "La porte du four bloque la circulation — seulement {0} po devant.\nMinimum requis : {1} po.", "La puerta del horno bloquea la circulación — solo {0}\" delante.\nMínimo requerido: {1}\"." }}, new() { Code = "WINDOW_FRONT", Match = o => Prefix(o) == "wind", Min = 36f, Text = new[] { "Window may not be obstructed — only {0}\" clear in front.\nMinimum required: {1}\".", "La fenêtre ne doit pas être obstruée — seulement {0} po devant.\nMinimum requis : {1} po.", "La ventana no debe estar obstruida — solo {0}\" delante.\nMínimo requerido: {1}\"." }}, new() { Code = "DOOR_FRONT", Match = o => Prefix(o) == "door", Min = 36f, Text = new[] { "Door may not be obstructed — only {0}\" clear in front.\nMinimum required: {1}\".", "La porte ne doit pas être obstruée — seulement {0} po devant.\nMinimum requis : {1} po.", "La puerta no debe estar obstruida — solo {0}\" delante.\nMínimo requerido: {1}\"." }}, new() { Code = "PATIO_FRONT", Match = o => Prefix(o) == "pati", Min = 36f, Text = new[] { "Patio may not be obstructed — only {0}\" clear in front.\nMinimum required: {1}\".", "Le patio ne doit pas être obstrué — seulement {0} po devant.\nMinimum requis : {1} po.", "El patio no debe estar obstruido — solo {0}\" delante.\nMínimo requerido: {1}\"." }}, new() { Code = "OPENING_FRONT", Match = o => Prefix(o) == "open", Min = 36f, Text = new[] { "Opening may not be obstructed — only {0}\" clear in front.\nMinimum required: {1}\".", "L'ouverture ne doit pas être obstruée — seulement {0} po devant.\nMinimum requis : {1} po.", "La abertura no debe estar obstruida — solo {0}\" delante.\nMínimo requerido: {1}\"." }}, // Palier SERRÉ, avant les deux suivants : sous 12" la porte ne peut plus s'ouvrir du tout, // c'est un défaut d'accès et non un simple passage étroit. Doit passer en premier, sinon le // seuil de 36" — toujours satisfait dès que celui-ci l'est — le masquerait. new() { Code = "CABINET_ACCESS", Match = o => o[2] == "Cabinet", Min = 12f, Text = new[] { "Cabinet may not be fully accessible — only {0}\" clear in front.\nMinimum required: {1}\".", "Le caisson risque de ne pas être pleinement accessible — seulement {0} po devant.\nMinimum requis : {1} po.", "El gabinete podría no ser totalmente accesible — solo {0}\" delante.\nMínimo requerido: {1}\"." }}, // Ensuite le passage devant un caisson, contrainte la plus générale. Les deux entrées // suivantes s'excluent — même seuil, mais le libellé dépend de l'implantation du caisson. new() { Code = "ISLAND_FRONT", Match = IsIslandCabinet, Min = 36f, Text = new[] { "Insufficient clearance around island — only {0}\" clear in front.\nMinimum required: {1}\".", "Dégagement insuffisant autour de l'îlot — seulement {0} po devant.\nMinimum requis : {1} po.", "Espacio insuficiente alrededor de la isla — solo {0}\" delante.\nMínimo requerido: {1}\"." }}, new() { Code = "CABINET_FRONT", Match = o => o[2] == "Cabinet" && !IsIslandCabinet(o), Min = 36f, Text = new[] { "Walkway clearance below recommended minimum ({1}\") — only {0}\" clear in front.", "Dégagement de passage sous le minimum recommandé ({1} po) — seulement {0} po devant.", "Espacio de paso por debajo del mínimo recomendado ({1}\") — solo {0}\" delante." }}, }; /// /// TRIANGLE D'ACTIVITÉ frigo / évier / cuisson : chaque côté doit mesurer entre TRIANGLE_LEG_MIN /// et TRIANGLE_LEG_MAX, et le périmètre rester entre TRIANGLE_TOTAL_MIN et TRIANGLE_TOTAL_MAX. /// Trop serré, les trois postes se gênent ; trop étiré, la circulation devient inutilement longue. /// La règle ne s'applique que si les trois postes existent. /// static Violation WorkTriangle() { if (!Vertex(o => Prefix(o) == "frid", out Vector3 pf, out string nf)) return null; if (!Vertex(IsSinkCabinet, out Vector3 ps, out string ns)) return null; if (!Vertex(IsCookingSurface, out Vector3 pc, out string nc)) return null; float fs = HorizontalDistance(pf, ps); float sc = HorizontalDistance(ps, pc); float cf = HorizontalDistance(pc, pf); float shortest = Mathf.Min(fs, Mathf.Min(sc, cf)); float longest = Mathf.Max(fs, Mathf.Max(sc, cf)); float total = fs + sc + cf; // Le défaut le plus parlant, dans l'ordre où on le corrige. string en, fr, es; if (shortest < TRIANGLE_LEG_MIN) { en = $"Kitchen work triangle may be inefficient — shortest leg {shortest:0.#}\", minimum {TRIANGLE_LEG_MIN:0}\"."; fr = $"Le triangle d'activité risque d'être inefficace — plus petit côté {shortest:0.#} po, minimum {TRIANGLE_LEG_MIN:0} po."; es = $"El triángulo de trabajo puede ser ineficiente — lado más corto {shortest:0.#}\", mínimo {TRIANGLE_LEG_MIN:0}\"."; } else if (longest > TRIANGLE_LEG_MAX) { en = $"Kitchen work triangle may be inefficient — longest leg {longest:0.#}\", maximum {TRIANGLE_LEG_MAX:0}\"."; fr = $"Le triangle d'activité risque d'être inefficace — plus grand côté {longest:0.#} po, maximum {TRIANGLE_LEG_MAX:0} po."; es = $"El triángulo de trabajo puede ser ineficiente — lado más largo {longest:0.#}\", máximo {TRIANGLE_LEG_MAX:0}\"."; } else if (total < TRIANGLE_TOTAL_MIN || total > TRIANGLE_TOTAL_MAX) { en = $"Kitchen work triangle may be inefficient — perimeter {total:0.#}\", allowed {TRIANGLE_TOTAL_MIN:0}\" to {TRIANGLE_TOTAL_MAX:0}\"."; fr = $"Le triangle d'activité risque d'être inefficace — périmètre {total:0.#} po, admis de {TRIANGLE_TOTAL_MIN:0} à {TRIANGLE_TOTAL_MAX:0} po."; es = $"El triángulo de trabajo puede ser ineficiente — perímetro {total:0.#}\", admitido de {TRIANGLE_TOTAL_MIN:0}\" a {TRIANGLE_TOTAL_MAX:0}\"."; } else return null; return new Violation { Code = "WORK_TRIANGLE", ObjectName = nf, RelatedName = ns, Also = new[] { nc }, // déplacer l'un OU l'autre des trois postes doit le signaler Text = new[] { en, fr, es } }; } /// Cabinet portant un évier : ADN[67] est la recette d'évier, "NONE" quand il n'y en a pas. static bool IsSinkCabinet(string[] o) => o.Length > 67 && !string.IsNullOrEmpty(o[67]) && o[67] != "NONE"; /// Position au sol du premier objet satisfaisant le prédicat, et son nom. static bool Vertex(Func match, out Vector3 pos, out string name) { foreach (string[] o in ObjectsWhere(match)) { GameObject g = Get.o2("SCENE", o[0]); if (g == null) continue; pos = g.transform.position; name = o[0]; return true; } pos = Vector3.zero; name = ""; return false; } /// /// Caisson d'ÎLOT : posé au plancher plutôt que rattaché à un mur (ADN[22] == "floor", cf. /// CreateCAB qui y recopie PAINT.SEL). /// static bool IsIslandCabinet(string[] o) => o[2] == "Cabinet" && o.Length > 22 && o[22] == "floor"; /// Cuisinière ou four encastré : porte de four. La table de cuisson n'en a pas. static bool IsRange(string[] o) => Prefix(o) == "stov" && ModelPrefix(o) != "TOPS"; /// /// SURFACE DE CUISSON : cuisinière (STOV) ou table encastrée (TOPS). Le four encastré (BUIL) en /// est exclu — il n'a pas de foyer, et il est justement prévu pour être logé dans un garde-manger. /// static bool IsCookingSurface(string[] o) => Prefix(o) == "stov" && ModelPrefix(o) != "BUIL"; /// Applique tous les dégagements frontaux de FRONT_SPECS. static Violation FrontClearances() { foreach (FrontSpec spec in FRONT_SPECS) { foreach (string[] o in ObjectsWhere(spec.Match)) { float clear = FrontClearance(o, out string blocker); if (clear >= spec.Min) continue; return new Violation { Code = spec.Code, ObjectName = o[0], RelatedName = blocker, Text = Format(spec.Text, clear, spec.Min) }; } } return null; } /// Remplit {0} (mesure) et {1} (minimum) dans les trois traductions. static string[] Format(string[] text, float got, float need) { var res = new string[text.Length]; for (int i = 0; i < text.Length; i++) res[i] = string.Format(text[i], got.ToString("0.#"), need.ToString("0")); return res; } /// /// Il faut STOOL_SIDE_MIN de chaque côté d'un tabouret et STOOL_BACK_MIN derrière, pour s'y /// asseoir et circuler dans son dos. /// static Violation StoolSeatingClearance() { foreach (string[] s in ObjectsWhere(o => Prefix(o) == "stoo")) { GameObject sg = Get.o2("SCENE", s[0]); if (sg == null) continue; Vector3 c = sg.transform.position; Vector3 back = -sg.transform.forward.normalized; // le devant du tabouret est son forward Vector3 side = Vector3.Cross(Vector3.up, back).normalized; float halfW = Width(s) * 0.5f; float halfD = Depth(s) * 0.5f; float behind = ClearanceFrom(s, c + back * halfD, back, side, halfW, out string backBy); float left = ClearanceFrom(s, c - side * halfW, -side, back, halfD, out string leftBy); float right = ClearanceFrom(s, c + side * halfW, side, back, halfD, out string rightBy); float sides = Mathf.Min(left, right); string sideBy = left <= right ? leftBy : rightBy; bool tooTight = behind < STOOL_BACK_MIN || sides < STOOL_SIDE_MIN; if (!tooTight) continue; // On nomme la dimension fautive : « dégagement insuffisant » seul n'aide pas à corriger. bool backFault = behind < STOOL_BACK_MIN; float got = backFault ? behind : sides; float need = backFault ? STOOL_BACK_MIN : STOOL_SIDE_MIN; string enWhere = backFault ? "behind" : "at the side"; string frWhere = backFault ? "derrière" : "sur le côté"; string esWhere = backFault ? "detrás" : "al lado"; return new Violation { Code = "STOOL_CLEARANCE", ObjectName = s[0], RelatedName = backFault ? backBy : sideBy, Text = new[] { $"Insufficient seating clearance — only {got:0.#}\" {enWhere} the stool.\nMinimum required: {need:0}\".", $"Dégagement insuffisant pour s'asseoir — seulement {got:0.#} po {frWhere} du tabouret.\nMinimum requis : {need:0} po.", $"Espacio insuficiente para sentarse — solo {got:0.#}\" {esWhere} del taburete.\nMínimo requerido: {need:0}\"." } }; } return null; } /// /// Il faut COOKTOP_SIDE_MIN de chaque côté d'une surface de cuisson : espace de pose et /// protection des surfaces voisines. /// static Violation CooktopSideClearance() { // Le four encastré est exclu : il n'a pas de foyer, et il est logé dans un garde-manger dont // les montants seraient comptés comme obstacles à 0". foreach (string[] s in ObjectsWhere(IsCookingSurface)) { GameObject sg = Get.o2("SCENE", s[0]); if (sg == null) continue; Vector3 c = sg.transform.position; Vector3 side = sg.transform.right.normalized; // axe de la LARGEUR Vector3 fwd = sg.transform.forward.normalized; float halfW = Width(s) * 0.5f; float halfD = Depth(s) * 0.5f; // Ne comptent que les objets qui DÉPASSENT du plan de cuisson : garde-manger, frigo, // mur. Une bande de sondage juste au-dessus de la surface écarte à la fois les caissons // de base voisins — qui SONT l'espace de pose exigé, pas un obstacle — et les caissons // muraux, qui passent bien plus haut. float surface = CookingSurfaceY(sg, s); float loY = surface + COOKTOP_PROBE_ABOVE - 1f; float hiY = surface + COOKTOP_PROBE_ABOVE + 1f; float left = ClearanceFrom(s, c - side * halfW, -side, fwd, halfD, loY, hiY, out string leftBy); float right = ClearanceFrom(s, c + side * halfW, side, fwd, halfD, loY, hiY, out string rightBy); float got = Mathf.Min(left, right); if (got >= COOKTOP_SIDE_MIN) continue; return new Violation { Code = "COOKTOP_SIDE_CLEARANCE", ObjectName = s[0], RelatedName = left <= right ? leftBy : rightBy, Text = new[] { $"Insufficient clearance around cooktop — only {got:0.#}\" at the side.\nMinimum required: {COOKTOP_SIDE_MIN:0}\".", $"Dégagement insuffisant autour de la surface de cuisson — seulement {got:0.#} po sur le côté.\nMinimum requis : {COOKTOP_SIDE_MIN:0} po.", $"Espacio insuficiente alrededor de la placa de cocción — solo {got:0.#}\" al lado.\nMínimo requerido: {COOKTOP_SIDE_MIN:0}\"." } }; } return null; } /// /// Deux caissons dont les FAÇADES se regardent — axes avant opposés — doivent laisser /// CABINET_FACING_MIN entre elles, sinon leurs portes se heurtent à l'ouverture. /// static Violation FacingCabinetDoors() { // GameObjects et englobants résolus UNE fois : la comparaison est en n², et Get.o2 comme // WorldBounds sont coûteux (Find et parcours des Renderer). var cabs = new List<(string[] adn, GameObject go, Bounds bounds)>(); foreach (string[] o in ObjectsWhere(x => x[2] == "Cabinet")) { GameObject g = Get.o2("SCENE", o[0]); if (g != null) cabs.Add((o, g, WorldBounds(g))); } for (int i = 0; i < cabs.Count; i++) { var a = cabs[i]; // Normale de FAÇADE, pas transform.forward : la porte d'un caisson est sur son -Z local. Vector3 af = DoorForward(a.go, a.adn); Vector3 aFace = a.go.transform.position + af * (Depth(a.adn) * 0.5f); Vector3 aRight = a.go.transform.right; float aHalfW = Width(a.adn) * 0.5f; float aBot = BottomY(a.go, a.adn), aTop = TopY(a.go, a.adn); for (int j = i + 1; j < cabs.Count; j++) { var b = cabs[j]; // Façades qui se regardent, et B réellement DEVANT A (pas dos à dos). if (Vector3.Dot(af, DoorForward(b.go, b.adn)) > FACING_DOT) continue; if (Vector3.Dot(b.go.transform.position - a.go.transform.position, af) <= 0f) continue; // Des portes ne se heurtent que si les caissons se recouvrent en HAUTEUR : un caisson // mural face à un caisson de base ouvre à des niveaux différents. if (aTop <= BottomY(b.go, b.adn) || TopY(b.go, b.adn) <= aBot) continue; // Et de face, sur la largeur des façades. if (!OverlapsAlong(b.bounds, aFace, aRight, aHalfW)) continue; float gap = ExtentGap(b.bounds, aFace, af); if (gap > CABINET_FACING_MIN) continue; return new Violation { Code = "CABINET_DOORS_FACING", ObjectName = a.adn[0], RelatedName = b.adn[0], Text = new[] { $"Cabinet doors may collide — facing fronts only {gap:0.#}\" apart.\nMinimum required: {CABINET_FACING_MIN:0}\".", $"Les portes des caissons risquent de se heurter — façades face à face à {gap:0.#} po.\nMinimum requis : {CABINET_FACING_MIN:0} po.", $"Las puertas de los gabinetes podrían chocar — frentes enfrentados a {gap:0.#}\".\nMínimo requerido: {CABINET_FACING_MIN:0}\"." } }; } } return null; } /// /// Distance libre devant un appareil, sur la largeur de sa façade. PositiveInfinity si rien ne /// barre le passage. /// static float FrontClearance(string[] a, out string blocker) { blocker = ""; GameObject ag = Get.o2("SCENE", a[0]); if (ag == null) return float.PositiveInfinity; Vector3 fwd = DoorForward(ag, a); return ClearanceFrom(a, ag.transform.position + fwd * (Depth(a) * 0.5f), fwd, ag.transform.right, Width(a) * 0.5f, out blocker); } /// /// Plus petit écart entre 'face' et un obstacle situé dans la direction 'dir', en ne retenant que /// ce qui recoupe la bande de demi-largeur 'halfWidth' portée par 'side' ET la tranche de hauteur /// de l'objet mesuré. /// /// Le filtre en hauteur est indispensable : sans lui un caisson de base placé sous une fenêtre — /// l'évier sous fenêtre, cas le plus courant — la déclarerait obstruée, alors qu'il passe /// entièrement en dessous. /// static float ClearanceFrom(string[] self, Vector3 face, Vector3 dir, Vector3 side, float halfWidth, out string blocker) { GameObject sg = Get.o2("SCENE", self[0]); float loY = sg != null ? BottomY(sg, self) : float.NegativeInfinity; float hiY = sg != null ? TopY(sg, self) : float.PositiveInfinity; return ClearanceFrom(self, face, dir, side, halfWidth, loY, hiY, out blocker); } /// /// Variante à tranche de hauteur IMPOSÉE, quand la tranche de l'objet lui-même ne convient pas /// pour décider ce qui gêne (voir CooktopSideClearance). /// static float ClearanceFrom(string[] self, Vector3 face, Vector3 dir, Vector3 side, float halfWidth, float loY, float hiY, out string blocker) { blocker = ""; float nearest = float.PositiveInfinity; foreach (string[] o in ObjectsWhere(IsObstacle)) { if (o[0] == self[0]) continue; GameObject og = Get.o2("SCENE", o[0]); if (og == null) continue; Bounds b = WorldBounds(og); // Tranches de hauteur disjointes -> l'obstacle passe au-dessus ou en dessous. if (b.max.y <= loY || b.min.y >= hiY) continue; // Seuil de 0.5" : un panneau de recouvrement ou un caisson affleurant la façade n'est // pas un obstacle DEVANT l'objet, il est à côté. float ahead = ExtentGap(b, face, dir); if (ahead <= 0.5f || ahead >= nearest) continue; if (!OverlapsAlong(b, face, side, halfWidth)) continue; nearest = ahead; blocker = o[0]; } // Un mur plus proche l'emporte, mais il n'a pas de nom d'objet à rapporter. float wall = WallClearance(face, dir, side, halfWidth); if (wall < nearest) { nearest = wall; blocker = ""; } return nearest; } /// /// Distance jusqu'au MUR le plus proche. Les murs ne sont pas dans _G.OBJs : on les atteint par /// raycast sur les maillages de sélection "m1".."mN", seuls à porter un collider actif /// (Create3D : le collider de la face "w#" est désactivé). /// /// Ces maillages sont DÉCOUPÉS aux ouvertures : un rayon qui vise une porte ou un patio passe au /// travers sans rien rencontrer, donc un passage ne compte pas comme obstacle. /// // Tampon réutilisé : la mesure tourne à chaque image pendant un glissement, RaycastAll y // allouerait un tableau par rayon. static readonly RaycastHit[] _probe = new RaycastHit[16]; static float WallClearance(Vector3 face, Vector3 dir, Vector3 side, float halfWidth) { float nearest = float.PositiveInfinity; // Trois rayons : axe et bords de la bande. Une ouverture pile au centre ne suffit donc pas // à dégager le passage si le mur barre encore les côtés. for (int k = -1; k <= 1; k++) { Vector3 origin = face + side * (k * halfWidth); int n = Physics.RaycastNonAlloc(origin, dir, _probe, WALL_PROBE_RANGE); for (int i = 0; i < n; i++) { if (!IsWallName(_probe[i].transform.name)) continue; if (_probe[i].distance < nearest) nearest = _probe[i].distance; } } return nearest; } /// Maillage de mur : "m3", "w12"... — préfixe m/w suivi du seul numéro. static bool IsWallName(string n) => !string.IsNullOrEmpty(n) && n.Length < 4 && (n[0] == 'm' || n[0] == 'w'); /// /// Objet susceptible de barrer un dégagement. Les ouvertures pratiquées dans un mur (fenêtre, /// porte, patio, opening) n'en sont pas : on passe au travers, ou elles ne dépassent pas du mur. /// Le tri en hauteur n'est pas fait ici mais dans ClearanceFrom, qui compare la tranche de /// l'obstacle à celle de l'objet mesuré. /// static bool IsObstacle(string[] o) { string p = Prefix(o); return p != "wind" && p != "door" && p != "open" && p != "pati" && p != "lamp"; } /// /// Direction d'ouverture de la porte, vers l'intérieur de la pièce. Déduite du MUR porteur quand /// il y en a un — convention sans ambiguïté, reprise de AiGet.FowardbyWall. Sinon (îlot), l'axe /// avant de l'objet. /// static Vector3 DoorForward(GameObject g, string[] o) { string wall = o.Length > 22 ? o[22] : ""; if (!string.IsNullOrEmpty(wall) && wall != "floor" && wall != "ceil") { GameObject w = Get.o2("ROOM", wall.Replace("m", "w")); if (w != null) return -w.transform.forward.normalized; } // Hors mur (îlot). Les familles n'ont pas la même convention d'axe : pour un CAISSON la // façade est sur le -Z local — ConstructBASE et ConstructWALL posent la porte à // frontBox = CenterBox.z - DB/2 — alors que les appareils regardent vers +Z (cf. // AiGet.GetSpaceSide, qui avance d'un +forward pour atteindre l'avant d'un frigo). bool isCabinet = o.Length > 2 && o[2] == "Cabinet"; return (isCabinet ? -1f : 1f) * g.transform.forward.normalized; } /// /// Distance de 'from' à la face la plus proche de l'englobant, le long de 'dir'. Négatif si /// l'englobant est derrière ou à cheval sur le plan de départ. /// static float ExtentGap(Bounds b, Vector3 from, Vector3 dir) => Vector3.Dot(b.center - from, dir) - ProjectedExtent(b, dir); /// L'englobant recoupe-t-il la bande de demi-largeur 'halfWidth' centrée sur 'from' ? static bool OverlapsAlong(Bounds b, Vector3 from, Vector3 axis, float halfWidth) => Mathf.Abs(Vector3.Dot(b.center - from, axis)) < halfWidth + ProjectedExtent(b, axis); /// Demi-étendue d'un englobant projetée sur un axe quelconque. static float ProjectedExtent(Bounds b, Vector3 axis) { Vector3 e = b.extents; return Mathf.Abs(axis.x) * e.x + Mathf.Abs(axis.y) * e.y + Mathf.Abs(axis.z) * e.z; } /// /// Paires (hotte, foyer) où la hotte SURPLOMBE le foyer — emprises au sol qui se recoupent. /// Une hotte ailleurs dans la pièce n'a rien à voir avec ce foyer. /// static IEnumerable<(string[] h, GameObject hg, string[] s, GameObject sg)> HoodOverStovePairs() { List hoods = ObjectsWhere(o => Prefix(o) == "hood"); List stoves = ObjectsWhere(IsCookingSurface); foreach (string[] h in hoods) { GameObject hg = Get.o2("SCENE", h[0]); if (hg == null) continue; Bounds hb = WorldBounds(hg); foreach (string[] s in stoves) { GameObject sg = Get.o2("SCENE", s[0]); if (sg == null) continue; if (HorizontalGap(hb, WorldBounds(sg)) > 0f) continue; yield return (h, hg, s, sg); } } } // ─── Outils communs aux normes ─────────────────────────────────────────── /// Préfixe de 4 caractères du NOM d'objet ("hood", "stov", "cabi"...), "" si non conforme. static string Prefix(string[] o) => o[0] != null && o[0].Length >= 4 ? o[0][..4] : ""; /// /// Préfixe de 4 caractères du MODÈLE, ADN[1] ("TOPS101" -> "TOPS"), en majuscules. C'est lui qui /// distingue les variantes qui partagent un même nom d'objet — une table de cuisson et une /// cuisinière s'appellent toutes deux "stovNN". /// static string ModelPrefix(string[] o) => o.Length > 1 && o[1] != null && o[1].Length >= 4 ? o[1][..4].ToUpperInvariant() : ""; /// Distance dans le plan du sol (XZ) entre deux points. static float HorizontalDistance(Vector3 a, Vector3 b) { float dx = a.x - b.x; float dz = a.z - b.z; return Mathf.Sqrt(dx * dx + dz * dz); } /// Écart horizontal (plan XZ) entre deux englobants ; 0 si les emprises se recoupent. static float HorizontalGap(Bounds a, Bounds b) { float dx = Mathf.Max(0f, Mathf.Max(a.min.x - b.max.x, b.min.x - a.max.x)); float dz = Mathf.Max(0f, Mathf.Max(a.min.z - b.max.z, b.min.z - a.max.z)); return Mathf.Sqrt(dx * dx + dz * dz); } /// Largeur déclarée d'un objet, ADN[6] ; 0 si absente. static float Width(string[] o) => o.Length > 6 && !string.IsNullOrEmpty(o[6]) ? DOIT.ConvertStringToNumber(o[6]) : 0f; /// Profondeur déclarée d'un objet, ADN[8] ; 0 si absente. static float Depth(string[] o) => o.Length > 8 && !string.IsNullOrEmpty(o[8]) ? DOIT.ConvertStringToNumber(o[8]) : 0f; /// Hauteur déclarée d'un objet, ADN[7] ; 0 si absente. static float Height(string[] o) => o.Length > 7 && !string.IsNullOrEmpty(o[7]) ? DOIT.ConvertStringToNumber(o[7]) : 0f; /// Dessous d'un objet : position courante moins la demi-hauteur déclarée. static float BottomY(GameObject g, string[] o) => g.transform.position.y - Height(o) * 0.5f; /// Dessus d'un objet : position courante plus la demi-hauteur déclarée. static float TopY(GameObject g, string[] o) => g.transform.position.y + Height(o) * 0.5f; /// /// Y du PLAN DE CUISSON d'un foyer. Posé au sol (cuisinière) : plancher + STOVE_TOP_HEIGHT — sa /// hauteur propre ne convient pas, dosseret et commandes la dépassent. Encastré plus haut (table /// de cuisson dans un comptoir) : son dessus. /// static float CookingSurfaceY(GameObject sg, string[] s) { float floor = -_G.HEIGHT * 0.5f; return BottomY(sg, s) <= floor + 1f ? floor + STOVE_TOP_HEIGHT : TopY(sg, s); } /// Englobant monde : union des Renderer, repli sur le collider puis sur la position. static Bounds WorldBounds(GameObject g) { Renderer[] r = g.GetComponentsInChildren(); if (r.Length > 0) { Bounds b = r[0].bounds; for (int i = 1; i < r.Length; i++) b.Encapsulate(r[i].bounds); return b; } Collider c = g.GetComponent(); return c != null ? c.bounds : new Bounds(g.transform.position, Vector3.zero); } /// ADN des objets présents dans la scène qui satisfont le prédicat. static List ObjectsWhere(Func match) { var list = new List(); for (int i = 0; i < _G.OBJnum; i++) { if (!DOIT.exist(i)) continue; string[] o = _G.OBJs[i]; if (o == null || o.Length < 23) continue; if (match(o)) list.Add(o); } return list; } }