mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
826 lines
43 KiB
JavaScript
826 lines
43 KiB
JavaScript
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`);
|
||
});
|