Files
houseplan-card/test/pdf-scene.test.mjs

826 lines
43 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import test from 'node:test';
import assert from 'node:assert/strict';
import { buildPdfPage } from '../test-build/pdf/pdf-scene.js';
import { pdfCommandBounds, pdfSceneFits } from '../test-build/pdf/pdf-layout.js';
import {
dedupeOppositeDimensionEdges, dimensionEpsilonUnits, stableDimensionEdges,
} from '../test-build/pdf/pdf-dimensions.js';
import { makeLargeHouseFixture } from '../demo/fixtures/large-house.mjs';
import { fixtureWallKey } from '../demo/fixtures/wall-key.mjs';
import { floorMinusBodies, geometryArea, physicalBodyParts } from '../test-build/physical-geometry.js';
import {
geometryOpenings, geometryPartitionOpeningCuts, geometryRoomOpeningInputs,
} from '../test-build/plan-geometry-preflight.js';
import { spaceModels, GRID_PITCH, GRID_STEP_N, NORM_W } from '../test-build/space-geometry.js';
import { innerContourForRoom, wallBodiesGeometry } from '../test-build/wall-thickness.js';
import { resolveZeroWalls } from '../test-build/zero-walls.js';
const MM = 72 / 25.4;
const WALL_FILL = [127 / 255, 127 / 255, 127 / 255];
const PDF_SCALES = [20, 25, 50, 75, 100, 150, 200, 250, 500];
const rawSpace = {
id: 'ground', title: 'Первый этаж', cell_cm: 5, view_box: [0, 0, 1, 1],
rooms: [{ id: 'room', name: 'Кухня', area: null,
poly: [[0.1, 0.1], [0.8, 0.1], [0.8, 0.7], [0.1, 0.7]],
wall_ids: ['top', 'right', 'bottom', 'left'] }],
walls: [],
wall_segments: [
{ id: 'top', a: [0.1, 0.1], b: [0.8, 0.1], cm: 0 },
{ id: 'right', a: [0.8, 0.1], b: [0.8, 0.7], cm: 0 },
{ id: 'bottom', a: [0.8, 0.7], b: [0.1, 0.7], cm: 0 },
{ id: 'left', a: [0.1, 0.7], b: [0.1, 0.1], cm: 0 },
],
partitions: [], wall_columns: [], decor: [], settings: {},
openings: [{ id: 'door', type: 'door', x: 0.45, y: 0.1, angle: 0, length: 0.12 }],
};
const config = { model_version: 9, spaces: [rawSpace], markers: [], settings: {} };
const space = spaceModels(config)[0];
const t = (key, vars) => key === 'pdf.scale' ? `Scale 1:${vars.n}` : key;
const page = (options) => buildPdfPage({
config, rawSpace, space, layout: {}, options, imperial: false,
cardTitle: 'House', version: 'test', now: new Date('2026-09-07T00:00:00Z'), t,
});
const sharedRaw = (() => {
const segments = [
{ id: 'tl', a: [0.1, 0.1], b: [0.5, 0.1], cm: 15 },
{ id: 'mid', a: [0.5, 0.1], b: [0.5, 0.8], cm: 15 },
{ id: 'bl', a: [0.5, 0.8], b: [0.1, 0.8], cm: 15 },
{ id: 'left', a: [0.1, 0.8], b: [0.1, 0.1], cm: 15 },
{ id: 'tr', a: [0.5, 0.1], b: [0.9, 0.1], cm: 15 },
{ id: 'right', a: [0.9, 0.1], b: [0.9, 0.8], cm: 15 },
{ id: 'br', a: [0.9, 0.8], b: [0.5, 0.8], cm: 15 },
];
return {
id: 'shared', title: 'Shared wall', cell_cm: 5, view_box: [0, 0, 1, 1],
rooms: [
{ id: 'left-room', name: 'Left', area: null,
poly: [[0.1, 0.1], [0.5, 0.1], [0.5, 0.8], [0.1, 0.8]],
wall_ids: ['tl', 'mid', 'bl', 'left'] },
{ id: 'right-room', name: 'Right', area: null,
poly: [[0.5, 0.1], [0.9, 0.1], [0.9, 0.8], [0.5, 0.8]],
wall_ids: ['tr', 'right', 'br', 'mid'] },
],
wall_segments: segments,
walls: segments.map((segment) => ({
key: fixtureWallKey(segment.a, segment.b), cm: segment.cm,
a: segment.a, b: segment.b,
})),
openings: [], partitions: [], wall_columns: [], decor: [], settings: {},
};
})();
const sharedPage = (options, extra = {}) => {
const sharedConfig = {
model_version: 10, spaces: [sharedRaw],
markers: [{ id: 'secret-device-marker', binding: 'entity:light.secret' }], settings: {},
};
return buildPdfPage({
config: sharedConfig, rawSpace: sharedRaw, space: spaceModels(sharedConfig)[0], layout: {},
options, imperial: false, cardTitle: 'House', version: 'test',
now: new Date('2026-09-07T00:00:00Z'), t, ...extra,
});
};
const buildRawPage = (raw, options, {
settings = {}, translator = t, layout = {}, ...extra
} = {}) => {
const localConfig = { model_version: 10, spaces: [raw], markers: [], settings };
return buildPdfPage({
config: localConfig, rawSpace: raw, space: spaceModels(localConfig)[0], layout,
options, imperial: false, cardTitle: 'House', version: 'test',
now: new Date('2026-09-07T00:00:00Z'), t: translator, ...extra,
});
};
const commandBox = (command) => command.rings.flat().reduce((box, [x, y]) => ({
minX: Math.min(box.minX, x), minY: Math.min(box.minY, y),
maxX: Math.max(box.maxX, x), maxY: Math.max(box.maxY, y),
}), { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity });
const pointInRing = ([x, y], ring) => {
let inside = false;
for (let index = 0, previous = ring.length - 1; index < ring.length; previous = index++) {
const [xi, yi] = ring[index];
const [xj, yj] = ring[previous];
if ((yi > y) !== (yj > y) && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) {
inside = !inside;
}
}
return inside;
};
const pointInEvenOddPath = (point, rings) => rings.reduce(
(inside, ring) => inside !== pointInRing(point, ring), false,
);
const assertFitsAndIsCentered = (output) => {
const tolerance = 0.5 * MM + 1e-8;
assert.ok(output.sceneBounds.minX >= output.planField.minX - tolerance);
assert.ok(output.sceneBounds.minY >= output.planField.minY - tolerance);
assert.ok(output.sceneBounds.maxX <= output.planField.maxX + tolerance);
assert.ok(output.sceneBounds.maxY <= output.planField.maxY + tolerance);
const sceneCenterX = (output.sceneBounds.minX + output.sceneBounds.maxX) / 2;
const sceneCenterY = (output.sceneBounds.minY + output.sceneBounds.maxY) / 2;
const fieldCenterX = (output.planField.minX + output.planField.maxX) / 2;
const fieldCenterY = (output.planField.minY + output.planField.maxY) / 2;
assert.ok(Math.abs(sceneCenterX - fieldCenterX) <= tolerance,
'the complete scene is horizontally centered in the plan field');
assert.ok(Math.abs(sceneCenterY - fieldCenterY) <= tolerance,
'the complete scene is vertically centered in the plan field');
};
const boxesOverlap = (a, b) => a.minX < b.maxX && a.maxX > b.minX
&& a.minY < b.maxY && a.maxY > b.minY;
const polygonRaw = (id, poly, { cellCm = 5, wallCm = 15, name = id } = {}) => {
const wallSegments = poly.map((a, index) => ({
id: `${id}-wall-${index}`, a, b: poly[(index + 1) % poly.length], cm: wallCm,
}));
return {
id, title: id, cell_cm: cellCm, view_box: [0, 0, 2, 2],
rooms: [{ id: `${id}-room`, name, area: null, poly,
wall_ids: wallSegments.map((wall) => wall.id) }],
walls: wallSegments.map((wall) => ({
key: fixtureWallKey(wall.a, wall.b), cm: wall.cm, a: wall.a, b: wall.b,
})),
wall_segments: wallSegments, openings: [], partitions: [],
wall_columns: [], decor: [], settings: {},
};
};
test('PDF scene includes architecture and respects names/dimensions switches', () => {
const full = page({ dimensions: true, roomNames: true, decor: false, backdrop: false });
const minimal = page({ dimensions: false, roomNames: false, decor: false, backdrop: false });
const fullText = full.commands.filter((command) => command.kind === 'text').map((command) => command.text);
const minimalText = minimal.commands.filter((command) => command.kind === 'text').map((command) => command.text);
assert.ok(fullText.includes('Кухня'));
assert.ok(fullText.some((value) => /m²/.test(value)));
assert.ok(!minimalText.includes('Кухня'));
assert.ok(!minimalText.some((value) => /m²/.test(value)));
assert.ok(!minimalText.some((value) => value !== '1 m'
&& /^[-+]?\d+(?:[.,]\d+)?\s(?:m|cm|ft|in)$/.test(value)));
assert.ok(full.commands.some((command) => command.kind === 'line' && command.dash),
'zero-thickness walls stay dashed');
assert.ok(!full.commands.some((command) => command.kind === 'path'),
'a valid zero-wall-only space does not receive a second solid contour fallback');
assert.ok(full.commands.some((command) => command.kind === 'line' && !command.dash),
'door leaf/arc is represented by vector lines');
});
test('#663 PDF renders both stair symbols and uses stair-adjusted clean area', () => {
const withStairs = structuredClone(rawSpace);
withStairs.stairs = [
{
id: 'straight', kind: 'straight', x: 0.4, y: 0.4, angle: 0,
direction: 'forward', length: 0.2, width: 0.1, target_space_id: 'upper',
},
{
id: 'spiral', kind: 'spiral', x: 0.68, y: 0.5, angle: 30,
direction: 'counterclockwise', radius: 0.06, target_space_id: null,
},
];
const output = buildRawPage(withStairs, {
dimensions: true, roomNames: false, decor: false, backdrop: false,
});
const stairOutlines = output.commands.filter((command) => command.kind === 'path'
&& (command.rings[0]?.length === 4 || command.rings[0]?.length === 64));
assert.equal(stairOutlines.length, 2, 'stairs are architectural output even when decor is hidden');
assert.equal(output.commands.filter((command) => command.kind === 'vector').length, 2,
'each stair keeps its direction arrow');
const area = output.commands.find((command) => command.kind === 'text'
&& /m²/.test(command.text));
assert.ok(area, 'room area is printed');
// Base room: 700×600 render units. The two non-overlapping footprints are
// 200×100 and a 60-unit-radius circle; cmPerUnit is 1.2.
const expectedM2 = (420_000 - 20_000 - Math.PI * 60 ** 2) * 1.2 ** 2 / 10_000;
assert.match(area.text, new RegExp(`^${expectedM2.toFixed(1).replace('.', '[.,]')}\\s*m²$`));
});
test('shared wall architecture is emitted once and devices never enter the PDF scene', () => {
const output = sharedPage({ dimensions: false, roomNames: false, decor: false, backdrop: false });
const architecture = output.commands.filter((command) => command.kind === 'path');
assert.equal(architecture.length, 1);
assert.ok(!output.commands.some((command) => command.kind === 'text'
&& command.text.includes('secret-device-marker')));
});
test('dimensions switch removes the external chain from physical walls', () => {
const texts = (dimensions) => sharedPage({
dimensions, roomNames: false, decor: false, backdrop: false,
}).commands.filter((command) => command.kind === 'text').map((command) => command.text)
.filter((value) => value !== '1 m' && /^[-+]?\d+(?:[.,]\d+)?\s(?:m|cm|ft|in)$/.test(value));
assert.ok(texts(true).length > 0, 'the thick outer wall has a dimension chain');
assert.deepEqual(texts(false), [], 'the option removes every dimension value');
});
test('stepped exterior keeps the complete reconstructable dimension chain', () => {
const step = [
[0.1, 0.1], [0.7, 0.1], [0.7, 0.3], [0.8, 0.3],
[0.8, 0.6], [0.7, 0.6], [0.7, 0.9], [0.1, 0.9],
];
const externalLabels = (poly) => {
const raw = polygonRaw('stepped', poly, { cellCm: 5, wallCm: 15, name: '' });
const output = buildRawPage(raw,
{ dimensions: true, roomNames: false, decor: false, backdrop: false });
const architecture = output.commands.find((command) => command.kind === 'path' && command.fill);
assert.ok(architecture, 'the stepped physical wall body reaches the PDF scene');
const box = commandBox(architecture);
const inPlanField = (command) => command.x >= output.planField.minX
&& command.x <= output.planField.maxX && command.y >= output.planField.minY
&& command.y <= output.planField.maxY;
const outsideWallBox = (command) => command.x < box.minX || command.x > box.maxX
|| command.y < box.minY || command.y > box.maxY;
return output.commands.filter((command) => command.kind === 'text'
&& /^\d+(?:[.,]\d+)?\s*(?:m|cm)$/.test(command.text)
&& inPlanField(command) && outsideWallBox(command));
};
for (const poly of [step, [...step].reverse()]) {
const labels = externalLabels(poly);
const values = labels.map((command) => command.text);
for (const required of ['1.20 m', '2.40 m', '3.60 m', '3.75 m']) {
assert.ok(values.includes(required),
`${required} remains in the reconstructable protrusion chain`);
}
assert.equal(values.filter((value) => value === '1.20 m').length, 1,
'the equal upper/lower protrusion depths are locally deduplicated');
assert.equal(values.length, 6,
'the four protrusion dimensions coexist with the overall width and height');
assert.ok(labels.every((command) => Math.abs(command.angle % 90) < 1e-8),
'the exterior chain contains no diagonal labels');
}
});
test('backdrop is below physical architecture and disappears with its option', () => {
const raster = {
id: 'backdrop', bytes: new Uint8Array([0xff, 0xd8, 0xff, 0xd9]),
width: 1, height: 1, x: 0, y: 0, drawWidth: 1000, drawHeight: 1000,
opacity: 0.6,
};
const visible = sharedPage(
{ dimensions: false, roomNames: false, decor: false, backdrop: true },
{ rasters: [raster] },
);
const imageIndex = visible.commands.findIndex((command) => command.kind === 'image');
const wallIndex = visible.commands.findIndex((command) => command.kind === 'path');
assert.ok(imageIndex >= 0 && wallIndex > imageIndex);
assert.ok(!sharedPage({ dimensions: false, roomNames: false, decor: false, backdrop: false })
.commands.some((command) => command.kind === 'image'));
});
test('physical bodies use exact grey, page-anchored 45 degree hatch and leave zero walls unfilled', () => {
const materialRaw = structuredClone(sharedRaw);
materialRaw.id = 'materials';
materialRaw.title = 'Materials';
materialRaw.view_box = [0, 0, 3, 1];
materialRaw.partitions = [{ id: 'partition', a: [1.1, 0.2], b: [1.5, 0.2], cm: 12 }];
materialRaw.wall_columns = [
{ id: 'square-column', shape: 'square', center: [1.9, 0.3], cm: 35, angle: 30 },
{ id: 'round-column', shape: 'circle', center: [2.3, 0.3], cm: 40 },
];
const zeroPoly = [[2.55, 0.1], [2.8, 0.1], [2.8, 0.4], [2.55, 0.4]];
const zeroWalls = zeroPoly.map((a, index) => ({
id: `zero-${index}`, a, b: zeroPoly[(index + 1) % zeroPoly.length], cm: 0,
}));
materialRaw.rooms.push({
id: 'zero-room', name: 'Zero', area: null, poly: zeroPoly,
wall_ids: zeroWalls.map((wall) => wall.id),
});
materialRaw.wall_segments.push(...zeroWalls);
const output = buildRawPage(materialRaw,
{ dimensions: false, roomNames: false, decor: false, backdrop: false });
const materialPaths = output.commands.filter((command) => command.kind === 'path' && command.fill);
assert.ok(materialPaths.length > 0, 'walls, partition and both column shapes form physical bodies');
for (const command of materialPaths) {
assert.deepEqual(command.fill, WALL_FILL);
assert.ok(command.hatch, 'every physical body has hatch metadata');
assert.ok(Math.abs(command.hatch.width - 0.18 * MM) < 1e-10);
assert.ok(command.hatch.lines.length > 2);
for (const [a, b] of command.hatch.lines) {
assert.ok(Math.abs((b[0] - a[0]) - (b[1] - a[1])) < 1e-8,
'hatch lines stay at 45 degrees in paper coordinates');
assert.equal(a[1], 0, 'hatch phase is anchored at the page top, not each body');
assert.equal(b[1], output.height, 'hatch spans the complete page before clipping');
}
const hatchStep = command.hatch.lines[1][0][0] - command.hatch.lines[0][0][0];
assert.ok(Math.abs(hatchStep - 3 * MM * Math.SQRT2) < 1e-8,
'perpendicular hatch spacing is exactly 3 mm');
}
const zeroLines = output.commands.filter((command) => command.kind === 'line' && command.dash);
assert.equal(zeroLines.length, 4, 'the zero-thickness room remains four dashed lines');
});
test('wall fill and even-odd hatch clipping preserve a clean opening tunnel', () => {
const closed = sharedPage({ dimensions: false, roomNames: false, decor: false, backdrop: false });
const openedRaw = structuredClone(sharedRaw);
openedRaw.openings = [{
id: 'middle-door', type: 'door', x: 0.5, y: 0.45, angle: 90, length: 0.2,
}];
const opened = buildRawPage(openedRaw,
{ dimensions: false, roomNames: false, decor: false, backdrop: false });
const closedWall = closed.commands.find((command) => command.kind === 'path' && command.fill);
const openedWall = opened.commands.find((command) => command.kind === 'path' && command.fill);
assert.ok(closedWall && openedWall);
const openedBox = commandBox(openedWall);
const tunnelCenter = [
(openedBox.minX + openedBox.maxX) / 2,
(openedBox.minY + openedBox.maxY) / 2,
];
assert.equal(pointInEvenOddPath(tunnelCenter, closedWall.rings), true,
'the shared wall is solid before an opening is projected');
assert.equal(pointInEvenOddPath(tunnelCenter, openedWall.rings), false,
'the opening is a real even-odd hole, so neither fill nor clipped hatch enters the tunnel');
});
test('dimension dedupe is local: opposite sides collapse but equal dimensions in both rooms survive', () => {
const output = sharedPage({ dimensions: true, roomNames: false, decor: false, backdrop: false });
const counts = new Map();
for (const command of output.commands.filter((candidate) => candidate.kind === 'text')) {
counts.set(command.text, (counts.get(command.text) || 0) + 1);
}
assert.equal(counts.get('8.55 m'), 1, 'one exterior vertical dimension remains');
assert.equal(counts.get('9.75 m'), 1, 'one exterior horizontal dimension remains');
assert.equal(counts.get('8.25 m'), 2, 'equal vertical values in separate rooms are not globally deduped');
assert.equal(counts.get('4.65 m'), 2, 'equal horizontal values in separate rooms are not globally deduped');
});
test('whole dimension lane keeps grouped labels centered with 1 mm clearance and no tangent jitter', () => {
const laneRaw = polygonRaw('lane', [
[0.1, 0.1], [0.9, 0.1], [0.9, 0.3],
[0.5, 0.3], [0.5, 0.9], [0.1, 0.9],
], { wallCm: 0, name: 'COLLISION LABEL' });
const options = { dimensions: true, roomNames: true, decor: false, backdrop: false };
const build = (y) => buildRawPage(laneRaw, options, {
layout: { 'rl_lane-room': { s: 'lane', x: 0.7, y } },
});
const control = build(0.20);
const collision = build(0.27);
const placements = (output) => {
const labels = output.commands.filter((command) => command.kind === 'text'
&& command.text === '4.80 m').sort((a, b) => a.x - b.x);
const walls = output.commands.filter((command) => command.kind === 'line' && command.dash
&& Math.abs(command.points[0][1] - command.points.at(-1)[1]) < 1e-8);
assert.equal(labels.length, 2, 'both equal-length horizontal edges remain dimensioned');
return labels.map((label) => {
const wall = walls.reduce((nearest, candidate) => {
const midpoint = (candidate.points[0][0] + candidate.points.at(-1)[0]) / 2;
const distance = Math.abs(midpoint - label.x);
return !nearest || distance < nearest.distance ? { candidate, distance } : nearest;
}, null);
assert.ok(wall && wall.distance < 1e-8,
'the dimension label stays centered on its own edge without tangent drift');
const box = pdfCommandBounds([label]);
return {
label,
box,
clearance: wall.candidate.points[0][1] - box.maxY,
};
});
};
const controlPlacements = placements(control);
const collisionPlacements = placements(collision);
const laneMoves = collisionPlacements.map((entry, index) =>
entry.clearance - controlPlacements[index].clearance);
assert.ok(laneMoves[0] >= 3 * MM - 1e-8, 'the collision advances the shared lane');
assert.ok(Math.abs(laneMoves[0] - laneMoves[1]) < 1e-8,
'one collision moves every label in the normal group by the same whole-lane amount');
for (const entry of collisionPlacements) {
assert.ok(entry.clearance >= MM - 1e-8,
'every moved label preserves at least 1 mm clearance from its wall');
}
const occupied = collision.commands.filter((command) => command.kind === 'text'
&& (command.text === 'COLLISION LABEL' || /m²$/.test(command.text)))
.map((command) => pdfCommandBounds([command]));
assert.ok(occupied.length >= 2, 'the fixture includes the room name and area obstacle');
for (const entry of collisionPlacements) {
assert.ok(occupied.every((box) => !boxesOverlap(entry.box, box)),
'the whole-lane move clears room labels without introducing a text collision');
}
});
test('vertical internal dimension uses the rendered font box and preserves 1 mm wall clearance', () => {
const verticalRaw = polygonRaw('vertical-clearance', [
[0.1, 0.1], [0.5, 0.1], [0.5, 0.9], [0.1, 0.9],
], { wallCm: 0, name: '' });
const output = buildRawPage(verticalRaw,
{ dimensions: true, roomNames: false, decor: false, backdrop: false });
const areaIndex = output.commands.findIndex((command) => command.kind === 'text'
&& /m²$/.test(command.text));
const label = output.commands.slice(areaIndex + 1).find((command) => command.kind === 'text'
&& command.angle === 90 && /^\d+(?:[.,]\d+)?\sm$/.test(command.text));
assert.ok(label, 'the fixture emits an internal vertical dimension');
const walls = output.commands.filter((command) => command.kind === 'line' && command.dash
&& Math.abs(command.points[0][0] - command.points.at(-1)[0]) < 1e-8);
const wall = walls.reduce((nearest, candidate) => {
const midpoint = (candidate.points[0][1] + candidate.points.at(-1)[1]) / 2;
const distance = Math.abs(midpoint - label.y);
return !nearest || distance < nearest.distance ? { candidate, distance } : nearest;
}, null);
assert.ok(wall && wall.distance < 1e-8,
'the vertical label remains centred on the tangent of its own wall');
const wallX = wall.candidate.points[0][0];
const bounds = pdfCommandBounds([label]);
const clearance = label.x > wallX ? bounds.minX - wallX : wallX - bounds.maxX;
assert.ok(clearance >= MM - 1e-8,
`the full ascent/descent box keeps 1 mm from the wall (got ${clearance / MM} mm)`);
});
test('blocked rectangular dimension is omitted instead of using an unsafe text fallback', () => {
const narrowRaw = polygonRaw('blocked-rectangle', [
[0.1, 0.1], [0.105, 0.1], [0.105, 0.9], [0.1, 0.9],
], { wallCm: 0, name: '' });
const output = buildRawPage(narrowRaw,
{ dimensions: true, roomNames: false, decor: false, backdrop: false });
const dimensionValues = output.commands.filter((command) => command.kind === 'text')
.map((command) => command.text)
.filter((value) => /^[-+]?\d+(?:[.,]\d+)?\s(?:m|cm|ft|in)$/.test(value));
assert.ok(!dimensionValues.includes('9.60 m'),
'a label that cannot fit between the two walls is never printed through them');
assert.ok(!output.commands.some((command) => command.kind === 'text' && /^R\d+$/.test(command.text)),
'a rectangle does not invent an ambiguous numbered callout');
});
test('scene dimensions include a near-axis edge but omit true diagonals', () => {
// The first edge is 0.2456 degrees from horizontal (inside the 0.25 degree
// contract); the other two edges are genuine diagonals.
const nearAxisTriangle = polygonRaw('near-axis', [[0.1, 0.1], [0.8, 0.103], [0.5, 0.7]],
{ wallCm: 0 });
const output = buildRawPage(nearAxisTriangle,
{ dimensions: true, roomNames: false, decor: false, backdrop: false });
const dimensionLabels = output.commands.filter((command) => command.kind === 'text'
&& command.text !== '1 m' && /^\d+(?:[.,]\d+)?\sm$/.test(command.text));
assert.deepEqual(dimensionLabels.map(({ text, angle }) => ({ text, angle })),
[{ text: '8.40 m', angle: 0 }]);
assert.equal(output.commands.filter((command) => command.kind === 'line' && command.dash).length, 3,
'all three physical zero-wall edges are still rendered even though diagonals are not dimensioned');
});
test('thick near-axis outer face retains its projected external dimension', () => {
const thickNearAxis = polygonRaw('thick-near-axis', [
[0.1, 0.1], [0.8, 0.103], [0.5, 0.7],
], { wallCm: 15, name: '' });
const output = buildRawPage(thickNearAxis,
{ dimensions: true, roomNames: false, decor: false, backdrop: false });
const values = output.commands.filter((command) => command.kind === 'text')
.map((command) => command.text);
assert.ok(values.includes('8.66 m'),
'the near-axis physical outer face keeps its mandatory projected dimension');
});
test('opposite dedupe keeps the thick near-axis side with a safe external lane', () => {
const pairedNearAxis = polygonRaw('paired-near-axis', [
[0.1, 0.1], [0.9, 0.103], [0.9, 0.8], [0.1, 0.8],
], { wallCm: 15, name: '' });
pairedNearAxis.view_box = [0, -0.1, 1, 1];
pairedNearAxis.wall_columns = [{
id: 'near-top-obstacle', shape: 'square', center: [0.5, 0.05], cm: 100, angle: 0,
}];
const output = buildRawPage(pairedNearAxis,
{ dimensions: true, roomNames: false, decor: false, backdrop: false });
const external = output.commands.find((command) => command.kind === 'text'
&& command.text === '9.75 m');
assert.ok(external, 'the paired physical outer face keeps one external dimension');
assert.ok(external.y > (output.planField.minY + output.planField.maxY) / 2,
'the side with a clear base lane wins instead of pushing the blocked side far away');
});
test('actual annotated scene bbox selects portrait/landscape, fits and centers at standard scale', () => {
const wide = polygonRaw('wide', [[0.05, 0.05], [1.85, 0.05], [1.85, 0.15], [0.05, 0.15]],
{ name: 'Wide' });
const tall = polygonRaw('tall', [[0.05, 0.05], [0.15, 0.05], [0.15, 1.85], [0.05, 1.85]],
{ name: 'Tall' });
const squareWithFixedAnnotations = polygonRaw('square',
[[0.1, 0.1], [0.9, 0.1], [0.9, 0.9], [0.1, 0.9]],
{ wallCm: 0, name: 'Square' });
const options = { dimensions: true, roomNames: true, decor: false, backdrop: false };
const widePage = buildRawPage(wide, options);
const tallPage = buildRawPage(tall, options);
const squarePage = buildRawPage(squareWithFixedAnnotations, options);
assert.ok(widePage.width > widePage.height, 'wide annotated scene selects landscape A4');
assert.ok(tallPage.height > tallPage.width, 'tall annotated scene selects portrait A4');
assert.ok(squarePage.width > squarePage.height,
'actual fixed-size annotations break a raw square tie in favour of the better-filled field');
assert.equal(widePage.scale, 100, 'wide fixture keeps the largest fitting standard scale');
assert.equal(tallPage.scale, 100, 'tall fixture keeps the largest fitting standard scale');
assert.ok(PDF_SCALES.includes(widePage.scale) && PDF_SCALES.includes(tallPage.scale));
assert.ok(widePage.commands.some((command) => command.kind === 'text'
&& command.text === 'Wide'));
assert.ok(tallPage.commands.some((command) => command.kind === 'text'
&& command.text === 'Tall'));
assertFitsAndIsCentered(widePage);
assertFitsAndIsCentered(tallPage);
assertFitsAndIsCentered(squarePage);
});
// #530 заменяет прежний «unprintable fixed callouts fail closed»: у страницы
// больше нет фиксированного блока, который мог не поместиться. Отказ остаётся
// только там, где не помещается сама архитектура (#53, тест ниже), а подпись,
// которой не хватает места, просто не печатается.
test('#530: a label that cannot fit is dropped, and the page is still produced', () => {
const dense = polygonRaw('fixed-callout-overflow', [
[0.1, 0.1], [0.14, 0.1], [0.14, 0.9], [0.128, 0.9],
[0.128, 0.3], [0.112, 0.3], [0.112, 0.9], [0.1, 0.9],
], { wallCm: 0, name: 'X'.repeat(1000) });
const built = buildRawPage(dense,
{ dimensions: true, roomNames: true, decor: false, backdrop: false });
const texts = built.commands.filter((command) => command.kind === 'text')
.map((command) => command.text);
assert.ok(!texts.some((value) => value.includes('X'.repeat(50))),
'a name that does not fit inside the room is omitted, not printed through the walls');
assert.ok(!texts.some((value) => /^R\d+/.test(value)),
'no numbered callout survives anywhere');
assert.ok(pdfSceneFits(built.sceneBounds, built.planField, 0.5 * MM),
'the page still fits its field');
});
test('oversized architecture brackets a larger printable scale and still returns a fitted page', () => {
const oversized = polygonRaw('fallback-scale', [
[0, 0], [50, 0], [50, 1], [0, 1],
], { wallCm: 0, name: '' });
const output = buildRawPage(oversized,
{ dimensions: false, roomNames: false, decor: false, backdrop: false });
assert.ok(output.scale > 500, 'the unbounded-space path selects a larger denominator');
assert.equal(output.scale % 50, 0, 'fallback denominators retain the established 50-step contract');
assertFitsAndIsCentered(output);
});
// #530 убрал единственный блок постоянного размера, который мог не поместиться
// на лист. Отказ при этом не исчез: архитектура сама может не влезть ни на одном
// представимом знаменателе — это последний случай, когда листа не будет вовсе.
test('#530: architecture too large for any denominator still fails closed', () => {
const impossible = polygonRaw('impossible-scale', [
[0, 0], [50000, 0], [50000, 1], [0, 1],
], { wallCm: 0, name: '' });
assert.throws(() => buildRawPage(impossible,
{ dimensions: false, roomNames: false, decor: false, backdrop: false }),
/pdf\.failed/, 'a plan that fits at no denominator is refused, not returned clipped');
});
test('rotated raster bounds do not reject the tighter fitting page orientation', () => {
const rasterRaw = polygonRaw('rotated-raster', [
[0.1, 0.1], [0.2, 0.1], [0.2, 0.2], [0.1, 0.2],
], { wallCm: 0, name: '' });
const raster = {
id: 'tall-after-rotation', bytes: new Uint8Array([0xff, 0xd8, 0xff, 0xd9]),
width: 1, height: 1, x: 0, y: 0, drawWidth: 4000, drawHeight: 100, angle: 90,
};
const output = buildRawPage(rasterRaw,
{ dimensions: false, roomNames: false, decor: false, backdrop: true },
{ rasters: [raster] });
assert.ok(output.height > output.width,
'the 90-degree raster is evaluated as tall and selects portrait A4');
assert.equal(output.scale, 250,
'rotated architecture prefilter keeps the tight standard 1:250 candidate');
assertFitsAndIsCentered(output);
});
test('PDF scene integrates the vector compass and never restores the architectural legend', () => {
const translationCalls = [];
const translator = (key, vars) => {
translationCalls.push(key);
return key === 'pdf.scale' ? `Scale 1:${vars.n}` : key;
};
const options = { dimensions: true, roomNames: true, decor: false, backdrop: false };
const withoutNorth = buildRawPage(sharedRaw, options, { translator });
const withNorth = buildRawPage(sharedRaw, options, {
settings: { north_deg: 90 }, translator,
});
assert.equal(withoutNorth.commands.filter((command) => command.kind === 'vector').length, 0);
const compass = withNorth.commands.filter((command) => command.kind === 'vector');
assert.equal(compass.length, 1);
assert.deepEqual(compass[0].fill, [0.08, 0.08, 0.08]);
assert.equal(compass[0].fillRule, 'evenodd');
assert.ok(compass[0].ops.filter((operation) => operation.op === 'M').length >= 2,
'both canonical compass paths reach the scene');
const texts = withNorth.commands.filter((command) => command.kind === 'text')
.map((command) => command.text);
assert.ok(texts.includes('pdf.north'));
assert.ok(texts.some((value) => value === '2026-09-07 · House Plan vtest'));
assert.ok(texts.some((value) => value.startsWith('Scale 1:')));
assert.ok(!texts.some((value) => value.startsWith('pdf.legend.')
|| value.toLowerCase().includes('wall · door · window')));
assert.ok(!translationCalls.some((key) => key.startsWith('pdf.legend.')),
'removed legend translations are never requested');
});
// #530. Прежняя редакция требовала обратного: каждое неукороченное ребро
// обязано было сохранить значение — прямо на плане или строкой нумерованного
// столбца сбоку. Столбец стоил целого шага масштаба (124 pt ширины поля) и
// переворачивал лист, а читался отдельно от чертежа; владелец отказался от
// него сознательно. Теперь контракт такой: печатается то, что помещается,
// каждое значение печатается один раз, столбца и меток нет вовсе.
test('#530: dense non-rectangular rooms print what fits, with no callout column', () => {
const denseRaw = structuredClone(rawSpace);
denseRaw.id = 'dense';
denseRaw.title = 'Dense';
denseRaw.rooms[0].id = 'dense-room';
denseRaw.rooms[0].name = 'A';
denseRaw.rooms[0].poly = [
[0.1, 0.1], [0.14, 0.1], [0.14, 0.9], [0.128, 0.9],
[0.128, 0.3], [0.112, 0.3], [0.112, 0.9], [0.1, 0.9],
];
denseRaw.wall_segments = denseRaw.rooms[0].poly.map((a, index, ring) => ({
id: `dense-${index}`, a, b: ring[(index + 1) % ring.length], cm: 0,
}));
denseRaw.rooms[0].wall_ids = denseRaw.wall_segments.map((wall) => wall.id);
denseRaw.openings = [];
const denseConfig = { model_version: 9, spaces: [denseRaw], markers: [], settings: {} };
const output = buildPdfPage({
config: denseConfig, rawSpace: denseRaw, space: spaceModels(denseConfig)[0], layout: {},
options: { dimensions: true, roomNames: true, decor: false, backdrop: false },
imperial: false, cardTitle: 'House', version: 'test',
now: new Date('2026-09-07T00:00:00Z'), t,
});
const texts = output.commands.filter((command) => command.kind === 'text')
.map((command) => command.text);
assert.ok(!texts.includes('pdf.internal_dimensions'), 'no callout section title');
assert.ok(!texts.some((value) => /^R\d+$/.test(value)), 'no callout marks on the plan');
assert.ok(!texts.some((value) => /^R\d+ .+: .+/.test(value)), 'no callout rows');
const printed = texts.filter((value) => value !== '1 m' && /^\d+(?:[.,]\d+)?\sm$/.test(value));
assert.ok(printed.length >= 1, 'the dimensions that fit are still printed');
assert.equal(new Set(printed).size, printed.length,
'a value is printed once: what used to go to the column is not duplicated on the plan');
});
// #530, свидетель случая владельца: квадратный дом ≈11 м, восемь комнат, три
// непрямоугольные. До правки такой дом печатался 1:100 на альбомном листе с
// десятью метками `R…` и столбцом сбоку — план занимал четверть поля. Тест
// держит три числа разом: масштаб, ориентацию и то, что подписей на плане
// стало БОЛЬШЕ, а не меньше, хотя столбец убрали.
const dachaRaw = (() => {
const seg = (id, a, b, cm = 15) => ({ id, a, b, cm });
const segs = [
seg('n', [0.05, 0.05], [0.95, 0.05]), seg('e', [0.95, 0.05], [0.95, 0.95]),
seg('s', [0.95, 0.95], [0.05, 0.95]), seg('w', [0.05, 0.95], [0.05, 0.05]),
seg('h1', [0.05, 0.45], [0.62, 0.45]), seg('v1', [0.62, 0.30], [0.62, 0.95]),
seg('h2', [0.62, 0.30], [0.95, 0.30]), seg('v2', [0.20, 0.45], [0.20, 0.95]),
seg('v3', [0.38, 0.45], [0.38, 0.75]), seg('h3', [0.20, 0.75], [0.38, 0.75]),
seg('h4', [0.62, 0.62], [0.95, 0.62]), seg('v4', [0.78, 0.30], [0.78, 0.62]),
];
return {
id: 'dacha', title: 'Ground floor', cell_cm: 5, view_box: [0, 0, 1, 1],
rooms: [
{ id: 'r1', name: 'Kitchen-living room', area: null,
poly: [[0.05, 0.05], [0.95, 0.05], [0.95, 0.30], [0.62, 0.30], [0.62, 0.45], [0.05, 0.45]],
wall_ids: ['n', 'e', 'h2', 'v1', 'h1', 'w'] },
{ id: 'r2', name: 'Sauna', area: null,
poly: [[0.05, 0.45], [0.20, 0.45], [0.20, 0.95], [0.05, 0.95]],
wall_ids: ['h1', 'v2', 's', 'w'] },
{ id: 'r3', name: 'Guest bathroom', area: null,
poly: [[0.20, 0.45], [0.38, 0.45], [0.38, 0.75], [0.20, 0.75]],
wall_ids: ['h1', 'v3', 'h3', 'v2'] },
{ id: 'r4', name: 'Boiler room', area: null,
poly: [[0.20, 0.75], [0.38, 0.75], [0.38, 0.95], [0.20, 0.95]],
wall_ids: ['h3', 'v3', 's', 'v2'] },
{ id: 'r5', name: 'Hallway', area: null,
poly: [[0.38, 0.45], [0.62, 0.45], [0.62, 0.95], [0.38, 0.95]],
wall_ids: ['h1', 'v1', 's', 'v3'] },
{ id: 'r6', name: 'Under-stair storage closet', area: null,
poly: [[0.62, 0.30], [0.78, 0.30], [0.78, 0.46], [0.70, 0.46], [0.70, 0.62], [0.62, 0.62]],
wall_ids: ['h2', 'v4', 'h4', 'v1'] },
{ id: 'r7', name: 'Guest bedroom', area: null,
poly: [[0.62, 0.62], [0.95, 0.62], [0.95, 0.95], [0.75, 0.95], [0.75, 0.86], [0.62, 0.86]],
wall_ids: ['h4', 'e', 's', 'v1'] },
{ id: 'r8', name: 'Pantry', area: null,
poly: [[0.78, 0.30], [0.95, 0.30], [0.95, 0.62], [0.90, 0.62], [0.90, 0.55],
[0.86, 0.55], [0.86, 0.48], [0.82, 0.48], [0.82, 0.40], [0.78, 0.40]],
wall_ids: ['h2', 'e', 'h4', 'v4'] },
],
walls: segs.map((wall) => ({
key: fixtureWallKey(wall.a, wall.b), a: wall.a, b: wall.b, cm: wall.cm,
})),
wall_segments: segs, partitions: [], wall_columns: [], decor: [], settings: {},
openings: [
{ id: 'd1', type: 'door', x: 0.5, y: 0.05, angle: 0, length: 0.06 },
{ id: 'd2', type: 'door', x: 0.5, y: 0.45, angle: 0, length: 0.06 },
{ id: 'w1', type: 'window', x: 0.95, y: 0.75, angle: 90, length: 0.08 },
],
};
})();
test('#530: a square house fills the sheet at the next standard scale, upright', () => {
const dachaConfig = { model_version: 9, spaces: [dachaRaw], markers: [], settings: {} };
const output = buildPdfPage({
config: dachaConfig, rawSpace: dachaRaw, space: spaceModels(dachaConfig)[0], layout: {},
options: { dimensions: true, roomNames: true, decor: false, backdrop: false },
imperial: false, cardTitle: 'House', version: 'test',
now: new Date('2026-09-11T00:00:00Z'), t,
});
assert.equal(output.scale, 75, 'the plan is drawn one standard step larger than before (#530)');
assert.ok(output.width < output.height, 'a square plan goes on an upright sheet');
const texts = output.commands.filter((command) => command.kind === 'text');
const values = texts.map((command) => command.text)
.filter((value) => value !== '1 m' && /^\d+(?:[.,]\d+)?\sm$/.test(value));
// Замер: 37 значений после правки против 35 до неё, при том что десять из
// прежних уезжали в столбец. Порог держит «не меньше, чем было».
assert.ok(values.length >= 36,
`every value that fits is printed on the drawing itself: ${values.length}`);
assert.ok(!texts.some((command) => /^R\d+/.test(command.text)), 'no callouts survive');
// Кегли: внутри плана уменьшены на четверть, хром страницы не тронут.
const sizes = [...new Set(texts.map((command) => command.size))].sort((a, b) => a - b);
assert.deepEqual(sizes, [5.25, 6, 6.75, 7, 8, 10, 14],
'in-plan labels are 5.25/6/6.75, page chrome stays 7/8/10/14');
});
test('decor toggle uses the canonical designer furniture vector path', () => {
const decorated = structuredClone(rawSpace);
decorated.decor = [{
id: 'sofa', kind: 'furniture', symbol: 'sofa',
x: 0.2, y: 0.3, w: 0.3, h: 0.15, angle: 30, width_cm: 2,
}];
const decoratedConfig = { model_version: 9, spaces: [decorated], markers: [], settings: {} };
const make = (decor) => buildPdfPage({
config: decoratedConfig, rawSpace: decorated, space: spaceModels(decoratedConfig)[0], layout: {},
options: { dimensions: false, roomNames: false, decor, backdrop: false },
imperial: false, cardTitle: 'House', version: 'test',
now: new Date('2026-09-07T00:00:00Z'), t,
});
assert.ok(make(true).commands.some((command) => command.kind === 'vector'
&& command.ops.some((operation) => operation.op === 'C')));
assert.ok(!make(false).commands.some((command) => command.kind === 'vector'));
const vectorFor = (symbol) => {
decorated.decor[0].symbol = symbol;
return make(true).commands.find((command) => command.kind === 'vector');
};
assert.deepEqual(vectorFor('cactus'), vectorFor('exercise'),
'saved 0.4.0 cactus exports the exercise drawing, not a placeholder rectangle');
assert.ok(vectorFor('bookshelf'));
assert.ok(vectorFor('shelf_floor'));
});
test('current 20-room large-house space builds from the visible geometry cache under 200 ms', () => {
// Здесь охраняется построение PDF из кеша видимой геометрии. Стресс-набор
// лестниц остаётся в общем large-house benchmark, чтобы этот узкий witness
// не смешивал два независимых бюджета (#663).
const fixture = makeLargeHouseFixture({ includeStairs: false });
const largeConfig = { ...fixture.config, model_version: 9 };
const largeRaw = largeConfig.spaces[0];
const largeSpace = spaceModels(largeConfig)[0];
assert.equal(largeSpace.rooms.length, 20);
const cellCm = largeRaw.cell_cm;
const zero = resolveZeroWalls(largeRaw, largeSpace, NORM_W, GRID_PITCH * 0.02);
const openings = geometryOpenings(largeRaw, largeSpace, cellCm, GRID_PITCH, NORM_W);
const cuts = geometryPartitionOpeningCuts(openings);
const extras = physicalBodyParts(
largeSpace, cellCm, GRID_PITCH, GRID_PITCH * 0.0002, cuts,
).all;
const roomOpenings = geometryRoomOpeningInputs(
openings, largeSpace, largeRaw.walls, zero.contour,
GRID_STEP_N, cellCm, GRID_PITCH, NORM_W,
);
const sharedWallGeometry = wallBodiesGeometry(
largeSpace.rooms, largeRaw.walls, zero.contour, roomOpenings,
GRID_STEP_N, cellCm, GRID_PITCH, NORM_W, extras,
);
const innerContours = new Map(largeSpace.rooms.map((room) => [room.id, innerContourForRoom(
largeSpace.rooms, room.id, largeRaw.walls, zero.contour, GRID_STEP_N,
cellCm, GRID_PITCH, NORM_W, sharedWallGeometry.roomGeom, sharedWallGeometry.multiWallNodes,
)]));
const roomAreas = new Map(largeSpace.rooms.map((room) => {
const contour = innerContours.get(room.id);
const cleaned = extras.length ? floorMinusBodies(contour, extras) : null;
const area = cleaned ? geometryArea(cleaned) : Math.abs(contour.reduce((sum, point, index) => {
const next = contour[(index + 1) % contour.length];
return sum + point[0] * next[1] - next[0] * point[1];
}, 0) / 2);
return [room.id, area];
}));
// The full suite runs test files concurrently on CI. Wall time counts periods when
// this worker is descheduled, while process.cpuUsage() also includes background
// V8 threads in this test-file process. Measure only the current JS worker thread
// so the agreed 200 ms product budget is neither weakened nor made dependent on
// unrelated parallel work.
const started = process.threadCpuUsage();
let contourCacheReads = 0;
let areaCacheReads = 0;
buildPdfPage({
config: largeConfig, rawSpace: largeRaw, space: largeSpace, layout: fixture.layout,
sharedWallGeometry,
resolveInnerContour: (roomId) => {
contourCacheReads++;
return innerContours.get(roomId);
},
resolveRoomArea: (roomId) => {
areaCacheReads++;
return roomAreas.get(roomId);
},
options: { dimensions: true, roomNames: true, decor: true, backdrop: false },
imperial: false, cardTitle: 'House', version: 'test',
now: new Date('2026-09-07T00:00:00Z'), t,
});
const elapsed = process.threadCpuUsage(started);
const elapsedMs = (elapsed.user + elapsed.system) / 1000;
assert.equal(contourCacheReads, largeSpace.rooms.length);
assert.equal(areaCacheReads, largeSpace.rooms.length);
assert.ok(elapsedMs < 200, `PDF scene build used ${elapsedMs.toFixed(1)} ms of CPU`);
});