fix: preserve near-orthogonal wall junctions

Issue: #279
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-08-24 10:27:13 +03:00
parent cd9916d72a
commit c0c30d645a
7 changed files with 254 additions and 3 deletions
+95
View File
@@ -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);
+6
View File
@@ -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
+5
View File
@@ -8,6 +8,11 @@
## Не выпущено
- Почти перпендикулярный T-стык теперь сохраняет всю реальную кладку при
небольшом допустимом уклоне оси. Два белых клина исчезают во всех режимах,
сохранённые координаты не выпрямляются, а явно диагональная фаска остаётся
ограниченной ([#279](https://github.com/Matysh/houseplan-card/issues/279)).
## v1.67.0-beta.8 — 2026-08-24
- Локальный сбой объединения стен больше не скрывает кладку всего пространства.
+4 -1
View File
@@ -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
+7 -2
View File
@@ -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. */
+54
View File
@@ -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
}
]
}
+83
View File
@@ -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,