[ADD] geometry: cut the seating plan into printed pages
A plan too large to read on one sheet must print as a grid of pages that join exactly. The cut picks the orientation that gives fewest pages, then the larger scale, then landscape; tiles meet at exact equality on 2^-20 cm coordinates, columns are lettered A to Z then AA, and a table or name block that straddles two pages is reported on both. The print bounding box includes measured name blocks but no handle or target. Checked: red first, including a fast-check property on drawn plans, papers and thresholds; 3245 node tests green from the index alone. --- FR --- [ADD] géométrie : découpe du plan de salle en pages imprimées Un plan trop grand pour se lire sur une feuille doit s'imprimer en une grille de pages qui se joignent exactement. La découpe choisit l'orientation qui donne le moins de pages, puis la plus grande échelle, puis le paysage ; les tuiles se touchent à l'égalité exacte sur des cotes de 2^-20 cm, les colonnes vont de A à Z puis AA, et une table ou un bloc de noms à cheval sur deux pages se signale sur les deux. L'englobant imprimé compte les blocs de noms mesurés, sans poignée ni cible. Vérifié : rouge d'abord, dont une propriété fast-check sur plans, papiers et seuils tirés ; 3245 node verts depuis l'index seul. Assisted-by: Claude Opus 5.5
This commit is contained in:
parent
88d35f5979
commit
0ac686487f
5 changed files with 800 additions and 2 deletions
223
src/geometrie/pages.js
Normal file
223
src/geometrie/pages.js
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// La découpe du plan en pages (§ 11.7) : un navigateur ne fragmente pas un
|
||||
// SVG, le plan imprimé pose donc un dessin par page, chacun sur sa tuile.
|
||||
// decouperPlan rend l'échelle, l'orientation et les tuiles contiguës du plan
|
||||
// entier, avec le cartouche de chacune et les tables qu'elle dessine.
|
||||
// Fonction pure : ni DOM, ni mesure ; la hauteur de la bande (pied,
|
||||
// mentions, barre, cartouche), mesurée au rendu de l'aperçu, entre en
|
||||
// paramètre.
|
||||
import { constante } from './constantes.js';
|
||||
import { PAPIERS } from './papiers.js';
|
||||
import { blocNoms } from './tables.js';
|
||||
import { TAILLE_TEXTE_LISTE_CM } from './texte.js';
|
||||
|
||||
/** @typedef {'auto'|'unePage'|'decoupe'} ModeDecoupe */
|
||||
|
||||
const MODES = new Set(['auto', 'unePage', 'decoupe']);
|
||||
|
||||
// Les cotes des tuiles sont des multiples de 2⁻²⁰ cm, en valeur absolue
|
||||
// sous 2³² cm : chacune s'écrit sur 52 bits de mantisse, et la somme d'une
|
||||
// abscisse et d'une largeur se calcule sans arrondi. Le bord droit d'une
|
||||
// tuile est ainsi, à l'égalité exacte, le bord gauche de la suivante.
|
||||
const QUANTUM = 2 ** -20;
|
||||
const BORNE_COTES = 2 ** 32;
|
||||
const versLeBas = (v) => Math.floor(v / QUANTUM) * QUANTUM;
|
||||
const versLeHaut = (v) => Math.ceil(v / QUANTUM) * QUANTUM;
|
||||
|
||||
// Lève RangeError quand la valeur n'est pas un nombre fini, ou qu'elle est
|
||||
// sous le plancher (strictement quand strict est vrai).
|
||||
function exigerNombre(nom, valeur, plancher, strict) {
|
||||
const valide =
|
||||
typeof valeur === 'number' && Number.isFinite(valeur) && (strict ? valeur > plancher : valeur >= plancher);
|
||||
if (!valide) {
|
||||
throw new RangeError(`${nom} : nombre fini ${strict ? '>' : '≥'} ${plancher} attendu, reçu ${String(valeur)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// La lettre de la colonne de rang n ≥ 1 : A à Z, puis AA, AB… (numération
|
||||
// bijective en base 26).
|
||||
function lettreColonne(n) {
|
||||
let lettres = '';
|
||||
for (let reste = n; reste > 0; reste = Math.floor((reste - 1) / 26)) {
|
||||
lettres = String.fromCharCode(65 + ((reste - 1) % 26)) + lettres;
|
||||
}
|
||||
return lettres;
|
||||
}
|
||||
|
||||
// Vrai quand les deux rectangles ont une intérieur commun : un rectangle
|
||||
// qui ne fait que toucher le bord d'une tuile ne la coupe pas.
|
||||
const coupe = (a, b) => a.minX < b.maxX && a.maxX > b.minX && a.minY < b.maxY && a.maxY > b.minY;
|
||||
|
||||
// La disposition du plan dans une orientation : la grille, l'échelle, et si
|
||||
// le plan se lit sur une page. Les dimensions du plan, W × H cm, sont nulles
|
||||
// pour le plan vide, qui prend l'échelle du seuil.
|
||||
function disposer(utile, plan, seuil, mode) {
|
||||
const { largeur: L, hauteur: Hu } = utile;
|
||||
if (plan.largeur === 0) return { colonnes: 1, rangees: 1, echelle: seuil, lisible: true };
|
||||
const unePage = Math.min(L / plan.largeur, Hu / plan.hauteur);
|
||||
const lisible = unePage >= seuil;
|
||||
if (mode === 'unePage' || (mode === 'auto' && lisible)) {
|
||||
return { colonnes: 1, rangees: 1, echelle: unePage, lisible };
|
||||
}
|
||||
const colonnes = Math.max(1, Math.ceil((plan.largeur * seuil) / L));
|
||||
const rangees = Math.max(1, Math.ceil((plan.hauteur * seuil) / Hu));
|
||||
const echelle = Math.min((colonnes * L) / plan.largeur, (rangees * Hu) / plan.hauteur);
|
||||
return { colonnes, rangees, echelle, lisible };
|
||||
}
|
||||
|
||||
// Les bords des tuiles sur un axe : n + 1 cotes, de la tuile de longueur
|
||||
// utile / echelle, arrondie par excès au quantum, et assez longue pour que n
|
||||
// tuiles couvrent l'étendue avec un quantum de part et d'autre ; la grille
|
||||
// est centrée sur l'étendue, son départ arrondi par défaut.
|
||||
function bords(n, utileMm, echelle, centre, etendue) {
|
||||
const tuile = versLeHaut(Math.max(utileMm / echelle, (etendue + 2 * QUANTUM) / n));
|
||||
const depart = versLeBas(centre - (n * tuile) / 2);
|
||||
const cotes = Array.from({ length: n + 1 }, (_, i) => depart + i * tuile);
|
||||
if (!(Math.abs(cotes[0]) < BORNE_COTES && Math.abs(cotes[n]) < BORNE_COTES)) {
|
||||
throw new RangeError(`plan hors des cotes représentables : ${String(cotes[0])} à ${String(cotes[n])} cm`);
|
||||
}
|
||||
return { cotes, tuile };
|
||||
}
|
||||
|
||||
/**
|
||||
* La découpe du plan en pages (§ 11.7).
|
||||
*
|
||||
* L'échelle e se compte en mm de papier par cm de dessin. Le texte des
|
||||
* listes, de corpsDessinCm cm dans le dessin, s'imprime à corpsDessinCm × e
|
||||
* mm : il se lit à partir du seuil e* = corpsMinMm / corpsDessinCm. La
|
||||
* surface utile d'une page est le papier, dans l'orientation, moins margeMm
|
||||
* sur chaque bord et moins la bande en hauteur : L × Hu mm.
|
||||
*
|
||||
* Le plan, W × H cm, tiendrait sur une page à e₁ = min(L/W, Hu/H) ;
|
||||
* lisibleSurUnePage dit e₁ ≥ e*. En mode 'unePage', ou en 'auto' quand le
|
||||
* plan se lit sur une page, une page à e₁. Sinon la grille minimale au seuil,
|
||||
* ⌈W·e* ÷ L⌉ colonnes × ⌈H·e* ÷ Hu⌉ rangées, à l'échelle la plus grande qu'elle
|
||||
* admet, min(nx·L/W, ny·Hu/H) ≥ e*. 'decoupe' rend cette grille même quand
|
||||
* le plan se lit sur une page : elle est alors d'une page.
|
||||
*
|
||||
* L'orientation est celle qui donne le moins de pages ; à pages égales, la
|
||||
* plus grande échelle ; à échelle égale aussi, le paysage.
|
||||
*
|
||||
* Les tuiles, toutes de même taille, se rangent par rangées de haut en bas,
|
||||
* de gauche à droite dans chaque rangée ; rang est l'indice dans pages,
|
||||
* cartouche.page le rang + 1. Elles sont contiguës, à l'égalité exacte, sans
|
||||
* recouvrement, et la grille est centrée sur le plan, qu'elle couvre. Une
|
||||
* tuile mesure la surface utile à l'échelle, arrondie par excès d'au plus
|
||||
* 3 × 2⁻²⁰ cm de dessin, moins d'un micromètre de papier.
|
||||
*
|
||||
* tables : les identifiants des tables dont l'emprise — le carré de
|
||||
* demi-côté rayonEmprise, plateau et chaises — ou, quand listes est donné,
|
||||
* le bloc de noms coupe la tuile, dans l'ordre de geometries. Le plan vide
|
||||
* (englobant null) rend une page sans table, centrée sur l'origine, à e*.
|
||||
*
|
||||
* @param {null|{minX: number, minY: number, maxX: number, maxY: number}} englobant
|
||||
* cm ; celui d'englobantImpression, des mêmes géométries et listes
|
||||
* @param {Object} options
|
||||
* @param {import('./papiers.js').Papier} options.papier
|
||||
* @param {number} options.bandeMm hauteur de la bande d'une page, mesurée à l'aperçu
|
||||
* @param {number} [options.margeMm] défaut : MARGE_PAGE_MM
|
||||
* @param {number} [options.corpsDessinCm] défaut : TAILLE_TEXTE_LISTE_CM
|
||||
* @param {number} [options.corpsMinMm] défaut : CORPS_MIN_IMPRESSION_MM
|
||||
* @param {ModeDecoupe} [options.mode] défaut : 'auto'
|
||||
* @param {import('./tables.js').GeometrieTable[]} [options.geometries]
|
||||
* @param {null|{largeur: number, hauteurs: Map<number, number>}} [options.listes]
|
||||
* @returns {{orientation: 'portrait'|'paysage', echelle: number, lisibleSurUnePage: boolean,
|
||||
* colonnes: number, rangees: number,
|
||||
* pages: Array<{rang: number, cadrage: {x: number, y: number, largeur: number, hauteur: number},
|
||||
* cartouche: {page: number, total: number, colonne: string, rangee: number,
|
||||
* voisins: {haut: number|null, droite: number|null,
|
||||
* bas: number|null, gauche: number|null}},
|
||||
* tables: number[]}>}}
|
||||
* @throws {RangeError} papier ou mode inconnu ; marge, bande ou corps hors de leur domaine ; page
|
||||
* sans surface utile ; englobant plat, inversé ou non fini ; table sans hauteur de liste
|
||||
*/
|
||||
export function decouperPlan(
|
||||
englobant,
|
||||
{
|
||||
papier,
|
||||
margeMm = constante('MARGE_PAGE_MM'),
|
||||
bandeMm,
|
||||
corpsDessinCm = TAILLE_TEXTE_LISTE_CM,
|
||||
corpsMinMm = constante('CORPS_MIN_IMPRESSION_MM'),
|
||||
mode = 'auto',
|
||||
geometries = [],
|
||||
listes = null,
|
||||
} = {},
|
||||
) {
|
||||
if (!Object.hasOwn(PAPIERS, papier)) throw new RangeError(`papier inconnu : ${String(papier)}`);
|
||||
if (!MODES.has(mode)) throw new RangeError(`mode de découpe inconnu : ${String(mode)}`);
|
||||
exigerNombre('marge', margeMm, 0, false);
|
||||
exigerNombre('bande', bandeMm, 0, false);
|
||||
exigerNombre('corps du dessin', corpsDessinCm, 0, true);
|
||||
exigerNombre('corps minimal', corpsMinMm, 0, true);
|
||||
let plan = { largeur: 0, hauteur: 0, centreX: 0, centreY: 0 };
|
||||
if (englobant !== null) {
|
||||
const { minX, minY, maxX, maxY } = englobant;
|
||||
for (const [nom, valeur] of [['minX', minX], ['minY', minY], ['maxX', maxX], ['maxY', maxY]]) {
|
||||
exigerNombre(`englobant, ${nom}`, valeur, -Infinity, true);
|
||||
}
|
||||
if (!(maxX > minX && maxY > minY)) throw new RangeError('englobant plat ou inversé');
|
||||
plan = { largeur: maxX - minX, hauteur: maxY - minY, centreX: (minX + maxX) / 2, centreY: (minY + maxY) / 2 };
|
||||
}
|
||||
const seuil = corpsMinMm / corpsDessinCm;
|
||||
const { largeur: petit, hauteur: grand } = PAPIERS[papier];
|
||||
|
||||
// Paysage d'abord : il garde la place à toute égalité.
|
||||
const candidats = [
|
||||
{ orientation: 'paysage', feuille: { largeur: grand, hauteur: petit } },
|
||||
{ orientation: 'portrait', feuille: { largeur: petit, hauteur: grand } },
|
||||
].map(({ orientation, feuille }) => {
|
||||
const utile = { largeur: feuille.largeur - 2 * margeMm, hauteur: feuille.hauteur - 2 * margeMm - bandeMm };
|
||||
if (!(utile.largeur > 0 && utile.hauteur > 0)) {
|
||||
throw new RangeError(`page ${orientation} sans surface utile : ${utile.largeur} × ${utile.hauteur} mm`);
|
||||
}
|
||||
return { orientation, utile, ...disposer(utile, plan, seuil, mode) };
|
||||
});
|
||||
const retenu = candidats.reduce((meilleur, c) => {
|
||||
const pages = c.colonnes * c.rangees;
|
||||
const pagesMeilleur = meilleur.colonnes * meilleur.rangees;
|
||||
return pages < pagesMeilleur || (pages === pagesMeilleur && c.echelle > meilleur.echelle) ? c : meilleur;
|
||||
});
|
||||
|
||||
const { orientation, utile, colonnes, rangees, echelle, lisible } = retenu;
|
||||
const enX = bords(colonnes, utile.largeur, echelle, plan.centreX, plan.largeur);
|
||||
const enY = bords(rangees, utile.hauteur, echelle, plan.centreY, plan.hauteur);
|
||||
const emprises = geometries.map((g) => ({
|
||||
id: g.id,
|
||||
rectangles: [
|
||||
{
|
||||
minX: g.centre.x - g.rayonEmprise,
|
||||
minY: g.centre.y - g.rayonEmprise,
|
||||
maxX: g.centre.x + g.rayonEmprise,
|
||||
maxY: g.centre.y + g.rayonEmprise,
|
||||
},
|
||||
...(listes === null ? [] : [blocNoms(g, listes)]),
|
||||
],
|
||||
}));
|
||||
const total = colonnes * rangees;
|
||||
const pages = Array.from({ length: total }, (_, rang) => {
|
||||
const c = rang % colonnes;
|
||||
const r = Math.floor(rang / colonnes);
|
||||
const tuile = { minX: enX.cotes[c], minY: enY.cotes[r], maxX: enX.cotes[c + 1], maxY: enY.cotes[r + 1] };
|
||||
return {
|
||||
rang,
|
||||
cadrage: { x: tuile.minX, y: tuile.minY, largeur: enX.tuile, hauteur: enY.tuile },
|
||||
cartouche: {
|
||||
page: rang + 1,
|
||||
total,
|
||||
colonne: lettreColonne(c + 1),
|
||||
rangee: r + 1,
|
||||
voisins: {
|
||||
haut: r > 0 ? rang - colonnes : null,
|
||||
droite: c + 1 < colonnes ? rang + 1 : null,
|
||||
bas: r + 1 < rangees ? rang + colonnes : null,
|
||||
gauche: c > 0 ? rang - 1 : null,
|
||||
},
|
||||
},
|
||||
tables: emprises.filter(({ rectangles }) => rectangles.some((rect) => coupe(rect, tuile))).map(({ id }) => id),
|
||||
};
|
||||
});
|
||||
return { orientation, echelle, lisibleSurUnePage: lisible, colonnes, rangees, pages };
|
||||
}
|
||||
467
src/geometrie/pages.test.js
Normal file
467
src/geometrie/pages.test.js
Normal file
|
|
@ -0,0 +1,467 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Épreuves de la découpe du plan en pages (§ 11.7) : une page quand le plan
|
||||
// s'y lit, la grille minimale au seuil de lisibilité sinon, l'échelle la plus
|
||||
// grande que cette grille admet, l'orientation qui donne le moins de pages,
|
||||
// des tuiles contiguës à l'égalité exacte, les cartouches et les tables que
|
||||
// chaque page dessine. Sauf mention, le papier est un A4 aux marges de 10 mm,
|
||||
// sans bande : 190 × 277 mm utiles en portrait, 277 × 190 en paysage ; et le
|
||||
// seuil e* = corpsMinMm / corpsDessinCm vaut 2 mm de papier par cm de dessin.
|
||||
// Les valeurs attendues se calculent dans le commentaire qui les précède.
|
||||
import assert from 'node:assert/strict';
|
||||
import fc from 'fast-check';
|
||||
import { describe, test } from '../../test/lanceur.js';
|
||||
import { constante } from './constantes.js';
|
||||
import { decouperPlan } from './pages.js';
|
||||
import { PAPIERS } from './papiers.js';
|
||||
import { englobant, englobantImpression, geometrieTable } from './tables.js';
|
||||
import { TAILLE_TEXTE_LISTE_CM } from './texte.js';
|
||||
|
||||
const OPTIONS = Object.freeze({ papier: 'A4', margeMm: 10, bandeMm: 0, corpsDessinCm: 1, corpsMinMm: 2 });
|
||||
|
||||
// Un englobant de coin (x, y) et de cotes largeur × hauteur, en cm.
|
||||
const boite = (x, y, largeur, hauteur) => ({ minX: x, minY: y, maxX: x + largeur, maxY: y + hauteur });
|
||||
|
||||
const table = (id, x, y) => ({ id, numero: id, sieges: null, forme: 'ronde', position: { x, y } });
|
||||
|
||||
// Les cotes d'une tuile s'arrondissent par excès à 2⁻²⁰ cm près : une cote
|
||||
// de cadrage se compare à 1e-5 cm près, une échelle à 1e-9 près.
|
||||
function proche(reel, attendu, libelle, tolerance = 1e-9) {
|
||||
assert.ok(Math.abs(reel - attendu) <= tolerance, `${libelle} : ${reel} au lieu de ${attendu}`);
|
||||
}
|
||||
|
||||
describe('decouperPlan : une page, ou la grille au seuil de lisibilité', () => {
|
||||
test('un plan qui se lit sur une page : une page, à l’échelle de la page, le plan centré', () => {
|
||||
// 100 × 50 cm. Paysage : e₁ = min(277/100, 190/50) = 2,77 ≥ 2, une page ;
|
||||
// la tuile mesure 277/2,77 = 100 cm sur 190/2,77 ≈ 68,592 cm, centrée :
|
||||
// y = −(68,592 − 50)/2.
|
||||
const plan = decouperPlan(boite(0, 0, 100, 50), OPTIONS);
|
||||
assert.equal(plan.orientation, 'paysage');
|
||||
assert.equal(plan.lisibleSurUnePage, true);
|
||||
assert.equal(plan.colonnes, 1);
|
||||
assert.equal(plan.rangees, 1);
|
||||
assert.equal(plan.pages.length, 1);
|
||||
proche(plan.echelle, 2.77, 'échelle');
|
||||
const { cadrage } = plan.pages[0];
|
||||
proche(cadrage.largeur, 100, 'largeur', 1e-5);
|
||||
proche(cadrage.hauteur, 190 / 2.77, 'hauteur', 1e-5);
|
||||
proche(cadrage.x, 0, 'x', 1e-5);
|
||||
proche(cadrage.y, -(190 / 2.77 - 50) / 2, 'y', 1e-5);
|
||||
});
|
||||
|
||||
test('sous le seuil : « auto » découpe, « unePage » rend une page illisible, « decoupe » rend la grille', () => {
|
||||
// 1 000 × 100 cm. Portrait : e₁ = min(0,19, 2,77) < 2 ; grille
|
||||
// ⌈1000·2/190⌉ × ⌈100·2/277⌉ = 11 × 1. Paysage : ⌈2000/277⌉ × ⌈200/190⌉ =
|
||||
// 8 × 2 = 16 pages. « auto » : portrait, 11 pages. « unePage » : une page
|
||||
// dans chaque orientation, le paysage plus grand, e₁ = min(0,277, 1,9).
|
||||
const auto = decouperPlan(boite(0, 0, 1000, 100), OPTIONS);
|
||||
assert.equal(auto.orientation, 'portrait');
|
||||
assert.equal(auto.lisibleSurUnePage, false);
|
||||
assert.deepEqual([auto.colonnes, auto.rangees, auto.pages.length], [11, 1, 11]);
|
||||
|
||||
const unePage = decouperPlan(boite(0, 0, 1000, 100), { ...OPTIONS, mode: 'unePage' });
|
||||
assert.equal(unePage.orientation, 'paysage');
|
||||
assert.equal(unePage.lisibleSurUnePage, false);
|
||||
assert.deepEqual([unePage.colonnes, unePage.rangees, unePage.pages.length], [1, 1, 1]);
|
||||
proche(unePage.echelle, 0.277, 'échelle sur une page');
|
||||
|
||||
assert.deepEqual(decouperPlan(boite(0, 0, 1000, 100), { ...OPTIONS, mode: 'decoupe' }), auto);
|
||||
});
|
||||
|
||||
test('« decoupe » sur un plan qui se lit : la grille au seuil est d’une page', () => {
|
||||
assert.deepEqual(
|
||||
decouperPlan(boite(0, 0, 100, 50), { ...OPTIONS, mode: 'decoupe' }),
|
||||
decouperPlan(boite(0, 0, 100, 50), OPTIONS),
|
||||
);
|
||||
});
|
||||
|
||||
test('le nombre de colonnes est l’arrondi par excès : W·e*/L = 2 exactement → 2, 2,01 → 3', () => {
|
||||
// Paysage, L = 277 : W = 277 donne 554/277 = 2 exactement, 2 colonnes
|
||||
// (portrait : ⌈554/190⌉ = 3). W = 277 × 1,005 donne 2,01 : 3 colonnes ;
|
||||
// le portrait en donne aussi 3, à l'échelle min(3·190/W, 27,7) ≈ 2,05,
|
||||
// moindre que celle du paysage, min(3·277/W, 19) ≈ 2,99.
|
||||
const deux = decouperPlan(boite(0, 0, 277, 10), OPTIONS);
|
||||
assert.deepEqual([deux.orientation, deux.colonnes, deux.rangees], ['paysage', 2, 1]);
|
||||
const trois = decouperPlan(boite(0, 0, 277 * 1.005, 10), OPTIONS);
|
||||
assert.deepEqual([trois.orientation, trois.colonnes, trois.rangees], ['paysage', 3, 1]);
|
||||
});
|
||||
|
||||
test('le nombre de rangées est l’arrondi par excès : H·e*/Hu = 2 exactement → 2, 2,01 → 3', () => {
|
||||
// Portrait, Hu = 277 : H = 277 donne 2 rangées (paysage : ⌈554/190⌉ = 3).
|
||||
// H = 277 × 1,005 : 3 rangées dans chaque orientation, le portrait à
|
||||
// l'échelle min(19, 3·277/H) ≈ 2,99, le paysage à min(27,7, 3·190/H) ≈ 2,05.
|
||||
const deux = decouperPlan(boite(0, 0, 10, 277), OPTIONS);
|
||||
assert.deepEqual([deux.orientation, deux.colonnes, deux.rangees], ['portrait', 1, 2]);
|
||||
const trois = decouperPlan(boite(0, 0, 10, 277 * 1.005), OPTIONS);
|
||||
assert.deepEqual([trois.orientation, trois.colonnes, trois.rangees], ['portrait', 1, 3]);
|
||||
});
|
||||
|
||||
test('l’échelle retenue est la plus grande que la grille minimale admet, pas le seuil', () => {
|
||||
// 1 000 × 100 cm en portrait, 11 colonnes : min(11·190/1000, 277/100) =
|
||||
// 2,09 > e* = 2. Tuile de 190/2,09 ≈ 90,909 cm sur 277/2,09 ≈ 132,54 cm ;
|
||||
// onze tuiles couvrent les 1 000 cm, la rangée est centrée sur les 100.
|
||||
const plan = decouperPlan(boite(0, 0, 1000, 100), OPTIONS);
|
||||
proche(plan.echelle, 2.09, 'échelle');
|
||||
const premiere = plan.pages[0].cadrage;
|
||||
proche(premiere.largeur, 190 / 2.09, 'largeur', 1e-5);
|
||||
proche(premiere.hauteur, 277 / 2.09, 'hauteur', 1e-5);
|
||||
proche(premiere.x, 0, 'x', 1e-5);
|
||||
proche(premiere.y, -(277 / 2.09 - 100) / 2, 'y', 1e-5);
|
||||
const derniere = plan.pages[10].cadrage;
|
||||
proche(derniere.x + derniere.largeur, 1000, 'bord droit de la dernière', 1e-4);
|
||||
});
|
||||
|
||||
test('la bande se retire de la hauteur utile, sous les deux marges ; le papier Lettre a ses cotes', () => {
|
||||
// 50 × 100 cm, bande de 17 mm, A4 portrait : 190 × 260 mm utiles,
|
||||
// e₁ = min(3,8, 2,6) = 2,6 ; le paysage, 277 × 173, découpe en 2.
|
||||
const a4 = decouperPlan(boite(0, 0, 50, 100), { ...OPTIONS, bandeMm: 17 });
|
||||
assert.deepEqual([a4.orientation, a4.pages.length], ['portrait', 1]);
|
||||
proche(a4.echelle, 2.6, 'échelle A4');
|
||||
proche(a4.pages[0].cadrage.hauteur * a4.echelle, 260, 'hauteur utile', 1e-4);
|
||||
proche(a4.pages[0].cadrage.largeur * a4.echelle, 190, 'largeur utile', 1e-4);
|
||||
// Lettre portrait, sans bande : 195,9 × 259,4 mm utiles, e₁ = 2,594.
|
||||
const lettre = decouperPlan(boite(0, 0, 50, 100), { ...OPTIONS, papier: 'Lettre' });
|
||||
assert.deepEqual([lettre.orientation, lettre.pages.length], ['portrait', 1]);
|
||||
proche(lettre.echelle, (PAPIERS.Lettre.hauteur - 20) / 100, 'échelle Lettre');
|
||||
});
|
||||
|
||||
test('marge, corps du dessin et corps minimal ont pour défaut le registre ; le mode, « auto »', () => {
|
||||
const englobe = boite(-300, 40, 2600, 900);
|
||||
assert.deepEqual(
|
||||
decouperPlan(englobe, { papier: 'A4', bandeMm: 12 }),
|
||||
decouperPlan(englobe, {
|
||||
papier: 'A4',
|
||||
bandeMm: 12,
|
||||
margeMm: constante('MARGE_PAGE_MM'),
|
||||
corpsDessinCm: TAILLE_TEXTE_LISTE_CM,
|
||||
corpsMinMm: constante('CORPS_MIN_IMPRESSION_MM'),
|
||||
mode: 'auto',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('decouperPlan : l’orientation', () => {
|
||||
test('paysage d’un plan large, portrait d’un plan haut', () => {
|
||||
// 100 × 50 : une page en paysage, deux en portrait. 50 × 100 : une page
|
||||
// en portrait (e₁ = 2,77), deux en paysage (e₁ = 1,9, ⌈200/190⌉ rangées).
|
||||
assert.equal(decouperPlan(boite(0, 0, 100, 50), OPTIONS).orientation, 'paysage');
|
||||
const haut = decouperPlan(boite(0, 0, 50, 100), OPTIONS);
|
||||
assert.deepEqual([haut.orientation, haut.pages.length], ['portrait', 1]);
|
||||
});
|
||||
|
||||
test('à pages égales, la plus grande échelle ; à échelle égale aussi, le paysage', () => {
|
||||
// « unePage » d'un plan haut : une page dans chaque orientation, le
|
||||
// portrait à 2,77, le paysage à 1,9. Un carré de 50 cm : 3,8 dans les deux.
|
||||
const haut = decouperPlan(boite(0, 0, 50, 100), { ...OPTIONS, mode: 'unePage' });
|
||||
assert.equal(haut.orientation, 'portrait');
|
||||
const carre = decouperPlan(boite(0, 0, 50, 50), OPTIONS);
|
||||
assert.equal(carre.orientation, 'paysage');
|
||||
proche(carre.echelle, 3.8, 'échelle du carré');
|
||||
});
|
||||
});
|
||||
|
||||
describe('decouperPlan : les cartouches', () => {
|
||||
test('une grille de 3 × 2 : rangs dans l’ordre de lecture, lettres, rangées, voisins nuls aux bords', () => {
|
||||
// 400 × 150 cm. Paysage : ⌈800/277⌉ × ⌈300/190⌉ = 3 × 2 ; portrait :
|
||||
// ⌈800/190⌉ × ⌈300/277⌉ = 5 × 2.
|
||||
const plan = decouperPlan(boite(0, 0, 400, 150), OPTIONS);
|
||||
assert.deepEqual([plan.orientation, plan.colonnes, plan.rangees], ['paysage', 3, 2]);
|
||||
const v = (haut, droite, bas, gauche) => ({ haut, droite, bas, gauche });
|
||||
assert.deepEqual(
|
||||
plan.pages.map(({ rang, cartouche }) => ({ rang, ...cartouche })),
|
||||
[
|
||||
{ rang: 0, page: 1, total: 6, colonne: 'A', rangee: 1, voisins: v(null, 1, 3, null) },
|
||||
{ rang: 1, page: 2, total: 6, colonne: 'B', rangee: 1, voisins: v(null, 2, 4, 0) },
|
||||
{ rang: 2, page: 3, total: 6, colonne: 'C', rangee: 1, voisins: v(null, null, 5, 1) },
|
||||
{ rang: 3, page: 4, total: 6, colonne: 'A', rangee: 2, voisins: v(0, 4, null, null) },
|
||||
{ rang: 4, page: 5, total: 6, colonne: 'B', rangee: 2, voisins: v(1, 5, null, 3) },
|
||||
{ rang: 5, page: 6, total: 6, colonne: 'C', rangee: 2, voisins: v(2, null, null, 4) },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('au-delà de Z, les colonnes continuent en AA, AB… ', () => {
|
||||
// 4 000 × 10 cm. Paysage : ⌈8000/277⌉ = 29 colonnes ; portrait : 43.
|
||||
const plan = decouperPlan(boite(0, 0, 4000, 10), OPTIONS);
|
||||
assert.equal(plan.colonnes, 29);
|
||||
assert.deepEqual(
|
||||
[0, 25, 26, 27, 28].map((rang) => plan.pages[rang].cartouche.colonne),
|
||||
['A', 'Z', 'AA', 'AB', 'AC'],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('decouperPlan : les tables de chaque page', () => {
|
||||
test('une table à cheval sur deux tuiles paraît sur les deux, chaque autre sur la sienne', () => {
|
||||
// Trois rondes de 8 en x = 0, 1 900, 4 000 : l'englobant court de −128,4
|
||||
// à 4 128,4 cm sur 256,8. Seuil e* = 0,1 : paysage ⌈425,7/277⌉ × 1 = 2
|
||||
// colonnes, portrait 3. La frontière tombe au milieu, x = 2 000 : le
|
||||
// centre de la deuxième est dans la première tuile, ses chaises
|
||||
// (emprise de 1 779,6 à 2 020,4) passent dans la seconde. Une emprise
|
||||
// réduite au plateau, ou à la moitié du demi-côté, n'y passerait pas.
|
||||
const geometries = [table(1, 0, 0), table(2, 1900, 0), table(3, 4000, 0)].map((t) => geometrieTable(t, 8));
|
||||
const plan = decouperPlan(englobant(geometries), { ...OPTIONS, corpsMinMm: 0.1, geometries });
|
||||
assert.deepEqual([plan.orientation, plan.colonnes, plan.rangees], ['paysage', 2, 1]);
|
||||
assert.ok(plan.pages[1].cadrage.x > geometries[1].centre.x, 'centre dans la première tuile');
|
||||
assert.deepEqual(
|
||||
plan.pages.map((p) => p.tables),
|
||||
[
|
||||
[1, 2],
|
||||
[2, 3],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('à cheval sur deux rangées : la même table, son centre au-dessus de la frontière', () => {
|
||||
// Les trois rondes ci-dessus, transposées en y = 0, 1 900, 4 000.
|
||||
// Portrait : ⌈425,7/277⌉ = 2 rangées sur ⌈25,7/190⌉ = 1 colonne ; le
|
||||
// paysage en demande ⌈425,7/190⌉ = 3. La frontière tombe en y = 2 000.
|
||||
const geometries = [table(1, 0, 0), table(2, 0, 1900), table(3, 0, 4000)].map((t) => geometrieTable(t, 8));
|
||||
const plan = decouperPlan(englobant(geometries), { ...OPTIONS, corpsMinMm: 0.1, geometries });
|
||||
assert.deepEqual([plan.orientation, plan.colonnes, plan.rangees], ['portrait', 1, 2]);
|
||||
assert.ok(plan.pages[1].cadrage.y > geometries[1].centre.y, 'centre dans la première rangée');
|
||||
assert.deepEqual(
|
||||
plan.pages.map((p) => p.tables),
|
||||
[
|
||||
[1, 2],
|
||||
[2, 3],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('une emprise qui ne fait que toucher le bord d’une tuile n’y paraît pas', () => {
|
||||
// Les tuiles des trois rondes ci-dessus, et deux emprises de demi-côté 50
|
||||
// posées contre leur frontière b, l'une à gauche, l'autre à droite : b est
|
||||
// un multiple de 2⁻²⁰ cm, b ± 50 ∓ 50 se calcule sans arrondi.
|
||||
const geometries = [table(1, 0, 0), table(2, 2000, 0), table(3, 4000, 0)].map((t) => geometrieTable(t, 8));
|
||||
const options = { ...OPTIONS, corpsMinMm: 0.1 };
|
||||
const b = decouperPlan(englobant(geometries), { ...options, geometries }).pages[1].cadrage.x;
|
||||
const contre = (id, x) => ({ id, centre: { x, y: 0 }, rayonEmprise: 50 });
|
||||
const plan = decouperPlan(englobant(geometries), {
|
||||
...options,
|
||||
geometries: [...geometries, contre(8, b - 50), contre(9, b + 50)],
|
||||
});
|
||||
assert.deepEqual(
|
||||
plan.pages.map((p) => p.tables),
|
||||
[
|
||||
[1, 2, 8],
|
||||
[2, 3, 9],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('un bloc de noms seul dans une tuile y fait paraître sa table', () => {
|
||||
// Une ronde de 8 en (0, 0), son bloc de 2 000 cm sur 300 depuis
|
||||
// l'origine de la liste (156,4 ; −76,4) : l'englobant d'impression court
|
||||
// de −128,4 à 2 156,4. Seuil e* = 0,2 : paysage ⌈457/277⌉ = 2 colonnes,
|
||||
// frontière en x = 1 014, au-delà de l'emprise (bord à 120,4) : la
|
||||
// seconde tuile ne coupe que le bloc.
|
||||
const geometries = [geometrieTable(table(1, 0, 0), 8)];
|
||||
const listes = { largeur: 2000, hauteurs: new Map([[1, 300]]) };
|
||||
const englobe = englobantImpression(geometries, { listes });
|
||||
const options = { ...OPTIONS, corpsMinMm: 0.2, geometries };
|
||||
const avec = decouperPlan(englobe, { ...options, listes });
|
||||
assert.deepEqual([avec.orientation, avec.colonnes, avec.rangees], ['paysage', 2, 1]);
|
||||
assert.ok(avec.pages[1].cadrage.x > geometries[0].centre.x + geometries[0].rayonEmprise);
|
||||
assert.deepEqual(
|
||||
avec.pages.map((p) => p.tables),
|
||||
[[1], [1]],
|
||||
);
|
||||
// Sans les listes, la même tuile ne coupe rien.
|
||||
const sans = decouperPlan(englobe, { ...options, listes: null });
|
||||
assert.deepEqual(
|
||||
sans.pages.map((p) => p.tables),
|
||||
[[1], []],
|
||||
);
|
||||
});
|
||||
|
||||
test('douze tables en grille, leurs listes comprises : chacune paraît sur une page au moins', () => {
|
||||
// Le pas d'une grille de rondes de 8 : 813 cm entre colonnes, 300 entre
|
||||
// rangées ; neuf lignes de 22,5 cm par liste. Seuils du registre.
|
||||
const geometries = Array.from({ length: 12 }, (_, i) =>
|
||||
geometrieTable(table(i + 1, (i % 4) * 813, Math.floor(i / 4) * 300), 8),
|
||||
);
|
||||
const listes = { largeur: constante('LARGEUR_BLOC_NOMS_CM'), hauteurs: new Map(geometries.map((g) => [g.id, 202.5])) };
|
||||
const plan = decouperPlan(englobantImpression(geometries, { listes }), {
|
||||
papier: 'A4',
|
||||
bandeMm: 15,
|
||||
geometries,
|
||||
listes,
|
||||
});
|
||||
assert.ok(plan.pages.length >= 2, `${plan.pages.length} page(s)`);
|
||||
const vues = new Set(plan.pages.flatMap((p) => p.tables));
|
||||
assert.deepEqual(
|
||||
geometries.map((g) => g.id).filter((id) => !vues.has(id)),
|
||||
[],
|
||||
);
|
||||
for (const page of plan.pages) {
|
||||
assert.deepEqual(
|
||||
page.tables,
|
||||
geometries.map((g) => g.id).filter((id) => page.tables.includes(id)),
|
||||
'les tables d’une page suivent l’ordre des géométries',
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('decouperPlan : le plan vide', () => {
|
||||
test('englobant null : une page, aucune table, une échelle finie, dans tout mode', () => {
|
||||
for (const mode of ['auto', 'unePage', 'decoupe']) {
|
||||
const plan = decouperPlan(null, { ...OPTIONS, mode });
|
||||
assert.equal(plan.pages.length, 1, mode);
|
||||
assert.ok(Number.isFinite(plan.echelle) && plan.echelle > 0, `${mode} : échelle ${plan.echelle}`);
|
||||
assert.equal(plan.lisibleSurUnePage, true, mode);
|
||||
assert.deepEqual([plan.colonnes, plan.rangees], [1, 1], mode);
|
||||
const [{ rang, cadrage, cartouche, tables }] = plan.pages;
|
||||
assert.equal(rang, 0);
|
||||
assert.ok(Object.values(cadrage).every(Number.isFinite), mode);
|
||||
assert.deepEqual(cartouche, {
|
||||
page: 1,
|
||||
total: 1,
|
||||
colonne: 'A',
|
||||
rangee: 1,
|
||||
voisins: { haut: null, droite: null, bas: null, gauche: null },
|
||||
});
|
||||
assert.deepEqual(tables, []);
|
||||
}
|
||||
});
|
||||
|
||||
test('englobant null : l’échelle du seuil, la page centrée sur l’origine, en paysage', () => {
|
||||
// Une page dans chaque orientation, à e* = 2 : égalité, le paysage. La
|
||||
// tuile mesure 277/2 = 138,5 cm sur 190/2 = 95 cm, de part et d'autre
|
||||
// de l'origine.
|
||||
const plan = decouperPlan(null, OPTIONS);
|
||||
assert.equal(plan.orientation, 'paysage');
|
||||
proche(plan.echelle, 2, 'échelle');
|
||||
const { cadrage } = plan.pages[0];
|
||||
proche(cadrage.largeur, 138.5, 'largeur', 1e-5);
|
||||
proche(cadrage.hauteur, 95, 'hauteur', 1e-5);
|
||||
proche(cadrage.x, -69.25, 'x', 1e-5);
|
||||
proche(cadrage.y, -47.5, 'y', 1e-5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('decouperPlan : les refus', () => {
|
||||
const ok = boite(0, 0, 100, 50);
|
||||
test('un papier inconnu, une bande absente ou négative, un mode inconnu', () => {
|
||||
assert.throws(() => decouperPlan(ok, { ...OPTIONS, papier: 'A3' }), RangeError);
|
||||
assert.throws(() => decouperPlan(ok, { ...OPTIONS, papier: 'toString' }), RangeError);
|
||||
assert.throws(() => decouperPlan(ok, { ...OPTIONS, bandeMm: undefined }), RangeError);
|
||||
assert.throws(() => decouperPlan(ok, { ...OPTIONS, bandeMm: -1 }), RangeError);
|
||||
assert.throws(() => decouperPlan(ok, { ...OPTIONS, mode: 'toujours' }), RangeError);
|
||||
});
|
||||
|
||||
test('des corps nuls ou non finis, une marge négative, une page sans surface utile', () => {
|
||||
assert.throws(() => decouperPlan(ok, { ...OPTIONS, corpsMinMm: 0 }), RangeError);
|
||||
assert.throws(() => decouperPlan(ok, { ...OPTIONS, corpsDessinCm: Number.NaN }), RangeError);
|
||||
assert.throws(() => decouperPlan(ok, { ...OPTIONS, margeMm: -1 }), RangeError);
|
||||
// A4 : 210 − 2 × 105 = 0 mm, en largeur du portrait et en hauteur du paysage.
|
||||
assert.throws(() => decouperPlan(ok, { ...OPTIONS, margeMm: 105 }), RangeError);
|
||||
assert.throws(() => decouperPlan(ok, { ...OPTIONS, bandeMm: 300 }), RangeError);
|
||||
});
|
||||
|
||||
test('un englobant plat, inversé, non fini, ou hors des cotes représentables', () => {
|
||||
assert.throws(() => decouperPlan(boite(0, 0, 0, 50), OPTIONS), RangeError);
|
||||
// Au-delà de 2³² cm, une cote multiple de 2⁻²⁰ ne tient plus sur 52 bits.
|
||||
assert.throws(() => decouperPlan(boite(2 ** 32, 0, 100, 50), OPTIONS), RangeError);
|
||||
assert.ok(decouperPlan(boite(2 ** 31, 0, 100, 50), OPTIONS).pages.length >= 1);
|
||||
assert.throws(() => decouperPlan({ minX: 0, minY: 10, maxX: 10, maxY: 0 }, OPTIONS), RangeError);
|
||||
assert.throws(() => decouperPlan(boite(0, 0, Infinity, 50), OPTIONS), RangeError);
|
||||
});
|
||||
|
||||
test('une table sans hauteur de liste quand les listes sont dessinées', () => {
|
||||
const geometries = [geometrieTable(table(1, 0, 0), 8), geometrieTable(table(2, 900, 0), 8)];
|
||||
const listes = { largeur: 500, hauteurs: new Map([[1, 100]]) };
|
||||
assert.throws(() => decouperPlan(englobant(geometries), { ...OPTIONS, geometries, listes }), RangeError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('decouperPlan : tuiles contiguës, sans recouvrement, couvrant l’englobant (propriété, § 14.12)', () => {
|
||||
// Graine fixée, écrite ici : un contre-exemple se rejoue.
|
||||
const GRAINE = 314_159;
|
||||
const TIRAGES = 400;
|
||||
// Cotes tirées sur une échelle logarithmique, de 10 cm à 500 m : des plans
|
||||
// d'une page et des plans découpés sur un axe ou sur les deux.
|
||||
const cote = fc.integer({ min: 10_000, max: 47_000 }).map((k) => 10 ** (k / 10_000));
|
||||
const coin = fc.double({ min: -100_000, max: 100_000, noNaN: true });
|
||||
const cas = fc.record({
|
||||
x: coin,
|
||||
y: coin,
|
||||
largeur: cote,
|
||||
hauteur: cote,
|
||||
papier: fc.constantFrom('A4', 'Lettre'),
|
||||
bandeMm: fc.double({ min: 0, max: 60, noNaN: true }),
|
||||
corpsMinMm: fc.double({ min: 0.5, max: 4, noNaN: true }),
|
||||
mode: fc.constantFrom('auto', 'unePage', 'decoupe'),
|
||||
});
|
||||
|
||||
test('bords à l’égalité exacte, grille complète, englobant couvert, page tenue dans sa surface utile', () => {
|
||||
const bilan = { tirages: 0, decoupes: 0, deuxAxes: 0, paysage: 0, portrait: 0, lisibles: 0 };
|
||||
fc.assert(
|
||||
fc.property(cas, ({ x, y, largeur, hauteur, papier, bandeMm, corpsMinMm, mode }) => {
|
||||
const englobe = boite(x, y, largeur, hauteur);
|
||||
const plan = decouperPlan(englobe, { papier, margeMm: 10, bandeMm, corpsDessinCm: 18, corpsMinMm, mode });
|
||||
const { colonnes, rangees, pages, echelle } = plan;
|
||||
bilan.tirages += 1;
|
||||
if (pages.length > 1) bilan.decoupes += 1;
|
||||
if (colonnes > 1 && rangees > 1) bilan.deuxAxes += 1;
|
||||
bilan[plan.orientation] += 1;
|
||||
if (plan.lisibleSurUnePage) bilan.lisibles += 1;
|
||||
|
||||
assert.equal(pages.length, colonnes * rangees);
|
||||
if (mode === 'unePage' || (mode === 'auto' && plan.lisibleSurUnePage)) assert.equal(pages.length, 1);
|
||||
// Hors d'« unePage », le texte imprimé atteint le corps minimal.
|
||||
if (mode !== 'unePage') assert.ok(echelle * 18 >= corpsMinMm * (1 - 1e-12), `échelle ${echelle}`);
|
||||
const papierOriente = plan.orientation === 'portrait'
|
||||
? PAPIERS[papier]
|
||||
: { largeur: PAPIERS[papier].hauteur, hauteur: PAPIERS[papier].largeur };
|
||||
const utileL = papierOriente.largeur - 20;
|
||||
const utileH = papierOriente.hauteur - 20 - bandeMm;
|
||||
|
||||
pages.forEach((page, rang) => {
|
||||
const { cadrage, cartouche } = page;
|
||||
const c = rang % colonnes;
|
||||
const r = Math.floor(rang / colonnes);
|
||||
assert.equal(page.rang, rang);
|
||||
assert.equal(cartouche.page, rang + 1);
|
||||
assert.equal(cartouche.total, pages.length);
|
||||
assert.equal(cartouche.rangee, r + 1);
|
||||
assert.ok(cadrage.largeur > 0 && cadrage.hauteur > 0);
|
||||
// La tuile, à l'échelle, tient dans la surface utile à 3 × 2⁻²⁰ cm près.
|
||||
assert.ok(cadrage.largeur * echelle <= utileL + 3 * 2 ** -20 * echelle * (1 + 1e-9), 'largeur utile');
|
||||
assert.ok(cadrage.hauteur * echelle <= utileH + 3 * 2 ** -20 * echelle * (1 + 1e-9), 'hauteur utile');
|
||||
if (c + 1 < colonnes) {
|
||||
const droite = pages[rang + 1].cadrage;
|
||||
assert.equal(cadrage.x + cadrage.largeur, droite.x, 'bord droit = bord gauche de la suivante');
|
||||
assert.equal(cadrage.y, droite.y);
|
||||
assert.equal(cadrage.hauteur, droite.hauteur);
|
||||
}
|
||||
if (r + 1 < rangees) {
|
||||
const bas = pages[rang + colonnes].cadrage;
|
||||
assert.equal(cadrage.y + cadrage.hauteur, bas.y, 'bord bas = bord haut de la suivante');
|
||||
assert.equal(cadrage.x, bas.x);
|
||||
assert.equal(cadrage.largeur, bas.largeur);
|
||||
}
|
||||
assert.equal(cartouche.voisins.droite, c + 1 < colonnes ? rang + 1 : null);
|
||||
assert.equal(cartouche.voisins.gauche, c > 0 ? rang - 1 : null);
|
||||
assert.equal(cartouche.voisins.bas, r + 1 < rangees ? rang + colonnes : null);
|
||||
assert.equal(cartouche.voisins.haut, r > 0 ? rang - colonnes : null);
|
||||
});
|
||||
const premiere = pages[0].cadrage;
|
||||
const derniere = pages[pages.length - 1].cadrage;
|
||||
assert.ok(premiere.x <= englobe.minX && premiere.y <= englobe.minY, 'coin haut gauche couvert');
|
||||
assert.ok(derniere.x + derniere.largeur >= englobe.maxX, 'bord droit couvert');
|
||||
assert.ok(derniere.y + derniere.hauteur >= englobe.maxY, 'bord bas couvert');
|
||||
}),
|
||||
{ seed: GRAINE, numRuns: TIRAGES },
|
||||
);
|
||||
// Un générateur qui ne tire que des plans d'une page rend la propriété
|
||||
// vraie sans rien éprouver : chaque famille de cas a son plancher.
|
||||
assert.equal(bilan.tirages, TIRAGES);
|
||||
for (const famille of ['decoupes', 'deuxAxes', 'paysage', 'portrait', 'lisibles']) {
|
||||
assert.ok(bilan[famille] >= 20, `${famille} : ${bilan[famille]}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -227,6 +227,57 @@ export function englobant(geometries) {
|
|||
return { minX, minY, maxX, maxY };
|
||||
}
|
||||
|
||||
/**
|
||||
* Le rectangle du bloc de noms d'une table sur le plan imprimé, en cm : de
|
||||
* origineListe vers la droite et le bas, large de listes.largeur, haut de la
|
||||
* hauteur que listes.hauteurs porte pour la table. La seule mesure de ce
|
||||
* rectangle : englobantImpression l'ajoute à l'englobant, la découpe en
|
||||
* pages (pages.js) le confronte à chaque tuile. Lève RangeError quand la
|
||||
* largeur ou la hauteur n'est pas un nombre fini ≥ 0, ou quand la table n'a
|
||||
* pas de hauteur : un bloc absent ferait disparaître sa liste d'une page
|
||||
* sans rien dire.
|
||||
*
|
||||
* @param {GeometrieTable} geometrie
|
||||
* @param {{largeur: number, hauteurs: Map<number, number>}} listes cm
|
||||
* @returns {{minX: number, minY: number, maxX: number, maxY: number}}
|
||||
*/
|
||||
export function blocNoms(geometrie, listes) {
|
||||
const { largeur, hauteurs } = listes;
|
||||
const hauteur = hauteurs.get(geometrie.id);
|
||||
for (const [nom, valeur] of [['largeur', largeur], [`hauteur de la table ${geometrie.id}`, hauteur]]) {
|
||||
if (typeof valeur !== 'number' || !Number.isFinite(valeur) || valeur < 0) {
|
||||
throw new RangeError(`liste : ${nom}, nombre fini ≥ 0 attendu, reçu ${String(valeur)}`);
|
||||
}
|
||||
}
|
||||
const { x, y } = geometrie.origineListe;
|
||||
return { minX: x, minY: y, maxX: x + largeur, maxY: y + hauteur };
|
||||
}
|
||||
|
||||
/**
|
||||
* L'englobant du plan imprimé : sans listes, englobant(geometries) tel quel ;
|
||||
* avec, étendu du bloc de noms de chaque table (blocNoms). Les étiquettes ne
|
||||
* sont pas à l'échelle (§ 7.7) : un plan imprimé avec ses listes couvre plus
|
||||
* de papier que son mobilier, et la découpe en pages en tient compte.
|
||||
*
|
||||
* @param {GeometrieTable[]} geometries
|
||||
* @param {{listes?: null|{largeur: number, hauteurs: Map<number, number>}}} [options]
|
||||
* listes : largeur des blocs et hauteur de chacun, par identifiant de table, en cm
|
||||
* @returns {{minX: number, minY: number, maxX: number, maxY: number}|null} null sans table
|
||||
*/
|
||||
export function englobantImpression(geometries, { listes = null } = {}) {
|
||||
const dessin = englobant(geometries);
|
||||
if (dessin === null || listes === null) return dessin;
|
||||
let { minX, minY, maxX, maxY } = dessin;
|
||||
for (const geometrie of geometries) {
|
||||
const bloc = blocNoms(geometrie, listes);
|
||||
minX = Math.min(minX, bloc.minX);
|
||||
minY = Math.min(minY, bloc.minY);
|
||||
maxX = Math.max(maxX, bloc.maxX);
|
||||
maxY = Math.max(maxY, bloc.maxY);
|
||||
}
|
||||
return { minX, minY, maxX, maxY };
|
||||
}
|
||||
|
||||
/**
|
||||
* Le pas de la grille des tables, en cm entiers, pour la plus grande emprise
|
||||
* E des tables données, chacune de sa forme et de son nombre de places :
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import {
|
|||
diametre,
|
||||
ecartsGrille,
|
||||
englobant,
|
||||
englobantImpression,
|
||||
geometrieTable,
|
||||
listeSurTable,
|
||||
placesSousPoignee,
|
||||
|
|
@ -348,6 +349,56 @@ describe('englobant : tout ce que le dessin pose de pointable', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('englobantImpression : l’englobant du plan imprimé, ses listes comprises (§ 7.7, § 11.7)', () => {
|
||||
// Ronde de 8 en (300, 600) : r = 240/π ≈ 76,39, emprise r + 44, origine de
|
||||
// la liste en (300 + r + 44 + 36, 600 − r) ; carrée de 8 en (−100, 50) :
|
||||
// emprise 104, origine de la liste en (40, −10).
|
||||
const r = 240 / Math.PI;
|
||||
const geometries = () => [geometrieTable(table(1, 'ronde', 300, 600), 8), geometrieTable(table(2, 'carree', -100, 50), 8)];
|
||||
|
||||
test('null sans table, avec ou sans listes', () => {
|
||||
assert.equal(englobantImpression([]), null);
|
||||
assert.equal(englobantImpression([], { listes: { largeur: 500, hauteurs: new Map() } }), null);
|
||||
});
|
||||
|
||||
test('sans listes, l’englobant du dessin, tel quel', () => {
|
||||
assert.deepEqual(englobantImpression(geometries()), englobant(geometries()));
|
||||
assert.deepEqual(englobantImpression(geometries(), { listes: null }), englobant(geometries()));
|
||||
});
|
||||
|
||||
test('avec listes : maxX au bord du bloc le plus à droite, maxY au pied du plus bas, le reste inchangé', () => {
|
||||
// Bloc de la ronde : x de 380 + r à 880 + r, y de 600 − r à 900 − r ;
|
||||
// bloc de la carrée : x de 40 à 540, y de −10 à 1 990, sous la ronde.
|
||||
const listes = { largeur: 500, hauteurs: new Map([[1, 300], [2, 2000]]) };
|
||||
const e = englobantImpression(geometries(), { listes });
|
||||
const dessin = englobant(geometries());
|
||||
proche(e.maxX, 300 + r + 44 + 36 + 500, 'maxX');
|
||||
proche(e.maxY, -10 + 2000, 'maxY');
|
||||
assert.equal(e.minX, dessin.minX);
|
||||
assert.equal(e.minY, dessin.minY);
|
||||
});
|
||||
|
||||
test('un bloc au-dessus et à gauche de tout ne se pose pas : il part de origineListe, vers la droite et le bas', () => {
|
||||
// Hauteurs nulles : chaque bloc se réduit à son origine, dans l'emprise
|
||||
// en y et à droite d'elle en x ; seul maxX peut croître.
|
||||
const listes = { largeur: 0, hauteurs: new Map([[1, 0], [2, 0]]) };
|
||||
const e = englobantImpression(geometries(), { listes });
|
||||
const dessin = englobant(geometries());
|
||||
assert.deepEqual([e.minX, e.minY, e.maxY], [dessin.minX, dessin.minY, dessin.maxY]);
|
||||
proche(e.maxX, Math.max(dessin.maxX, 300 + r + 80), 'maxX');
|
||||
});
|
||||
|
||||
test('une table sans hauteur de liste, une largeur ou une hauteur négative ou non finie : RangeError', () => {
|
||||
const hauteurs = new Map([[1, 300]]);
|
||||
assert.throws(() => englobantImpression(geometries(), { listes: { largeur: 500, hauteurs } }), RangeError);
|
||||
const deux = new Map([[1, 300], [2, 100]]);
|
||||
assert.throws(() => englobantImpression(geometries(), { listes: { largeur: -1, hauteurs: deux } }), RangeError);
|
||||
assert.throws(() => englobantImpression(geometries(), { listes: { largeur: Infinity, hauteurs: deux } }), RangeError);
|
||||
const negative = new Map([[1, 300], [2, -5]]);
|
||||
assert.throws(() => englobantImpression(geometries(), { listes: { largeur: 500, hauteurs: negative } }), RangeError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ecartsGrille : le pas d\u{2019}une grille de tables (§ 7.4, § 7.7)', () => {
|
||||
// colonnes = ⌈2 × emprise + 2 × ECART_LISTE + LARGEUR_BLOC_NOMS_CM⌉ ;
|
||||
// rangées = max(ECART_RANGEES, ⌈2 × emprise + ECART_LISTE⌉), l'emprise la
|
||||
|
|
|
|||
|
|
@ -3644,9 +3644,11 @@ function releverFixturesLivrees(racine) {
|
|||
}
|
||||
|
||||
// Les seules épreuves qui chargent fast-check (§ 14.12) : la formule du
|
||||
// plafond et la détection du séparateur, qui gagnent au rétrécissement ; et
|
||||
// plafond et la détection du séparateur, qui gagnent au rétrécissement ;
|
||||
// deux propriétés du stockage, sur des paires de charges et des séances
|
||||
// tirées, dont le contre-exemple rétréci tient en quelques opérations.
|
||||
// tirées, dont le contre-exemple rétréci tient en quelques opérations ; et
|
||||
// la découpe du plan en pages, dont les tuiles se juxtaposent à l'égalité
|
||||
// exacte sur des englobants, papiers et seuils tirés.
|
||||
const CHARGEURS_DE_FAST_CHECK = [
|
||||
{ fichier: 'src/moteur/plafond.test.js', raison: 'la formule du plafond, arbitrée par énumération exhaustive' },
|
||||
{ fichier: 'src/moteur/plafond.long.test.js', raison: 'la grande grille du plafond, sous la même graine écrite' },
|
||||
|
|
@ -3659,6 +3661,10 @@ const CHARGEURS_DE_FAST_CHECK = [
|
|||
fichier: 'src/stockage/journal.long.test.js',
|
||||
raison: 'chaque instant d’une séance tirée se restitue tel qu’il s’est écrit',
|
||||
},
|
||||
{
|
||||
fichier: 'src/geometrie/pages.test.js',
|
||||
raison: 'les tuiles de la découpe, contiguës à l’égalité exacte, sur des englobants tirés',
|
||||
},
|
||||
];
|
||||
|
||||
// Relevé des chargements de fast-check, sous-chemins compris, dans les
|
||||
|
|
|
|||
Loading…
Reference in a new issue