mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
fix(walls): bound multi-wall repairs to finite rays
Issue: #271 User-Visible: yes
This commit is contained in:
+11
-1
@@ -153,7 +153,11 @@ export function prepareGoldenFixture(scenario) {
|
||||
if (scenario.junctionPatchResilience) {
|
||||
if (!Array.isArray(scenario.retainedWedgeProbe)
|
||||
|| scenario.retainedWedgeProbe.length !== 2
|
||||
|| !scenario.retainedWedgeProbe.every(Number.isFinite)) {
|
||||
|| !scenario.retainedWedgeProbe.every(Number.isFinite)
|
||||
|| !Array.isArray(scenario.absentWallProbes)
|
||||
|| scenario.absentWallProbes.length < 1
|
||||
|| scenario.absentWallProbes.some((point) => !Array.isArray(point)
|
||||
|| point.length !== 2 || !point.every(Number.isFinite))) {
|
||||
throw new Error(`invalid golden retainedWedgeProbe: ${scenario.id}`);
|
||||
}
|
||||
fixture.config.spaces.push({
|
||||
@@ -578,6 +582,12 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
|| !papers.some((paper) => paper.isPointInFill?.(point))) {
|
||||
throw new Error(`golden retained T-junction wedge contract failed: ${scenario.id}`);
|
||||
}
|
||||
for (const absent of scenario.absentWallProbes || []) {
|
||||
const absentPoint = new DOMPoint(absent[0] * 1000, absent[1] * card._spaceH);
|
||||
if (wall?.isPointInFill?.(absentPoint)) {
|
||||
throw new Error(`golden finite multi-wall ray contract failed: ${scenario.id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (scenario.wallKeyRoundtrip) {
|
||||
const { node, incidentArm } = scenario.wallKeyRoundtrip;
|
||||
|
||||
@@ -263,10 +263,12 @@ export const GOLDEN_SCENARIOS = Object.freeze([
|
||||
{ id: 'junction-patch-resilience-plan-dark', fixture: 'visual',
|
||||
space: 'golden-junction-patch-resilience', junctionPatchResilience: true, mode: 'plan',
|
||||
retainedWedgeProbe: [0.8955, 0.556],
|
||||
absentWallProbes: [[0.420833333, 0.37625], [0.936524285, 0.345833333]],
|
||||
theme: 'dark', viewport: { width: 1180, height: 900 }, ...page },
|
||||
{ id: 'junction-patch-resilience-view-dark', fixture: 'visual',
|
||||
space: 'golden-junction-patch-resilience', junctionPatchResilience: true, mode: 'view',
|
||||
retainedWedgeProbe: [0.8955, 0.556],
|
||||
absentWallProbes: [[0.420833333, 0.37625], [0.936524285, 0.345833333]],
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'multiwall-junction-bevel-view-dark', fixture: 'visual',
|
||||
space: 'golden-multiwall-junction', mode: 'view',
|
||||
|
||||
@@ -12,8 +12,21 @@ const result = await page.evaluate(async (spaceFixture) => {
|
||||
const card = window.__card;
|
||||
const root = () => card.shadowRoot || card.renderRoot;
|
||||
const retainedWedgeProbe = [895.5, 556];
|
||||
const finiteRayOutsideProbe = [0.420833333 * 1000, 0.37625 * card._spaceH];
|
||||
const svgContains = (element, point = retainedWedgeProbe) =>
|
||||
!!element?.isPointInFill?.(new DOMPoint(point[0], point[1]));
|
||||
const pathDataContains = (d, point) => {
|
||||
const svgRoot = root().querySelector('svg');
|
||||
if (!svgRoot || !d) return false;
|
||||
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
path.setAttribute('d', d);
|
||||
path.setAttribute('fill-rule', 'evenodd');
|
||||
path.setAttribute('visibility', 'hidden');
|
||||
svgRoot.append(path);
|
||||
const contains = svgContains(path, point);
|
||||
path.remove();
|
||||
return contains;
|
||||
};
|
||||
const ringContains = (ring, point) => {
|
||||
let inside = false;
|
||||
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
|
||||
@@ -79,6 +92,7 @@ const result = await page.evaluate(async (spaceFixture) => {
|
||||
out.planKeepsMasonry = !!planD && !!planPath && canonical?.d === planD;
|
||||
out.paperKeepsFootprint = !!canonical?.paperD && !!root().querySelector('.hp-paper');
|
||||
out.planRetainsMeasuredWedge = svgContains(planPath);
|
||||
out.planStopsAtFiniteRayEndpoint = !svgContains(planPath, finiteRayOutsideProbe);
|
||||
out.paperRetainsMeasuredWedge = [...root().querySelectorAll('.hp-paper')]
|
||||
.some((paper) => svgContains(paper));
|
||||
|
||||
@@ -89,6 +103,9 @@ const result = await page.evaluate(async (spaceFixture) => {
|
||||
&& barriers.occluders.length > 0
|
||||
&& !!barriers.fingerprint;
|
||||
out.lightAndSunRetainMeasuredWedge = geometryContains(barriers.masonryGeometry);
|
||||
out.lightAndSunStopAtFiniteRayEndpoint = !geometryContains(
|
||||
barriers.masonryGeometry, finiteRayOutsideProbe,
|
||||
);
|
||||
const barrierFingerprint = barriers.fingerprint;
|
||||
const cacheBeforeState = card._wallUnionCache;
|
||||
|
||||
@@ -113,6 +130,9 @@ const result = await page.evaluate(async (spaceFixture) => {
|
||||
await update(false);
|
||||
out.viewMatchesPlan = root().querySelector('[data-hp="wall"]')?.getAttribute('d') === planD;
|
||||
out.viewRetainsMeasuredWedge = svgContains(root().querySelector('[data-hp="wall"]'));
|
||||
out.viewStopsAtFiniteRayEndpoint = !svgContains(
|
||||
root().querySelector('[data-hp="wall"]'), finiteRayOutsideProbe,
|
||||
);
|
||||
card._hoverRoom = { space: spaceFixture.id, room: model.rooms[0] };
|
||||
const hoverFloor = card._roomHoverPaths(model);
|
||||
out.cleanFloorConsumerStaysNonEmpty = !!hoverFloor?.fillD && !!hoverFloor.outlineD;
|
||||
@@ -120,10 +140,13 @@ const result = await page.evaluate(async (spaceFixture) => {
|
||||
card._hoverRoom = { space: spaceFixture.id, room };
|
||||
const floor = card._roomHoverPaths(model);
|
||||
if (!floor?.fillD) return true;
|
||||
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
path.setAttribute('d', floor.fillD);
|
||||
path.setAttribute('fill-rule', 'evenodd');
|
||||
return !svgContains(path);
|
||||
return !pathDataContains(floor.fillD, retainedWedgeProbe);
|
||||
});
|
||||
out.cleanFloorOwnsAreaAfterFiniteEndpoint = model.rooms.some((room) => {
|
||||
card._hoverRoom = { space: spaceFixture.id, room };
|
||||
const floor = card._roomHoverPaths(model);
|
||||
if (!floor?.fillD) return false;
|
||||
return pathDataContains(floor.fillD, finiteRayOutsideProbe);
|
||||
});
|
||||
card._hoverRoom = null;
|
||||
|
||||
@@ -132,6 +155,9 @@ const result = await page.evaluate(async (spaceFixture) => {
|
||||
await update(false);
|
||||
out.kioskMatchesPlan = root().querySelector('[data-hp="wall"]')?.getAttribute('d') === planD;
|
||||
out.kioskRetainsMeasuredWedge = svgContains(root().querySelector('[data-hp="wall"]'));
|
||||
out.kioskStopsAtFiniteRayEndpoint = !svgContains(
|
||||
root().querySelector('[data-hp="wall"]'), finiteRayOutsideProbe,
|
||||
);
|
||||
card._config.kiosk = kioskBefore;
|
||||
await update(false);
|
||||
|
||||
@@ -158,6 +184,9 @@ const result = await page.evaluate(async (spaceFixture) => {
|
||||
out.staticRetainsMeasuredWedge = svgContains(
|
||||
staticCard.renderRoot?.querySelector('[data-hp="wall"]'),
|
||||
);
|
||||
out.staticStopsAtFiniteRayEndpoint = !svgContains(
|
||||
staticCard.renderRoot?.querySelector('[data-hp="wall"]'), finiteRayOutsideProbe,
|
||||
);
|
||||
staticCard.remove();
|
||||
|
||||
const labs = Object.freeze(['iso']);
|
||||
@@ -169,6 +198,9 @@ const result = await page.evaluate(async (spaceFixture) => {
|
||||
out.hiddenIsoKeepsMasonry = !!root().querySelector('[data-hp="iso-walls"]')
|
||||
&& isoWalls.flat(2).length > 0;
|
||||
out.hiddenIsoRetainsMeasuredWedge = geometryContains(isoWalls);
|
||||
out.hiddenIsoStopsAtFiniteRayEndpoint = !geometryContains(
|
||||
isoWalls, finiteRayOutsideProbe,
|
||||
);
|
||||
|
||||
out.renderNeverWritesConfig = JSON.stringify(card._serverCfg.spaces[0]) === sourceBefore;
|
||||
return out;
|
||||
|
||||
@@ -429,9 +429,12 @@ prevents one cosmetic junction repair from blanking a whole plan without
|
||||
masking a real structural failure (#197).
|
||||
The same structural pass builds one scale-relative physical endpoint map for
|
||||
room profiles, exterior intervals and junction patches (#249). Co-directional
|
||||
duplicates collapse while opposite rays remain distinct. At degree 3+ nodes it
|
||||
uses `H = max(incident half-depth)` and clips excessive overlap to a straight
|
||||
bevel bounded by `1.25 × H`; degree-2 joins keep the legacy `MITRE_LIMIT = 4`.
|
||||
duplicates collapse while opposite rays remain distinct. Each canonical
|
||||
direction retains the non-dominated finite `(half-depth, length)` supports of
|
||||
its source intervals, so local reconstruction cannot invent masonry, paper or
|
||||
an occluder after a real endpoint (#271). At degree 3+ nodes it uses
|
||||
`H = max(incident half-depth)` and clips excessive overlap to a straight bevel
|
||||
bounded by `1.25 × H`; degree-2 joins keep the legacy `MITRE_LIMIT = 4`.
|
||||
The final bevel is applied to canonical masonry after its room/atomic/exterior
|
||||
union, preventing later boolean inputs from recreating the discarded spike.
|
||||
Canonical masonry replaces each affected local mask with complete physical ray
|
||||
|
||||
@@ -8,6 +8,11 @@
|
||||
place; fragments between two topology nodes or touching an opening boundary
|
||||
remain protected. Preview, Apply, reload and maintenance Undo use the same
|
||||
canonical result ([#273](https://github.com/Matysh/houseplan-card/issues/273)).
|
||||
- Degree-3+ wall junctions no longer extend a short wall arm up to `8×` the
|
||||
largest half-thickness. Plan, View, Static, hidden Iso, floor, shadows and
|
||||
light barriers now stop at the real saved endpoint, including when a short
|
||||
thick arm shares its direction with a longer thin wall
|
||||
([#271](https://github.com/Matysh/houseplan-card/issues/271)).
|
||||
|
||||
## v1.67.0-beta.5 — 2026-08-23
|
||||
|
||||
|
||||
@@ -15,6 +15,11 @@
|
||||
по-прежнему защищены. Preview, Apply, перезагрузка и служебная отмена
|
||||
используют один канонический результат
|
||||
([#273](https://github.com/Matysh/houseplan-card/issues/273)).
|
||||
- Стыки трёх и более стен больше не продолжают короткое плечо на расстояние до
|
||||
`8×` максимальной полутолщины. Plan, View, Static, скрытая изометрия, пол,
|
||||
тени и световые барьеры теперь заканчиваются в реальной сохранённой точке —
|
||||
в том числе когда короткое толстое плечо сонаправлено более длинной тонкой
|
||||
стене ([#271](https://github.com/Matysh/houseplan-card/issues/271)).
|
||||
|
||||
## v1.67.0-beta.5 — 2026-08-23
|
||||
|
||||
|
||||
@@ -2648,6 +2648,16 @@ require hands on real hardware — they remain for the human pass.
|
||||
junction-patch-resilience-plan-dark +
|
||||
junction-patch-resilience-view-dark; mutation:
|
||||
multi-wall-paper-full-origin-cut].
|
||||
- [ ] **Degree-3 repair stops at every finite ray endpoint (#271)**: canonical
|
||||
co-directional rays retain separate short-thick and long-thin supports;
|
||||
the rebuilt masonry, paper and light barrier contain the real short arm
|
||||
but no area after its endpoint. Plan, View, kiosk, Static, hidden Iso and
|
||||
clean floor agree, independent of owner order, winding and scale
|
||||
[unit: test/wall-thickness.test.mjs; auto:
|
||||
smoke_junction_patch_resilience; golden:
|
||||
junction-patch-resilience-plan-dark +
|
||||
junction-patch-resilience-view-dark; mutation:
|
||||
multi-wall-finite-ray-disabled].
|
||||
- [ ] **Openings cut the slab**: a door/window/gate on a thick wall leaves a gap in
|
||||
the body; the door swing is offset toward the inner face and gate leaves
|
||||
toward the exterior face; with
|
||||
|
||||
+3
-1
@@ -327,7 +327,9 @@ Wall thickness is stored in real units. A room may have different thicknesses
|
||||
on different spans. Open wall branches and T-junctions are allowed; shared
|
||||
geometry remains joined without painted end caps. At a saved T-junction both
|
||||
physical half-walls stay solid: the bounded bevel removes only an excessive
|
||||
projecting corner and never leaves a white triangular gap.
|
||||
projecting corner and never leaves a white triangular gap. A short arm stops at
|
||||
its real endpoint; the join repair never draws a cap, shadow or light barrier
|
||||
where no wall was saved.
|
||||
If Resize moves only part of a longer thick wall, the moved part keeps its
|
||||
thickness and follows the room while the remaining continuation stays in
|
||||
place. Openings on the moved part follow it in the same Undo/Redo operation.
|
||||
|
||||
@@ -391,7 +391,9 @@ T-соединение входит в проходящую стену без в
|
||||
карточке. Толщина каждого ранее поставленного отрезка остаётся своей; свободный
|
||||
конец незамкнутой стены остаётся плоским. У сохранённого T-стыка обе половины
|
||||
физической стены остаются сплошными: фаска убирает только чрезмерно выступающий
|
||||
угол и не оставляет белого треугольного клина.
|
||||
угол и не оставляет белого треугольного клина. Короткое плечо заканчивается в
|
||||
своей реальной точке: исправление стыка не рисует торец, тень или световой
|
||||
барьер там, где сохранённой стены нет.
|
||||
|
||||
Каждый законченный отрезок цепочки сохраняется сразу. Esc/Ctrl+Z удаляет
|
||||
последнюю точку. Pan, pinch и `pointercancel` ничего не завершают и не добавляют.
|
||||
|
||||
@@ -107,10 +107,13 @@ joins; bevel when the mitre spike exceeds `MITRE_LIMIT × thickness`.
|
||||
At a physical node with **three or more distinct incident rays**, the stricter
|
||||
multi-wall rule applies (#249). Shared room ownership and reversed interval
|
||||
direction do not create extra rays. One structural node map records the largest
|
||||
incident half-depth `H`; every excessive join is cut back with a straight local
|
||||
bevel and may not extend beyond `R = 1.25 × H`. Inside the room union, a bounded
|
||||
mask replaces the legacy ring with the complete finite ray strips, retains their
|
||||
overlap through `R`, and removes only the remaining excessive pairwise wedge.
|
||||
incident half-depth `H` and the finite `(half-depth, endpoint distance)` supports
|
||||
of every co-directional ray. A longer thin support never extends a shorter thick
|
||||
support, and no repair may continue either one beyond its saved endpoint (#271).
|
||||
Every excessive join is cut back with a straight local bevel and may not extend
|
||||
beyond `R = 1.25 × H`. Inside the room union, a bounded mask replaces the legacy
|
||||
ring with the complete finite ray strips, retains their overlap through `R`, and
|
||||
removes only the remaining excessive pairwise wedge.
|
||||
The same bounded rule applies to the exterior half-wall and paper envelope:
|
||||
local ray strips are clipped to that physical envelope rather than the room
|
||||
centre, so a valid T-junction cannot become a white wedge. This keeps the node
|
||||
|
||||
@@ -292,6 +292,19 @@ export const MUTANTS = [
|
||||
+ ' else if (envelope) localInside = intersection(localInside, envelope);',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'multi-wall-finite-ray-disabled',
|
||||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||||
+ '&& node --test --test-name-pattern="issue #271 keeps finite" '
|
||||
+ 'test/wall-thickness.test.mjs',
|
||||
because: 'a degree-3 repair must stop at every real interval endpoint; restoring the '
|
||||
+ 'node-wide 8H rectangle recreates the phantom wall and light barrier from #271',
|
||||
patches: [{
|
||||
file: 'src/wall-thickness.ts',
|
||||
find: ' const supportExtent = Math.min(extent, support.length);',
|
||||
replace: ' const supportExtent = extent;',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'wall-exact-span-fallback-disabled',
|
||||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||||
|
||||
@@ -43,11 +43,15 @@ export const SMOKE_LINKS = [
|
||||
{
|
||||
// #261: the browser observes the canonical wall/paper paths and downstream
|
||||
// geometries, while the pure bevel helpers remain internal to the bundle.
|
||||
symbols: ['multiWallBevelTriangles', 'multiWallBevelTrianglesAt'],
|
||||
symbols: [
|
||||
'multiWallBevelTriangles', 'multiWallBevelTrianglesAt',
|
||||
'buildMultiWallNodeMap', 'MultiWallNodeRay', 'MultiWallNodeRaySupport',
|
||||
],
|
||||
smokes: ['smoke_junction_patch_resilience.mjs', 'smoke_multiwall_junction.mjs'],
|
||||
because: 'the #197 smoke probes the repaired exterior wedge across Plan, View, kiosk, '
|
||||
+ 'Static, hidden Iso and light barriers, while the #249 smoke proves the old excessive '
|
||||
+ 'wedge stays empty; neither browser scenario calls the pure bevel helpers by name',
|
||||
+ 'wedge stays empty, while #271 also proves finite ray endpoints across Plan, Static, '
|
||||
+ 'hidden Iso and light barriers; the browser scenarios call none of the pure helpers by name',
|
||||
},
|
||||
{
|
||||
// #258: the browser sees only the resulting path/caches; it cannot call
|
||||
|
||||
+82
-28
@@ -48,10 +48,22 @@ 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;
|
||||
|
||||
export interface MultiWallNodeRaySupport {
|
||||
/** Physical half-depth owned by this finite co-directional interval. */
|
||||
halfDepth: number;
|
||||
/** Distance from the canonical node to the interval's real endpoint. */
|
||||
length: number;
|
||||
}
|
||||
|
||||
export interface MultiWallNodeRay {
|
||||
/** Unit direction from the canonical node toward the interval's other end. */
|
||||
u: [number, number];
|
||||
/** Largest incident half-depth at the node; used by the join formula. */
|
||||
halfDepth: number;
|
||||
/** Furthest real endpoint in this direction. */
|
||||
length: number;
|
||||
/** Non-dominated finite strips whose union is the physical ray support. */
|
||||
supports: MultiWallNodeRaySupport[];
|
||||
}
|
||||
|
||||
export interface MultiWallNode {
|
||||
@@ -1463,7 +1475,9 @@ export interface RoomWallProfile extends AtomicPoly {
|
||||
|
||||
interface PendingMultiWallNode {
|
||||
point: [number, number];
|
||||
rays: Array<{ u: [number, number]; halfDepth: number; angle: number }>;
|
||||
rays: Array<{
|
||||
u: [number, number]; halfDepth: number; length: number; angle: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
function spatialBucket(point: number[], epsilon: number): [number, number] {
|
||||
@@ -1565,39 +1579,72 @@ export function buildMultiWallNodeMap(
|
||||
const u: [number, number] = [dx / length, dy / length];
|
||||
let angle = Math.atan2(u[1], u[0]);
|
||||
if (angle < 0) angle += Math.PI * 2;
|
||||
node.rays.push({ u, halfDepth: endpoint.halfDepth, angle });
|
||||
node.rays.push({ u, halfDepth: endpoint.halfDepth, length, angle });
|
||||
}
|
||||
|
||||
const nodes: MultiWallNode[] = [];
|
||||
const angleEps = 1e-9;
|
||||
const canonicalSupports = (
|
||||
input: MultiWallNodeRaySupport[],
|
||||
): MultiWallNodeRaySupport[] => {
|
||||
// Endpoint clustering uses a deliberately visible plan-space tolerance;
|
||||
// dominance between already matched physical strips must not. Otherwise a
|
||||
// long thin strip can erase a shorter thick strip merely because their
|
||||
// half-depth difference is below the node lookup epsilon.
|
||||
const supportEps = 1e-9 * Math.max(1, scale);
|
||||
const validSupports = input.filter((support) => Number.isFinite(support.halfDepth)
|
||||
&& support.halfDepth > 0 && Number.isFinite(support.length) && support.length > eps);
|
||||
return validSupports
|
||||
.filter((support, index) => !validSupports.some((other, otherIndex) => (
|
||||
otherIndex !== index
|
||||
&& other.halfDepth >= support.halfDepth - supportEps
|
||||
&& other.length >= support.length - supportEps
|
||||
&& (other.halfDepth > support.halfDepth + supportEps
|
||||
|| other.length > support.length + supportEps
|
||||
|| otherIndex < index)
|
||||
)))
|
||||
.sort((a, b) => a.length - b.length || a.halfDepth - b.halfDepth)
|
||||
.map((support) => ({ ...support }));
|
||||
};
|
||||
for (const node of pending) {
|
||||
const sorted = node.rays.sort((a, b) => a.angle - b.angle || a.halfDepth - b.halfDepth);
|
||||
const rays: Array<{ u: [number, number]; halfDepth: number; angle: number }> = [];
|
||||
const sorted = node.rays.sort((a, b) => a.angle - b.angle
|
||||
|| a.length - b.length || a.halfDepth - b.halfDepth);
|
||||
const rays: Array<{
|
||||
u: [number, number]; angle: number; supports: MultiWallNodeRaySupport[];
|
||||
}> = [];
|
||||
for (const ray of sorted) {
|
||||
const previous = rays[rays.length - 1];
|
||||
if (previous && Math.abs(ray.angle - previous.angle) <= angleEps) {
|
||||
if (ray.halfDepth > previous.halfDepth) {
|
||||
previous.halfDepth = ray.halfDepth;
|
||||
previous.u = ray.u;
|
||||
}
|
||||
previous.supports.push({ halfDepth: ray.halfDepth, length: ray.length });
|
||||
} else {
|
||||
rays.push({ ...ray, u: [...ray.u] });
|
||||
rays.push({
|
||||
u: [...ray.u], angle: ray.angle,
|
||||
supports: [{ halfDepth: ray.halfDepth, length: ray.length }],
|
||||
});
|
||||
}
|
||||
}
|
||||
if (rays.length > 1
|
||||
&& Math.PI * 2 - rays[rays.length - 1].angle + rays[0].angle <= angleEps) {
|
||||
const last = rays.pop()!;
|
||||
if (last.halfDepth > rays[0].halfDepth) {
|
||||
rays[0].halfDepth = last.halfDepth;
|
||||
rays[0].u = last.u;
|
||||
}
|
||||
rays[0].supports.push(...last.supports);
|
||||
}
|
||||
if (rays.length < 3) continue;
|
||||
const halfDepth = Math.max(...rays.map((ray) => ray.halfDepth));
|
||||
const canonicalRays = rays.map((ray) => {
|
||||
const supports = canonicalSupports(ray.supports);
|
||||
return {
|
||||
u: [...ray.u] as [number, number],
|
||||
halfDepth: Math.max(...supports.map((support) => support.halfDepth)),
|
||||
length: Math.max(...supports.map((support) => support.length)),
|
||||
supports,
|
||||
};
|
||||
}).filter((ray) => Number.isFinite(ray.halfDepth) && ray.halfDepth > 0
|
||||
&& Number.isFinite(ray.length) && ray.length > eps);
|
||||
if (canonicalRays.length < 3) continue;
|
||||
const halfDepth = Math.max(...canonicalRays.map((ray) => ray.halfDepth));
|
||||
if (!(halfDepth > 0) || !Number.isFinite(halfDepth)) continue;
|
||||
nodes.push({
|
||||
point: [...node.point],
|
||||
rays: rays.map(({ u, halfDepth: half }) => ({ u: [...u], halfDepth: half })),
|
||||
rays: canonicalRays,
|
||||
halfDepth,
|
||||
limit: MULTI_WALL_JOIN_LIMIT * halfDepth,
|
||||
});
|
||||
@@ -2048,19 +2095,26 @@ function bevelMultiWallBody(
|
||||
let local: any = null;
|
||||
for (const ray of node.rays) {
|
||||
const n = [-ray.u[1], ray.u[0]];
|
||||
const rectangle = stableJunctionPatch([
|
||||
[node.point[0] + n[0] * ray.halfDepth,
|
||||
node.point[1] + n[1] * ray.halfDepth],
|
||||
[node.point[0] + ray.u[0] * extent + n[0] * ray.halfDepth,
|
||||
node.point[1] + ray.u[1] * extent + n[1] * ray.halfDepth],
|
||||
[node.point[0] + ray.u[0] * extent - n[0] * ray.halfDepth,
|
||||
node.point[1] + ray.u[1] * extent - n[1] * ray.halfDepth],
|
||||
[node.point[0] - n[0] * ray.halfDepth,
|
||||
node.point[1] - n[1] * ray.halfDepth],
|
||||
], map.coordinateScale);
|
||||
if (!rectangle) continue;
|
||||
const piece: any = closedRing(rectangle) as any;
|
||||
local = local ? union(local, piece) : piece;
|
||||
// A canonical direction may be owned by overlapping room intervals
|
||||
// with different depth/length pairs. Rebuild their finite union; using
|
||||
// the node-wide 8H extent here invents a wall after a short endpoint.
|
||||
for (const support of ray.supports) {
|
||||
const supportExtent = Math.min(extent, support.length);
|
||||
if (!(supportExtent > map.epsilon)) continue;
|
||||
const rectangle = stableJunctionPatch([
|
||||
[node.point[0] + n[0] * support.halfDepth,
|
||||
node.point[1] + n[1] * support.halfDepth],
|
||||
[node.point[0] + ray.u[0] * supportExtent + n[0] * support.halfDepth,
|
||||
node.point[1] + ray.u[1] * supportExtent + n[1] * support.halfDepth],
|
||||
[node.point[0] + ray.u[0] * supportExtent - n[0] * support.halfDepth,
|
||||
node.point[1] + ray.u[1] * supportExtent - n[1] * support.halfDepth],
|
||||
[node.point[0] - n[0] * support.halfDepth,
|
||||
node.point[1] - n[1] * support.halfDepth],
|
||||
], map.coordinateScale);
|
||||
if (!rectangle) continue;
|
||||
const piece: any = closedRing(rectangle) as any;
|
||||
local = local ? union(local, piece) : piece;
|
||||
}
|
||||
}
|
||||
for (const triangle of multiWallBevelTriangles({
|
||||
...map,
|
||||
|
||||
@@ -372,6 +372,10 @@ test('issue #197 golden keeps the complete junction fixture in Plan and View', (
|
||||
assert.equal(scenario.theme, 'dark');
|
||||
assert.equal(scenario.junctionPatchResilience, true);
|
||||
assert.deepEqual(scenario.retainedWedgeProbe, [0.8955, 0.556]);
|
||||
assert.deepEqual(
|
||||
scenario.absentWallProbes,
|
||||
[[0.420833333, 0.37625], [0.936524285, 0.345833333]],
|
||||
);
|
||||
const fixture = prepareGoldenFixture(scenario);
|
||||
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
|
||||
assert.ok(space);
|
||||
|
||||
@@ -837,6 +837,83 @@ test('issue #249 bounds the exported three-wall junction with straight bevels',
|
||||
assert.equal(JSON.stringify({ rooms, walls }), before, 'geometry mutated saved data');
|
||||
});
|
||||
|
||||
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,
|
||||
open: false, half,
|
||||
});
|
||||
const source = [
|
||||
interval('east-thick-short', [10, 0], 8),
|
||||
interval('east-thin-long', [100, 0], 4),
|
||||
interval('east-owner-duplicate', [100, 0], 4),
|
||||
interval('north', [0, -100], 5),
|
||||
interval('west', [-100, 0], 5),
|
||||
];
|
||||
const map = buildMultiWallNodeMap(source, 5);
|
||||
assert.equal(map.nodes.length, 1);
|
||||
const east = map.nodes[0].rays.find((ray) => ray.u[0] > 0.99);
|
||||
assert.ok(east);
|
||||
assert.equal(east.halfDepth, 8);
|
||||
assert.equal(east.length, 100);
|
||||
assert.deepEqual(east.supports, [
|
||||
{ halfDepth: 8, length: 10 },
|
||||
{ halfDepth: 4, length: 100 },
|
||||
], 'the shorter thick strip and longer thin strip need separate finite support');
|
||||
const permuted = buildMultiWallNodeMap(
|
||||
[...source].reverse().map((item) => ({ ...item, a: item.b, b: item.a })),
|
||||
5,
|
||||
);
|
||||
const raySignature = (nodeMap) => nodeMap.nodes[0].rays.map((ray) => ({
|
||||
u: ray.u.map((value) => Math.round(value * 1e9) / 1e9),
|
||||
halfDepth: ray.halfDepth,
|
||||
length: ray.length,
|
||||
supports: ray.supports,
|
||||
}));
|
||||
assert.deepEqual(raySignature(permuted), raySignature(map));
|
||||
|
||||
const rooms = [
|
||||
{ id: 'lower', poly: [[-1000, 0], [0, 0], [0, 20], [500, 20], [500, 1000], [-1000, 1000]] },
|
||||
{ id: 'upper', poly: [[-1000, -1000], [0, -1000], [0, 0], [-1000, 0]] },
|
||||
];
|
||||
let walls = [];
|
||||
for (const [a, b] of [
|
||||
[[-1000, 0], [0, 0]],
|
||||
[[0, -1000], [0, 0]],
|
||||
[[0, 0], [0, 20]],
|
||||
]) walls = setWallThickness(walls, a, b, 15, pitch, 1);
|
||||
const nodeMap = buildMultiWallNodeMap(
|
||||
wallIntervals(rooms, walls, [], pitch, 1, GRID_PITCH, 1),
|
||||
pitch * 0.04 * 4,
|
||||
);
|
||||
const node = nodeMap.nodes.find((candidate) =>
|
||||
Math.hypot(candidate.point[0], candidate.point[1]) < 1e-7);
|
||||
assert.ok(node);
|
||||
const short = node.rays.find((ray) => ray.u[1] > 0.99);
|
||||
assert.ok(short);
|
||||
closeTo(short.length, 20, 1e-7);
|
||||
assert.ok(8 * node.halfDepth > short.length * 5,
|
||||
'fixture no longer distinguishes the old 8H rebuild from the finite interval');
|
||||
|
||||
const geometry = wallBodiesGeometry(
|
||||
rooms, walls, [], [], pitch, 1, GRID_PITCH, 1,
|
||||
);
|
||||
assert.ok(geometry);
|
||||
assertProbeInside(geometry.geom, [0, 10], 'the finite short arm disappeared');
|
||||
assertProbeOutside(
|
||||
geometry.geom, [0, 100],
|
||||
'the degree-3 repair rebuilt masonry after the short ray endpoint',
|
||||
);
|
||||
assertProbeOutside(
|
||||
geometry.roomGeom, [0, 100],
|
||||
'the pre-opening canonical masonry still contains the phantom ray',
|
||||
);
|
||||
const cleanFloor = difference(closedGeometry(rooms[0].poly), geometry.roomGeom);
|
||||
assertProbeInside(
|
||||
cleanFloor, [1, 100],
|
||||
'the phantom ray still removes usable clean-floor area after its endpoint',
|
||||
);
|
||||
});
|
||||
|
||||
test('issue #249 node classification is order, direction and scale independent', () => {
|
||||
const cases = [
|
||||
{ angles: [0, 30, 200], halves: [5, 5, 5], bevel: true },
|
||||
@@ -866,6 +943,11 @@ test('issue #249 node classification is order, direction and scale independent',
|
||||
Math.round(ray.u[0] * 1e9) / 1e9,
|
||||
Math.round(ray.u[1] * 1e9) / 1e9,
|
||||
ray.halfDepth / scale,
|
||||
Math.round((ray.length / scale) * 1e9) / 1e9,
|
||||
ray.supports.map((support) => [
|
||||
support.halfDepth / scale,
|
||||
Math.round((support.length / scale) * 1e9) / 1e9,
|
||||
]),
|
||||
]),
|
||||
}));
|
||||
const makeFanGeometry = (fixture, permuted = false) => {
|
||||
@@ -1412,7 +1494,9 @@ test('issue #197 keeps the full masonry when one virtual-junction patch has ULP
|
||||
// #249 intentionally bevels degree-3+ nodes in this older fixture too.
|
||||
// #249 retains the physical multi-wall overlap up to R instead of reducing
|
||||
// right-angle arms to point contacts.
|
||||
closeTo(geometryArea(geometry.geom), 124568.27047237023, 1e-6);
|
||||
// #271 removes only the area that the old node-wide 8H rectangles invented
|
||||
// after finite ray endpoints; all semantic #197/#249/#261 probes above stay.
|
||||
closeTo(geometryArea(geometry.geom), 124244.26848307278, 1e-6);
|
||||
closeTo(geometryArea(geometry.paperGeom), 727303.8153386558, 1e-6);
|
||||
assert.equal(
|
||||
JSON.stringify({ rooms, walls, cuts, openings, extraBodies }), before,
|
||||
|
||||
Reference in New Issue
Block a user