mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-07 06:59:46 +00:00
committed by
claude[bot]
parent
5bebc26aeb
commit
6c48c6d5c6
+170
-23
@@ -59,7 +59,8 @@ import {
|
||||
floorFootprintGeometry,
|
||||
innerContourForRoom, roomWallProfile, outsetContour,
|
||||
openingInnerFaceOffsetFromIndex, openingTunnelGeometriesFromIndex,
|
||||
openingWallIndex as buildOpeningWallIndex, applyWallThicknessToNewRoom,
|
||||
openingWallIndex as buildOpeningWallIndex, resolveOpeningWallAssociation,
|
||||
applyWallThicknessToNewRoom,
|
||||
drawWallPreviewD, DRAW_WALL_DEFAULT_CM, wallIntervals, materializeWallIntervals,
|
||||
normalizeWallIntervals,
|
||||
intervalCmAt, wallBodyNeedsSolid, type OpeningTunnelGeometry, type OpeningWallIndex,
|
||||
@@ -128,6 +129,10 @@ import {
|
||||
geometryAllRings, intersectionPaths, partitionBody, polyclipPathD,
|
||||
pointInOpaquePlanBody, pointInPhysicalBody, sameColumnPlacement, physicalBodies,
|
||||
} from './physical-geometry';
|
||||
import {
|
||||
buildPlanSnapGeometry, resolvePlanSnap,
|
||||
type PlanSnapCandidate, type PlanSnapGeometry,
|
||||
} from './plan-snap-overlay';
|
||||
import {
|
||||
LightSegment, polygonSegments, splitAtIntersections, visibilityPolygon,
|
||||
} from './light-visibility';
|
||||
@@ -1074,7 +1079,7 @@ class HouseplanCard extends LitElement {
|
||||
if (this._activeDraftId) this._resumeDraftBySpace[this._space] = this._activeDraftId;
|
||||
this._space = id;
|
||||
this._path = [];
|
||||
this._cursorPt = null;
|
||||
this._clearPlanSnapHover();
|
||||
this._clearOpeningPlacement(true);
|
||||
this._tool = 'draw';
|
||||
this._openWallAnchor = null;
|
||||
@@ -1192,6 +1197,7 @@ class HouseplanCard extends LitElement {
|
||||
* (and vice versa) on every pointer move. */
|
||||
private _openingWallIndexCache = new Map<string, OpeningWallIndex>();
|
||||
private _openingPlacementIntervalsCache: { key: string; value: WallInterval[] } | null = null;
|
||||
private _planSnapGeometryCache: { key: string; value: PlanSnapGeometry } | null = null;
|
||||
private _physicalBodiesCache: {
|
||||
key: string; drafts: number[][][]; partitions: number[][][];
|
||||
columns: number[][][]; all: number[][][];
|
||||
@@ -1245,6 +1251,7 @@ class HouseplanCard extends LitElement {
|
||||
private _rszLive: { x: number; y: number; text: string; area?: boolean }[] | null = null;
|
||||
private _path: number[][] = []; // current outline (render units, vertices snapped to the grid)
|
||||
private _cursorPt: number[] | null = null;
|
||||
private _planSnapHover: { contextKey: string; candidate: PlanSnapCandidate | null } | null = null;
|
||||
private _mergeSel: string | null = null;
|
||||
/** Session-only explicit type/width chosen in the Opening sub-panel. */
|
||||
private _openingPreset: OpeningPlacementPreset | null = null;
|
||||
@@ -1718,7 +1725,7 @@ class HouseplanCard extends LitElement {
|
||||
this._space = id;
|
||||
this._selId = null;
|
||||
this._path = [];
|
||||
this._cursorPt = null;
|
||||
this._clearPlanSnapHover();
|
||||
this._clearOpeningPlacement(true);
|
||||
this._tool = 'draw';
|
||||
this._openWallAnchor = null;
|
||||
@@ -2000,7 +2007,7 @@ class HouseplanCard extends LitElement {
|
||||
// the user's first click after returning into P2.
|
||||
this._openWallAnchor = null;
|
||||
this._boundaryRestoreGuard = null;
|
||||
this._cursorPt = null;
|
||||
this._clearPlanSnapHover();
|
||||
this._clearOpeningPlacement(true);
|
||||
this._touchContacts.clear();
|
||||
this._touchSequenceMultitouch = false;
|
||||
@@ -5127,6 +5134,8 @@ class HouseplanCard extends LitElement {
|
||||
if (this._tool === 'opening') {
|
||||
this._cursorPt = null;
|
||||
this._clearOpeningPlacement(false);
|
||||
} else if (this._tool === 'draw' || this._tool === 'partition') {
|
||||
this._clearPlanSnapHover();
|
||||
}
|
||||
const pts = [...this._pointers.values()];
|
||||
const dist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y);
|
||||
@@ -5192,6 +5201,8 @@ class HouseplanCard extends LitElement {
|
||||
if (this._tool === 'opening') {
|
||||
this._cursorPt = null;
|
||||
this._clearOpeningPlacement(false);
|
||||
} else if (this._tool === 'draw' || this._tool === 'partition') {
|
||||
this._clearPlanSnapHover();
|
||||
}
|
||||
const pts = [...this._pointers.values()];
|
||||
const dist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y);
|
||||
@@ -5211,6 +5222,8 @@ class HouseplanCard extends LitElement {
|
||||
if (this._tool === 'opening') {
|
||||
this._cursorPt = null;
|
||||
this._clearOpeningPlacement(false);
|
||||
} else if (this._tool === 'draw' || this._tool === 'partition') {
|
||||
this._clearPlanSnapHover();
|
||||
}
|
||||
}
|
||||
// Which gesture is this? Decided once, on the first movement worth the
|
||||
@@ -5242,9 +5255,13 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
|
||||
private _stagePointerLeave(_ev: PointerEvent): void {
|
||||
if (!this._markup || this._tool !== 'opening') return;
|
||||
this._cursorPt = null;
|
||||
this._clearOpeningPlacement(false);
|
||||
if (!this._markup) return;
|
||||
if (this._tool === 'opening') {
|
||||
this._cursorPt = null;
|
||||
this._clearOpeningPlacement(false);
|
||||
} else if (this._tool === 'draw' || this._tool === 'partition') {
|
||||
this._clearPlanSnapHover();
|
||||
}
|
||||
}
|
||||
|
||||
private _stagePointerUp(ev: PointerEvent): void {
|
||||
@@ -5827,7 +5844,7 @@ class HouseplanCard extends LitElement {
|
||||
});
|
||||
}
|
||||
this._path = [];
|
||||
this._cursorPt = null;
|
||||
this._clearPlanSnapHover();
|
||||
this._clearOpeningPlacement(true);
|
||||
this._openWallAnchor = null;
|
||||
this._boundaryRestoreGuard = null;
|
||||
@@ -5941,6 +5958,91 @@ class HouseplanCard extends LitElement {
|
||||
return this._snap(candidate);
|
||||
}
|
||||
|
||||
/** Canonical physical opening slots on room-wall centrelines. */
|
||||
private _planSnapOpeningCuts(space: SpaceModel, openCuts: number[][]): number[][] {
|
||||
if (!this._openingsR.length) return [];
|
||||
const index = this._openingWallIndexFor(space, openCuts).value;
|
||||
const cuts: number[][] = [];
|
||||
for (const opening of this._openingsR) {
|
||||
const input = {
|
||||
x: opening.rx, y: opening.ry,
|
||||
angle: Number(opening.angle) || 0,
|
||||
length: opening.rlen,
|
||||
};
|
||||
const association = resolveOpeningWallAssociation(index, input);
|
||||
if (!association.negative && !association.positive) continue;
|
||||
const rad = input.angle * Math.PI / 180;
|
||||
const dx = Math.cos(rad) * input.length / 2;
|
||||
const dy = Math.sin(rad) * input.length / 2;
|
||||
cuts.push([input.x - dx, input.y - dy, input.x + dx, input.y + dy]);
|
||||
}
|
||||
return cuts;
|
||||
}
|
||||
|
||||
/** Static architectural axes are rebuilt only when structural editor state changes. */
|
||||
private _planSnapGeometrySnapshot(): { key: string; value: PlanSnapGeometry } {
|
||||
const space = this._spaceModel();
|
||||
const key = [
|
||||
this._space, this._cfgEpoch, this._activeDraftId || '',
|
||||
space.rooms.length, space.room_drafts.length, space.partitions.length,
|
||||
].join('|');
|
||||
if (this._planSnapGeometryCache?.key === key) return this._planSnapGeometryCache;
|
||||
const openCuts = this._openCuts();
|
||||
const value = buildPlanSnapGeometry({
|
||||
space,
|
||||
activeDraftId: this._activeDraftId,
|
||||
roomCuts: [...openCuts, ...this._planSnapOpeningCuts(space, openCuts)],
|
||||
epsilon: this._gridPitch * 0.0002,
|
||||
});
|
||||
this._planSnapGeometryCache = { key, value };
|
||||
return this._planSnapGeometryCache;
|
||||
}
|
||||
|
||||
private _planSnapContextKey(geometryKey: string): string {
|
||||
const first = this._path[0];
|
||||
const anchor = this._path[this._path.length - 1];
|
||||
return [
|
||||
geometryKey, this._tool, this._path.length,
|
||||
first ? `${first[0]},${first[1]}` : '',
|
||||
anchor ? `${anchor[0]},${anchor[1]}` : '',
|
||||
].join('|');
|
||||
}
|
||||
|
||||
private _resolvePlanDrawPoint(
|
||||
raw: number[], lock45: boolean,
|
||||
): { point: number[]; candidate: PlanSnapCandidate | null; contextKey: string } {
|
||||
const snapshot = this._planSnapGeometrySnapshot();
|
||||
const anchor = this._path[this._path.length - 1];
|
||||
const closure = this._tool === 'draw' && this._path.length >= 3
|
||||
? [{ point: this._path[0], key: 'closure:first-point' }]
|
||||
: [];
|
||||
const candidate = resolvePlanSnap(snapshot.value, raw, {
|
||||
tolerance: this._cssPxToRender(12),
|
||||
gridStep: this._gridPitch,
|
||||
excludePoints: anchor ? [anchor] : [],
|
||||
extraEndpoints: closure,
|
||||
epsilon: this._gridPitch * 0.0002,
|
||||
});
|
||||
return {
|
||||
point: candidate ? [...candidate.point] : this._snapDrawPoint(raw, lock45),
|
||||
candidate,
|
||||
contextKey: this._planSnapContextKey(snapshot.key),
|
||||
};
|
||||
}
|
||||
|
||||
private get _activePlanSnapCandidate(): PlanSnapCandidate | null {
|
||||
if (!this._markup || (this._tool !== 'draw' && this._tool !== 'partition')) return null;
|
||||
const hover = this._planSnapHover;
|
||||
if (!hover) return null;
|
||||
const snapshot = this._planSnapGeometrySnapshot();
|
||||
return hover.contextKey === this._planSnapContextKey(snapshot.key) ? hover.candidate : null;
|
||||
}
|
||||
|
||||
private _clearPlanSnapHover(clearCursor = true): void {
|
||||
this._planSnapHover = null;
|
||||
if (clearCursor) this._cursorPt = null;
|
||||
}
|
||||
|
||||
private _samePt(a: number[], b: number[]): boolean {
|
||||
return samePoint(a, b);
|
||||
}
|
||||
@@ -6105,7 +6207,7 @@ class HouseplanCard extends LitElement {
|
||||
/** Drop every transient gesture before replacing committed geometry. */
|
||||
private _clearGeometryGesture(): void {
|
||||
this._path = [];
|
||||
this._cursorPt = null;
|
||||
this._clearPlanSnapHover();
|
||||
this._clearOpeningPlacement(false);
|
||||
this._mergeSel = null;
|
||||
this._mergeDialog = null;
|
||||
@@ -6137,7 +6239,7 @@ class HouseplanCard extends LitElement {
|
||||
if (this._tool !== 'boundary' || !this._openWallAnchor) return false;
|
||||
this._openWallAnchor = null;
|
||||
this._boundaryRestoreGuard = null;
|
||||
this._cursorPt = null;
|
||||
this._clearPlanSnapHover();
|
||||
this.requestUpdate();
|
||||
return true;
|
||||
}
|
||||
@@ -6164,6 +6266,8 @@ class HouseplanCard extends LitElement {
|
||||
if (this._tool === 'opening') {
|
||||
this._cursorPt = null;
|
||||
this._clearOpeningPlacement(false);
|
||||
} else if (this._tool === 'draw' || this._tool === 'partition') {
|
||||
this._clearPlanSnapHover();
|
||||
}
|
||||
const viewportGestureEnded = !!this._pinchStart || !!this._panStart;
|
||||
this._pointers.delete(ev.pointerId);
|
||||
@@ -6340,7 +6444,7 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
this._path = [...this._path, [...this._path[0]]];
|
||||
this._closingWallCm = closingCm;
|
||||
this._cursorPt = null;
|
||||
this._clearPlanSnapHover();
|
||||
this._nameSel = '';
|
||||
this._areaSel = '';
|
||||
this._resetRoomDialogFields();
|
||||
@@ -6409,7 +6513,7 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
// draw: clicks on grid points build the outline. Nothing is written to the config
|
||||
// until the contour closes — an abandoned outline leaves no lines behind.
|
||||
const pt = this._snapDrawPoint(raw, ev.shiftKey);
|
||||
const pt = this._resolvePlanDrawPoint(raw, ev.shiftKey).point;
|
||||
if (ev.ctrlKey || ev.metaKey) {
|
||||
ev.preventDefault();
|
||||
this._closeRoomContour(true);
|
||||
@@ -6573,7 +6677,7 @@ class HouseplanCard extends LitElement {
|
||||
this._closingWallCm = Number(mergedSegments[mergedSegments.length - 1]?.cm)
|
||||
|| DRAW_WALL_DEFAULT_CM;
|
||||
this._path = [...persistedPoints, [...persistedPoints[0]]];
|
||||
this._cursorPt = null;
|
||||
this._clearPlanSnapHover();
|
||||
this._nameSel = '';
|
||||
this._areaSel = '';
|
||||
this._resetRoomDialogFields();
|
||||
@@ -6608,7 +6712,7 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
|
||||
private _partitionClick(raw: number[], lock45: boolean): void {
|
||||
const pt = this._snapDrawPoint(raw, lock45);
|
||||
const pt = this._resolvePlanDrawPoint(raw, lock45).point;
|
||||
if (!this._path.length) { this._path = [pt]; return; }
|
||||
const a = this._path[0];
|
||||
if (this._samePt(a, pt)) return;
|
||||
@@ -6625,7 +6729,7 @@ class HouseplanCard extends LitElement {
|
||||
this._activeDraftId = null;
|
||||
this._draftSegmentCms = [];
|
||||
this._closingWallCm = null;
|
||||
this._cursorPt = null;
|
||||
this._clearPlanSnapHover();
|
||||
this._recordGeometry(this._t('history.partition_add'), before);
|
||||
this._saveConfig();
|
||||
}
|
||||
@@ -11030,14 +11134,18 @@ class HouseplanCard extends LitElement {
|
||||
this._cursorPt = this._svgPoint(ev);
|
||||
return;
|
||||
}
|
||||
const drawing = (this._tool === 'draw' || this._tool === 'partition')
|
||||
&& this._path.length && !this._contourClosed;
|
||||
const architectural = (this._tool === 'draw' || this._tool === 'partition')
|
||||
&& !this._contourClosed;
|
||||
const cutting = this._tool === 'split' && !!this._splitSel?.pts?.length;
|
||||
if (!drawing && !cutting) return;
|
||||
if (!architectural && !cutting) return;
|
||||
const raw = this._svgPoint(ev);
|
||||
this._cursorPt = drawing
|
||||
? this._snapDrawPoint(raw, ev.shiftKey)
|
||||
: this._snap(raw);
|
||||
if (architectural) {
|
||||
const resolved = this._resolvePlanDrawPoint(raw, ev.shiftKey);
|
||||
this._planSnapHover = { contextKey: resolved.contextKey, candidate: resolved.candidate };
|
||||
this._cursorPt = resolved.point;
|
||||
return;
|
||||
}
|
||||
this._cursorPt = this._snap(raw);
|
||||
}
|
||||
|
||||
/** One resolved architectural candidate shared by hover and click. */
|
||||
@@ -11214,7 +11322,7 @@ class HouseplanCard extends LitElement {
|
||||
this._activeDraftId = null;
|
||||
this._draftSegmentCms = [];
|
||||
this._closingWallCm = null;
|
||||
this._cursorPt = null;
|
||||
this._clearPlanSnapHover();
|
||||
this._roomDialog = false;
|
||||
this._pendingSplit = null;
|
||||
this._splitSel = null;
|
||||
@@ -11236,7 +11344,7 @@ class HouseplanCard extends LitElement {
|
||||
this._activeDraftId = id;
|
||||
this._path = draft.points.map((p) => [...p]);
|
||||
this._draftSegmentCms = draft.segments.map((s) => s.cm);
|
||||
this._cursorPt = null;
|
||||
this._clearPlanSnapHover();
|
||||
}
|
||||
|
||||
/** Cancel in the dialog: the outline is open again (the closing point is removed). */
|
||||
@@ -14509,6 +14617,8 @@ class HouseplanCard extends LitElement {
|
||||
inside thick jambs without changing the stored span. */}
|
||||
${!this._editing ? this._renderOpenWalls(disp) : nothing}
|
||||
${this._renderWallBodies(disp)}
|
||||
${this._markup ? svg`<g class="hp-editor-only-layer"
|
||||
opacity="${modeVisual?.editorWeight ?? 1}">${this._renderPlanSnapOverlay()}</g>` : nothing}
|
||||
${this._markup ? svg`<g class="hp-editor-only-layer"
|
||||
opacity="${modeVisual?.editorWeight ?? 1}">${this._renderOpeningPlacementPreview()}</g>` : nothing}
|
||||
${opMeasure?.guide ? this._renderOpeningCenterTick(opMeasure.guide) : nothing}
|
||||
@@ -16529,6 +16639,43 @@ class HouseplanCard extends LitElement {
|
||||
return svg`<g class="physical-editor">${draftSegs}${partitions}${columns}${ghost}${chrome}</g>`;
|
||||
}
|
||||
|
||||
private _renderPlanSnapOverlay(): TemplateResult {
|
||||
if (!this._markup || (this._tool !== 'draw' && this._tool !== 'partition')) {
|
||||
return svg`` as unknown as TemplateResult;
|
||||
}
|
||||
const geometry = this._planSnapGeometrySnapshot().value;
|
||||
const active = this._activePlanSnapCandidate;
|
||||
const staticRadius = wallCmToUnits(5, this._cellCm, this._gridPitch);
|
||||
const activeRadius = wallCmToUnits(10, this._cellCm, this._gridPitch);
|
||||
const activeAt = (point: readonly number[]) => !!active && samePoint(
|
||||
[point[0], point[1]], active.point, this._gridPitch * 0.0002,
|
||||
);
|
||||
const activeHasStaticNode = !!active && geometry.endpoints.some((endpoint) => activeAt(endpoint.point));
|
||||
return svg`<g class="plan-snap-overlay" data-hp="plan-snap-overlay"
|
||||
data-segment-count=${geometry.segments.length}
|
||||
data-endpoint-count=${geometry.endpoints.length}
|
||||
aria-hidden="true" pointer-events="none">
|
||||
${geometry.segments.map((segment) => svg`<line class="plan-snap-line"
|
||||
data-key=${segment.key} data-source-kind=${segment.sourceKind}
|
||||
x1=${segment.a[0]} y1=${segment.a[1]} x2=${segment.b[0]} y2=${segment.b[1]}
|
||||
vector-effect="non-scaling-stroke" pointer-events="none"></line>`)}
|
||||
${geometry.endpoints.map((endpoint) => {
|
||||
const isActive = activeAt(endpoint.point);
|
||||
return svg`<circle class="plan-snap-node ${isActive ? 'active' : ''}"
|
||||
data-kind="endpoint" data-key=${endpoint.key} data-active=${isActive ? 'true' : 'false'}
|
||||
cx=${endpoint.point[0]} cy=${endpoint.point[1]}
|
||||
r=${isActive ? activeRadius : staticRadius}
|
||||
pointer-events="none"></circle>`;
|
||||
})}
|
||||
${active && !activeHasStaticNode
|
||||
? svg`<circle class="plan-snap-node active dynamic" data-kind=${active.kind}
|
||||
data-key=${active.key} data-active="true"
|
||||
cx=${active.point[0]} cy=${active.point[1]} r=${activeRadius}
|
||||
pointer-events="none"></circle>`
|
||||
: nothing}
|
||||
</g>` as unknown as TemplateResult;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
import { cutSegments, distToSegment, roomEdges } from './logic';
|
||||
import type { SpaceModel } from './types';
|
||||
|
||||
export type PlanSnapSourceKind = 'room' | 'draft' | 'partition';
|
||||
|
||||
export interface PlanSnapSegment {
|
||||
a: [number, number];
|
||||
b: [number, number];
|
||||
key: string;
|
||||
sourceKind: PlanSnapSourceKind;
|
||||
sourceId: string;
|
||||
}
|
||||
|
||||
export interface PlanSnapEndpoint {
|
||||
point: [number, number];
|
||||
key: string;
|
||||
}
|
||||
|
||||
export interface PlanSnapGeometry {
|
||||
segments: PlanSnapSegment[];
|
||||
endpoints: PlanSnapEndpoint[];
|
||||
}
|
||||
|
||||
export interface PlanSnapExtraEndpoint {
|
||||
point: readonly number[];
|
||||
key: string;
|
||||
}
|
||||
|
||||
export type PlanSnapCandidate =
|
||||
| {
|
||||
kind: 'endpoint';
|
||||
point: [number, number];
|
||||
key: string;
|
||||
distance: number;
|
||||
}
|
||||
| {
|
||||
kind: 'line';
|
||||
point: [number, number];
|
||||
key: string;
|
||||
distance: number;
|
||||
segment: PlanSnapSegment;
|
||||
};
|
||||
|
||||
export interface BuildPlanSnapGeometryOptions {
|
||||
space: Pick<SpaceModel, 'rooms' | 'room_drafts' | 'partitions'>;
|
||||
activeDraftId?: string | null;
|
||||
roomCuts?: readonly number[][];
|
||||
epsilon?: number;
|
||||
}
|
||||
|
||||
export interface ResolvePlanSnapOptions {
|
||||
tolerance: number;
|
||||
gridStep: number;
|
||||
excludePoints?: readonly (readonly number[])[];
|
||||
extraEndpoints?: readonly PlanSnapExtraEndpoint[];
|
||||
epsilon?: number;
|
||||
}
|
||||
|
||||
interface SourceSegment {
|
||||
a: [number, number];
|
||||
b: [number, number];
|
||||
kind: PlanSnapSourceKind;
|
||||
id: string;
|
||||
cuts: readonly number[][];
|
||||
}
|
||||
|
||||
const DEFAULT_EPSILON = 0.001;
|
||||
|
||||
function finitePoint(point: readonly number[] | null | undefined): point is readonly [number, number] {
|
||||
return !!point && point.length >= 2 && Number.isFinite(point[0]) && Number.isFinite(point[1]);
|
||||
}
|
||||
|
||||
function comparePoint(a: readonly number[], b: readonly number[]): number {
|
||||
return a[0] - b[0] || a[1] - b[1];
|
||||
}
|
||||
|
||||
function pointKey(point: readonly number[]): string {
|
||||
return `${point[0].toFixed(6)},${point[1].toFixed(6)}`;
|
||||
}
|
||||
|
||||
function pointsEqual(a: readonly number[], b: readonly number[], epsilon: number): boolean {
|
||||
return Math.abs(a[0] - b[0]) < epsilon && Math.abs(a[1] - b[1]) < epsilon;
|
||||
}
|
||||
|
||||
function canonicalPair(
|
||||
a: readonly number[],
|
||||
b: readonly number[],
|
||||
): [[number, number], [number, number]] {
|
||||
const first = comparePoint(a, b) <= 0 ? a : b;
|
||||
const second = first === a ? b : a;
|
||||
return [[first[0], first[1]], [second[0], second[1]]];
|
||||
}
|
||||
|
||||
function sourceKey(source: SourceSegment): string {
|
||||
const [a, b] = canonicalPair(source.a, source.b);
|
||||
return `${source.kind}|${source.id}|${pointKey(a)}|${pointKey(b)}`;
|
||||
}
|
||||
|
||||
function segmentKey(source: SourceSegment, a: readonly number[], b: readonly number[]): string {
|
||||
const [ca, cb] = canonicalPair(a, b);
|
||||
return `${sourceKey(source)}|${pointKey(ca)}|${pointKey(cb)}`;
|
||||
}
|
||||
|
||||
function touches(point: readonly number[], segment: readonly number[], epsilon: number): boolean {
|
||||
return pointsEqual(point, [segment[0], segment[1]], epsilon)
|
||||
|| pointsEqual(point, [segment[2], segment[3]], epsilon);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the immutable architectural axes used by both the overlay and snap resolver.
|
||||
* Opening/open-span cuts apply only to room-owned walls; saved drafts and independent
|
||||
* partitions keep their own complete axes. Cut boundaries never become static nodes.
|
||||
*/
|
||||
export function buildPlanSnapGeometry(options: BuildPlanSnapGeometryOptions): PlanSnapGeometry {
|
||||
const epsilon = options.epsilon ?? DEFAULT_EPSILON;
|
||||
const roomCuts = options.roomCuts || [];
|
||||
const sources: SourceSegment[] = [];
|
||||
|
||||
for (const [index, segment] of roomEdges(options.space.rooms).entries()) {
|
||||
if (segment.length < 4) continue;
|
||||
sources.push({
|
||||
a: [segment[0], segment[1]],
|
||||
b: [segment[2], segment[3]],
|
||||
kind: 'room',
|
||||
id: `room-edge-${index}`,
|
||||
cuts: roomCuts,
|
||||
});
|
||||
}
|
||||
|
||||
for (const draft of options.space.room_drafts || []) {
|
||||
if (draft.id === options.activeDraftId) continue;
|
||||
for (let index = 0; index + 1 < draft.points.length; index++) {
|
||||
const a = draft.points[index];
|
||||
const b = draft.points[index + 1];
|
||||
if (!finitePoint(a) || !finitePoint(b)) continue;
|
||||
sources.push({
|
||||
a: [a[0], a[1]], b: [b[0], b[1]], kind: 'draft',
|
||||
id: `${draft.id}:${index}`, cuts: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const partition of options.space.partitions || []) {
|
||||
if (!finitePoint(partition.a) || !finitePoint(partition.b)) continue;
|
||||
sources.push({
|
||||
a: [partition.a[0], partition.a[1]],
|
||||
b: [partition.b[0], partition.b[1]],
|
||||
kind: 'partition',
|
||||
id: partition.id,
|
||||
cuts: [],
|
||||
});
|
||||
}
|
||||
|
||||
const segmentsByAxis = new Map<string, PlanSnapSegment>();
|
||||
const endpointKeys = new Map<string, PlanSnapEndpoint>();
|
||||
|
||||
for (const source of sources) {
|
||||
const sourceLine = [source.a[0], source.a[1], source.b[0], source.b[1]];
|
||||
const solids = cutSegments([sourceLine], source.cuts as number[][], epsilon);
|
||||
if (!solids.length) continue;
|
||||
|
||||
for (const solid of solids) {
|
||||
if (solid.length < 4) continue;
|
||||
const [a, b] = canonicalPair([solid[0], solid[1]], [solid[2], solid[3]]);
|
||||
if (pointsEqual(a, b, epsilon)) continue;
|
||||
const axisKey = `${pointKey(a)}|${pointKey(b)}`;
|
||||
const candidate: PlanSnapSegment = {
|
||||
a, b,
|
||||
key: segmentKey(source, a, b),
|
||||
sourceKind: source.kind,
|
||||
sourceId: source.id,
|
||||
};
|
||||
const existing = segmentsByAxis.get(axisKey);
|
||||
if (!existing || candidate.key.localeCompare(existing.key) < 0) {
|
||||
segmentsByAxis.set(axisKey, candidate);
|
||||
}
|
||||
}
|
||||
|
||||
for (const point of [source.a, source.b] as const) {
|
||||
if (!solids.some((solid) => touches(point, solid, epsilon))) continue;
|
||||
const key = pointKey(point);
|
||||
if (!endpointKeys.has(key)) endpointKeys.set(key, { point: [point[0], point[1]], key });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
segments: [...segmentsByAxis.values()].sort((a, b) => a.key.localeCompare(b.key)),
|
||||
endpoints: [...endpointKeys.values()].sort((a, b) => a.key.localeCompare(b.key)),
|
||||
};
|
||||
}
|
||||
|
||||
function isExcluded(
|
||||
point: readonly number[],
|
||||
excluded: readonly (readonly number[])[],
|
||||
epsilon: number,
|
||||
): boolean {
|
||||
return excluded.some((candidate) => finitePoint(candidate) && pointsEqual(
|
||||
point, candidate, epsilon,
|
||||
));
|
||||
}
|
||||
|
||||
function better(distance: number, key: string, current: PlanSnapCandidate | null): boolean {
|
||||
if (!current) return true;
|
||||
const delta = distance - current.distance;
|
||||
return delta < -1e-9 || (Math.abs(delta) <= 1e-9 && key.localeCompare(current.key) < 0);
|
||||
}
|
||||
|
||||
function quantizedPoint(segment: PlanSnapSegment, pointer: readonly number[], step: number): [number, number] {
|
||||
const dx = segment.b[0] - segment.a[0];
|
||||
const dy = segment.b[1] - segment.a[1];
|
||||
const length = Math.hypot(dx, dy);
|
||||
if (!(length > 0)) return [...segment.a];
|
||||
const ux = dx / length;
|
||||
const uy = dy / length;
|
||||
const projected = Math.max(0, Math.min(
|
||||
length,
|
||||
(pointer[0] - segment.a[0]) * ux + (pointer[1] - segment.a[1]) * uy,
|
||||
));
|
||||
const distance = step > 0
|
||||
? Math.max(0, Math.min(length, Math.round(projected / step) * step))
|
||||
: projected;
|
||||
return [segment.a[0] + ux * distance, segment.a[1] + uy * distance];
|
||||
}
|
||||
|
||||
/** Resolve exactly one endpoint-first or wall-bound line candidate. */
|
||||
export function resolvePlanSnap(
|
||||
geometry: PlanSnapGeometry,
|
||||
pointer: readonly number[],
|
||||
options: ResolvePlanSnapOptions,
|
||||
): PlanSnapCandidate | null {
|
||||
if (!finitePoint(pointer) || !(options.tolerance >= 0)) return null;
|
||||
const epsilon = options.epsilon ?? DEFAULT_EPSILON;
|
||||
const excluded = options.excludePoints || [];
|
||||
let bestEndpoint: PlanSnapCandidate | null = null;
|
||||
const endpoints: PlanSnapEndpoint[] = [
|
||||
...geometry.endpoints,
|
||||
...(options.extraEndpoints || [])
|
||||
.filter((entry) => finitePoint(entry.point))
|
||||
.map((entry): PlanSnapEndpoint => ({
|
||||
point: [entry.point[0], entry.point[1]], key: entry.key,
|
||||
})),
|
||||
];
|
||||
|
||||
for (const endpoint of endpoints) {
|
||||
if (isExcluded(endpoint.point, excluded, epsilon)) continue;
|
||||
const distance = Math.hypot(pointer[0] - endpoint.point[0], pointer[1] - endpoint.point[1]);
|
||||
if (distance > options.tolerance || !better(distance, endpoint.key, bestEndpoint)) continue;
|
||||
bestEndpoint = {
|
||||
kind: 'endpoint', point: [...endpoint.point], key: endpoint.key, distance,
|
||||
};
|
||||
}
|
||||
if (bestEndpoint) return bestEndpoint;
|
||||
|
||||
let bestLine: PlanSnapCandidate | null = null;
|
||||
for (const segment of geometry.segments) {
|
||||
const line = [segment.a[0], segment.a[1], segment.b[0], segment.b[1]];
|
||||
const distance = distToSegment([pointer[0], pointer[1]], line);
|
||||
if (distance > options.tolerance) continue;
|
||||
const point = quantizedPoint(segment, pointer, options.gridStep);
|
||||
if (isExcluded(point, excluded, epsilon) || !better(distance, segment.key, bestLine)) continue;
|
||||
bestLine = { kind: 'line', point, key: segment.key, distance, segment };
|
||||
}
|
||||
return bestLine;
|
||||
}
|
||||
@@ -1664,6 +1664,67 @@ export const cardStyles = css`
|
||||
fill: #4bd28f;
|
||||
stroke: #04121f;
|
||||
}
|
||||
.plan-snap-overlay,
|
||||
.plan-snap-overlay * {
|
||||
pointer-events: none;
|
||||
}
|
||||
.plan-snap-line {
|
||||
fill: none;
|
||||
stroke: color-mix(in srgb, var(--hp-accent) 82%, white 18%);
|
||||
stroke-width: 1;
|
||||
stroke-linecap: round;
|
||||
opacity: 0.92;
|
||||
}
|
||||
.plan-snap-node {
|
||||
fill: var(--ha-card-background, var(--card-background-color, #fff));
|
||||
stroke: color-mix(in srgb, var(--hp-accent) 88%, #07131c 12%);
|
||||
stroke-width: 1;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
.plan-snap-node.active {
|
||||
fill: #ffc14d;
|
||||
stroke: #171006;
|
||||
stroke-width: 2;
|
||||
}
|
||||
.plan-snap-node.active.dynamic {
|
||||
fill: #4bd28f;
|
||||
stroke: #04121f;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.plan-snap-line {
|
||||
stroke: color-mix(in srgb, var(--hp-accent) 72%, white 28%);
|
||||
}
|
||||
.plan-snap-node {
|
||||
fill: #17242c;
|
||||
stroke: #9bdcf5;
|
||||
}
|
||||
.plan-snap-node.active {
|
||||
fill: #ffc14d;
|
||||
stroke: #fff4d6;
|
||||
}
|
||||
.plan-snap-node.active.dynamic {
|
||||
fill: #4bd28f;
|
||||
stroke: #eafff4;
|
||||
}
|
||||
}
|
||||
@media (forced-colors: active) {
|
||||
.plan-snap-line {
|
||||
stroke: CanvasText;
|
||||
opacity: 1;
|
||||
forced-color-adjust: auto;
|
||||
}
|
||||
.plan-snap-node {
|
||||
fill: Canvas;
|
||||
stroke: CanvasText;
|
||||
forced-color-adjust: auto;
|
||||
}
|
||||
.plan-snap-node.active,
|
||||
.plan-snap-node.active.dynamic {
|
||||
fill: Highlight;
|
||||
stroke: HighlightText;
|
||||
forced-color-adjust: auto;
|
||||
}
|
||||
}
|
||||
.areasel,
|
||||
.namein {
|
||||
background: var(--hp-bg);
|
||||
|
||||
Reference in New Issue
Block a user