diff --git a/demo/smoke_near_axis_optimize.mjs b/demo/smoke_near_axis_optimize.mjs new file mode 100644 index 00000000..4fc6a7e8 --- /dev/null +++ b/demo/smoke_near_axis_optimize.mjs @@ -0,0 +1,101 @@ +// #290: production Walls/Optimize path for the tracked 316x1 shared wall. +import { readFileSync } from 'node:fs'; +import { launch, checkAll, finish } from './serve.mjs'; + +const space = JSON.parse(readFileSync( + new URL('../test/fixtures/279-near-orthogonal-junction.json', import.meta.url), 'utf8', +)); +const { page, browser } = await launch({ width: 980, height: 900 }); + +const out = await page.evaluate(async (sourceSpace) => { + const result = {}; + const card = window.__card; + const clone = (value) => JSON.parse(JSON.stringify(value)); + const original = { + model_version: 7, + spaces: [{ id: 'near-axis', title: 'Near axis', view_box: [-2, 2, 3.2, 2], ...sourceSpace }], + markers: [], settings: {}, + }; + let serverConfig = clone(original), serverLayout = {}, backup = null; + const sent = []; + const baseCall = card.hass.callWS.bind(card.hass); + card.hass = { + ...card.hass, + callWS: async (message) => { + if (message.type === 'houseplan/plan/optimize') { + sent.push(message.type); + backup = { config: clone(serverConfig), layout: clone(serverLayout) }; + serverConfig = clone(message.config); + serverLayout = clone(message.layout); + return { ok: true, config_rev: 2, layout_rev: 2, can_undo: true }; + } + if (message.type === 'houseplan/plan/optimize_undo') { + sent.push(message.type); + serverConfig = clone(backup.config); + serverLayout = clone(backup.layout); + return { ok: true, config_rev: 3, layout_rev: 3, can_undo: false }; + } + if (message.type === 'houseplan/config/get') { + return { config: clone(serverConfig), rev: 3, can_write: true, can_optimize_undo: !!backup }; + } + if (message.type === 'houseplan/layout/get') return { layout: clone(serverLayout), rev: 3 }; + return baseCall(message); + }, + }; + card._serverCfg = clone(original); + card._layout = {}; + card._space = 'near-axis'; + card._modelCache = null; + card._frame = null; + card.requestUpdate(); + await card.updateComplete; + + card._openAlignDialog(); + await card.updateComplete; + const preview = card._alignDialog; + const dialogText = card.renderRoot.querySelector('hp-dialog')?.textContent || ''; + result.previewCountsPhysicalWallOnce = preview?.report.wallsStraightened === 1 + && preview.report.wallsStraightenSkipped === 0 + && Math.abs(preview.report.maxStraightenShiftCm - 1) < 1e-6; + result.previewUsesProductionPreflight = preview?.preflight?.ok === true; + result.previewNamesLossyRepair = dialogText.includes(card._t('gs.optimize_walls_straightened', { + n: '1', cm: '1', + })); + result.previewDoesNotWrite = sent.length === 0 + && JSON.stringify(card._serverCfg) === JSON.stringify(original); + + card._alignDialog = null; + await card.updateComplete; + result.cancelDoesNotWrite = sent.length === 0; + + card._openAlignDialog(); + await card.updateComplete; + await card._runAlignToGrid(); + await card.updateComplete; + const north = card._serverCfg.spaces[0].rooms.find((room) => room.id === 'north-west'); + const south = card._serverCfg.spaces[0].rooms.find((room) => room.id === 'south-west'); + result.applyUsesOneAtomicWrite = sent.filter((type) => type === 'houseplan/plan/optimize').length === 1; + result.applyMovesBothOwnersToExactAxis = north.poly[1][1] === north.poly[2][1] + && JSON.stringify(north.poly[1]) === JSON.stringify(south.poly[0]) + && JSON.stringify(north.poly[2]) === JSON.stringify(south.poly[3]); + result.applyRekeysWall = card._serverCfg.spaces[0].walls.some((wall) => ( + wall.a[1] === wall.b[1] && wall.key.endsWith('@0.0000') + )); + + await card._loadFromServer(); + await card.updateComplete; + card._openAlignDialog(); + await card.updateComplete; + result.reloadIsIdempotent = card._alignDialog.report.wallsStraightened === 0; + card._alignDialog = null; + + await card._undoPlanOptimization(); + await card.updateComplete; + result.undoRestoresExactGeometry = JSON.stringify(card._serverCfg.spaces) + === JSON.stringify(original.spaces); + result.undoServerSnapshotIsByteExact = JSON.stringify(serverConfig) === JSON.stringify(original); + result.undoIsOneDeep = card._canOptimizeUndo === false; + return result; +}, space); + +await finish(browser, checkAll(out)); diff --git a/demo/smoke_plan_drawing_repairs.mjs b/demo/smoke_plan_drawing_repairs.mjs index cb6bee0c..58511e76 100644 --- a/demo/smoke_plan_drawing_repairs.mjs +++ b/demo/smoke_plan_drawing_repairs.mjs @@ -41,6 +41,14 @@ const out = await page.evaluate(async () => { ]; await reset(); + const stage = root().querySelector('.stage'); + const rect = stage.getBoundingClientRect(); + const view = card._viewOr(card._baseVb()); + const clickAt = (x, y, shiftKey = false, ctrlKey = false) => new MouseEvent('click', { + clientX: rect.left + ((x - view.x) / view.w) * rect.width, + clientY: rect.top + ((y - view.y) / view.h) * rect.height, + bubbles: true, shiftKey, ctrlKey, + }); card._path = [[100, 600]]; card._cursorPt = [200, 600]; await update(); @@ -60,6 +68,30 @@ const out = await page.evaluate(async () => { result.angleColourMatchesActualVector = exactLabelGreen && !root().querySelector('.measurelabel')?.classList.contains('on45'); + await reset(); + const nearRaw = [100 + card._gridPitch * 316, 100 + card._gridPitch]; + card._path = [[100, 100]]; + const near = card._resolvePlanDrawPoint(nearRaw, false); + card._cursorPt = near.point; + await update(); + const previewLine = root().querySelector('.active-axis'); + result.nearAxisPreviewIsStraight = near.point[1] === 100 + && +previewLine?.getAttribute('y1') === +previewLine?.getAttribute('y2'); + card._markupClick(clickAt(...nearRaw)); + await update(); + result.nearAxisClickPersistsStraightDraft = card._path.length === 2 + && card._path[1][1] === 100 + && card._curSpaceCfg.room_drafts?.[0]?.points?.[1]?.[1] === 0.1; + + await reset({ partitions: [{ + id: 'near-target', a: [nearRaw[0] / 1000, nearRaw[1] / 1000], + b: [nearRaw[0] / 1000, nearRaw[1] / 1000 + 0.1], cm: 20, + }] }); + card._path = [[100, 100]]; + const conflictWithAxis = card._resolvePlanDrawPoint(nearRaw, false); + result.nearAxisRuleDoesNotClaimWrongEndpoint = !conflictWithAxis.candidate + && conflictWithAxis.point[0] === nearRaw[0] && conflictWithAxis.point[1] === 100; + await reset({ partitions: [ { id: 'near-a', a: [0.1, 0.1], b: [0.1, 0.3], cm: 20 }, { id: 'near-b', a: [0.106, 0.1], b: [0.106, 0.3], cm: 20 }, @@ -76,14 +108,6 @@ const out = await page.evaluate(async () => { result.keepExistingFaceIsNoop = JSON.stringify(card._curSpaceCfg) === exactBefore && !card._roomDialog && !card._wallFaceBatch; card._path = []; - const stage = root().querySelector('.stage'); - const rect = stage.getBoundingClientRect(); - const view = card._viewOr(card._baseVb()); - const clickAt = (x, y, shiftKey = false) => new MouseEvent('click', { - clientX: rect.left + ((x - view.x) / view.w) * rect.width, - clientY: rect.top + ((y - view.y) / view.h) * rect.height, - bubbles: true, shiftKey, - }); card._markupClick(clickAt(300, 300, true)); result.shiftBypassesExistingFaceOffer = card._path.length === 1 && !card._roomDialog; card._cancelPath(); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ce27d5a1..1a2e37e8 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -548,6 +548,15 @@ scale helpers remain pure-test history only and are tree-shaken from the production interaction path. Exact `a/b` wall endpoints remain identity and the quantised midpoint/direction `key` remains only a compatibility index. +Near-axis geometry has one shared classifier in `src/near-axis.ts` (#290). +Walls applies it after architectural/grid resolution and before hover/commit, +moving only the free endpoint. Resize validates that its fixed-topology output +contains no near-axis edge. Explicit Optimize runs the lossy legacy repair only +after grid alignment, moves coincident room endpoint owners atomically, then +reuses ordinary opening projection and wall/open-span rekeying. Unique physical +count, maximum centimetres and skipped candidates stay separate from ordinary +grid movement; no load/save migration invokes this repair. + All physical-geometry writers share the same transaction boundary (#278). `checkSpacePhysicalGeometry()` validates the exact candidate through canonical wall and floor builders before history or save. A failed or degraded candidate diff --git a/docs/CANVAS.md b/docs/CANVAS.md index 52e292a0..f307fa47 100644 --- a/docs/CANVAS.md +++ b/docs/CANVAS.md @@ -429,6 +429,13 @@ the user is asked to zoom. Exact coincident endpoints are still one node. The active thick rubber-band always paints its centreline and final node above the body; an active snap marker replaces, rather than duplicates, that final node. +After architectural snapping, a Walls segment within `0.25°` of a horizontal +or vertical axis is made exact by moving only its free endpoint (#290). Hover, +the active marker and click consume that same point. A nearby saved endpoint +which would require the forbidden one-step slope therefore loses snap +ownership instead of being joined invisibly. Shift-selected 45° rays and true +diagonals outside the shared tolerance are unchanged. + On an axis-aligned wall whose corners are on the grid — every wall the editor itself draws — the two rules give the same point. An opening is also kept inside its wall by half its own length. @@ -491,6 +498,13 @@ touching virtual spans per room pair, compacts consecutive real-wall intervals of equal thickness and stamps `model_version`. Unknown fields are preserved and every pass is idempotent. +The explicit pass also repairs pre-existing near-axis room walls, saved wall +chains and independent walls after ordinary grid alignment (#290). Coincident +room-owner copies count as one physical wall and move as one endpoint +equivalence class. The preview reports the unique count, maximum physical +movement and unsafe skipped candidates; only Confirm writes, and Undo restores +the prior geometry. Exact axes and true diagonals are not candidates. + The optimizer deliberately does **not** alter backdrop calibration or saved view boxes, deduplicate markers, or delete files. It may delete an unattached layout entry only after classifying its owner against current rooms, marker diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 96becb98..231aea8a 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -12,6 +12,12 @@ gaps from the real second-floor plan are now closed consistently in every wall consumer without extending the short arm or changing saved geometry ([#288](https://github.com/Matysh/houseplan-card/issues/288)). +- Walls now stores visually horizontal or vertical segments on an exact axis + instead of allowing an invisible one-grid-step slope. **Optimize plans** can + explicitly straighten the same defect in older plans, counting a shared wall + once, reporting the maximum movement before confirmation and preserving true + diagonals; Cancel and one-deep Undo keep the operation reversible + ([#290](https://github.com/Matysh/houseplan-card/issues/290)). ## v1.67.0-beta.9 — 2026-08-24 diff --git a/docs/CHANGELOG.ru.md b/docs/CHANGELOG.ru.md index 600a17f4..0718a52c 100755 --- a/docs/CHANGELOG.ru.md +++ b/docs/CHANGELOG.ru.md @@ -18,6 +18,13 @@ шагов на реальном плане второго этажа закрыты во всех потребителях геометрии стен без удлинения короткого луча и без изменения сохранённого плана ([#288](https://github.com/Matysh/houseplan-card/issues/288)). +- Инструмент «Стены» теперь сохраняет визуально горизонтальный или вертикальный + отрезок строго по оси и не создаёт незаметный уклон в один шаг. + **Оптимизировать планы** может явно выпрямить такой дефект в старом плане: + общая стена считается один раз, максимальный сдвиг показывается до + подтверждения, настоящие диагонали сохраняются, а Cancel и одноуровневый Undo + делают операцию обратимой + ([#290](https://github.com/Matysh/houseplan-card/issues/290)). ## v1.67.0-beta.9 — 2026-08-24 diff --git a/docs/CONFIG-COMPATIBILITY.md b/docs/CONFIG-COMPATIBILITY.md index c386c308..e6b18244 100644 --- a/docs/CONFIG-COMPATIBILITY.md +++ b/docs/CONFIG-COMPATIBILITY.md @@ -97,6 +97,14 @@ block the repair. Normal read, render, Save and editor paths remain lossless; only confirmed Optimize applies it, with the ordinary preview and server Undo (#198, #273). +Explicit Optimize may also straighten a stored wall whose slope is non-zero +but no more than `0.25°` from an axis (#290). This is a confirmed lossy repair, +not a read migration or schema change. All coincident room-owner endpoints move +together, wall/opening identities are reprojected by the canonical pipeline, +and true diagonals survive byte-equivalent. Older clients continue reading the +result as ordinary polygon geometry; reverting code does not require a storage +migration. + ## Open-passage opening type (#157) `space.openings[].type` additionally accepts the literal `passage`. Its diff --git a/docs/RESIZE.md b/docs/RESIZE.md index d4857ac2..7bcc3d7e 100644 --- a/docs/RESIZE.md +++ b/docs/RESIZE.md @@ -62,7 +62,9 @@ The production controller reaches only four pure operations in `src/resize.ts`: planned room and translates each ordinary moving-wall opening once. 4. `validateSafeResize(...)` proves room identity/count, topology, orientation, simplicity, minimum clearance, exact shared endpoints, foreign-room - relations, physical obstacles and opening jamb clearance. + relations, physical obstacles and opening jamb clearance. It also rejects + any near-axis postcondition: a safe candidate is exact horizontal/vertical, + never a sub-`0.25°` arithmetic slope (#290). Historical general-transform helpers remain only for old pure-test history. `houseplan-card.ts` must not import or call `applyRoomScale`, diff --git a/docs/TESTING.md b/docs/TESTING.md index 36680296..64abbe2f 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -2852,6 +2852,18 @@ require hands on real hardware — they remain for the human pass. onto a longer query, and scale/direction/row order do not change the resolver [auto: test/wall-thickness.test.mjs + test/open-spans.test.mjs + smoke_resize_virtual_thick + mutation-gate]. +- [ ] **Near-axis authoring and explicit repair (#290)**: the shared + `0.25°` classifier includes `316×1`, excludes `316×2` and 30° diagonals, + and Walls preview/click persist `316×0` without claiming the wrong saved + endpoint. Optimize deduplicates the tracked shared wall across two room + owners, reports one wall and an exact physical maximum, rekeys thickness, + passes production preflight, applies one atomic write, reloads as a no-op + and restores the original through one Undo. Saved drafts and independent + walls use the same classifier; unsafe candidates are counted as skipped + [unit: test/near-axis.test.mjs; auto: smoke_plan_drawing_repairs + + smoke_near_axis_optimize; mutations: `near-axis-threshold-weakened`, + `near-axis-inclusive-boundary-disabled`, + `near-axis-authoring-snap-bypassed`]. - [ ] **Explicit Optimize cleans only an isolated micro-interval (#198)**: `22 → 15 → 22` with a centre shorter than half a grid step and no room/opening node becomes one 22 cm run in Preview and Apply; Cancel diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md index 326fc8a3..e8445f3e 100644 --- a/docs/USER-GUIDE.md +++ b/docs/USER-GUIDE.md @@ -302,6 +302,12 @@ Existing segment endpoints and lines appear above walls while drawing. An endpoint grows when the next click will join it. A point on a line shows where the click will create a valid junction. +A segment which is visually horizontal or vertical within `0.25°` is stored as +an exact axis: House Plan moves only the free endpoint, and preview already +shows the final result. A real diagonal remains unchanged. Older invisible +one-grid-step slopes are offered separately by **Optimize plans**, with the +number of walls and maximum movement shown before confirmation. + ### Plan tools at a glance | Tool | Result | Room area | Light and shadow | Main limit | @@ -335,6 +341,10 @@ small enclosed hole. At a perpendicular T/X junction, the complete physical width of every participating wall remains solid through the node, including closely spaced neighbouring junctions. This is a rendering correction: a valid plan may remain unchanged when **Optimize plans** is run. +Legacy near-axis walls are different: **Optimize plans** may explicitly +straighten them, counts a shared wall once across both rooms and leaves an +unsafe candidate unchanged. Cancel writes nothing and the confirmed batch has +the normal one-deep Undo. Resize changes one room, or exactly two rooms when their shared wall coincides endpoint-to-endpoint. The wall stops at the first corner, opening, foreign room or other position that would change topology; no more than two rooms can change. diff --git a/docs/USER-GUIDE.ru.md b/docs/USER-GUIDE.ru.md index 395520bb..c10bacd2 100644 --- a/docs/USER-GUIDE.ru.md +++ b/docs/USER-GUIDE.ru.md @@ -400,6 +400,15 @@ T-соединение входит в проходящую стену без в соседних стыков. Это исправление отображения: для валидного плана команда **Оптимизировать планы** может ничего не изменить. +Отрезок, который отклонён от горизонтали или вертикали не больше чем на +`0,25°`, инструмент «Стены» сразу показывает и сохраняет строго по оси: меняется +только свободный конец. Настоящие диагонали не выпрямляются. Старые невидимые +уступы в один шаг предлагается исправить через **Оптимизировать планы**: до +подтверждения показываются число физических стен и максимальный сдвиг, общая +стена двух комнат считается один раз, а небезопасные варианты пропускаются. +Отмена ничего не записывает, подтверждённое исправление можно отменить обычным +Undo оптимизации. + Каждый законченный отрезок цепочки сохраняется сразу. Esc/Ctrl+Z удаляет последнюю точку. Pan, pinch и `pointercancel` ничего не завершают и не добавляют. После перезагрузки сохранённый draft можно продолжить кликом по его концу. diff --git a/docs/WALL-THICKNESS.md b/docs/WALL-THICKNESS.md index 2e423512..75bac538 100644 --- a/docs/WALL-THICKNESS.md +++ b/docs/WALL-THICKNESS.md @@ -156,6 +156,13 @@ short ray to the global radius and never treats an unrelated outer continuation as node-owned material. This closes the real `349 / 120 / 5`-step gaps while preserving the finite-ray and opening contracts from #271. +`NEAR_AXIS_MAX_DEGREES` in `src/near-axis.ts` is the single `0.25°` product +constant (#290). The masonry pair classifier derives its sine tolerance from +that source; Walls authoring and explicit Optimize use the corresponding +minor/major slope. Rendering may tolerate a legacy saved slope, but new Walls +segments are exact-axis and Optimize changes legacy geometry only after its +lossy preview is confirmed. + Clean-floor consumers subtract the cached, repaired canonical room masonry from their source room and take its outer component. The result is clipped to the source room on fallback. Openings and independent partitions are deliberately diff --git a/scripts/model-invariants.mjs b/scripts/model-invariants.mjs index c45bb482..457fe502 100644 --- a/scripts/model-invariants.mjs +++ b/scripts/model-invariants.mjs @@ -4,7 +4,7 @@ * * npm run invariants -- --config <файл> # экспорт, config/get или сырой config * npm run invariants -- --config <файл> --layout <файл> - * npm run invariants -- --config <файл> --json + * npm run invariants -- --config <файл> --near-axis --json * * Зачем это существует. Самый дорогой класс дефектов проекта — не ошибки * формул, а потеря согласованности между геометрией и ссылками на неё: #253 @@ -19,6 +19,7 @@ import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { checkOptimizeGeometry } from '../test-build/plan-geometry-preflight.js'; +import { classifyNearAxisSegment } from '../test-build/near-axis.js'; /** Доля шага сетки, в пределах которой запись считается лежащей на ребре. */ const EDGE_TOLERANCE = 0.004; @@ -44,6 +45,42 @@ const roomPolygon = (room) => { const edgesOf = (poly) => poly.map((a, index) => [a, poly[(index + 1) % poly.length]]); +/** Deduplicated physical near-axis segments, including both room-owner copies. */ +export function nearAxisProfile(config) { + const spaces = []; + let total = 0; + const keyOf = (a, b) => { + const ka = `${a[0]},${a[1]}`, kb = `${b[0]},${b[1]}`; + return ka < kb ? `${ka}|${kb}` : `${kb}|${ka}`; + }; + for (const space of config?.spaces || []) { + const found = new Set(); + for (const room of space?.rooms || []) { + const poly = roomPolygon(room); + if (!poly) continue; + for (const [a, b] of edgesOf(poly)) { + if (classifyNearAxisSegment(a, b)) found.add(keyOf(a, b)); + } + } + for (const draft of space?.room_drafts || []) { + for (let index = 0; index + 1 < (draft?.points || []).length; index++) { + const a = point(draft.points[index]), b = point(draft.points[index + 1]); + if (a && b && classifyNearAxisSegment(a, b)) found.add(`draft:${draft.id}:${index}`); + } + } + for (const partition of space?.partitions || []) { + const a = point(partition?.a), b = point(partition?.b); + if (a && b && classifyNearAxisSegment(a, b)) { + found.add(`partition:${partition.id || keyOf(a, b)}`); + } + } + const count = found.size; + spaces.push({ spaceId: String(space?.id || ''), count }); + total += count; + } + return { total, spaces }; +} + const distToSegment = (p, a, b) => { const dx = b[0] - a[0], dy = b[1] - a[1]; const len2 = dx * dx + dy * dy; @@ -587,7 +624,7 @@ function main(argv) { const configPath = arg('--config'); if (!configPath) { console.error('использование: model-invariants.mjs --config <файл> [--layout <файл>]' - + ' [--lattice] [--json]'); + + ' [--lattice|--near-axis] [--json]'); return 2; } const model = readModel(readFileSync(configPath, 'utf8')); @@ -599,6 +636,17 @@ function main(argv) { else console.log(latticeReport(profile)); return 0; } + if (argv.includes('--near-axis')) { + const profile = nearAxisProfile(model.config); + if (argv.includes('--json')) console.log(JSON.stringify({ nearAxis: profile }, null, 2)); + else { + console.log(`Почти осевых физических стен: ${profile.total}.`); + for (const space of profile.spaces.filter((item) => item.count)) { + console.log(` ${space.spaceId || '(без id)'}: ${space.count}`); + } + } + return 0; + } const violations = [ ...checkReferences(model, { notes }), ...checkWallKeys(model.config, { notes }), diff --git a/scripts/mutation-gate.mjs b/scripts/mutation-gate.mjs index d3f302e8..33c0c346 100644 --- a/scripts/mutation-gate.mjs +++ b/scripts/mutation-gate.mjs @@ -697,9 +697,9 @@ export const MUTANTS = [ patches: [{ file: 'src/plan-optimizer.ts', find: ' const persistedConfig = canonicalizeConfigGeometry(config);\n' - + ' const persistedLayout = canonicalizeLayoutGeometry(aligned.layout);', + + ' const persistedLayout = canonicalizeLayoutGeometry(finalAligned.layout);', replace: ' const persistedConfig = config;\n' - + ' const persistedLayout = aligned.layout;', + + ' const persistedLayout = finalAligned.layout;', }], }, { @@ -941,6 +941,40 @@ export const MUTANTS = [ replace: ' if (false && pointInOpaquePlanBody(sourcePoint, masonryGeometry, opaqueBodies)) {', }], }, + { + id: 'near-axis-threshold-weakened', + guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + + '&& node --test --test-name-pattern="#290 near-axis boundary" test/near-axis.test.mjs', + because: 'the measured 316x1 wall must stay inside the one shared 0.25 degree drafting ' + + 'tolerance while 316x2 and a true diagonal remain outside (#290)', + patches: [{ + file: 'src/near-axis.ts', + find: 'export const NEAR_AXIS_MAX_DEGREES = 0.25;', + replace: 'export const NEAR_AXIS_MAX_DEGREES = 0.1;', + }], + }, + { + id: 'near-axis-inclusive-boundary-disabled', + guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + + '&& node --test --test-name-pattern="#290 near-axis boundary" test/near-axis.test.mjs', + because: 'the product contract includes an edge exactly on the 0.25 degree boundary (#290)', + patches: [{ + file: 'src/near-axis.ts', + find: ' if (minor / major > NEAR_AXIS_MAX_SLOPE) return null;', + replace: ' if (minor / major >= NEAR_AXIS_MAX_SLOPE) return null;', + }], + }, + { + id: 'near-axis-authoring-snap-bypassed', + guard: 'npm run bundle:sync && node demo/smoke_plan_drawing_repairs.mjs', + because: 'the production Walls hover and click must persist the exact same straight endpoint ' + + 'instead of merely repairing old data through Optimize (#290)', + patches: [{ + file: 'src/houseplan-card.ts', + find: ' const point = anchor ? snapNearAxisEndpoint(anchor, snapped) : snapped;', + replace: ' const point = snapped;', + }], + }, { id: 'optimizer-micro-interval-cleanup-disabled', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' diff --git a/scripts/smoke-links.mjs b/scripts/smoke-links.mjs index 780a2527..15728f6f 100644 --- a/scripts/smoke-links.mjs +++ b/scripts/smoke-links.mjs @@ -27,6 +27,16 @@ /** @type {SmokeLink[]} */ export const SMOKE_LINKS = [ + { + symbols: [ + 'classifyNearAxisSegment', 'snapNearAxisEndpoint', 'repairNearAxisRoomWalls', + 'NEAR_AXIS_MAX_DEGREES', 'NEAR_AXIS_MAX_SLOPE', + ], + smokes: ['smoke_plan_drawing_repairs.mjs', 'smoke_near_axis_optimize.mjs'], + because: 'the production bundle must make the Walls hover/click candidate exact-axis, then ' + + 'preview one deduplicated real shared wall, pass production geometry preflight, apply one ' + + 'atomic Optimize write, reload idempotently and restore the original through one Undo (#290)', + }, { // #285: смок не произносит ни одного имени продуктового кода — он подставляет // геометрию реального этажа и спрашивает сам продукт через isPointInFill, diff --git a/src/houseplan-card.ts b/src/houseplan-card.ts index 42b90403..533b6b90 100755 --- a/src/houseplan-card.ts +++ b/src/houseplan-card.ts @@ -189,6 +189,7 @@ import { clampCanvasR, clampCanvasN, type ContentItem, type Rect, } from './space-geometry'; import { optimizePlans, type OptimizeReport } from './plan-optimizer'; +import { snapNearAxisEndpoint } from './near-axis'; import type { SpaceReferenceRepairContext } from './space-reference-repair'; import { collectSpaceMarkerDependencies } from './space-deletion'; import { @@ -6916,9 +6917,15 @@ class HouseplanCard extends LitElement { ? resolveStrictPlanSnap(snapshot.value, raw, { ...options, anchor }) : resolvePlanSnapResult(snapshot.value, raw, options); const candidate = resolution.kind === 'resolved' ? resolution.candidate : null; + const snapped = candidate ? [...candidate.point] : this._snapDrawPoint(raw, lock45); + const point = anchor ? snapNearAxisEndpoint(anchor, snapped) : snapped; + // A nearby topology endpoint loses snap ownership when the drafting rule + // moves the actually persisted point away from it. Preview and click must + // describe the exact same geometry (#290). + const effectiveCandidate = candidate && samePoint(point, candidate.point) ? candidate : null; return { - point: candidate ? [...candidate.point] : this._snapDrawPoint(raw, lock45), - candidate, + point, + candidate: effectiveCandidate, conflicts: resolution.kind === 'ambiguous' ? resolution.conflicts : [], ambiguous: resolution.kind === 'ambiguous', contextKey: this._planSnapContextKey(snapshot.key), @@ -7528,7 +7535,9 @@ class HouseplanCard extends LitElement { if (this._path.length < 3) return; // Preserve the established shortcut, but commit its closing wall through // the same graph/draft path as an ordinary click on the first node. - pt = [...this._path[0]]; + pt = snapNearAxisEndpoint( + this._path[this._path.length - 1], this._path[0], + ); } // Island rooms (v1.34.0): drawing INSIDE an existing room is legal — the // contour may become a nested room (a column, an inner room). Partial @@ -15326,7 +15335,7 @@ class HouseplanCard extends LitElement { m: String(d.report.migrated + d.report.canonicalized + d.report.coordsCanonicalized + d.report.wallsMerged + d.report.spansMerged + d.report.partitionsMerged + d.report.partitionsReconciled - + d.report.openingsRehosted), + + d.report.openingsRehosted + d.report.wallsStraightened), r: String(d.report.spaceRefsRemapped + d.report.roomRefsRemapped + d.report.positionsRemapped + d.report.markersDetached + d.report.orphanRoomLabelsRemoved + d.report.orphanDevicePositionsRemoved @@ -16337,7 +16346,14 @@ class HouseplanCard extends LitElement { const modelMaintenance = r.migrated + r.canonicalized + r.coordsCanonicalized + r.wallsMerged + r.spansMerged + r.partitionsMerged + r.partitionsReconciled + r.openingsRehosted; - const gridWarning = r.moved + r.rotated + r.removedDrafts + r.coordsCanonicalized; + const gridWarning = r.moved + r.rotated + r.removedDrafts + + r.coordsCanonicalized + r.wallsStraightened; + const straightenCm = Math.ceil(r.maxStraightenShiftCm * 10) / 10; + const straightenSpace = (this._serverCfg?.spaces || []).find( + (space: any) => String(space?.id || '') === r.maxStraightenSpace, + ); + const straightenWhere = (this._serverCfg?.spaces || []).length > 1 && straightenSpace + ? String(straightenSpace.title || straightenSpace.id) : ''; const removed = r.orphanRoomLabelsRemoved + r.orphanDevicePositionsRemoved + r.orphanGroupPositionsRemoved; const liveNames = r.liveMissingPositions.map((item) => item.name).filter(Boolean); @@ -16416,6 +16432,14 @@ class HouseplanCard extends LitElement { ${r.openingsRehosted ? html`

