mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-04 05:41:34 +00:00
feat: junction limits refuse the write in every editing surface (#329)
П3 measures the WALL, not the catalogue atom: a short filler segment that compensates a thickness step (owner's fixture, 5 cm = (30-20)/2) is a legal continuation of a long same-thickness wall, so the rule walks the maximal collinear run through the shared nodes before judging the length. Resize stops at the last allowed position and names the broken rule instead of the generic "geometry cannot be saved"; the Thickness dialog refuses through its own toast. Both channels are pinned by demo/smoke_junction_limits plus three mutants (angle threshold, write barrier, degenerate apex bevel). Issue: #329 User-Visible: yes
This commit is contained in:
+28
-1
@@ -1683,6 +1683,8 @@ class HouseplanCard extends LitElement {
|
||||
private _resize = new ResizeController<
|
||||
ResizePreview, ResizeLiveLabel[], SpaceGeometryState, ResizeWallUnion, ResizeWallArtifact
|
||||
>();
|
||||
/** #329 AC7a: the limit the LAST projection broke, or null if it broke none. */
|
||||
private _rszLimitViolation: JunctionLimitViolation | null = null;
|
||||
private _path: number[][] = []; // current outline (render units, vertices snapped to the grid)
|
||||
private _cursorPt: number[] | null = null;
|
||||
private _planSnapHover: {
|
||||
@@ -8917,6 +8919,9 @@ class HouseplanCard extends LitElement {
|
||||
changedRoomIds: readonly string[],
|
||||
sourceRooms: readonly { id: string; poly: number[][]; wall_ids?: string[] }[],
|
||||
): ResizeProjectionResult<ResizePreview, ResizeWallArtifact> {
|
||||
// #329 AC7a: only the LAST projection may explain the refusal, so the
|
||||
// previous verdict never leaks into a rejection of another kind.
|
||||
this._rszLimitViolation = null;
|
||||
const real = this._serverCfg?.spaces.find((s: any) => s.id === this._space);
|
||||
if (!real || !this._serverCfg) return { ok: false, reason: 'missing-context' };
|
||||
const s = JSON.parse(snapshot); // fresh deep copies every move — free to mutate
|
||||
@@ -9024,6 +9029,22 @@ class HouseplanCard extends LitElement {
|
||||
if (!preflight.ok) {
|
||||
return { ok: false, reason: 'physical-geometry' };
|
||||
}
|
||||
// #329 AC7a: a step that would ADD a junction-limit violation is never
|
||||
// projected, so the drag stops at the last allowed position instead of
|
||||
// committing an impossible plan.
|
||||
const limitCandidate = {
|
||||
...this._serverCfg,
|
||||
spaces: (this._serverCfg?.spaces || []).map(
|
||||
(space: any) => (space?.id === this._space ? sp : space),
|
||||
),
|
||||
};
|
||||
const limited = this._junctionLimitsIntroduced(
|
||||
limitCandidate, this._serverCfg, this._space,
|
||||
);
|
||||
if (limited.length) {
|
||||
this._rszLimitViolation = limited[0];
|
||||
return { ok: false, reason: 'junction-limit' };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
@@ -9160,7 +9181,13 @@ class HouseplanCard extends LitElement {
|
||||
// the pointer visibly stops there and pointerup can commit only that
|
||||
// already-rendered safe candidate.
|
||||
if (result.notify) {
|
||||
this._showToast(this._t('resize.preview_failed'));
|
||||
// #329 AC7a: a step stopped by a junction limit names THAT rule — the
|
||||
// generic "geometry cannot be saved" wording would hide which limit
|
||||
// the wall ran into.
|
||||
this._showToast(this._rszLimitViolation
|
||||
? `${this._t('resize.limit_stopped')} — `
|
||||
+ this._junctionLimitLabel(this._rszLimitViolation)
|
||||
: this._t('resize.preview_failed'));
|
||||
}
|
||||
this.requestUpdate();
|
||||
return;
|
||||
|
||||
@@ -92,6 +92,7 @@
|
||||
"resize.disabled.thickness-conflict": "The wall thickness cannot be preserved safely",
|
||||
"resize.disabled.opening-conflict": "An opening on this wall cannot be moved safely",
|
||||
"resize.disabled.invalid-geometry": "This wall cannot be resized without changing the plan structure",
|
||||
"resize.limit_stopped": "Wall stopped: the next step would break a junction limit",
|
||||
"resize.commit_failed": "Resize was cancelled because the final plan did not pass the geometry check",
|
||||
"resize.preview_failed": "Resize stopped at the last safe position because the plan geometry could not be preserved",
|
||||
"markup.opening": "Opening",
|
||||
|
||||
@@ -92,6 +92,7 @@
|
||||
"resize.disabled.thickness-conflict": "Толщину стены нельзя безопасно сохранить",
|
||||
"resize.disabled.opening-conflict": "Проём на этой стене нельзя безопасно переместить",
|
||||
"resize.disabled.invalid-geometry": "Эту стену нельзя переместить без изменения структуры плана",
|
||||
"resize.limit_stopped": "Стена остановлена: дальше нарушается ограничение стыков",
|
||||
"resize.commit_failed": "Изменение размера отменено: итоговый план не прошёл проверку геометрии",
|
||||
"resize.preview_failed": "Стена остановлена в последней безопасной позиции: геометрию плана нельзя сохранить без потерь",
|
||||
"markup.opening": "Проём",
|
||||
|
||||
+54
-3
@@ -96,7 +96,57 @@ export function checkNodes(
|
||||
return violations;
|
||||
}
|
||||
|
||||
/** П3: a segment is at least 20 cm and never shorter than its own thickness. */
|
||||
/** Direction of a segment normalised to [0, 180). */
|
||||
const axisDegrees = (segment: LimitSegment): number => {
|
||||
const degrees = (Math.atan2(segment.b[1] - segment.a[1], segment.b[0] - segment.a[0])
|
||||
* 180) / Math.PI;
|
||||
return ((degrees % 180) + 180) % 180;
|
||||
};
|
||||
|
||||
const collinear = (left: LimitSegment, right: LimitSegment, toleranceDeg = 1): boolean => {
|
||||
const delta = Math.abs(axisDegrees(left) - axisDegrees(right));
|
||||
return Math.min(delta, 180 - delta) <= toleranceDeg;
|
||||
};
|
||||
|
||||
/**
|
||||
* Length of the whole WALL a segment belongs to, not of the atom.
|
||||
*
|
||||
* The model splits a straight wall into atoms at every junction, so a plain
|
||||
* run picks up short pieces that no one drew: where a 30 cm wall meets a
|
||||
* 20 cm one, atomisation leaves a (30−20)/2 = 5 cm piece that compensates the
|
||||
* thickness step (owner report 2026-08-27). Those pieces are collinear
|
||||
* continuations of the same wall at the same thickness, so П3 measures the
|
||||
* maximal collinear chain through the segment's nodes.
|
||||
*/
|
||||
export function collinearRunLengthUnits(
|
||||
segment: LimitSegment, segments: readonly LimitSegment[],
|
||||
): number {
|
||||
const usable = usableSegments(segments);
|
||||
const byNode = new Map<string, LimitSegment[]>();
|
||||
for (const item of usable) {
|
||||
for (const point of [item.a, item.b]) {
|
||||
byNode.set(key(point), [...(byNode.get(key(point)) || []), item]);
|
||||
}
|
||||
}
|
||||
const visited = new Set<LimitSegment>([segment]);
|
||||
let total = length(segment.a, segment.b);
|
||||
const walk = (from: LimitSegment, node: number[]): void => {
|
||||
const next = (byNode.get(key(node)) || []).find((candidate) => (
|
||||
!visited.has(candidate)
|
||||
&& collinear(candidate, from)
|
||||
&& Number(candidate.cm || 0) === Number(from.cm || 0)
|
||||
));
|
||||
if (!next) return;
|
||||
visited.add(next);
|
||||
total += length(next.a, next.b);
|
||||
walk(next, key(next.a) === key(node) ? next.b : next.a);
|
||||
};
|
||||
walk(segment, segment.a);
|
||||
walk(segment, segment.b);
|
||||
return total;
|
||||
}
|
||||
|
||||
/** П3: a wall is at least 20 cm and never shorter than its own thickness. */
|
||||
export function checkSegmentLengths(
|
||||
segments: readonly LimitSegment[],
|
||||
cellCm: number,
|
||||
@@ -104,8 +154,9 @@ export function checkSegmentLengths(
|
||||
{ minLengthCm = MIN_SEGMENT_LENGTH_CM } = {},
|
||||
): JunctionLimitViolation[] {
|
||||
const violations: JunctionLimitViolation[] = [];
|
||||
for (const segment of usableSegments(segments)) {
|
||||
const units = length(segment.a, segment.b);
|
||||
const usable = usableSegments(segments);
|
||||
for (const segment of usable) {
|
||||
const units = collinearRunLengthUnits(segment, usable);
|
||||
const cm = (units / gridPitch) * (cellCm || 1);
|
||||
const limit = Math.max(minLengthCm, Number(segment.cm) > 0 ? Number(segment.cm) : 0);
|
||||
if (cm < limit - 1e-9) {
|
||||
|
||||
@@ -24,7 +24,10 @@ export interface ResizeProjection<TPreview, TArtifact> {
|
||||
|
||||
export type ResizeProjectionResult<TPreview, TArtifact> =
|
||||
| { ok: true; value: ResizeProjection<TPreview, TArtifact> }
|
||||
| { ok: false; reason: 'missing-context' | 'wall-metadata' | 'physical-geometry' };
|
||||
| { ok: false;
|
||||
// 'junction-limit' is #329: the step would ADD a wall-junction violation.
|
||||
reason: 'missing-context' | 'wall-metadata' | 'physical-geometry'
|
||||
| 'junction-limit' };
|
||||
|
||||
export type ResizeMoveOutcome<TPreview, TLabels, TArtifact> =
|
||||
| { kind: 'accepted'; preview: TPreview; labels: TLabels; artifact: TArtifact | null }
|
||||
|
||||
Reference in New Issue
Block a user