diff --git a/src/geometrie/designation.js b/src/geometrie/designation.js new file mode 100644 index 0000000..0ab13c9 --- /dev/null +++ b/src/geometrie/designation.js @@ -0,0 +1,75 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// La désignation d'un siège (§ 7.3, § 14.10) : ce que contient un point de +// dessin, lu sur les géométries de tables.js, celles mêmes que le dessin pose. +// Le dépôt d'un glissement ne cherche pas sa cible dans le DOM : il convertit +// le point en coordonnées de dessin et le passe à designer. + +/** Taille d'écran minimale d'une cible de siège, en px (§ 7.3). */ +export const CIBLE_MIN_PX = 24; + +// Vrai quand le point est dans le disque (ronde) ou le carré aux côtés +// parallèles aux axes (carrée) de demi-taille donnée, centré sur la table, +// bord compris. +function dans(point, { centre, forme }, demiTaille) { + const dx = point.x - centre.x; + const dy = point.y - centre.y; + return forme === 'ronde' ? Math.hypot(dx, dy) <= demiTaille : Math.max(Math.abs(dx), Math.abs(dy)) <= demiTaille; +} + +/** + * Régime des cibles d'une table au zoom k : 'sieges' quand k × pas ≥ + * CIBLE_MIN_PX, 'table' sinon. Le seuil se décide table par table, sur le + * pas de chacune. + * + * @param {number} k px/cm + * @param {import('./tables.js').GeometrieTable} geometrie + * @returns {'sieges'|'table'} + */ +export function regimeCibles(k, geometrie) { + return k * geometrie.pas >= CIBLE_MIN_PX ? 'sieges' : 'table'; +} + +/** + * Ce que désigne un point de dessin au zoom k. + * + * Les tables s'examinent de la dernière reçue à la première, l'ordre inverse + * de la peinture : la première dont la surface peinte contient le point + * décide. En régime table, la surface est l'emprise — disque d'une ronde, + * carré d'une carrée — et la table est désignée. En régime sièges, elle est + * le plateau et les chaises : un point dans une chaise désigne son siège ; + * un point sur le plateau ne désigne rien, et cache les chaises des tables + * peintes dessous ; entre les chaises, le point passe aux tables de dessous. + * Deux chaises d'une même table ne se recouvrent pas. + * + * @param {{x: number, y: number}} point cm + * @param {import('./tables.js').GeometrieTable[]} geometries dans l'ordre du dessin + * @param {number} k px/cm + * @returns {{table: number, siege: number}|{table: number}|null} + */ +export function designer(point, geometries, k) { + for (let i = geometries.length - 1; i >= 0; i -= 1) { + const geometrie = geometries[i]; + if (regimeCibles(k, geometrie) === 'table') { + if (dans(point, geometrie, geometrie.rayonEmprise)) return { table: geometrie.id }; + continue; + } + const chaise = geometrie.chaises.find((c) => Math.hypot(point.x - c.x, point.y - c.y) <= c.rayon); + if (chaise !== undefined) return { table: geometrie.id, siege: chaise.siege }; + if (dans(point, geometrie, geometrie.demiTaille)) return null; + } + return null; +} + +/** + * Vrai quand le point n'est dans l'emprise — disque ou carré — d'aucune + * table : le sol, où un dépôt retire la personne du tour. + * + * @param {{x: number, y: number}} point cm + * @param {import('./tables.js').GeometrieTable[]} geometries + * @returns {boolean} + */ +export function surLeSol(point, geometries) { + return !geometries.some((geometrie) => dans(point, geometrie, geometrie.rayonEmprise)); +} diff --git a/src/geometrie/designation.test.js b/src/geometrie/designation.test.js new file mode 100644 index 0000000..0a9f296 --- /dev/null +++ b/src/geometrie/designation.test.js @@ -0,0 +1,188 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Épreuves de la désignation (§ 7.3, § 14.10) : le régime des cibles, décidé +// table par table sur le pas de chacune, et le siège que désigne un point de +// dessin, qui est celui que geometrieTable pose — sur les quatre +// démonstrations livrées et sur des carrées, à un zoom où les sièges sont +// des cibles et à un zoom où la table l'est. +import assert from 'node:assert/strict'; +import { describe, test } from '../../test/lanceur.js'; +import { LIVREES } from '../demo/livrees.js'; +import { analyser, capacite } from '../stockage/document.js'; +import { CIBLE_MIN_PX, designer, regimeCibles, surLeSol } from './designation.js'; +import { englobant, geometrieTable } from './tables.js'; + +const table = (id, forme, x, y) => ({ id, numero: id, sieges: null, forme, position: { x, y } }); + +const milieu = (a, b) => ({ x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }); + +describe('regimeCibles (§ 7.3)', () => { + test('le seuil est de 24 px', () => { + assert.equal(CIBLE_MIN_PX, 24); + }); + + test('ronde de 8, pas 60 : 0,40 px/cm et au-dessus, les sièges', () => { + const g = geometrieTable(table(1, 'ronde', 0, 0), 8); + assert.equal(regimeCibles(0.41, g), 'sieges'); + assert.equal(regimeCibles(0.4, g), 'sieges'); + assert.equal(regimeCibles(0.39, g), 'table'); + }); + + test('ronde de 3, pas 70π/3 : bascule entre 0,33 et 0,32', () => { + const g = geometrieTable(table(1, 'ronde', 0, 0), 3); + assert.equal(regimeCibles(0.33, g), 'sieges'); + assert.equal(regimeCibles(0.32, g), 'table'); + }); + + test('carrée de 3, pas 70 : bascule entre 0,35 et 0,34', () => { + const g = geometrieTable(table(1, 'carree', 0, 0), 3); + assert.equal(regimeCibles(0.35, g), 'sieges'); + assert.equal(regimeCibles(0.34, g), 'table'); + }); + + test('ronde de 7, pas 60 exactement : sièges à 0,40', () => { + assert.equal(regimeCibles(0.4, geometrieTable(table(1, 'ronde', 0, 0), 7)), 'sieges'); + }); +}); + +describe('l’emprise d’une carrée est un carré', () => { + // Carrée de 12 au centre (0, 0) : côté 180, emprise 90 + 44 = 134 ; le + // siège 6, dernier du côté droit, est en (112, 60). + const geometries = [geometrieTable(table(7, 'carree', 0, 0), 12)]; + + test('(125, 125) : hors du plateau et des chaises, dans le coin de l’emprise', () => { + const point = { x: 125, y: 125 }; + assert.deepEqual(designer(point, geometries, 0.2), { table: 7 }); + assert.equal(designer(point, geometries, 1), null); + assert.equal(surLeSol(point, geometries), false); + }); + + test('(130, 60) : dans la chaise du siège 6, à 143 cm du centre', () => { + const point = { x: 130, y: 60 }; + assert.deepEqual(designer(point, geometries, 0.2), { table: 7 }); + assert.deepEqual(designer(point, geometries, 1), { table: 7, siege: 6 }); + assert.equal(surLeSol(point, geometries), false); + }); + + test('(140, 0) est le sol', () => { + const point = { x: 140, y: 0 }; + assert.equal(designer(point, geometries, 0.2), null); + assert.equal(designer(point, geometries, 1), null); + assert.equal(surLeSol(point, geometries), true); + }); +}); + +// Les géométries de chaque démonstration livrée, telles que le dessin les +// pose, puis trois carrées de 3, 8 et 12 : les démonstrations n'ont que des +// rondes. +const JEUX = [ + ...LIVREES.map(({ cle, texte }) => { + const { charge } = analyser(texte); + return { nom: cle, geometries: charge.tables.map((t) => geometrieTable(t, capacite(charge, t))) }; + }), + { + nom: 'carrées', + geometries: [ + geometrieTable(table(1, 'carree', 0, 0), 3), + geometrieTable(table(2, 'carree', 1000, 0), 8), + geometrieTable(table(3, 'carree', 2000, 0), 12), + ], + }, +]; + +describe('le siège désigné est celui que le dessin pose (§ 7.3, § 14.10)', () => { + const ATTENDUES = { grande: 260, 'grande-sans-exception': 264, petite: 12, 'petite-conflit': 12, 'carrées': 23 }; + + test('cinq jeux, dans l’ordre', () => { + assert.deepEqual(JEUX.map(({ nom }) => nom), ['grande', 'grande-sans-exception', 'petite', 'petite-conflit', 'carrées']); + }); + + for (const { nom, geometries } of JEUX) { + test(`${nom}, k = 1 : chaque chaise rend sa table et son siège`, () => { + let chaises = 0; + for (const g of geometries) { + assert.equal(regimeCibles(1, g), 'sieges'); + for (const chaise of g.chaises) { + assert.deepEqual(designer(chaise, geometries, 1), { table: g.id, siege: chaise.siege }); + chaises += 1; + } + } + assert.ok(ATTENDUES[nom] > 0); + assert.equal(chaises, ATTENDUES[nom]); + }); + + test(`${nom}, k = 1 : ni le milieu de deux chaises voisines, ni le centre d’une table ne désigne rien`, () => { + let milieux = 0; + for (const g of geometries) { + const n = g.chaises.length; + for (let i = 0; i < n; i += 1) { + const point = milieu(g.chaises[i], g.chaises[(i + 1) % n]); + assert.equal(designer(point, geometries, 1), null, `table ${g.id}, sièges ${i + 1} et ${((i + 1) % n) + 1}`); + assert.equal(surLeSol(point, geometries), false); + milieux += 1; + } + assert.equal(designer(g.centre, geometries, 1), null, `centre de la table ${g.id}`); + assert.equal(surLeSol(g.centre, geometries), false); + } + assert.equal(milieux, ATTENDUES[nom]); + }); + + test(`${nom} : un point à 10 m de tout est le sol`, () => { + const e = englobant(geometries); + const loin = { x: e.maxX + 1000, y: e.maxY + 1000 }; + assert.equal(surLeSol(loin, geometries), true); + assert.equal(designer(loin, geometries, 1), null); + assert.equal(designer(loin, geometries, 0.2), null); + }); + + test(`${nom}, k = 0,2 : chaque chaise, chaque milieu et chaque centre rendent la table, jamais un siège`, () => { + let points = 0; + for (const g of geometries) { + assert.equal(regimeCibles(0.2, g), 'table'); + const n = g.chaises.length; + const candidats = [g.centre, ...g.chaises, ...g.chaises.map((c, i) => milieu(c, g.chaises[(i + 1) % n]))]; + for (const point of candidats) { + assert.deepEqual(designer(point, geometries, 0.2), { table: g.id }); + points += 1; + } + } + assert.equal(points, 2 * ATTENDUES[nom] + geometries.length); + }); + } +}); + +describe('tables qui se recouvrent : la dernière reçue, peinte au-dessus', () => { + test('deux tables au même centre : la seconde', () => { + const geometries = [geometrieTable(table(1, 'ronde', 0, 0), 8), geometrieTable(table(2, 'ronde', 0, 0), 8)]; + const chaise = geometries[0].chaises[2]; + assert.deepEqual(designer(chaise, geometries, 1), { table: 2, siege: 3 }); + assert.deepEqual(designer({ x: 0, y: 0 }, geometries, 0.2), { table: 2 }); + }); + + test('régime sièges : le plateau du dessus cache la chaise de dessous', () => { + // Rondes de 8 en (0, 0) et (100, 0) : le siège 3 de la première, en + // (98,394 ; 0), tombe dans le plateau de la seconde, de rayon 76,394. + const premiere = geometrieTable(table(1, 'ronde', 0, 0), 8); + const seconde = geometrieTable(table(2, 'ronde', 100, 0), 8); + const cachee = premiere.chaises[2]; + assert.equal(designer(cachee, [premiere, seconde], 1), null); + assert.deepEqual(designer(cachee, [seconde, premiere], 1), { table: 1, siege: 3 }); + assert.deepEqual(designer(premiere.chaises[6], [premiere, seconde], 1), { table: 1, siege: 7 }); + }); + + test('régimes différents : chaque table se juge sur son propre pas', () => { + // À k = 0,35, la ronde de 8 (pas 60) est en régime table, la carrée de 3 + // (pas 70) en régime sièges. + const ronde = geometrieTable(table(1, 'ronde', 0, 0), 8); + const carree = geometrieTable(table(2, 'carree', 1000, 0), 3); + const geometries = [ronde, carree]; + assert.deepEqual(designer(ronde.chaises[0], geometries, 0.35), { table: 1 }); + assert.deepEqual(designer(carree.chaises[0], geometries, 0.35), { table: 2, siege: 1 }); + }); + + test('sans table, aucun point n’est désigné et tout est le sol', () => { + assert.equal(designer({ x: 0, y: 0 }, [], 1), null); + assert.equal(surLeSol({ x: 0, y: 0 }, []), true); + }); +}); diff --git a/src/geometrie/tables.js b/src/geometrie/tables.js new file mode 100644 index 0000000..361d8b7 --- /dev/null +++ b/src/geometrie/tables.js @@ -0,0 +1,239 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// La géométrie d'une table (§ 7.1, § 7.7), en centimètres de dessin, l'axe y +// vers le bas : plateau tiré du nombre de places, chaises numérotées depuis le +// haut dans le sens horaire, emprise, poignée et origine du bloc de noms. Le +// dessin pose les chaises par geometrieTable et la désignation d'un siège +// (designation.js) lit les mêmes chaises : une seule implémentation, que le +// dessin et le dépôt partagent. Aucune table ne tourne : la géométrie ne +// porte aucun angle. + +/** + * @typedef {Object} GeometrieTable en cm + * @property {number} id + * @property {number} numero + * @property {'ronde'|'carree'} forme + * @property {{x: number, y: number}} centre + * @property {number} demiTaille rayon du plateau (ronde) ou demi-côté (carrée) + * @property {number} pas distance entre deux sièges voisins, au bord du plateau + * @property {Array<{siege: number, x: number, y: number, rayon: number}>} chaises sièges 1 à places + * @property {{x: number, y: number}} poignee centre du disque de la poignée + * @property {number} rayonEmprise demiTaille + 2 × RAYON_CHAISE : rayon du disque d'emprise + * (ronde) ou demi-côté du carré d'emprise (carrée) + * @property {{x: number, y: number}} origineListe coin haut gauche du bloc de noms + */ + +/** Diamètre minimal d'une table ronde, côté minimal d'une carrée, en cm : + * réglage par défaut, non norme (§ 7.7). */ +export const PLANCHER_DIAMETRE = 70; +/** Longueur de bord par convive, en cm (§ 7.7). */ +export const LARGEUR_PAR_PLACE = 60; +/** Rayon d'une chaise, en cm. */ +export const RAYON_CHAISE = 22; +/** Rayon du disque de la poignée, en cm. */ +export const RAYON_POIGNEE = 15; +/** Distance entre l'emprise et le bord de la poignée, en cm. */ +export const ECART_POIGNEE = 8; +/** Distance entre l'emprise et le bloc de noms, en cm. */ +export const ECART_LISTE = 10; +/** Distance entre deux cases de la grille des tables créées, en cm. */ +export const PAS_GRILLE = 300; + +// Accroissement du rayon d'une ronde pour une place de plus, au-dessus du +// plancher : le périmètre croît de LARGEUR_PAR_PLACE, le rayon de 60 / 2π. +const RAYON_PAR_PLACE = LARGEUR_PAR_PLACE / (2 * Math.PI); + +// Les quatre côtés d'une carrée, dans l'ordre où ils reçoivent les places — +// haut, droit, bas, gauche — : le coin où commence le côté dans le sens +// horaire, en demi-côtés depuis le centre, la direction de parcours et la +// normale sortante. +const COTES = [ + { depart: { x: -1, y: -1 }, direction: { x: 1, y: 0 }, normale: { x: 0, y: -1 } }, + { depart: { x: 1, y: -1 }, direction: { x: 0, y: 1 }, normale: { x: 1, y: 0 } }, + { depart: { x: 1, y: 1 }, direction: { x: -1, y: 0 }, normale: { x: 0, y: 1 } }, + { depart: { x: -1, y: 1 }, direction: { x: 0, y: -1 }, normale: { x: -1, y: 0 } }, +]; + +// Lève RangeError sur un nombre de places qui n'est pas un entier ≥ 1, ou un +// diamètre ou côté saisi qui n'est pas un nombre fini > 0 : la géométrie +// d'une table se calcule sans valeur de repli. +function exigerPlaces(places, saisi) { + if (!Number.isInteger(places) || places < 1) { + throw new RangeError(`nombre de places entier ≥ 1 attendu, reçu ${JSON.stringify(places)}`); + } + if (saisi !== null && !(Number.isFinite(saisi) && saisi > 0)) { + throw new RangeError(`dimension saisie > 0 attendue, reçu ${JSON.stringify(saisi)}`); + } +} + +/** + * Diamètre d'une table ronde (§ 7.7) : max(70, places × 60 / π), ou le + * diamètre saisi, qui l'emporte. + * + * @param {number} places entier ≥ 1 + * @param {{saisi?: number|null}} [options] + * @returns {number} cm + */ +export function diametre(places, { saisi = null } = {}) { + exigerPlaces(places, saisi); + return saisi ?? Math.max(PLANCHER_DIAMETRE, (places * LARGEUR_PAR_PLACE) / Math.PI); +} + +/** + * Côté d'une table carrée : max(70, ⌈places / 4⌉ × 60) — le côté le plus + * garni porte ⌈places / 4⌉ sièges —, ou le côté saisi, qui l'emporte. + * + * @param {number} places entier ≥ 1 + * @param {{saisi?: number|null}} [options] + * @returns {number} cm + */ +export function cote(places, { saisi = null } = {}) { + exigerPlaces(places, saisi); + return saisi ?? Math.max(PLANCHER_DIAMETRE, Math.ceil(places / 4) * LARGEUR_PAR_PLACE); +} + +// Chaises d'une ronde : sur le cercle de rayon demiTaille + RAYON_CHAISE, +// siège s à l'angle −π/2 + 2π(s − 1)/places, le siège 1 en haut et la +// numérotation dans le sens horaire à l'écran (y vers le bas). +function chaisesRonde(centre, demiTaille, places) { + const rayon = demiTaille + RAYON_CHAISE; + return Array.from({ length: places }, (_, i) => { + const angle = -Math.PI / 2 + (2 * Math.PI * i) / places; + return { siege: i + 1, x: centre.x + rayon * Math.cos(angle), y: centre.y + rayon * Math.sin(angle), rayon: RAYON_CHAISE }; + }); +} + +// Nombre de sièges de chaque côté d'une carrée, dans l'ordre de COTES : +// ⌊places / 4⌋ chacun, et une place en surplus à chacun des premiers côtés. +const siegesParCote = (places) => COTES.map((_, i) => Math.floor(places / 4) + (i < places % 4 ? 1 : 0)); + +// Chaises d'une carrée : sur chaque côté, n sièges espacés de côté / n, le +// premier à côté / 2n du coin de départ, leur centre à RAYON_CHAISE au-delà +// du bord ; le siège 1 est le premier du côté haut. +function chaisesCarree(centre, demiTaille, places) { + const chaises = []; + siegesParCote(places).forEach((nombre, i) => { + const { depart, direction, normale } = COTES[i]; + for (let j = 0; j < nombre; j += 1) { + const avance = ((j + 0.5) * 2 * demiTaille) / nombre; + chaises.push({ + siege: chaises.length + 1, + x: centre.x + depart.x * demiTaille + direction.x * avance + normale.x * RAYON_CHAISE, + y: centre.y + depart.y * demiTaille + direction.y * avance + normale.y * RAYON_CHAISE, + rayon: RAYON_CHAISE, + }); + } + }); + return chaises; +} + +/** + * Géométrie d'une table, en cm (contrat de données, GeometrieTable). + * + * Le pas d'une ronde est π × diamètre / places, calculé sous la forme + * max(60, 70π / places) qui lui est égale : elle rend 60 exactement dès que + * le périmètre borne la table, là où le produit rend 59,999… pour certains + * nombres de places et ferait basculer le régime des cibles (designation.js) + * sous le seuil. Le pas d'une carrée est son côté divisé par le nombre de + * sièges du côté le plus garni. + * + * La poignée est un disque de rayon RAYON_POIGNEE sur la diagonale à +45° + * (vers le bas à droite), son bord à ECART_POIGNEE de l'emprise : son centre + * est à rayonEmprise + 23 cm du centre d'une ronde, à rayonEmprise × √2 + 23 + * cm de celui d'une carrée, au-delà du coin de son emprise. + * + * @param {{id: number, numero: number, forme: 'ronde'|'carree', position: {x: number, y: number}}} table + * l'enregistrement de charge.tables ; position est le centre + * @param {number} places la capacité courante, entier ≥ 1 + * @returns {GeometrieTable} + * @throws {RangeError} pour une forme inconnue ou un nombre de places invalide + */ +export function geometrieTable(table, places) { + const { id, numero, forme } = table; + if (forme !== 'ronde' && forme !== 'carree') { + throw new RangeError(`forme de table inconnue : ${JSON.stringify(forme)}`); + } + const centre = { x: table.position.x, y: table.position.y }; + const ronde = forme === 'ronde'; + const demiTaille = (ronde ? diametre(places) : cote(places)) / 2; + const pas = ronde + ? Math.max(LARGEUR_PAR_PLACE, (PLANCHER_DIAMETRE * Math.PI) / places) + : (2 * demiTaille) / Math.ceil(places / 4); + const chaises = ronde ? chaisesRonde(centre, demiTaille, places) : chaisesCarree(centre, demiTaille, places); + const rayonEmprise = demiTaille + 2 * RAYON_CHAISE; + const distancePoignee = (ronde ? rayonEmprise : rayonEmprise * Math.SQRT2) + ECART_POIGNEE + RAYON_POIGNEE; + const decalage = distancePoignee / Math.SQRT2; + return { + id, + numero, + forme, + centre, + demiTaille, + pas, + chaises, + poignee: { x: centre.x + decalage, y: centre.y + decalage }, + rayonEmprise, + origineListe: { x: centre.x + rayonEmprise + ECART_LISTE, y: centre.y - demiTaille }, + }; +} + +/** + * Rectangle englobant des emprises : pour chaque table, le carré de + * demi-côté rayonEmprise centré sur elle, qui circonscrit le disque d'une + * ronde et égale l'emprise d'une carrée. + * + * @param {GeometrieTable[]} geometries + * @returns {{minX: number, minY: number, maxX: number, maxY: number}|null} null sans table + */ +export function englobant(geometries) { + if (geometries.length === 0) return null; + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (const { centre, rayonEmprise } of geometries) { + minX = Math.min(minX, centre.x - rayonEmprise); + minY = Math.min(minY, centre.y - rayonEmprise); + maxX = Math.max(maxX, centre.x + rayonEmprise); + maxY = Math.max(maxY, centre.y + rayonEmprise); + } + return { minX, minY, maxX, maxY }; +} + +/** + * Position d'une table créée : la première case libre, ligne par ligne, d'une + * grille de PAS_GRILLE cm à ⌈√(T + 1)⌉ colonnes, T = centres.length, partant + * de (0, 0) ; une case est prise quand un centre est à PAS_GRILLE / 2 ou + * moins. centres : les positions de charge.tables, que l'appelant passe sans + * construire de géométrie. + * + * La recherche s'arrête : un centre est à PAS_GRILLE / 2 ou moins d'au plus + * deux cases, le plus petit disque qui contient trois cases ayant un rayon + * d'au moins PAS_GRILLE / √2 ; l'une des 2T + 1 premières cases est donc + * libre. + * + * @param {Array<{x: number, y: number}>} centres + * @returns {{x: number, y: number}} + */ +export function caseLibre(centres) { + const colonnes = Math.ceil(Math.sqrt(centres.length + 1)); + for (let rang = 0; ; rang += 1) { + const candidate = { x: (rang % colonnes) * PAS_GRILLE, y: Math.floor(rang / colonnes) * PAS_GRILLE }; + const prise = centres.some(({ x, y }) => Math.hypot(x - candidate.x, y - candidate.y) <= PAS_GRILLE / 2); + if (!prise) return candidate; + } +} + +/** + * Nombre de places sous la poignée : placesInitiales + round(ecartRadial × + * 2π / 60), jamais sous 2. L'écart se compte depuis le début du geste + * (§ 7.2) ; un demi-pas exact s'arrondit vers le haut (Math.round). + * + * @param {number} placesInitiales les places au début du geste + * @param {number} ecartRadial cm, projeté sur l'axe de la poignée, positif en s'éloignant du centre + * @returns {number} entier ≥ 2 + */ +export function placesSousPoignee(placesInitiales, ecartRadial) { + return Math.max(2, placesInitiales + Math.round(ecartRadial / RAYON_PAR_PLACE)); +} diff --git a/src/geometrie/tables.test.js b/src/geometrie/tables.test.js new file mode 100644 index 0000000..0b2c09a --- /dev/null +++ b/src/geometrie/tables.test.js @@ -0,0 +1,331 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Épreuves de la géométrie des tables (§ 7.1, § 7.7) : diamètre et côté tirés +// du nombre de places, chaises posées dans le sens horaire depuis le haut, +// emprise, poignée hors de toute chaise, bloc de noms, englobant, case libre +// d'une table créée et nombre de places sous la poignée. Les valeurs +// attendues se calculent à la main dans le commentaire qui les précède. +import assert from 'node:assert/strict'; +import { describe, test } from '../../test/lanceur.js'; +import { surLeSol } from './designation.js'; +import { + ECART_LISTE, + ECART_POIGNEE, + LARGEUR_PAR_PLACE, + PAS_GRILLE, + PLANCHER_DIAMETRE, + RAYON_CHAISE, + RAYON_POIGNEE, + caseLibre, + cote, + diametre, + englobant, + geometrieTable, + placesSousPoignee, +} from './tables.js'; + +const EPSILON = 1e-9; + +// Affirme |reel − attendu| ≤ EPSILON, le libellé nommant la grandeur. +function proche(reel, attendu, libelle) { + assert.ok(Math.abs(reel - attendu) <= EPSILON, `${libelle} : ${reel} au lieu de ${attendu}`); +} + +function procheDuPoint(reel, attendu, libelle) { + proche(reel.x, attendu.x, `${libelle}, x`); + proche(reel.y, attendu.y, `${libelle}, y`); +} + +const distance = (a, b) => Math.hypot(a.x - b.x, a.y - b.y); + +// Une table du fichier d'état : identifiant, numéro, forme et centre. +const table = (id, forme, x, y) => ({ id, numero: id, sieges: null, forme, position: { x, y } }); + +describe('constantes', () => { + test('les valeurs du contrat', () => { + assert.equal(PLANCHER_DIAMETRE, 70); + assert.equal(LARGEUR_PAR_PLACE, 60); + assert.equal(RAYON_CHAISE, 22); + assert.equal(RAYON_POIGNEE, 15); + assert.equal(ECART_POIGNEE, 8); + assert.equal(ECART_LISTE, 10); + assert.equal(PAS_GRILLE, 300); + }); +}); + +describe('diametre (§ 7.7)', () => { + test('le plancher de 70 cm borne 2 et 3 places, le périmètre borne dès 4', () => { + assert.equal(diametre(2), 70); + assert.equal(diametre(3), 70); + // 4 × 60 / π = 76,394 > 70 : un plancher appliqué jusqu'à 6 places rendrait 70. + proche(diametre(4), 240 / Math.PI, 'diamètre de 4'); + proche(diametre(5), 300 / Math.PI, 'diamètre de 5'); + }); + + test('115, 153 et 191 cm pour 6, 8 et 10 places, arrondis (§ 7.7)', () => { + assert.equal(Math.round(diametre(6)), 115); + assert.equal(Math.round(diametre(8)), 153); + assert.equal(Math.round(diametre(10)), 191); + }); + + test('un diamètre saisi l’emporte', () => { + assert.equal(diametre(8, { saisi: 180 }), 180); + assert.equal(diametre(2, { saisi: 50 }), 50); + }); +}); + +describe('cote', () => { + test('max(70, ⌈places / 4⌉ × 60)', () => { + assert.equal(cote(3), 70); + assert.equal(cote(5), 120); + assert.equal(cote(8), 120); + assert.equal(cote(9), 180); + }); + + test('un côté saisi l’emporte', () => { + assert.equal(cote(8, { saisi: 150 }), 150); + }); +}); + +describe('geometrieTable, ronde', () => { + // Ronde de 8 au centre (300, 600) : diamètre 480/π, demi-taille 240/π + // (≈ 76,394), chaises sur le cercle de rayon 240/π + 22 (≈ 98,394). + const g = geometrieTable(table(4, 'ronde', 300, 600), 8); + const r = 240 / Math.PI; + + test('identité, forme, centre, demi-taille et pas', () => { + assert.equal(g.id, 4); + assert.equal(g.numero, 4); + assert.equal(g.forme, 'ronde'); + assert.deepEqual(g.centre, { x: 300, y: 600 }); + proche(g.demiTaille, r, 'demi-taille'); + proche(g.pas, 60, 'pas'); + }); + + test('huit chaises de rayon 22, siège 1 en haut, sens horaire', () => { + assert.equal(g.chaises.length, 8); + g.chaises.forEach((chaise, i) => { + assert.equal(chaise.siege, i + 1); + assert.equal(chaise.rayon, RAYON_CHAISE); + proche(distance(chaise, g.centre), r + 22, `distance du siège ${chaise.siege}`); + }); + procheDuPoint(g.chaises[0], { x: 300, y: 600 - (r + 22) }, 'siège 1'); + procheDuPoint(g.chaises[2], { x: 300 + r + 22, y: 600 }, 'siège 3'); + procheDuPoint(g.chaises[4], { x: 300, y: 600 + r + 22 }, 'siège 5'); + }); + + test('emprise, poignée et origine de la liste', () => { + proche(g.rayonEmprise, r + 44, 'rayon d’emprise'); + const ecart = (r + 44 + 23) / Math.SQRT2; + procheDuPoint(g.poignee, { x: 300 + ecart, y: 600 + ecart }, 'poignée'); + procheDuPoint(g.origineListe, { x: 300 + r + 54, y: 600 - r }, 'origine de la liste'); + }); + + test('ronde de 3 : plancher de 70 cm, pas 70π/3', () => { + const petite = geometrieTable(table(1, 'ronde', 0, 0), 3); + proche(petite.demiTaille, 35, 'demi-taille'); + proche(petite.pas, (70 * Math.PI) / 3, 'pas'); + }); + + test('le pas vaut 60 exactement au-dessus du plancher, de 4 à 40 places', () => { + // Le produit π × (places × 60 / π) / places rend 59,999… pour 7 places : + // le régime des cibles basculerait alors un cran trop tôt. + let comptees = 0; + for (let places = 4; places <= 40; places += 1) { + assert.equal(geometrieTable(table(1, 'ronde', 0, 0), places).pas, 60, `${places} places`); + comptees += 1; + } + assert.equal(comptees, 37); + }); +}); + +describe('geometrieTable, carrée', () => { + // Carrée de 8 au centre (300, 600) : côté 120, demi-côté 60, deux sièges + // par côté à 30 et 90 cm du coin, centres à 60 + 22 = 82 cm de l'axe. + const g = geometrieTable(table(2, 'carree', 300, 600), 8); + + test('côté 120, pas 60, deux sièges par côté dans le sens horaire', () => { + assert.equal(g.forme, 'carree'); + assert.equal(g.demiTaille, 60); + assert.equal(g.pas, 60); + assert.equal(g.chaises.length, 8); + const attendus = [ + [270, 518], [330, 518], // haut, de gauche à droite + [382, 570], [382, 630], // droit, de haut en bas + [330, 682], [270, 682], // bas, de droite à gauche + [218, 630], [218, 570], // gauche, de bas en haut + ]; + g.chaises.forEach((chaise, i) => { + assert.equal(chaise.siege, i + 1); + assert.equal(chaise.rayon, RAYON_CHAISE); + procheDuPoint(chaise, { x: attendus[i][0], y: attendus[i][1] }, `siège ${i + 1}`); + }); + }); + + test('emprise 104, poignée au-delà du coin, liste en (414, 540)', () => { + assert.equal(g.rayonEmprise, 104); + procheDuPoint(g.poignee, { x: 404 + 23 / Math.SQRT2, y: 704 + 23 / Math.SQRT2 }, 'poignée'); + procheDuPoint(g.origineListe, { x: 414, y: 540 }, 'origine de la liste'); + }); + + test('carrée de 3 : côtés garnis 1, 1, 1, 0 et pas 70', () => { + // Côté 70, demi-côté 35 : un siège au milieu du haut, du droit et du bas. + const petite = geometrieTable(table(1, 'carree', 0, 0), 3); + assert.equal(petite.pas, 70); + assert.equal(petite.chaises.length, 3); + procheDuPoint(petite.chaises[0], { x: 0, y: -57 }, 'siège 1'); + procheDuPoint(petite.chaises[1], { x: 57, y: 0 }, 'siège 2'); + procheDuPoint(petite.chaises[2], { x: 0, y: 57 }, 'siège 3'); + }); + + test('carrée de 5 : le côté haut reçoit la place en surplus', () => { + // Côté 120 : 2 sièges en haut, puis 1 à droite, en bas et à gauche. + const g5 = geometrieTable(table(1, 'carree', 0, 0), 5); + assert.equal(g5.pas, 60); + const attendus = [[-30, -82], [30, -82], [82, 0], [0, 82], [-82, 0]]; + assert.equal(g5.chaises.length, attendus.length); + g5.chaises.forEach((chaise, i) => procheDuPoint(chaise, { x: attendus[i][0], y: attendus[i][1] }, `siège ${i + 1}`)); + }); +}); + +describe('emprise et chaises, de 2 à 40 places', () => { + test('l’emprise contient chaque chaise, et deux chaises ne se touchent pas', () => { + let tables = 0; + for (const forme of ['ronde', 'carree']) { + for (let places = 2; places <= 40; places += 1) { + const g = geometrieTable(table(1, forme, 10, -20), places); + assert.equal(g.chaises.length, places); + proche(g.rayonEmprise, g.demiTaille + 2 * RAYON_CHAISE, `${forme} de ${places}, emprise`); + for (const chaise of g.chaises) { + const dx = Math.abs(chaise.x - g.centre.x); + const dy = Math.abs(chaise.y - g.centre.y); + const portee = forme === 'ronde' ? Math.hypot(dx, dy) : Math.max(dx, dy); + assert.ok(portee + chaise.rayon <= g.rayonEmprise + EPSILON, `${forme} de ${places}, siège ${chaise.siege}`); + } + for (let i = 0; i < places; i += 1) { + for (let j = i + 1; j < places; j += 1) { + assert.ok(distance(g.chaises[i], g.chaises[j]) >= 2 * RAYON_CHAISE, `${forme} de ${places}, sièges ${i + 1} et ${j + 1}`); + } + } + tables += 1; + } + } + assert.equal(tables, 78); + }); +}); + +describe('la poignée ne touche aucune chaise (§ 7.1)', () => { + test('rondes de 3, 8 et 16, carrées de 3, 8, 12 et 16', () => { + const cas = [ + ['ronde', 3], ['ronde', 8], ['ronde', 16], + ['carree', 3], ['carree', 8], ['carree', 12], ['carree', 16], + ]; + let eprouvees = 0; + for (const [forme, places] of cas) { + const g = geometrieTable(table(1, forme, 0, 0), places); + assert.ok(g.chaises.length > 0); + for (const chaise of g.chaises) { + assert.ok( + distance(g.poignee, chaise) > RAYON_CHAISE + RAYON_POIGNEE, + `${forme} de ${places}, siège ${chaise.siege} à ${distance(g.poignee, chaise)} cm`, + ); + } + assert.equal(surLeSol(g.poignee, [g]), true, `${forme} de ${places} : centre de la poignée hors du sol`); + eprouvees += 1; + } + assert.equal(eprouvees, 7); + }); +}); + +describe('aucune rotation (§ 7.1)', () => { + test('la géométrie ne porte aucun angle et une seule poignée', () => { + for (const forme of ['ronde', 'carree']) { + const g = geometrieTable(table(1, forme, 0, 0), 8); + assert.deepEqual( + Object.keys(g).sort(), + ['centre', 'chaises', 'demiTaille', 'forme', 'id', 'numero', 'origineListe', 'pas', 'poignee', 'rayonEmprise'], + ); + assert.deepEqual(Object.keys(g.poignee).sort(), ['x', 'y']); + for (const chaise of g.chaises) assert.deepEqual(Object.keys(chaise).sort(), ['rayon', 'siege', 'x', 'y']); + } + }); +}); + +describe('geometrieTable refuse une table qu’elle ne sait pas dessiner', () => { + test('forme inconnue, places nulles ou non entières', () => { + assert.throws(() => geometrieTable(table(1, 'ovale', 0, 0), 8), RangeError); + assert.throws(() => geometrieTable(table(1, 'ronde', 0, 0), 0), RangeError); + assert.throws(() => geometrieTable(table(1, 'carree', 0, 0), 2.5), RangeError); + }); + + test('diametre et cote : places invalides, dimension saisie nulle ou non finie', () => { + assert.throws(() => diametre(0), RangeError); + assert.throws(() => cote(1.5), RangeError); + assert.throws(() => diametre(8, { saisi: 0 }), RangeError); + assert.throws(() => cote(8, { saisi: Number.NaN }), RangeError); + assert.throws(() => diametre(8, { saisi: Infinity }), RangeError); + }); +}); + +describe('englobant', () => { + test('null sans table', () => { + assert.equal(englobant([]), null); + }); + + test('deux tables : min et max exacts de leurs emprises', () => { + // Ronde de 8 en (300, 600), emprise 240/π + 44 ; carrée de 8 en + // (−100, 50), emprise 104. + const r = 240 / Math.PI + 44; + const e = englobant([ + geometrieTable(table(1, 'ronde', 300, 600), 8), + geometrieTable(table(2, 'carree', -100, 50), 8), + ]); + assert.deepEqual(Object.keys(e).sort(), ['maxX', 'maxY', 'minX', 'minY']); + proche(e.minX, -204, 'minX'); + proche(e.minY, -54, 'minY'); + proche(e.maxX, 300 + r, 'maxX'); + proche(e.maxY, 600 + r, 'maxY'); + }); +}); + +describe('caseLibre', () => { + test('sans table, l’origine', () => { + assert.deepEqual(caseLibre([]), { x: 0, y: 0 }); + }); + + test('deux centres en (0, 0) et (300, 0) : deux colonnes, la case suivante est (0, 300)', () => { + assert.deepEqual(caseLibre([{ x: 0, y: 0 }, { x: 300, y: 0 }]), { x: 0, y: 300 }); + }); + + test('un centre en (150, 0) prend les deux premières cases', () => { + assert.deepEqual(caseLibre([{ x: 150, y: 0 }]), { x: 0, y: 300 }); + }); + + test('un centre à plus de PAS_GRILLE / 2 laisse la case libre', () => { + assert.deepEqual(caseLibre([{ x: 150.001, y: 0 }]), { x: 0, y: 0 }); + }); + + test('quatre centres sur 2 × 2 : trois colonnes, la case suivante est (600, 0)', () => { + const centres = [{ x: 0, y: 0 }, { x: 300, y: 0 }, { x: 0, y: 300 }, { x: 300, y: 300 }]; + assert.deepEqual(caseLibre(centres.slice(0, 3)), { x: 300, y: 300 }); + assert.deepEqual(caseLibre(centres), { x: 600, y: 0 }); + }); +}); + +describe('placesSousPoignee', () => { + const PAS_RAYON = 60 / (2 * Math.PI); + + test('écart nul, deux pas, dix pas en arrière, un pas', () => { + assert.equal(placesSousPoignee(8, 0), 8); + assert.equal(placesSousPoignee(8, 2 * PAS_RAYON), 10); + assert.equal(placesSousPoignee(8, -10 * PAS_RAYON), 2); + assert.equal(placesSousPoignee(3, PAS_RAYON), 4); + }); + + test('l’écart s’arrondit au pas le plus proche', () => { + assert.equal(placesSousPoignee(8, 0.4 * PAS_RAYON), 8); + assert.equal(placesSousPoignee(8, 0.6 * PAS_RAYON), 9); + assert.equal(placesSousPoignee(8, -0.6 * PAS_RAYON), 7); + }); +});