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;
}
}