mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
fix: preserve near-orthogonal wall junctions
Issue: #279 User-Visible: yes
This commit is contained in:
@@ -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);
|
||||
@@ -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
|
||||
|
||||
@@ -8,6 +8,11 @@
|
||||
|
||||
## Не выпущено
|
||||
|
||||
- Почти перпендикулярный T-стык теперь сохраняет всю реальную кладку при
|
||||
небольшом допустимом уклоне оси. Два белых клина исчезают во всех режимах,
|
||||
сохранённые координаты не выпрямляются, а явно диагональная фаска остаётся
|
||||
ограниченной ([#279](https://github.com/Matysh/houseplan-card/issues/279)).
|
||||
|
||||
## v1.67.0-beta.8 — 2026-08-24
|
||||
|
||||
- Локальный сбой объединения стен больше не скрывает кладку всего пространства.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user