diff --git a/demo/smoke_near_orthogonal_junction.mjs b/demo/smoke_near_orthogonal_junction.mjs new file mode 100644 index 00000000..f70a768f --- /dev/null +++ b/demo/smoke_near_orthogonal_junction.mjs @@ -0,0 +1,95 @@ +/** Issue #279: the exact near-orthogonal T keeps every finite incident strip. */ +import { readFileSync } from 'node:fs'; +import { launch, checkAll, finish } from './serve.mjs'; + +const fixture = JSON.parse(readFileSync( + new URL('../test/fixtures/279-near-orthogonal-junction.json', import.meta.url), 'utf8', +)); +const { page, browser } = await launch({ width: 1000, height: 760 }, 1); + +const result = await page.evaluate(async (source) => { + const card = window.__card; + const root = () => card.shadowRoot || card.renderRoot; + const frame = () => new Promise((done) => + requestAnimationFrame(() => requestAnimationFrame(done))); + const settle = async () => { + await card.updateComplete; + while (card._modeTransitionBusy) await frame(); + await frame(); + }; + const update = async (structural = false) => { + if (structural) { + card._cfgEpoch++; + card._modelCache = null; + card._frame = null; + card._wallUnionCache = null; + card._physicalBodiesCache = null; + card._lightBarrierCache = null; + card._isoGeometryCache.clear(); + } + card.requestUpdate(); + await settle(); + }; + const space = { + id: 'issue-279', + title: 'Issue 279', + cell_cm: source.cell_cm, + view_box: [-1.8, 1.95, 3, 2.05], + rooms: structuredClone(source.rooms), + walls: structuredClone(source.walls), + settings: { show_borders: true, fill_mode: 'none' }, + }; + card._serverCfg = { + ...structuredClone(card._serverCfg), spaces: [space], markers: [], + }; + card._layout = {}; + card._space = space.id; + card._setMode('plan'); + card._tool = 'select'; + await update(true); + + const persisted = JSON.stringify(card._serverCfg.spaces[0]); + const node = source.node.map((value) => value * 1000); + const halfDepth = (20 / source.cell_cm) * (1000 / 240) / 2; + const incident = source.walls.map((wall) => { + const other = Math.hypot(wall.a[0] - source.node[0], wall.a[1] - source.node[1]) + > 1e-8 ? wall.a : wall.b; + const dx = other[0] * 1000 - node[0]; + const dy = other[1] * 1000 - node[1]; + const length = Math.hypot(dx, dy); + return { u: [dx / length, dy / length], length }; + }); + const missingSamples = (path) => { + let missing = 0; + for (const ray of incident) { + const n = [-ray.u[1], ray.u[0]]; + for (let t = 3; t <= Math.min(100, ray.length - 3); t += 3) { + for (let cross = -halfDepth + 3; cross <= halfDepth - 3; cross += 3) { + const point = new DOMPoint( + node[0] + ray.u[0] * t + n[0] * cross, + node[1] + ray.u[1] * t + n[1] * cross, + ); + if (!path?.isPointInFill?.(point)) missing++; + } + } + } + return missing; + }; + + const planPath = root().querySelector('[data-hp="wall"]'); + const planD = planPath?.getAttribute('d') || ''; + const out = { + planHasCanonicalWall: !!planD, + planKeepsEveryIncidentSample: missingSamples(planPath) === 0, + renderNeverWritesConfig: JSON.stringify(card._serverCfg.spaces[0]) === persisted, + }; + card._setMode('view'); + await update(false); + const viewPath = root().querySelector('[data-hp="wall"]'); + out.viewMatchesPlan = viewPath?.getAttribute('d') === planD; + out.viewKeepsEveryIncidentSample = missingSamples(viewPath) === 0; + return out; +}, fixture); + +checkAll(result); +await finish(browser, result); diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index d7aa1ff5..42ea8761 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased +- A nearly perpendicular T-junction now preserves all physical masonry when + its saved axis has a small valid slope. The two white wedges disappear in + every mode without straightening stored coordinates, while deliberately + diagonal bevels stay bounded + ([#279](https://github.com/Matysh/houseplan-card/issues/279)). + ## v1.67.0-beta.8 — 2026-08-24 - A local wall-union failure can no longer blank the masonry of an entire diff --git a/docs/CHANGELOG.ru.md b/docs/CHANGELOG.ru.md index 6077d5ed..fe9428f5 100755 --- a/docs/CHANGELOG.ru.md +++ b/docs/CHANGELOG.ru.md @@ -8,6 +8,11 @@ ## Не выпущено +- Почти перпендикулярный T-стык теперь сохраняет всю реальную кладку при + небольшом допустимом уклоне оси. Два белых клина исчезают во всех режимах, + сохранённые координаты не выпрямляются, а явно диагональная фаска остаётся + ограниченной ([#279](https://github.com/Matysh/houseplan-card/issues/279)). + ## v1.67.0-beta.8 — 2026-08-24 - Локальный сбой объединения стен больше не скрывает кладку всего пространства. diff --git a/docs/WALL-THICKNESS.md b/docs/WALL-THICKNESS.md index 6ba29b87..fa04f57d 100644 --- a/docs/WALL-THICKNESS.md +++ b/docs/WALL-THICKNESS.md @@ -135,7 +135,10 @@ Exterior connectivity alone is not sufficient (#275). At a degree-3+ node, every finite ray that has a perpendicular partner owns its complete physical strip through the local repair window. The effective bevel cut excludes the union of those protected strips, then the reconstruction unions them back as a -boolean fail-safe. Rays are classified pair by pair: a diagonal ray in a mixed +boolean fail-safe. A pair remains physically near-orthogonal when its angular +deviation from 90° is at most `0.25°` (#279); this small drafting tolerance is +independent of `cell_cm` and does not rewrite the saved axes. Rays are +classified pair by pair: a diagonal ray in a mixed orthogonal/diagonal node remains subject to the bounded #249 bevel unless it has its own perpendicular partner. The non-orthogonal #249 fixture therefore keeps its approved empty wedge. Protected strips are unioned once for the diff --git a/src/wall-thickness.ts b/src/wall-thickness.ts index 1cfea8e5..d6545551 100644 --- a/src/wall-thickness.ts +++ b/src/wall-thickness.ts @@ -80,8 +80,13 @@ export const MITRE_LIMIT = 4; /** Multi-ray joins stay inside this × the largest incident half-depth (#249). */ export const MULTI_WALL_JOIN_LIMIT = 1.25; -/** Normalized dot-product tolerance for a physically orthogonal ray pair. */ -export const MULTI_WALL_ORTHOGONAL_DOT_EPSILON = 1e-9; +/** Maximum drafting deviation still rendered as a physical T/X junction (#279). */ +export const MULTI_WALL_NEAR_ORTHOGONAL_MAX_DEGREES = 0.25; + +/** Normalized dot-product tolerance for a physically near-orthogonal ray pair. */ +export const MULTI_WALL_ORTHOGONAL_DOT_EPSILON = Math.sin( + MULTI_WALL_NEAR_ORTHOGONAL_MAX_DEGREES * Math.PI / 180, +); export interface MultiWallNodeRaySupport { /** Physical half-depth owned by this finite co-directional interval. */ diff --git a/test/fixtures/279-near-orthogonal-junction.json b/test/fixtures/279-near-orthogonal-junction.json new file mode 100644 index 00000000..303ea85d --- /dev/null +++ b/test/fixtures/279-near-orthogonal-junction.json @@ -0,0 +1,54 @@ +{ + "cell_cm": 1, + "node": [-0.354166667, 2.954166667], + "rooms": [ + { + "id": "north-west", + "poly": [ + [-1.670833333, 3.866666667], + [-1.670833333, 2.95], + [-0.354166667, 2.954166667], + [-0.354166667, 3.866666667] + ] + }, + { + "id": "south-west", + "poly": [ + [-1.670833333, 2.95], + [-1.670833333, 2.0875], + [-0.354166667, 2.0875], + [-0.354166667, 2.954166667] + ] + }, + { + "id": "east", + "poly": [ + [-0.354166667, 2.954166667], + [-0.354166667, 2.2875], + [1.058333333, 2.2875], + [1.058333333, 3.866666667], + [-0.354166667, 3.866666667] + ] + } + ], + "walls": [ + { + "key": "-1.012500,2.954167@0.0033", + "a": [-1.670833333, 2.95], + "b": [-0.354166667, 2.954166667], + "cm": 20 + }, + { + "key": "-0.354167,2.620833@1.5706", + "a": [-0.354166667, 2.2875], + "b": [-0.354166667, 2.954166667], + "cm": 20 + }, + { + "key": "-0.354167,3.410417@1.5706", + "a": [-0.354166667, 2.954166667], + "b": [-0.354166667, 3.866666667], + "cm": 20 + } + ] +} diff --git a/test/wall-thickness.test.mjs b/test/wall-thickness.test.mjs index d6fb25d7..b03c226e 100644 --- a/test/wall-thickness.test.mjs +++ b/test/wall-thickness.test.mjs @@ -13,6 +13,7 @@ import { innerContourForRoom, innerEdgeSpan, ownEdgeOffsets, paperRoomShapesWithWalls, WALL_MIN_CM, WALL_MAX_CM, MITRE_LIMIT, MULTI_WALL_JOIN_LIMIT, buildMultiWallNodeMap, multiWallBevelTriangles, + MULTI_WALL_NEAR_ORTHOGONAL_MAX_DEGREES, MULTI_WALL_ORTHOGONAL_DOT_EPSILON, multiWallProtectedRayIndexes, multiWallProtectedStripGeometry, atomicPolyForRoom, insetOffsetsForRoom, wallIntervals, materializeWallIntervals, @@ -1079,6 +1080,88 @@ test('issue #275 classifies orthogonal rays by pair, including mixed-node and ep ); }); +test('issue #279 protects the exact near-orthogonal T without changing #249', () => { + const fixture = JSON.parse(readFileSync( + new URL('./fixtures/279-near-orthogonal-junction.json', import.meta.url), 'utf8', + )); + const rooms = fixture.rooms.map((room) => ({ + ...room, + poly: room.poly.map(([x, y]) => [x * NORM_W, y * NORM_W]), + })); + let walls = []; + for (const wall of fixture.walls) { + walls = setWallThickness( + walls, + wall.a.map((value) => value * NORM_W), + wall.b.map((value) => value * NORM_W), + wall.cm, + pitch, + NORM_W, + ); + } + const map = buildMultiWallNodeMap( + wallIntervals(rooms, walls, [], pitch, fixture.cell_cm, GRID_PITCH, NORM_W), + pitch * NORM_W * 0.04 * 4, + NORM_W, + ); + const expectedNode = fixture.node.map((value) => value * NORM_W); + const node = map.nodes.find((candidate) => Math.hypot( + candidate.point[0] - expectedNode[0], candidate.point[1] - expectedNode[1], + ) <= map.epsilon); + assert.ok(node, 'the exact beta.8 junction disappeared'); + assert.equal(node.rays.length, 3); + assert.deepEqual( + multiWallProtectedRayIndexes(node, 1e-9), + [], + 'the fixture must still reproduce the strict-dot regression', + ); + assert.deepEqual(multiWallProtectedRayIndexes(node), [0, 1, 2]); + + const protectedStrips = multiWallProtectedStripGeometry(node, map); + const geometry = wallBodiesGeometry( + rooms, walls, [], [], pitch, fixture.cell_cm, GRID_PITCH, NORM_W, + ); + assert.ok(protectedStrips && geometry); + closeTo(geometryDifferenceArea(protectedStrips, geometry.roomGeom), 0, 1e-6); + closeTo(geometryDifferenceArea(protectedStrips, geometry.geom), 0, 1e-6); + for (const ray of node.rays) { + assertProbeInside(geometry.geom, [ + node.point[0] + ray.u[0] * node.halfDepth * 2, + node.point[1] + ray.u[1] * node.halfDepth * 2, + ], 'a near-orthogonal arm detached from the junction'); + } +}); + +test('issue #279 near-orthogonal boundary is explicit, mirrored and bounded', () => { + const ray = (degrees) => { + const radians = degrees * Math.PI / 180; + return { + u: [Math.cos(radians), Math.sin(radians)], + halfDepth: 5, + length: 100, + supports: [{ halfDepth: 5, length: 100 }], + }; + }; + const node = (deviation) => ({ + point: [0, 0], + rays: [ray(0), ray(90 + deviation), ray(180)], + halfDepth: 5, + limit: 5 * MULTI_WALL_JOIN_LIMIT, + }); + const inside = MULTI_WALL_NEAR_ORTHOGONAL_MAX_DEGREES - 1e-6; + const outside = MULTI_WALL_NEAR_ORTHOGONAL_MAX_DEGREES + 1e-6; + for (const deviation of [0, 0.181315, -0.181315, inside, -inside]) { + assert.deepEqual(multiWallProtectedRayIndexes(node(deviation)), [0, 1, 2]); + } + for (const deviation of [outside, -outside, 1, -1]) { + assert.deepEqual( + multiWallProtectedRayIndexes(node(deviation)), + [], + `${deviation} degrees must retain the bounded non-orthogonal bevel`, + ); + } +}); + test('issue #271 keeps finite co-directional ray supports and never rebuilds past an endpoint', () => { const interval = (key, b, half) => ({ roomId: key, a: [0, 0], b, key, kind: 'outer', cm: half * 2,