${this._t( 'gs.optimize_openings_rehosted', { n: String(r.openingsRehosted) }, )}

` : nothing} + ${r.wallsStraightened ? html`

${this._t( + 'gs.optimize_walls_straightened', { + n: String(r.wallsStraightened), cm: String(straightenCm), + }, + )}

` : nothing} + ${straightenWhere ? html`

${this._t( + 'gs.optimize_walls_straightened_where', { s: straightenWhere }, + )}

` : nothing} ${r.glowSpacesMigrated || r.glowRoomsMigrated ? html`

${this._t('gs.optimize_glow_migration', { spaces: String(r.glowSpacesMigrated), @@ -16423,6 +16447,11 @@ class HouseplanCard extends LitElement { })}

` : nothing} ${gridWarning ? html`
${this._t('gs.align_warn')}
` : nothing}`} + ${!failed && r.wallsStraightenSkipped ? html`

${this._t( + 'gs.optimize_walls_straighten_skipped', { + n: String(r.wallsStraightenSkipped), + }, + )}

` : nothing} ${repaired ? html`

${this._t('gs.optimize_references', { spaces: String(r.spaceRefsRemapped), rooms: String(r.roomRefsRemapped), diff --git a/src/i18n/en.json b/src/i18n/en.json index f0040046..07c75d83 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -817,6 +817,9 @@ "gs.optimize_changes": "Model migrations: {m}; spaces updated: {c}; noisy coordinate values removed: {p}; merged real-wall fragments: {w}; virtual fragments: {s}; independent walls: {i}.", "gs.optimize_coincident_partitions": "Coincident partitions converted to room walls: {n}.", "gs.optimize_openings_rehosted": "Openings reattached to room walls: {n}.", + "gs.optimize_walls_straightened": "Walls straightened: {n}; maximum movement: {cm} cm.", + "gs.optimize_walls_straightened_where": "Largest wall correction: {s}.", + "gs.optimize_walls_straighten_skipped": "Near-axis walls left unchanged because they could not be repaired safely: {n}.", "gs.optimize_glow_migration": "Legacy Glow: {spaces} spaces → no data fill + independent Glow; {rooms} rooms → inherited data fill + independent Glow.", "gs.optimize_references": "References repaired: spaces — {spaces}; rooms — {rooms}; positions — {positions}; devices detached from missing spaces — {detached}.", "gs.optimize_reference_more": ", and {n} more", diff --git a/src/i18n/ru.json b/src/i18n/ru.json index d2d4d9b2..fd4c48fc 100644 --- a/src/i18n/ru.json +++ b/src/i18n/ru.json @@ -817,6 +817,9 @@ "gs.optimize_changes": "Миграций модели: {m}; обновлено пространств: {c}; устранён шум координат: {p}; объединено отрезков реальных стен: {w}; виртуальных: {s}; независимых: {i}.", "gs.optimize_coincident_partitions": "Совпадающие перегородки преобразованы в стены: {n}.", "gs.optimize_openings_rehosted": "Проёмы перепривязаны к стенам комнат: {n}.", + "gs.optimize_walls_straightened": "Выпрямлено стен: {n}; максимальное перемещение: {cm} см.", + "gs.optimize_walls_straightened_where": "Максимальная правка стены: {s}.", + "gs.optimize_walls_straighten_skipped": "Оставлено почти осевых стен, которые нельзя безопасно выпрямить: {n}.", "gs.optimize_glow_migration": "Старый Glow: пространств — {spaces} → без заливки данных + независимый Glow; комнат — {rooms} → наследуемая заливка + независимый Glow.", "gs.optimize_references": "Исправлено ссылок: пространства — {spaces}; комнаты — {rooms}; позиции — {positions}; устройств отвязано от отсутствующих пространств — {detached}.", "gs.optimize_reference_more": ", и ещё {n}", diff --git a/src/near-axis.ts b/src/near-axis.ts new file mode 100644 index 00000000..966184b1 --- /dev/null +++ b/src/near-axis.ts @@ -0,0 +1,320 @@ +import { + polygonArea, roomPoly, roomsOverlap, segmentsProperlyCross, +} from './logic'; + +/** One drafting tolerance shared by authoring, maintenance and masonry joins. */ +export const NEAR_AXIS_MAX_DEGREES = 0.25; +export const NEAR_AXIS_MAX_SLOPE = Math.tan(NEAR_AXIS_MAX_DEGREES * Math.PI / 180); + +export type NearAxis = 'horizontal' | 'vertical'; + +export interface NearAxisClassification { + axis: NearAxis; + major: number; + minor: number; + angleDegrees: number; +} + +/** Exact-axis segments are already canonical and are deliberately not candidates. */ +export function classifyNearAxisSegment( + a: readonly number[], b: readonly number[], +): NearAxisClassification | null { + const dx = Math.abs(Number(b[0]) - Number(a[0])); + const dy = Math.abs(Number(b[1]) - Number(a[1])); + if (![dx, dy].every(Number.isFinite) || !(dx > 0) || !(dy > 0)) return null; + const axis: NearAxis = dx >= dy ? 'horizontal' : 'vertical'; + const major = Math.max(dx, dy); + const minor = Math.min(dx, dy); + if (minor / major > NEAR_AXIS_MAX_SLOPE) return null; + return { + axis, major, minor, + angleDegrees: Math.atan2(minor, major) * 180 / Math.PI, + }; +} + +/** Authoring moves only the free endpoint; the anchor is never rewritten silently. */ +export function snapNearAxisEndpoint( + anchor: readonly number[], point: readonly number[], +): number[] { + const classified = classifyNearAxisSegment(anchor, point); + if (!classified) return [Number(point[0]), Number(point[1])]; + return classified.axis === 'horizontal' + ? [Number(point[0]), Number(anchor[1])] + : [Number(anchor[0]), Number(point[1])]; +} + +export interface NearAxisRepairReport { + wallsStraightened: number; + wallsStraightenSkipped: number; + maxStraightenShift: number; +} + +export interface NearAxisRepairResult { + space: any; + report: NearAxisRepairReport; + changed: boolean; +} + +interface EndpointMove { + from: number[]; + to: number[]; +} + +interface RepairCandidate { + key: string; + a: number[]; + b: number[]; + axis: NearAxis; +} + +const pointKey = (point: readonly number[]): string => `${point[0]},${point[1]}`; +const samePoint = (a: readonly number[], b: readonly number[]): boolean => ( + a[0] === b[0] && a[1] === b[1] +); +const segmentKey = (a: readonly number[], b: readonly number[]): string => { + const ka = pointKey(a), kb = pointKey(b); + return ka < kb ? `${ka}|${kb}` : `${kb}|${ka}`; +}; + +const signedArea = (poly: number[][]): number => { + let sum = 0; + for (let i = 0; i < poly.length; i++) { + const a = poly[i], b = poly[(i + 1) % poly.length]; + sum += a[0] * b[1] - b[0] * a[1]; + } + return sum / 2; +}; + +const simplePolygon = (poly: number[][]): boolean => { + if (poly.length < 3) return false; + for (let i = 0; i < poly.length; i++) { + if (samePoint(poly[i], poly[(i + 1) % poly.length])) return false; + for (let j = i + 1; j < poly.length; j++) { + if (j === i + 1 || (i === 0 && j === poly.length - 1)) continue; + if (segmentsProperlyCross( + poly[i], poly[(i + 1) % poly.length], + poly[j], poly[(j + 1) % poly.length], + )) return false; + } + } + return polygonArea(poly) > 1e-12; +}; + +const simpleOpenPath = (points: number[][]): boolean => { + for (let i = 0; i + 1 < points.length; i++) { + if (samePoint(points[i], points[i + 1])) return false; + for (let j = i + 2; j + 1 < points.length; j++) { + if (segmentsProperlyCross(points[i], points[i + 1], points[j], points[j + 1])) { + return false; + } + } + } + return true; +}; + +const replacePoint = (point: number[], move: EndpointMove): number[] => ( + samePoint(point, move.from) ? [...move.to] : [...point] +); + +const replaceRoomPoints = (rooms: any[], move: EndpointMove): any[] => rooms.map((room) => { + if (!Array.isArray(room?.poly)) return room; + return { ...room, poly: room.poly.map((point: number[]) => replacePoint(point, move)) }; +}); + +const roomMoveIsSafe = (before: any[], after: any[], move: EndpointMove): boolean => { + const changed = before.flatMap((room, index) => ( + roomPoly(room)?.some((point) => samePoint(point, move.from)) ? [index] : [] + )); + if (!changed.length) return false; + for (const i of changed) { + const source = roomPoly(before[i]); + const candidate = roomPoly(after[i]); + if (!source || !candidate) continue; + if (!simplePolygon(candidate)) return false; + const sourceSign = Math.sign(signedArea(source)); + const candidateSign = Math.sign(signedArea(candidate)); + if (sourceSign && candidateSign && sourceSign !== candidateSign) return false; + } + const compared = new Set(); + for (const i of changed) { + const a = roomPoly(after[i]); + if (!a) continue; + for (let j = 0; j < after.length; j++) { + if (i === j) continue; + const pair = i < j ? `${i}:${j}` : `${j}:${i}`; + if (compared.has(pair)) continue; + compared.add(pair); + const b = roomPoly(after[j]); + if (b && roomsOverlap(a, b)) return false; + } + } + return true; +}; + +const exactIncidentDegree = (rooms: any[], point: number[]): number => { + let count = 0; + for (const room of rooms) { + const poly = roomPoly(room); + if (!poly) continue; + for (let i = 0; i < poly.length; i++) { + const a = poly[i], b = poly[(i + 1) % poly.length]; + if (!samePoint(a, point) && !samePoint(b, point)) continue; + if (a[0] === b[0] || a[1] === b[1]) count++; + } + } + return count; +}; + +const candidateMoves = (candidate: RepairCandidate, rooms: any[]): EndpointMove[] => { + const { a, b, axis } = candidate; + const moveB = axis === 'horizontal' + ? { from: b, to: [b[0], a[1]] } + : { from: b, to: [a[0], b[1]] }; + const moveA = axis === 'horizontal' + ? { from: a, to: [a[0], b[1]] } + : { from: a, to: [b[0], a[1]] }; + const ranked = [ + { move: moveB, preservedDegree: exactIncidentDegree(rooms, a) }, + { move: moveA, preservedDegree: exactIncidentDegree(rooms, b) }, + ]; + return ranked.sort((left, right) => ( + right.preservedDegree - left.preservedDegree + || pointKey(left.move.to).localeCompare(pointKey(right.move.to)) + || pointKey(left.move.from).localeCompare(pointKey(right.move.from)) + )).map((item) => item.move); +}; + +/** + * Explicit Optimize repair for room walls. Candidates come from immutable input; + * coincident room-owner copies are deduplicated by physical segment identity. + */ +export function repairNearAxisRoomWalls(spaceIn: any): NearAxisRepairResult { + const space = JSON.parse(JSON.stringify(spaceIn || {})); + let rooms = Array.isArray(space.rooms) ? space.rooms : []; + const candidates = new Map(); + for (const room of rooms) { + const poly = roomPoly(room); + if (!poly) continue; + for (let i = 0; i < poly.length; i++) { + const a = [...poly[i]], b = [...poly[(i + 1) % poly.length]]; + const classified = classifyNearAxisSegment(a, b); + if (!classified) continue; + const key = segmentKey(a, b); + if (!candidates.has(key)) candidates.set(key, { key, a, b, axis: classified.axis }); + } + } + + let wallsStraightened = 0; + let wallsStraightenSkipped = 0; + let maxStraightenShift = 0; + const reservedMoves = new Map(); + for (const candidate of [...candidates.values()].sort((a, b) => a.key.localeCompare(b.key))) { + let accepted: EndpointMove | null = null; + for (const move of candidateMoves(candidate, rooms)) { + const reserved = reservedMoves.get(pointKey(move.from)); + if (reserved && reserved !== pointKey(move.to)) continue; + const nextRooms = replaceRoomPoints(rooms, move); + if (!roomMoveIsSafe(rooms, nextRooms, move)) continue; + accepted = move; + rooms = nextRooms; + break; + } + if (!accepted) { + wallsStraightenSkipped++; + continue; + } + reservedMoves.set(pointKey(accepted.from), pointKey(accepted.to)); + wallsStraightened++; + maxStraightenShift = Math.max( + maxStraightenShift, + Math.hypot(accepted.to[0] - accepted.from[0], accepted.to[1] - accepted.from[1]), + ); + } + space.rooms = rooms; + + // Saved wall chains are independent authoring records. Preserve their point + // and segment counts; only an equivalence-class endpoint coordinate moves. + for (const draft of space.room_drafts || []) { + if (!Array.isArray(draft?.points) || draft.points.length < 2) continue; + const source = draft.points.map((point: number[]) => [...point]); + const draftCandidates = source.slice(0, -1).flatMap((a: number[], index: number) => { + const b = source[index + 1]; + const classified = classifyNearAxisSegment(a, b); + return classified ? [{ + key: `${String(draft.id || '')}:${index}`, + a: [...a], b: [...b], axis: classified.axis, + } as RepairCandidate] : []; + }); + let points = source; + for (const candidate of draftCandidates) { + let accepted: EndpointMove | null = null; + for (const move of candidateMoves(candidate, [{ poly: points }])) { + const next = points.map((point: number[]) => replacePoint(point, move)); + const closed = next.length >= 4 && samePoint(next[0], next[next.length - 1]); + const ring = closed ? next.slice(0, -1) : next; + if (closed ? !simplePolygon(ring) : !simpleOpenPath(ring)) continue; + accepted = move; + points = next; + break; + } + if (!accepted) { + wallsStraightenSkipped++; + continue; + } + wallsStraightened++; + maxStraightenShift = Math.max( + maxStraightenShift, + Math.hypot(accepted.to[0] - accepted.from[0], accepted.to[1] - accepted.from[1]), + ); + } + draft.points = points; + } + + // Independent partitions have one owner. Hosted openings are retained only + // when their along-wall interval still fits the exact-axis candidate. + for (const partition of space.partitions || []) { + if (!Array.isArray(partition?.a) || !Array.isArray(partition?.b)) continue; + const classified = classifyNearAxisSegment(partition.a, partition.b); + if (!classified) continue; + const candidate: RepairCandidate = { + key: String(partition.id || segmentKey(partition.a, partition.b)), + a: [...partition.a], b: [...partition.b], axis: classified.axis, + }; + let accepted: EndpointMove | null = null; + for (const move of candidateMoves(candidate, [{ poly: [partition.a, partition.b] }])) { + const a = replacePoint(partition.a, move); + const b = replacePoint(partition.b, move); + const length = Math.hypot(b[0] - a[0], b[1] - a[1]); + if (!(length > 0)) continue; + const openingsFit = (space.openings || []).filter((opening: any) => ( + opening?.host?.kind === 'partition' && opening.host.id === partition.id + )).every((opening: any) => { + const t = Number(opening.host.t); + const openingLength = Number(opening.length); + return Number.isFinite(t) && t >= 0 && t <= 1 + && Number.isFinite(openingLength) && openingLength > 0 + && t * length - openingLength / 2 >= -1e-12 + && t * length + openingLength / 2 <= length + 1e-12; + }); + if (!openingsFit) continue; + accepted = move; + partition.a = a; + partition.b = b; + break; + } + if (!accepted) { + wallsStraightenSkipped++; + continue; + } + wallsStraightened++; + maxStraightenShift = Math.max( + maxStraightenShift, + Math.hypot(accepted.to[0] - accepted.from[0], accepted.to[1] - accepted.from[1]), + ); + } + return { + space, + report: { wallsStraightened, wallsStraightenSkipped, maxStraightenShift }, + changed: wallsStraightened > 0, + }; +} diff --git a/src/plan-optimizer.ts b/src/plan-optimizer.ts index 960aec80..16a5efd2 100644 --- a/src/plan-optimizer.ts +++ b/src/plan-optimizer.ts @@ -32,6 +32,7 @@ import { reconcileCoincidentPartitions } from './coincident-partitions'; import { repairSpaceReferences, type SpaceReferenceRepairContext, type SpaceReferenceReport, } from './space-reference-repair'; +import { repairNearAxisRoomWalls } from './near-axis'; /** Bump when a new lossless maintenance pass is added. */ export const PLAN_MODEL_VERSION = 7; @@ -64,6 +65,14 @@ export interface OptimizeReport extends AlignReport, SpaceReferenceReport { partitionsReconciled: number; /** Hosted openings materialised onto the coincident shared room wall. */ openingsRehosted: number; + /** Unique physical near-axis walls accepted for explicit straightening. */ + wallsStraightened: number; + /** Near-axis walls found but rejected by structural safety checks. */ + wallsStraightenSkipped: number; + /** Largest accepted endpoint movement in centimetres. */ + maxStraightenShiftCm: number; + /** Space owning the largest accepted straightening movement. */ + maxStraightenSpace: string; } export interface OptimizeResult { @@ -431,8 +440,41 @@ export function optimizePlans( const beforeSpaces = clone(config.spaces || []); const aligned = alignAllToGrid(config.spaces || [], references.layout); - config.spaces = aligned.spaces; - const alignReport: AlignReport = { ...aligned.report }; + let wallsStraightened = 0; + let wallsStraightenSkipped = 0; + let maxStraightenShiftCm = 0; + let maxStraightenSpace = ''; + const straightenedSpaces = aligned.spaces.map((space: any) => { + const repaired = repairNearAxisRoomWalls(space); + wallsStraightened += repaired.report.wallsStraightened; + wallsStraightenSkipped += repaired.report.wallsStraightenSkipped; + const cellCm = Number(space?.cell_cm) > 0 ? Number(space.cell_cm) : DEFAULT_CELL_CM; + const shiftCm = (repaired.report.maxStraightenShift / GRID_STEP_N) * cellCm; + if (shiftCm > maxStraightenShiftCm) { + maxStraightenShiftCm = shiftCm; + maxStraightenSpace = String(space?.id || ''); + } + return repaired.space; + }); + // Openings are wall-bound. Once a room endpoint has been straightened, run + // the same production alignment once more so their centres/angles follow + // the final host instead of the pre-repair edge. + const finalAligned = wallsStraightened + ? alignAllToGrid(straightenedSpaces, aligned.layout) + : { ...aligned, spaces: straightenedSpaces }; + config.spaces = finalAligned.spaces; + const alignReport: AlignReport = wallsStraightened ? { + ...aligned.report, + moved: aligned.report.moved + finalAligned.report.moved, + coordsCanonicalized: aligned.report.coordsCanonicalized + + finalAligned.report.coordsCanonicalized, + maxShift: Math.max(aligned.report.maxShift, finalAligned.report.maxShift), + maxShiftCm: Math.max(aligned.report.maxShiftCm, finalAligned.report.maxShiftCm), + maxSpace: finalAligned.report.maxShiftCm > aligned.report.maxShiftCm + ? finalAligned.report.maxSpace : aligned.report.maxSpace, + rotated: aligned.report.rotated + finalAligned.report.rotated, + removedDrafts: aligned.report.removedDrafts + finalAligned.report.removedDrafts, + } : { ...aligned.report }; let wallsMerged = 0; let spansMerged = 0; @@ -588,7 +630,7 @@ export function optimizePlans( // Canonicalise the complete pair before both the diff and the return so the // preview, durable intent, live stores and next preview all see one target. const persistedConfig = canonicalizeConfigGeometry(config); - const persistedLayout = canonicalizeLayoutGeometry(aligned.layout); + const persistedLayout = canonicalizeLayoutGeometry(finalAligned.layout); // A version marker is bookkeeping, not maintenance by itself. Persist it // only alongside a real config/layout transformation; otherwise an already @@ -645,6 +687,10 @@ export function optimizePlans( partitionsMerged: changed ? partitionsMerged : 0, partitionsReconciled: changed ? partitionsReconciled : 0, openingsRehosted: changed ? openingsRehosted : 0, + wallsStraightened: changed ? wallsStraightened : 0, + wallsStraightenSkipped, + maxStraightenShiftCm: changed ? maxStraightenShiftCm : 0, + maxStraightenSpace: changed ? maxStraightenSpace : '', ...persistedReferences, }, changed, diff --git a/src/resize.ts b/src/resize.ts index 09eb7745..34255bd3 100644 --- a/src/resize.ts +++ b/src/resize.ts @@ -14,6 +14,7 @@ import { intersection } from 'polyclip-ts'; import { polygonArea, segmentsProperlyCross, polyContainsPoly, roomsOverlap, } from './logic'; +import { classifyNearAxisSegment } from './near-axis'; /** Minimal room dimension in centimetres (owner: «мин. габарит ~30 см»). */ export const MIN_ROOM_CM = 30; @@ -1014,6 +1015,9 @@ export function validateSafeResize( const edge = plan.edgeByRoom[roomId]; if (!original || !next || next.length !== plan.topology[roomId] || next.length !== original.poly.length || !polyIsSimple(next)) return false; + for (let index = 0; index < next.length; index++) { + if (classifyNearAxisSegment(next[index], next[(index + 1) % next.length])) return false; + } const s0 = signedArea(original.poly); const s1 = signedArea(next); if (Math.abs(s1) < eps || s0 * s1 <= 0) return false; diff --git a/src/wall-thickness.ts b/src/wall-thickness.ts index 032f883a..1ca82f5e 100644 --- a/src/wall-thickness.ts +++ b/src/wall-thickness.ts @@ -8,6 +8,7 @@ */ import { union, difference, intersection } from 'polyclip-ts'; import { polygonArea, roomPoly, roomEdges, sharedBoundary, paperRoomShapes } from './logic'; +import { NEAR_AXIS_MAX_DEGREES } from './near-axis'; export interface WallEntry { key: string; @@ -81,7 +82,7 @@ export const MITRE_LIMIT = 4; export const MULTI_WALL_JOIN_LIMIT = 1.25; /** Maximum drafting deviation still rendered as a physical T/X junction (#279). */ -export const MULTI_WALL_NEAR_ORTHOGONAL_MAX_DEGREES = 0.25; +export const MULTI_WALL_NEAR_ORTHOGONAL_MAX_DEGREES = NEAR_AXIS_MAX_DEGREES; /** Normalized dot-product tolerance for a physically near-orthogonal ray pair. */ export const MULTI_WALL_ORTHOGONAL_DOT_EPSILON = Math.sin( diff --git a/test/model-invariants.test.mjs b/test/model-invariants.test.mjs index 12a896c3..3733a0c4 100644 --- a/test/model-invariants.test.mjs +++ b/test/model-invariants.test.mjs @@ -6,7 +6,7 @@ import { fileURLToPath } from 'node:url'; import { checkMixedRoleRecords, checkReferences, checkWallKeys, checkWallRecordsPreserved, - keyMidpoint, latticeProfile, readModel, wallKey, + keyMidpoint, latticeProfile, nearAxisProfile, readModel, wallKey, } from '../scripts/model-invariants.mjs'; import { wallKey as productWallKey } from '../test-build/wall-thickness.js'; import { GRID_STEP_N } from '../test-build/space-geometry.js'; @@ -495,3 +495,11 @@ test('#287: реальные планы проекта эту проверку `${file}: ложное срабатывание на реальном плане`); } }); + +test('#290 near-axis audit deduplicates shared room-owner copies', () => { + const fixture = JSON.parse(readFileSync( + resolve(repoRoot, 'test/fixtures/279-near-orthogonal-junction.json'), 'utf8', + )); + const profile = nearAxisProfile({ spaces: [{ id: 'near', ...fixture }] }); + assert.deepEqual(profile, { total: 1, spaces: [{ spaceId: 'near', count: 1 }] }); +}); diff --git a/test/near-axis.test.mjs b/test/near-axis.test.mjs new file mode 100644 index 00000000..e6af38b3 --- /dev/null +++ b/test/near-axis.test.mjs @@ -0,0 +1,104 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; + +import { + NEAR_AXIS_MAX_DEGREES, + NEAR_AXIS_MAX_SLOPE, + classifyNearAxisSegment, + repairNearAxisRoomWalls, + snapNearAxisEndpoint, +} from '../test-build/near-axis.js'; +import { optimizePlans } from '../test-build/plan-optimizer.js'; +import { MULTI_WALL_NEAR_ORTHOGONAL_MAX_DEGREES } from '../test-build/wall-thickness.js'; + +const fixture = JSON.parse(readFileSync( + new URL('./fixtures/279-near-orthogonal-junction.json', import.meta.url), 'utf8', +)); +const clone = (value) => JSON.parse(JSON.stringify(value)); + +test('#290 near-axis boundary is shared, inclusive and bounded', () => { + assert.equal(NEAR_AXIS_MAX_DEGREES, 0.25); + assert.equal(MULTI_WALL_NEAR_ORTHOGONAL_MAX_DEGREES, NEAR_AXIS_MAX_DEGREES); + assert.equal(classifyNearAxisSegment([0, 0], [316, 0]), null); + assert.equal(classifyNearAxisSegment([0, 0], [316, 1])?.axis, 'horizontal'); + assert.equal(classifyNearAxisSegment([0, 0], [316, 2]), null); + assert.equal(classifyNearAxisSegment([0, 0], [1, NEAR_AXIS_MAX_SLOPE])?.axis, 'horizontal'); + assert.equal(classifyNearAxisSegment([0, 0], [1, NEAR_AXIS_MAX_SLOPE * 1.000001]), null); + assert.equal(classifyNearAxisSegment([0, 0], [1, Math.tan(Math.PI / 6)]), null); + assert.equal(classifyNearAxisSegment([0, 0], [1, 316])?.axis, 'vertical'); + assert.equal(classifyNearAxisSegment([1, 316], [0, 0])?.axis, 'vertical'); +}); + +test('#290 Walls rule moves only the free endpoint', () => { + assert.deepEqual(snapNearAxisEndpoint([10, 20], [326, 21]), [326, 20]); + assert.deepEqual(snapNearAxisEndpoint([10, 20], [11, 336]), [10, 336]); + assert.deepEqual(snapNearAxisEndpoint([10, 20], [326, 22]), [326, 22]); + assert.deepEqual(snapNearAxisEndpoint([10, 20], [110, 120]), [110, 120]); +}); + +test('#290 repairs a duplicated 316x1 physical wall once without mutating input', () => { + const input = clone(fixture); + const before = JSON.stringify(input); + const result = repairNearAxisRoomWalls(input); + assert.equal(JSON.stringify(input), before); + assert.equal(result.report.wallsStraightened, 1); + assert.equal(result.report.wallsStraightenSkipped, 0); + assert.ok(Math.abs(result.report.maxStraightenShift - 1 / 240) < 1e-9); + const north = result.space.rooms.find((room) => room.id === 'north-west'); + const south = result.space.rooms.find((room) => room.id === 'south-west'); + assert.deepEqual(north.poly[1], south.poly[0]); + assert.deepEqual(north.poly[2], south.poly[3]); + assert.equal(north.poly[1][1], north.poly[2][1]); +}); + +test('#290 repairs saved drafts and independent partitions but preserves true diagonals', () => { + const input = { + rooms: [], + room_drafts: [{ + id: 'draft', points: [[0, 0], [316, 1], [316, 20]], + segments: [{ cm: 15 }, { cm: 15 }], + }], + partitions: [ + { id: 'near', a: [0, 100], b: [316, 101], cm: 15 }, + { id: 'diagonal', a: [0, 200], b: [100, 300], cm: 15 }, + ], + }; + const result = repairNearAxisRoomWalls(input); + assert.equal(result.report.wallsStraightened, 2); + assert.equal(result.space.room_drafts[0].points.length, 3); + assert.equal(result.space.room_drafts[0].segments.length, 2); + assert.equal(result.space.room_drafts[0].points[0][1], result.space.room_drafts[0].points[1][1]); + assert.equal(result.space.partitions[0].a[1], result.space.partitions[0].b[1]); + assert.deepEqual(result.space.partitions[1], input.partitions[1]); +}); + +test('#290 skips a repair that would no longer fit a hosted opening', () => { + const input = { + rooms: [], + partitions: [{ id: 'host', a: [0, 0], b: [316, 1], cm: 15 }], + openings: [{ + id: 'opening', + length: 316.001, + host: { kind: 'partition', id: 'host', t: 0.5 }, + }], + }; + const result = repairNearAxisRoomWalls(input); + assert.equal(result.report.wallsStraightened, 0); + assert.equal(result.report.wallsStraightenSkipped, 1); + assert.deepEqual(result.space.partitions, input.partitions); +}); + +test('#290 Optimize reports physical count, exact max centimetres and is idempotent', () => { + const config = { spaces: [{ id: 'floor', title: 'Floor', ...clone(fixture) }] }; + const first = optimizePlans(config, {}); + assert.equal(first.report.wallsStraightened, 1); + assert.equal(first.report.wallsStraightenSkipped, 0); + assert.ok(Math.abs(first.report.maxStraightenShiftCm - 1) < 1e-6); + assert.equal(first.report.maxStraightenSpace, 'floor'); + assert.equal(first.changed, true); + const second = optimizePlans(first.config, first.layout); + assert.equal(second.changed, false); + assert.equal(second.report.wallsStraightened, 0); + assert.equal(second.report.maxStraightenShiftCm, 0); +}); diff --git a/test/plan-optimizer.test.mjs b/test/plan-optimizer.test.mjs index f2c0e3c2..c7e985e0 100644 --- a/test/plan-optimizer.test.mjs +++ b/test/plan-optimizer.test.mjs @@ -43,6 +43,7 @@ const assertNoPersistedChanges = (result) => { 'moved', 'coordsCanonicalized', 'rotated', 'removedDrafts', 'migrated', 'glowSpacesMigrated', 'glowRoomsMigrated', 'canonicalized', 'wallsMerged', 'spansMerged', 'partitionsMerged', 'partitionsReconciled', 'openingsRehosted', + 'wallsStraightened', 'spaceRefsRemapped', 'roomRefsRemapped', 'positionsRemapped', 'markersDetached', 'orphanRoomLabelsRemoved', 'orphanDevicePositionsRemoved', 'orphanGroupPositionsRemoved', @@ -51,6 +52,8 @@ const assertNoPersistedChanges = (result) => { assert.equal(result.report.maxShift, 0); assert.equal(result.report.maxShiftCm, 0); assert.equal(result.report.maxSpace, ''); + assert.equal(result.report.maxStraightenShiftCm, 0); + assert.equal(result.report.maxStraightenSpace, ''); }; const coincidentPartitionConfig = ({ roomCm = 20, partitionCm = 20, partial = false } = {}) => ({