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:
Codex
2026-08-27 23:31:42 +03:00
parent 7d15bd06e9
commit f514fb27fe
28 changed files with 592 additions and 185 deletions
+28 -1
View File
@@ -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;
+1
View File
@@ -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",
+1
View File
@@ -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
View File
@@ -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) {
+4 -1
View File
@@ -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 }