mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-05 14:19:04 +00:00
committed by
claude[bot]
parent
b860ef4c43
commit
087f7cf381
+99
-22
@@ -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
@@ -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
@@ -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
@@ -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(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user