mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-07 15:09:30 +00:00
+36
-27
@@ -55,7 +55,7 @@ import {
|
||||
import {
|
||||
degradeWalls, rekeyWallsAfterMove,
|
||||
setWallThickness, setWallThicknessForRoom, cmToField, wallCmToUnits,
|
||||
wallEdgeBodies, wallBodiesGeometry, wallBodiesUnionPath, paperRoomShapesWithWalls,
|
||||
wallEdgeBodies, wallBodiesGeometry, wallBodiesUnionPath,
|
||||
innerContourForRoom, roomWallProfile, outsetContour,
|
||||
openingInnerFaceOffsetFromIndex, openingTunnelGeometriesFromIndex,
|
||||
openingWallIndex as buildOpeningWallIndex, applyWallThicknessToNewRoom,
|
||||
@@ -4432,7 +4432,7 @@ class HouseplanCard extends LitElement {
|
||||
rooms: space.rooms, walls, openCuts, openings,
|
||||
partitions: space.partitions, roomDrafts: space.room_drafts, columns: space.wall_columns,
|
||||
cellCm: this._cellCm, gridPitch: this._gridPitch, wallKeyPitch: this._wallKeyPitch,
|
||||
camera: ISO_CAMERA, wallHeight: ISO_WALL_HEIGHT, algorithm: 1,
|
||||
camera: ISO_CAMERA, wallHeight: ISO_WALL_HEIGHT, algorithm: 2,
|
||||
})}`;
|
||||
return {
|
||||
key,
|
||||
@@ -9646,13 +9646,39 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
|
||||
/** Paper under rooms, grown by shared-wall half-thickness when set. */
|
||||
private _paperShapes(rooms: any[]): Array<{ poly: string } | { rect: { x: number; y: number; w: number; h: number; rx: number } }> {
|
||||
private _paperShapes(rooms: any[]): Array<
|
||||
| { path: string }
|
||||
| { poly: string }
|
||||
| { rect: { x: number; y: number; w: number; h: number; rx: number } }
|
||||
> {
|
||||
const walls = this._spaceWalls;
|
||||
if (!walls.length) return paperRoomShapes(rooms);
|
||||
const united = this._wallUnionGeometry();
|
||||
return united?.paperD ? [{ path: united.paperD }] : paperRoomShapes(rooms);
|
||||
}
|
||||
|
||||
/** Canonical paper + masonry geometry, cached by structural config epoch. */
|
||||
private _wallUnionGeometry(): ReturnType<typeof wallBodiesUnionPath> {
|
||||
const walls = this._spaceWalls;
|
||||
const extras = this._physicalBodiesR();
|
||||
if (!walls.length && !extras.length) return null;
|
||||
const openCuts = this._openPairs().flatMap((p) => p.segs);
|
||||
return paperRoomShapesWithWalls(
|
||||
rooms, walls, openCuts, this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W,
|
||||
);
|
||||
const openings = (this._curSpaceCfg?.openings || []).map((o: any) => ({
|
||||
x: Number(o.x) * NORM_W, y: Number(o.y) * NORM_W,
|
||||
angle: Number(o.angle) || 0,
|
||||
length: (Number(o.length) > 0 ? Number(o.length) : 0.9) * NORM_W,
|
||||
}));
|
||||
const unionKey = `${this._space}|${this._cfgEpoch}|${this._spaceModel().rooms.length}`;
|
||||
if (!this._wallUnionCache || this._wallUnionCache.key !== unionKey) {
|
||||
this._wallUnionCache = {
|
||||
key: unionKey,
|
||||
value: wallBodiesUnionPath(
|
||||
this._spaceModel().rooms, walls, openCuts, openings,
|
||||
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W, extras,
|
||||
),
|
||||
};
|
||||
}
|
||||
return this._wallUnionCache.value;
|
||||
}
|
||||
|
||||
/** Thick-wall spans in render units — suppress centreline stroke under bodies. */
|
||||
@@ -10009,26 +10035,7 @@ class HouseplanCard extends LitElement {
|
||||
if (this._renderProjection === 'iso') return svg`` as unknown as TemplateResult;
|
||||
if (disp && !disp.showBorders && (this._mode === 'view' || this._mode === 'devices'))
|
||||
return svg`` as unknown as TemplateResult;
|
||||
const walls = this._spaceWalls;
|
||||
const extras = this._physicalBodiesR();
|
||||
if (!walls.length && !extras.length) return svg`` as unknown as TemplateResult;
|
||||
const openCuts = this._openPairs().flatMap((p) => p.segs);
|
||||
const openings = (this._curSpaceCfg?.openings || []).map((o: any) => ({
|
||||
x: Number(o.x) * NORM_W, y: Number(o.y) * NORM_W,
|
||||
angle: Number(o.angle) || 0,
|
||||
length: (Number(o.length) > 0 ? Number(o.length) : 0.9) * NORM_W,
|
||||
}));
|
||||
const unionKey = `${this._space}|${this._cfgEpoch}|${this._spaceModel().rooms.length}`;
|
||||
if (!this._wallUnionCache || this._wallUnionCache.key !== unionKey) {
|
||||
this._wallUnionCache = {
|
||||
key: unionKey,
|
||||
value: wallBodiesUnionPath(
|
||||
this._spaceModel().rooms, walls, openCuts, openings,
|
||||
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W, extras,
|
||||
),
|
||||
};
|
||||
}
|
||||
const united = this._wallUnionCache.value;
|
||||
const united = this._wallUnionGeometry();
|
||||
if (!united) return svg`` as unknown as TemplateResult;
|
||||
const stage = this._stageEl;
|
||||
const v = this._viewOr(this._baseVb());
|
||||
@@ -14026,7 +14033,9 @@ class HouseplanCard extends LitElement {
|
||||
(styles.ts) is composited once for the whole sheet, so
|
||||
adjacent rooms never cast seams onto each other's paper. */}
|
||||
${this._wallHatchDefs(disp.color)}${svg`<g class="hp-paperg">${this._paperShapes(space.rooms).map((sh) =>
|
||||
'poly' in sh
|
||||
'path' in sh
|
||||
? svg`<path class="hp-paper" d="${sh.path}" fill-rule="evenodd" pointer-events="none"></path>`
|
||||
: 'poly' in sh
|
||||
? svg`<polygon class="hp-paper" points="${sh.poly}" pointer-events="none"></polygon>`
|
||||
: svg`<rect class="hp-paper" x="${sh.rect.x}" y="${sh.rect.y}" width="${sh.rect.w}" height="${sh.rect.h}" rx="${sh.rect.rx}" pointer-events="none"></rect>`,
|
||||
)}</g>`}
|
||||
|
||||
+40
-9
@@ -12,9 +12,7 @@ import {
|
||||
spaceDisplayOf, fillColorsOf, roomFillModeOf, roomGlowOf,
|
||||
roomCustomFillOf, resolveEffectiveRoomFill, stageBgOf, paperRoomShapes,
|
||||
} from './logic';
|
||||
import {
|
||||
wallBodiesUnionPath, paperRoomShapesWithWalls, wallBodyNeedsSolid, type WallEntry,
|
||||
} from './wall-thickness';
|
||||
import { wallBodiesUnionPath, wallBodyNeedsSolid, type WallEntry } from './wall-thickness';
|
||||
import { DEFAULT_ICON_RULES, compileIconRules, EXCLUDED_DOMAINS } from './rules';
|
||||
import { t, type Lang } from './i18n';
|
||||
import { bgModeOf, northDegOf, sunStateOf, dayPhase } from './sun';
|
||||
@@ -27,6 +25,7 @@ import {
|
||||
import { presentationSnapshotKey } from './render-device-snapshot';
|
||||
import { deviceFaceStyle, renderDeviceFace } from './device-face';
|
||||
import { valueBadgeTitle } from './device-value-badge';
|
||||
import { contentFingerprint } from './visual-continuity';
|
||||
import {
|
||||
spaceModels, roomCenter, defaultPositions, markerPos, labelPos, spaceFrame, iconCqw, NORM_W,
|
||||
GRID_STEP_N, GRID_PITCH,
|
||||
@@ -35,6 +34,29 @@ import {
|
||||
|
||||
export { spaceModels } from './space-geometry';
|
||||
|
||||
type StaticWallGeometry = ReturnType<typeof wallBodiesUnionPath>;
|
||||
type StaticWallGeometryEntry = { fingerprint: string; value: StaticWallGeometry };
|
||||
const staticWallGeometryCache = new WeakMap<object, Map<string, StaticWallGeometryEntry>>();
|
||||
|
||||
/** Static cards receive the same immutable server-config object on HA ticks. */
|
||||
function cachedStaticWallGeometry(
|
||||
cfg: ServerConfig,
|
||||
spaceId: string,
|
||||
fingerprint: string,
|
||||
build: () => StaticWallGeometry,
|
||||
): StaticWallGeometry {
|
||||
let spaces = staticWallGeometryCache.get(cfg as object);
|
||||
if (!spaces) {
|
||||
spaces = new Map<string, StaticWallGeometryEntry>();
|
||||
staticWallGeometryCache.set(cfg as object, spaces);
|
||||
}
|
||||
const cached = spaces.get(spaceId);
|
||||
if (cached?.fingerprint === fingerprint) return cached.value;
|
||||
const value = build();
|
||||
spaces.set(spaceId, { fingerprint, value });
|
||||
return value;
|
||||
}
|
||||
|
||||
export interface StaticRenderOpts {
|
||||
hass: any;
|
||||
registry?: HaRegistrySnapshot;
|
||||
@@ -306,12 +328,19 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
|
||||
// gaps between detached buildings, and an empty space has no paper at all,
|
||||
// image or no image. The picture is drawn ON the paper, one layer above.
|
||||
|
||||
const paperShapes = walls.length
|
||||
? paperRoomShapesWithWalls(space.rooms, walls, [], GRID_STEP_N, cellCm, GRID_PITCH, NORM_W)
|
||||
: paperRoomShapes(space.rooms);
|
||||
const wallUnion = (walls.length || extras.length) && disp.showBorders
|
||||
? wallBodiesUnionPath(space.rooms, walls, [], [], GRID_STEP_N, cellCm, GRID_PITCH, NORM_W, extras)
|
||||
const needsCanonicalWallGeometry = !!(walls.length || (extras.length && disp.showBorders));
|
||||
const wallGeometryFingerprint = needsCanonicalWallGeometry
|
||||
? contentFingerprint({ rooms: space.rooms, walls, extras, cellCm })
|
||||
: '';
|
||||
const canonicalWallGeometry = needsCanonicalWallGeometry
|
||||
? cachedStaticWallGeometry(o.cfg, space.id, wallGeometryFingerprint, () => wallBodiesUnionPath(
|
||||
space.rooms, walls, [], [], GRID_STEP_N, cellCm, GRID_PITCH, NORM_W, extras,
|
||||
))
|
||||
: null;
|
||||
const paperShapes = walls.length && canonicalWallGeometry?.paperD
|
||||
? [{ path: canonicalWallGeometry.paperD }]
|
||||
: paperRoomShapes(space.rooms);
|
||||
const wallUnion = disp.showBorders ? canonicalWallGeometry : null;
|
||||
const pxPerUnit = o.stageWidth && vb[2] ? o.stageWidth / vb[2] : 1;
|
||||
const solidWall = !!wallUnion && wallBodyNeedsSolid(wallUnion.depthUnits, pxPerUnit);
|
||||
const wallStroke = disp.color || '#607d8b';
|
||||
@@ -326,7 +355,9 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
|
||||
</pattern>
|
||||
</defs>` : nothing}
|
||||
${paperShapes.map((sh) =>
|
||||
'poly' in sh
|
||||
'path' in sh
|
||||
? svg`<path class="hp-paper" d="${sh.path}" fill-rule="evenodd"></path>`
|
||||
: 'poly' in sh
|
||||
? svg`<polygon class="hp-paper" points="${sh.poly}"></polygon>`
|
||||
: svg`<rect class="hp-paper" x="${sh.rect.x}" y="${sh.rect.y}" width="${sh.rect.w}" height="${sh.rect.h}" rx="${sh.rect.rx}"></rect>`,
|
||||
)}
|
||||
|
||||
+177
-39
@@ -6,8 +6,8 @@
|
||||
* displayed m² use the inner (inset) contour. Wall-length rulers stay on the
|
||||
* centreline.
|
||||
*/
|
||||
import { union, difference } from 'polyclip-ts';
|
||||
import { polygonArea, roomPoly, roomEdges, sharedBoundary } from './logic';
|
||||
import { union, difference, intersection } from 'polyclip-ts';
|
||||
import { polygonArea, roomPoly, roomEdges, sharedBoundary, paperRoomShapes } from './logic';
|
||||
|
||||
export interface WallEntry {
|
||||
key: string;
|
||||
@@ -1206,6 +1206,113 @@ function closedRing(poly: number[][]): number[][][] {
|
||||
return [ring];
|
||||
}
|
||||
|
||||
interface ExteriorEnvelopeGeometry {
|
||||
/** Union of room centrelines. Shared Split edges disappear from this shape. */
|
||||
centre: any;
|
||||
/** Wall shell generated only from the surviving exterior boundary. */
|
||||
shell: any;
|
||||
}
|
||||
|
||||
/** Open every ring of a polyclip MultiPolygon and drop its closing duplicate. */
|
||||
function geometryRings(geom: any): number[][][] {
|
||||
const out: number[][][] = [];
|
||||
for (const polygon of Array.isArray(geom) ? geom : []) {
|
||||
if (!Array.isArray(polygon)) continue;
|
||||
for (const raw of polygon) {
|
||||
if (!Array.isArray(raw) || raw.length < 4) continue;
|
||||
const ring = raw.slice(0, -1).map((p: number[]) => [p[0], p[1]]);
|
||||
if (ring.length >= 3) out.push(ring);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function pointOnSegment(p: number[], a: number[], b: number[], eps: number): boolean {
|
||||
if (distToSeg(p[0], p[1], a[0], a[1], b[0], b[1]) > eps) return false;
|
||||
const dx = b[0] - a[0], dy = b[1] - a[1];
|
||||
const dot = (p[0] - a[0]) * dx + (p[1] - a[1]) * dy;
|
||||
const len2 = dx * dx + dy * dy;
|
||||
return dot >= -eps && dot <= len2 + eps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a boolean-union boundary at every stored exterior interval endpoint.
|
||||
* Polyclip is allowed to collapse a collinear child-room vertex; retaining the
|
||||
* interval breakpoints is what preserves unequal wall depths on the two sides.
|
||||
*/
|
||||
function exteriorBoundaryProfile(
|
||||
ring: number[][],
|
||||
outer: WallInterval[],
|
||||
eps: number,
|
||||
): { poly: number[][]; offsets: number[] } | null {
|
||||
const poly: number[][] = [];
|
||||
const offsets: number[] = [];
|
||||
for (let i = 0; i < ring.length; i++) {
|
||||
const a = ring[i], b = ring[(i + 1) % ring.length];
|
||||
const dx = b[0] - a[0], dy = b[1] - a[1];
|
||||
const len2 = dx * dx + dy * dy;
|
||||
if (!(len2 > eps * eps)) continue;
|
||||
const cuts = [0, 1];
|
||||
for (const iv of outer) {
|
||||
for (const p of [iv.a, iv.b]) {
|
||||
if (!pointOnSegment(p, a, b, eps)) continue;
|
||||
const t = ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / len2;
|
||||
if (t > eps && t < 1 - eps) cuts.push(t);
|
||||
}
|
||||
}
|
||||
cuts.sort((x, y) => x - y);
|
||||
const unique = cuts.filter((t, at) => at === 0 || Math.abs(t - cuts[at - 1]) > eps);
|
||||
for (let at = 0; at < unique.length - 1; at++) {
|
||||
const t0 = unique[at], t1 = unique[at + 1];
|
||||
const p = [a[0] + dx * t0, a[1] + dy * t0];
|
||||
const mid = [a[0] + dx * (t0 + t1) / 2, a[1] + dy * (t0 + t1) / 2];
|
||||
let half = 0;
|
||||
for (const iv of outer) {
|
||||
if (pointOnSegment(mid, iv.a, iv.b, eps)) half = Math.max(half, iv.half);
|
||||
}
|
||||
poly.push(p);
|
||||
offsets.push(half);
|
||||
}
|
||||
}
|
||||
return poly.length >= 3 && offsets.length === poly.length ? { poly, offsets } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exterior masonry is derived from the union of room centrelines, not from
|
||||
* each room independently. A Split edge therefore vanishes before mitres are
|
||||
* built and cannot turn its artificial child corner into part of the facade.
|
||||
*/
|
||||
function exteriorEnvelopeGeometry(
|
||||
rooms: any[],
|
||||
walls: WallEntry[] | null | undefined,
|
||||
openCuts: number[][],
|
||||
pitch: number,
|
||||
cellCm: number,
|
||||
gridPitch: number,
|
||||
coordScale: number,
|
||||
): ExteriorEnvelopeGeometry | null {
|
||||
const polys = (rooms || []).map(roomPoly).filter((p): p is number[][] => !!p && p.length >= 3);
|
||||
if (!polys.length) return null;
|
||||
let centre: any = union(closedRing(polys[0]) as any);
|
||||
for (let i = 1; i < polys.length; i++) centre = union(centre, closedRing(polys[i]) as any);
|
||||
|
||||
const outer = wallIntervals(
|
||||
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale,
|
||||
).filter((iv) => iv.kind === 'outer' && iv.half > 0);
|
||||
const eps = openEps(pitch, coordScale) * 4;
|
||||
let shell: any = null;
|
||||
for (const ring of geometryRings(centre)) {
|
||||
const profile = exteriorBoundaryProfile(ring, outer, eps);
|
||||
if (!profile || !profile.offsets.some((o) => o > 0)) continue;
|
||||
const outset = outsetContour(profile.poly, profile.offsets);
|
||||
const inset = insetContour(profile.poly, profile.offsets);
|
||||
if (!outset || !inset) continue;
|
||||
const piece = difference(closedRing(outset) as any, closedRing(inset) as any);
|
||||
shell = shell ? union(shell, piece) : piece;
|
||||
}
|
||||
return { centre, shell: shell || [] };
|
||||
}
|
||||
|
||||
function polyclipToPathD(geom: any): string {
|
||||
if (!geom) return '';
|
||||
let d = '';
|
||||
@@ -1368,7 +1475,7 @@ export function wallBodiesGeometry(
|
||||
gridPitch: number,
|
||||
coordScale = 1,
|
||||
extraBodies: number[][][] = [],
|
||||
): { geom: any; depthUnits: number } | null {
|
||||
): { geom: any; paperGeom: any; depthUnits: number } | null {
|
||||
if (!walls?.length && !extraBodies.length) return null;
|
||||
const roomRings: { outset: number[][]; inset: number[][] | null }[] = [];
|
||||
let maxDepth = 0;
|
||||
@@ -1404,17 +1511,54 @@ export function wallBodiesGeometry(
|
||||
? openingWallIndex(rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale)
|
||||
: null;
|
||||
try {
|
||||
const exterior = exteriorEnvelopeGeometry(
|
||||
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale,
|
||||
);
|
||||
// Paper and masonry share this one structural pass. Renderers cache the
|
||||
// returned pair, so a live HA state update never repeats exterior topology.
|
||||
const paperGeom = exterior
|
||||
? (exterior.shell?.length ? union(exterior.centre, exterior.shell) : exterior.centre)
|
||||
: [];
|
||||
const bodyOf = (ring: typeof roomRings[number]): any => {
|
||||
const outset: any = closedRing(ring.outset);
|
||||
return ring.inset ? difference(outset, closedRing(ring.inset) as any) : outset;
|
||||
};
|
||||
let body: any = roomRings.length ? bodyOf(roomRings[0]) : null;
|
||||
for (let i = 1; i < roomRings.length; i++) body = union(body, bodyOf(roomRings[i]));
|
||||
let body: any = null;
|
||||
for (const ring of roomRings) {
|
||||
try {
|
||||
const piece = bodyOf(ring);
|
||||
body = body ? union(body, piece) : piece;
|
||||
} catch {
|
||||
// An acute child contour may be invalid for boolean subtraction. The
|
||||
// interval pass below still supplies its physical wall without letting
|
||||
// the artificial mitre back into the exterior envelope.
|
||||
}
|
||||
}
|
||||
// Per-room rings preserve established L/T/nested joins. Atomic quads are
|
||||
// also included so a rejected acute child ring cannot remove a divider or
|
||||
// an interior half-wall. Clipping them to the centre union gives a hard
|
||||
// facade boundary; the canonical exterior shell is added afterwards.
|
||||
if (exterior) {
|
||||
for (const edge of wallEdgeBodies(
|
||||
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale,
|
||||
)) {
|
||||
try {
|
||||
const piece = intersection(closedRing(edge.quad) as any, exterior.centre);
|
||||
body = body ? union(body, piece) : piece;
|
||||
} catch {
|
||||
// A valid per-room ring may already own this interval. If neither
|
||||
// representation is usable the final result fails closed below.
|
||||
}
|
||||
}
|
||||
}
|
||||
// The room-ring subtraction above cannot infer a mitre between real arms
|
||||
// owned by different contours at a virtual T. Add only those missing
|
||||
// junction pieces, then let physical openings cut through them as usual.
|
||||
for (const patch of junctions)
|
||||
body = body ? union(body, closedRing(patch) as any) : closedRing(patch);
|
||||
if (body && exterior) body = intersection(body, exterior.centre);
|
||||
if (exterior?.shell?.length)
|
||||
body = body ? union(body, exterior.shell) : exterior.shell;
|
||||
// cut opening tunnels (axis-aligned to opening angle)
|
||||
for (const o of openings) {
|
||||
if (!(o.length > 0)) continue;
|
||||
@@ -1439,7 +1583,7 @@ export function wallBodiesGeometry(
|
||||
if (extra.length < 3) continue;
|
||||
body = body ? union(body, closedRing(extra) as any) : [closedRing(extra)];
|
||||
}
|
||||
return { geom: body || [], depthUnits: maxDepth };
|
||||
return { geom: body || [], paperGeom, depthUnits: maxDepth };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -1457,31 +1601,19 @@ export function wallBodiesUnionPath(
|
||||
/** Independent physical bodies are unioned only after room openings are cut,
|
||||
* so a door/window/gate can never punch a coincident partition or column. */
|
||||
extraBodies: number[][][] = [],
|
||||
): { d: string; depthUnits: number; fillRule: 'evenodd' | 'nonzero' } | null {
|
||||
): { d: string; paperD: string; depthUnits: number; fillRule: 'evenodd' | 'nonzero' } | null {
|
||||
if (!walls?.length && !extraBodies.length) return null;
|
||||
const united = wallBodiesGeometry(
|
||||
rooms, walls, openCuts, openings, pitch, cellCm, gridPitch, coordScale, extraBodies,
|
||||
);
|
||||
const d = united ? polyclipToPathD(united.geom) : '';
|
||||
if (united && d) return { d, depthUnits: united.depthUnits, fillRule: 'evenodd' };
|
||||
const paperD = united ? polyclipToPathD(united.paperGeom) : '';
|
||||
if (united && d) return { d, paperD, depthUnits: united.depthUnits, fillRule: 'evenodd' };
|
||||
if (united) return null; // successful empty result: do not resurrect raw rings
|
||||
// fall back to evenodd rings concatenated
|
||||
const rings = wallBodyRings(rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale);
|
||||
const extraD = extraBodies.map((poly) => polyToPath(poly)).join(' ');
|
||||
if (!rings.length && !extraD) return null;
|
||||
// `united` is null on this branch: a successful-but-empty union returned
|
||||
// above and must not be resurrected by the raw-ring fallback.
|
||||
let maxDepth = 0;
|
||||
for (const ring of rings) maxDepth = Math.max(maxDepth, ring.depthUnits);
|
||||
// Each room ring already reverses its inset. `nonzero` therefore keeps
|
||||
// floors as holes while overlapping independent rings add instead of
|
||||
// cancelling one another (the old even-odd fallback produced pinholes at
|
||||
// exactly the complex junctions for which a fallback is needed).
|
||||
return {
|
||||
d: [rings.map((r) => r.d).join(' '), extraD].filter(Boolean).join(' '),
|
||||
depthUnits: maxDepth,
|
||||
fillRule: 'nonzero',
|
||||
};
|
||||
// Fail closed. The old raw per-room-ring fallback is the exact algorithm
|
||||
// that creates an exterior tooth at a corner Split, so resurrecting it after
|
||||
// a boolean failure would make malformed input violate the facade invariant.
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1666,22 +1798,28 @@ export function paperRoomShapesWithWalls(
|
||||
cellCm: number,
|
||||
gridPitch: number,
|
||||
coordScale = 1,
|
||||
): Array<{ poly: string } | { rect: { x: number; y: number; w: number; h: number; rx: number } }> {
|
||||
const out: Array<{ poly: string } | { rect: { x: number; y: number; w: number; h: number; rx: number } }> = [];
|
||||
for (const r of rooms || []) {
|
||||
const poly = roomPoly(r);
|
||||
if (poly && poly.length >= 3) {
|
||||
const pr = roomWallProfile(rooms, r.id, walls, openCuts, pitch, cellCm, gridPitch, coordScale);
|
||||
const grown = pr && pr.offsets.some((o) => o > 0)
|
||||
? outsetContour(pr.poly, pr.offsets)
|
||||
: null;
|
||||
const use = grown || poly;
|
||||
out.push({ poly: use.map((p) => p.join(',')).join(' ') });
|
||||
} else if (r && r.x != null && r.y != null && r.w != null && r.h != null) {
|
||||
out.push({ rect: { x: r.x, y: r.y, w: r.w, h: r.h, rx: Math.min(r.w, r.h) * 0.03 } });
|
||||
): Array<
|
||||
| { path: string }
|
||||
| { poly: string }
|
||||
| { rect: { x: number; y: number; w: number; h: number; rx: number } }
|
||||
> {
|
||||
if (!walls?.length) return paperRoomShapes(rooms);
|
||||
try {
|
||||
const exterior = exteriorEnvelopeGeometry(
|
||||
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale,
|
||||
);
|
||||
if (exterior) {
|
||||
const paper = exterior.shell?.length
|
||||
? union(exterior.centre, exterior.shell)
|
||||
: exterior.centre;
|
||||
const path = polyclipToPathD(paper);
|
||||
if (path) return [{ path }];
|
||||
}
|
||||
} catch {
|
||||
// Safe fallback below: exact room centrelines never reproduce the known
|
||||
// exterior Split spike, even when boolean offsetting rejected bad input.
|
||||
}
|
||||
return out;
|
||||
return paperRoomShapes(rooms);
|
||||
}
|
||||
|
||||
interface OpeningWallEdge {
|
||||
|
||||
Reference in New Issue
Block a user