feat: join independent wall junctions

Issue: #141
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-08-14 12:11:08 +00:00
committed by claude[bot]
parent b860ef4c43
commit 087f7cf381
22 changed files with 1131 additions and 433 deletions
+99 -22
View File
@@ -61,10 +61,11 @@ import {
openingInnerFaceOffsetFromIndex, openingTunnelGeometriesFromIndex,
openingWallIndex as buildOpeningWallIndex, resolveOpeningWallAssociation,
applyWallThicknessToNewRoom,
drawWallPreviewD, DRAW_WALL_DEFAULT_CM, wallIntervals, materializeWallIntervals,
drawWallPreviewD, linearWallJoinPatches, DRAW_WALL_DEFAULT_CM,
wallIntervals, materializeWallIntervals,
normalizeWallIntervals,
intervalCmAt, wallBodyNeedsSolid, type OpeningTunnelGeometry, type OpeningWallIndex,
type WallEntry, type WallInterval,
type LinearWallSegment, type WallEntry, type WallInterval,
} from './wall-thickness';
import {
resolveOpenCuts, resolveBoundaryTarget, snapOpenPoint,
@@ -125,13 +126,14 @@ import type {
} from './types';
import {
COLUMN_MAX_CM, canonicalColumnAngle, clampColumnCm, columnBody,
directionalOccluders, draftBodies, floorMinusBodies, geometryArea, geometryOuterRings,
directionalOccluders, floorMinusBodies, geometryArea, geometryOuterRings,
geometryAllRings, intersectionPaths, partitionBody, polyclipPathD,
pointInOpaquePlanBody, pointInPhysicalBody, sameColumnPlacement, physicalBodies,
pointInOpaquePlanBody, pointInPhysicalBody, sameColumnPlacement,
physicalBodies, physicalBodySet,
} from './physical-geometry';
import {
buildPlanSnapGeometry, resolvePlanSnap,
type PlanSnapCandidate, type PlanSnapGeometry,
type PlanSnapCandidate, type PlanSnapGeometry, type PlanSnapSegment,
} from './plan-snap-overlay';
import {
LightSegment, polygonSegments, splitAtIntersections, visibilityPolygon,
@@ -1200,7 +1202,7 @@ class HouseplanCard extends LitElement {
private _planSnapGeometryCache: { key: string; value: PlanSnapGeometry } | null = null;
private _physicalBodiesCache: {
key: string; drafts: number[][][]; partitions: number[][][];
columns: number[][][]; all: number[][][];
columns: number[][][]; patches: number[][][]; all: number[][][]; geometry: any | null;
} | null = null;
private _cleanFloorCache = new Map<string, {
floor: number[][]; geom: any; path: string; area: number;
@@ -8228,7 +8230,7 @@ class HouseplanCard extends LitElement {
* partitions/drafts/columns. Openings intentionally still use room walls
* only; furniture is allowed to lean against every real obstacle. */
private get _furnWalls(): number[][] {
const faces = this._physicalBodiesR().flatMap((body) =>
const faces = this._rawPhysicalBodiesR().flatMap((body) =>
body.map((a, i) => {
const b = body[(i + 1) % body.length];
return [a[0], a[1], b[0], b[1]];
@@ -11621,17 +11623,18 @@ class HouseplanCard extends LitElement {
private _physicalBodiesR(space = this._spaceModel()): number[][][] {
const key = `${space.id}|${this._cfgEpoch}|${this._cellCm}|${this._gridPitch}`;
if (this._physicalBodiesCache?.key === key) return this._physicalBodiesCache.all;
const drafts = (space.room_drafts || []).flatMap((d) =>
draftBodies(d, this._cellCm, this._gridPitch));
const partitions = (space.partitions || []).flatMap((p) => {
const body = partitionBody(p.a, p.b, p.cm, this._cellCm, this._gridPitch);
return body ? [body] : [];
});
const columns = (space.wall_columns || []).map((c) =>
columnBody(c, this._cellCm, this._gridPitch));
const all = [...drafts, ...partitions, ...columns];
this._physicalBodiesCache = { key, drafts, partitions, columns, all };
return all;
const frame = physicalBodySet(
space, this._cellCm, this._gridPitch, this._gridPitch * 0.0002,
);
this._physicalBodiesCache = { key, ...frame };
return frame.all;
}
/** Per-record bodies remain the editor/furniture identity surface. */
private _rawPhysicalBodiesR(space = this._spaceModel()): number[][][] {
this._physicalBodiesR(space);
const frame = this._physicalBodiesCache;
return frame ? [...frame.drafts, ...frame.partitions, ...frame.columns] : [];
}
/** Cached clean floor. A cheap bbox pass is the spatial index needed for
@@ -13619,14 +13622,20 @@ class HouseplanCard extends LitElement {
// jamb faces. Treating a wall as its centreline let light bleed half a wall
// deep (a bright bar at every opening) and started every shadow half a wall
// away from the corner that casts it.
const masonry = walls.length
const masonry = walls.length || physical.length
? wallBodiesGeometry(
space.rooms, walls, openCuts,
passages.map((o) => ({ x: o.rx, y: o.ry, angle: o.angle, length: o.rlen })),
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W,
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W, physical,
)
: null;
for (const ring of geometryAllRings(masonry?.geom)) occluders.push(...polygonSegments(ring));
if (masonry) {
for (const ring of geometryAllRings(masonry.geom)) occluders.push(...polygonSegments(ring));
} else {
// Malformed legacy geometry must remain opaque even when the canonical
// boolean pass cannot produce a joined result.
for (const body of physical) occluders.push(...polygonSegments(body));
}
// Edges without any thickness are still walls; so is a room outline when
// the boolean pass above could not run at all.
for (const { poly } of polys) {
@@ -13634,7 +13643,6 @@ class HouseplanCard extends LitElement {
occluders.push(seg as LightSegment);
}
}
for (const body of physical) occluders.push(...polygonSegments(body));
const value = {
occluders: splitAtIntersections(occluders),
floor: polys.map((x) => x.poly),
@@ -16676,6 +16684,57 @@ class HouseplanCard extends LitElement {
</g>` as unknown as TemplateResult;
}
/** Physical depth for one immutable architectural snap segment. */
private _planSnapPhysicalSegment(segment: PlanSnapSegment): LinearWallSegment | null {
let cm = 0;
const space = this._spaceModel();
if (segment.sourceKind === 'partition') {
cm = Number(space.partitions.find((item) => item.id === segment.sourceId)?.cm) || 0;
} else if (segment.sourceKind === 'draft') {
const match = /^(.*):(\d+)$/.exec(segment.sourceId);
const draft = match ? space.room_drafts.find((item) => item.id === match[1]) : null;
cm = draft && match ? Number(draft.segments[Number(match[2])]?.cm) || 0 : 0;
} else {
cm = intervalCmAt(
space.rooms, this._spaceWalls, this._openCuts(),
[segment.a[0], segment.a[1], segment.b[0], segment.b[1]],
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W,
);
}
if (!(cm > 0)) return null;
return {
a: [...segment.a], b: [...segment.b],
halfDepth: wallCmToUnits(cm, this._cellCm, this._gridPitch) / 2,
};
}
/**
* Local target patches make a snapped rubber-band meet saved masonry before
* the click. The expensive saved union stays cached; pointermove examines
* only immutable snap axes touching a preview vertex.
*/
private _drawPreviewJoinPatchD(
points: number[][], halfDepths: number[],
): string {
if (points.length < 2) return '';
const preview: LinearWallSegment[] = [];
for (let i = 0; i + 1 < points.length; i++) {
if (!(halfDepths[i] > 0)) continue;
preview.push({ a: points[i], b: points[i + 1], halfDepth: halfDepths[i] });
}
if (!preview.length) return '';
const eps = this._gridPitch * 0.0002;
const touching = this._planSnapGeometrySnapshot().value.segments
.filter((segment) => points.some((point) => distToSegment(point, [
segment.a[0], segment.a[1], segment.b[0], segment.b[1],
]) <= eps))
.map((segment) => this._planSnapPhysicalSegment(segment))
.filter((segment): segment is LinearWallSegment => !!segment);
const patches = linearWallJoinPatches([...preview, ...touching], eps);
return patches.map((patch) =>
`M ${patch.map((point) => `${point[0]} ${point[1]}`).join(' L ')} Z`).join(' ');
}
private _renderMarkupLayer(vb: number[]): TemplateResult {
// derived walls minus the open stretches — those are drawn dashed on top
const openCuts = this._openPairs().flatMap((p) => p.segs);
@@ -16694,13 +16753,27 @@ class HouseplanCard extends LitElement {
if (this._cursorPt) return [...path, this._cursorPt];
return path.length >= 2 ? path : null;
})();
const previewHalfDepths = previewPts
? previewPts.slice(0, -1).map((_, i) => {
const cm = Number(this._draftSegmentCms[i]) > 0
? Number(this._draftSegmentCms[i])
: this._contourClosed && i === previewPts.length - 2
? (this._closingWallCm || drawCm || DRAW_WALL_DEFAULT_CM)
: (drawCm || DRAW_WALL_DEFAULT_CM);
return wallCmToUnits(cm, this._cellCm, this._gridPitch) / 2;
})
: [];
const previewD = previewPts
? drawWallPreviewD(
previewPts,
wallCmToUnits(drawCm!, this._cellCm, this._gridPitch) / 2,
this._contourClosed,
previewHalfDepths,
)
: '';
const previewJoinPatchD = previewPts
? this._drawPreviewJoinPatchD(previewPts, previewHalfDepths)
: '';
return svg`
${this._gridLevels()
? svg`<rect x="${view.x}" y="${view.y}" width="${view.w}" height="${view.h}" fill="url(#hp-grid-major)" pointer-events="none"></rect>`
@@ -16718,6 +16791,10 @@ class HouseplanCard extends LitElement {
? svg`<path class="drawwall-preview-fill" d="${previewD}"></path>
<path class="drawwall-preview" d="${previewD}"></path>`
: nothing}
${previewJoinPatchD
? svg`<path class="drawwall-preview-fill" d="${previewJoinPatchD}"></path>
<path class="drawwall-preview" style="stroke:none" d="${previewJoinPatchD}"></path>`
: nothing}
${path.length > 1
? svg`<polyline class="pathline" points="${path.map((p) => p.join(',')).join(' ')}"></polyline>`
: nothing}
+61 -17
View File
@@ -1,7 +1,10 @@
/** Geometry shared by independent partitions, saved room drafts and columns. */
import { difference, intersection, union } from 'polyclip-ts';
import { polygonArea } from './logic';
import { wallCmToUnits } from './wall-thickness';
import {
linearWallBody, linearWallJoinPatches, wallCmToUnits,
type LinearWallSegment,
} from './wall-thickness';
import type {
PartitionCfg, RoomDraftCfg, SpaceModel, WallColumnCfg,
} from './types';
@@ -37,19 +40,12 @@ export function polyclipPathD(geom: any): string {
return out.join(' ');
}
/** A wall segment has flat ends. Joining is delegated to polygon union. */
/** A wall segment has flat ends. Canonical node joins are added by `physicalBodySet`. */
export function partitionBody(
a: number[], b: number[], cm: number, cellCm: number, gridPitch: number,
): number[][] | null {
const dx = b[0] - a[0], dy = b[1] - a[1];
const len = Math.hypot(dx, dy);
if (!(len > 1e-9)) return null;
const half = wallCmToUnits(cm, cellCm, gridPitch) / 2;
const nx = (-dy / len) * half, ny = (dx / len) * half;
return [
[a[0] + nx, a[1] + ny], [b[0] + nx, b[1] + ny],
[b[0] - nx, b[1] - ny], [a[0] - nx, a[1] - ny],
];
return linearWallBody({ a, b, halfDepth: half });
}
export function columnBody(
@@ -91,14 +87,62 @@ export function physicalBodies(
cellCm: number,
gridPitch: number,
): number[][][] {
const out: number[][][] = [];
for (const p of space.partitions || []) {
const body = partitionBody(p.a, p.b, p.cm, cellCm, gridPitch);
if (body) out.push(body);
return physicalBodySet(space, cellCm, gridPitch).all;
}
export interface PhysicalBodySet {
drafts: number[][][];
partitions: number[][][];
columns: number[][][];
/** Bounded mitre/bevel volumes; never persisted or independently editable. */
patches: number[][][];
/** Canonical independent volume inputs for boolean render/floor/light consumers. */
all: number[][][];
/** Unioned geometry without raw overlap/butt-face boundaries. */
geometry: any | null;
}
/** Raw editable bodies plus their computed, order-independent junction volumes. */
export function physicalBodySet(
space: Pick<SpaceModel, 'partitions' | 'room_drafts' | 'wall_columns'>,
cellCm: number,
gridPitch: number,
epsilon = Math.max(gridPitch * 0.0002, 1e-9),
): PhysicalBodySet {
const draftSegments: LinearWallSegment[] = [];
const partitionSegments: LinearWallSegment[] = [];
const drafts: number[][][] = [];
const partitions: number[][][] = [];
for (const draft of space.room_drafts || []) {
for (let i = 0; i + 1 < draft.points.length; i++) {
const halfDepth = wallCmToUnits(
draft.segments[i]?.cm || 15, cellCm, gridPitch,
) / 2;
const segment = { a: draft.points[i], b: draft.points[i + 1], halfDepth };
const body = linearWallBody(segment);
if (!body) continue;
draftSegments.push(segment);
drafts.push(body);
}
}
for (const d of space.room_drafts || []) out.push(...draftBodies(d, cellCm, gridPitch));
for (const c of space.wall_columns || []) out.push(columnBody(c, cellCm, gridPitch));
return out;
for (const partition of space.partitions || []) {
const segment = {
a: partition.a,
b: partition.b,
halfDepth: wallCmToUnits(partition.cm, cellCm, gridPitch) / 2,
};
const body = linearWallBody(segment);
if (!body) continue;
partitionSegments.push(segment);
partitions.push(body);
}
const columns = (space.wall_columns || []).map((column) =>
columnBody(column, cellCm, gridPitch));
const patches = linearWallJoinPatches(
[...draftSegments, ...partitionSegments], epsilon,
);
const all = [...drafts, ...partitions, ...patches, ...columns];
return { drafts, partitions, columns, patches, all, geometry: unionBodies(all) };
}
export function unionBodies(bodies: number[][][]): any | null {
+30 -1
View File
@@ -38,6 +38,8 @@ export { spaceModels } from './space-geometry';
type StaticWallGeometry = ReturnType<typeof wallBodiesUnionPath>;
type StaticWallGeometryEntry = { fingerprint: string; value: StaticWallGeometry };
const staticWallGeometryCache = new WeakMap<object, Map<string, StaticWallGeometryEntry>>();
type StaticPhysicalBodiesEntry = { fingerprint: string; value: number[][][] };
const staticPhysicalBodiesCache = new WeakMap<object, Map<string, StaticPhysicalBodiesEntry>>();
/** Static cards receive the same immutable server-config object on HA ticks. */
function cachedStaticWallGeometry(
@@ -58,6 +60,24 @@ function cachedStaticWallGeometry(
return value;
}
function cachedStaticPhysicalBodies(
cfg: ServerConfig,
spaceId: string,
fingerprint: string,
build: () => number[][][],
): number[][][] {
let spaces = staticPhysicalBodiesCache.get(cfg as object);
if (!spaces) {
spaces = new Map<string, StaticPhysicalBodiesEntry>();
staticPhysicalBodiesCache.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;
@@ -173,7 +193,16 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
const spCfg: any = o.cfg.spaces.find((s: any) => s.id === o.spaceId) || {};
const walls: WallEntry[] = Array.isArray(spCfg.walls) ? spCfg.walls : [];
const cellCm = Number(spCfg.cell_cm) > 0 ? Number(spCfg.cell_cm) : 5;
const extras = physicalBodies(space, cellCm, GRID_PITCH);
const physicalFingerprint = contentFingerprint({
partitions: space.partitions,
roomDrafts: space.room_drafts,
columns: space.wall_columns,
cellCm,
});
const extras = cachedStaticPhysicalBodies(
o.cfg, space.id, physicalFingerprint,
() => physicalBodies(space, cellCm, GRID_PITCH),
);
for (const body of extras) {
const xs = body.map((p) => p[0]), ys = body.map((p) => p[1]);
if (xs.length) placed.push({
+165 -17
View File
@@ -27,6 +27,13 @@ export const WALL_HATCH_MIN_PX = 3;
/** Mitre spikes longer than this × thickness fall back to a bevel. */
export const MITRE_LIMIT = 4;
/** One finite physical wall centreline with its already-converted half depth. */
export interface LinearWallSegment {
a: number[];
b: number[];
halfDepth: number;
}
// ------------------------------- units --------------------------------------
/** Shared full/static render policy for the thin-on-screen fallback. */
@@ -483,14 +490,161 @@ export function applyWallThicknessToNewRoom(
return out;
}
/** A flat-capped body for one already-scaled centreline segment. */
export function linearWallBody(segment: LinearWallSegment): number[][] | null {
const { a, b, halfDepth } = segment;
if (!Array.isArray(a) || !Array.isArray(b) || a.length < 2 || b.length < 2
|| ![a[0], a[1], b[0], b[1]].every(Number.isFinite)) return null;
const dx = b[0] - a[0], dy = b[1] - a[1];
const len = Math.hypot(dx, dy);
if (!(len > 1e-9) || !(halfDepth > 0) || !Number.isFinite(halfDepth)) return null;
const nx = (-dy / len) * halfDepth, ny = (dx / len) * halfDepth;
return [
[a[0] + nx, a[1] + ny], [b[0] + nx, b[1] + ny],
[b[0] - nx, b[1] - ny], [a[0] - nx, a[1] - ny],
];
}
interface JunctionRay {
u: [number, number];
halfDepth: number;
}
function closePoint(a: number[], b: number[], epsilon: number): boolean {
return Math.hypot(a[0] - b[0], a[1] - b[1]) <= epsilon;
}
function pointOnSegmentInterior(
point: number[], segment: LinearWallSegment, epsilon: number,
): boolean {
const dx = segment.b[0] - segment.a[0], dy = segment.b[1] - segment.a[1];
const len2 = dx * dx + dy * dy;
if (!(len2 > epsilon * epsilon)) return false;
const t = ((point[0] - segment.a[0]) * dx + (point[1] - segment.a[1]) * dy) / len2;
if (!(t > 0 && t < 1)) return false;
const q = [segment.a[0] + dx * t, segment.a[1] + dy * t];
return Math.hypot(point[0] - q[0], point[1] - q[1]) <= epsilon;
}
function addJunctionRay(rays: JunctionRay[], dx: number, dy: number, halfDepth: number): void {
const len = Math.hypot(dx, dy);
if (!(len > 1e-9) || !(halfDepth > 0)) return;
const u: [number, number] = [dx / len, dy / len];
const same = rays.find((ray) =>
Math.abs(ray.u[0] * u[1] - ray.u[1] * u[0]) < 1e-9
&& ray.u[0] * u[0] + ray.u[1] * u[1] > 1 - 1e-9);
if (same) same.halfDepth = Math.max(same.halfDepth, halfDepth);
else rays.push({ u, halfDepth });
}
/**
* Missing node volumes for flat-capped linear wall segments.
*
* Endpoints are the only nodes. An endpoint may also land in another segment's
* interior (the non-persisted T produced by #137); that through segment then
* contributes two incident rays. Each non-collinear ray pair receives the
* same bounded mitre/bevel used by room contours. Unioning these patches with
* the raw bodies removes the tooth without changing caps at degree-one nodes.
*/
export function linearWallJoinPatches(
input: LinearWallSegment[], epsilon = 1e-6,
): number[][][] {
const segments = (input || []).filter((segment) =>
segment && Array.isArray(segment.a) && Array.isArray(segment.b)
&& segment.a.length >= 2 && segment.b.length >= 2
&& segment.a.every(Number.isFinite) && segment.b.every(Number.isFinite)
&& Number.isFinite(segment.halfDepth) && segment.halfDepth > 0
&& Math.hypot(segment.b[0] - segment.a[0], segment.b[1] - segment.a[1]) > 1e-9);
if (segments.length < 2) return [];
const eps = Math.max(Number.isFinite(epsilon) ? epsilon : 0, 1e-9);
const endpoints = segments.flatMap((segment) => [segment.a, segment.b])
.map((point) => [point[0], point[1]])
.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
const nodes: number[][] = [];
for (const point of endpoints) {
if (!nodes.some((node) => closePoint(node, point, eps))) nodes.push(point);
}
const patches: number[][][] = [];
for (const node of nodes) {
const rays: JunctionRay[] = [];
for (const segment of segments) {
if (closePoint(node, segment.a, eps)) {
addJunctionRay(
rays, segment.b[0] - segment.a[0], segment.b[1] - segment.a[1],
segment.halfDepth,
);
} else if (closePoint(node, segment.b, eps)) {
addJunctionRay(
rays, segment.a[0] - segment.b[0], segment.a[1] - segment.b[1],
segment.halfDepth,
);
} else if (pointOnSegmentInterior(node, segment, eps)) {
addJunctionRay(
rays, segment.a[0] - node[0], segment.a[1] - node[1], segment.halfDepth,
);
addJunctionRay(
rays, segment.b[0] - node[0], segment.b[1] - node[1], segment.halfDepth,
);
}
}
if (rays.length < 2) continue;
rays.sort((a, b) => Math.atan2(a.u[1], a.u[0]) - Math.atan2(b.u[1], b.u[0])
|| a.halfDepth - b.halfDepth);
for (let i = 0; i < rays.length; i++) {
for (let j = i + 1; j < rays.length; j++) {
const a = rays[i], b = rays[j];
const cross = a.u[0] * b.u[1] - a.u[1] * b.u[0];
if (Math.abs(cross) < 1e-9) continue;
const nA = [-a.u[1], a.u[0]];
const nB = [-b.u[1], b.u[0]];
const sign = cross < 0 ? 1 : -1;
const pA = [
node[0] + nA[0] * a.halfDepth * sign,
node[1] + nA[1] * a.halfDepth * sign,
];
const pB = [
node[0] - nB[0] * b.halfDepth * sign,
node[1] - nB[1] * b.halfDepth * sign,
];
const hit = lineIntersect(pA, a.u, pB, b.u);
const limit = MITRE_LIMIT * Math.max(a.halfDepth, b.halfDepth);
const patch = hit && Math.hypot(hit[0] - node[0], hit[1] - node[1]) <= limit
? [node.slice(), pA, hit, pB]
: [node.slice(), pA, pB];
if (Math.abs(signedArea(patch)) > eps * eps) patches.push(patch);
}
}
}
return patches;
}
function unionSimpleBodies(bodies: number[][][]): any | null {
let geom: any = null;
try {
for (const body of bodies) {
if (body.length < 3) continue;
const piece: any = closedRing(body);
geom = geom ? union(geom, piece) : piece;
}
return geom;
} catch {
return null;
}
}
/**
* SVG path for the thick-wall preview while drawing a room outline.
* Closed contours use outset−inset; open polylines use per-segment quads.
* Closed contours use outset−inset; open polylines use the same bounded joins
* as persisted independent walls. `segmentHalfDepths` preserves the thickness
* already committed for each draft segment while the last rubber-band uses the
* current session value.
*/
export function drawWallPreviewD(
pts: number[][],
halfDepth: number,
closed: boolean,
segmentHalfDepths?: number[],
): string {
if (!(halfDepth > 0) || !pts || pts.length < 2) return '';
if (closed && pts.length >= 3) {
@@ -501,7 +655,7 @@ export function drawWallPreviewD(
poly = pts.slice(0, -1);
}
if (poly.length >= 3) {
const offs = poly.map(() => halfDepth);
const offs = poly.map((_, i) => segmentHalfDepths?.[i] || halfDepth);
const outset = outsetContour(poly, offs);
const inset = insetContour(poly, offs);
if (outset && inset) {
@@ -509,24 +663,18 @@ export function drawWallPreviewD(
}
}
}
let d = '';
const segments: LinearWallSegment[] = [];
for (let i = 0; i < pts.length - 1; i++) {
const a = pts[i], b = pts[i + 1];
const dx = b[0] - a[0], dy = b[1] - a[1];
const L = Math.hypot(dx, dy);
if (L < 1e-9) continue;
const ux = dx / L, uy = dy / L;
const nx = -uy, ny = ux;
const h = halfDepth;
const quad = [
[a[0] + nx * h, a[1] + ny * h],
[b[0] + nx * h, b[1] + ny * h],
[b[0] - nx * h, b[1] - ny * h],
[a[0] - nx * h, a[1] - ny * h],
];
d += (d ? ' ' : '') + polyToPath(quad);
const h = segmentHalfDepths?.[i] || halfDepth;
if (Math.hypot(b[0] - a[0], b[1] - a[1]) >= 1e-9 && h > 0)
segments.push({ a, b, halfDepth: h });
}
return d;
const bodies = segments.map(linearWallBody).filter((body): body is number[][] => !!body);
const joined = [...bodies, ...linearWallJoinPatches(segments)];
const geom = unionSimpleBodies(joined);
if (geom) return polyclipToPathD(geom);
return joined.map((body) => polyToPath(body)).join(' ');
}
/**