fix: alignment guides follow the live gesture again (#521)

#451 moved every editor gesture onto the live painter, and the guides stayed
behind in the settled scene. While a gesture runs, the settled scene is not
re-rendered at all, so the guides did not follow the marker in the device
editor, the shape in the backdrop editor, or the cursor while a contour is
drawn in the plan editor. Measured with real pointer events on the demo stand
against `origin/dev`, after waiting for the editor chrome to settle: three
gestures, each exactly on another object's axis, 0 settled render cycles,
`.alignline` 0 and no `.alignguides` group in all three.

The report called it two breaks. It is one — the layer — plus one thing that
would have broken the repair: `_alignPoint` read `_pos`, which during a live
gesture answers from the snapshot of the last settled render. Over one drag:
live 254.17 → 220.83 while `_pos` stayed at 254.17, eight grid steps behind,
so a restored layer would have drawn the guide at the marker's old place.

The live template now paints the guides in all three modes (the device editor
had no template at all — `paintDevice` only moves the marker element), and
`_alignPoint` takes the live position. The settled copy of
`.hp-editor-only-layer` is made transparent for the duration of any editor
gesture, not only in plan mode: two guides, one of them stale, is what the
user would otherwise see when an unrelated settled render lands mid-gesture.
`_renderAlignGuides` on the card becomes soft — a gesture that starts while
the editor runtime is still loading must cost nothing, and an exception inside
a `requestAnimationFrame` paint would take the whole gesture with it.

The witness is rewritten around the defect that hid this for two stable
releases: the old smoke assigned `_deviceDrag`/`_decorDraft` wholesale, and an
assignment with `oldValue == null` does not route to the live path — it
verified a state a real gesture never reaches. Every scenario now drives real
`PointerEvent`s, waits for silence first (the `_hdrH` settling window right
after entering a mode hands out settled frames that make even the broken code
draw a guide), and asserts zero settled cycles during the movements plus
exactly one `.alignguides` group. #400's exclusion is checked without touching
the drag state: the dragged marker must simply be absent from the candidates.
On `origin/dev` the smoke fails on nine of its facts; a witness that stays
green before the fix was the actual bug here.

Mutants: `live-editor-devices-drops-align-guides`,
`live-editor-decor-drops-align-guides`, `live-editor-plan-drops-align-guides`,
`align-point-reads-frozen-snapshot` — one per AC, all guarded by the smoke.
`test/smoke-harness-contract.test.mjs` pins that the smoke cannot go back to
fabricating gesture state.

Issue: #521
User-Visible: yes
This commit is contained in:
Claude
2026-09-10 22:20:18 +03:00
parent fa7ac02c0f
commit 53585b451b
51 changed files with 699 additions and 407 deletions
+7 -3
View File
@@ -12891,7 +12891,9 @@ export class HouseplanCard extends LitElement {
}
if (this._mode === 'devices' && this._deviceDrag?.moved) {
const d = this._devices.find((x) => x.id === this._deviceDrag!.id);
return d ? (() => { const p = this._pos(d); return [p.x, p.y]; })() : null;
// #521: live, not `_pos` — during a gesture `_pos` answers from the last
// settled snapshot, i.e. where the marker stood before the drag began.
return d ? (() => { const p = this._livePos(d); return [p.x, p.y]; })() : null;
}
if (this._mode === 'decor') {
if (this._decorDraft) return this._decorDraft.b;
@@ -12912,8 +12914,10 @@ export class HouseplanCard extends LitElement {
return this._editorRuntimeOrThrow()._alignCandidates();
}
private _renderAlignGuides(): TemplateResult {
return this._editorRuntimeOrThrow()._renderAlignGuides();
// #521: soft, because the live gesture painter calls this too — an exception
// inside a `requestAnimationFrame` paint would take the gesture with it.
private _renderAlignGuides(): TemplateResult | typeof nothing {
return this._editorRuntime?._renderAlignGuides() ?? nothing;
}
/** Perpendicular dashed tick through the wall's center while a dragged opening
+18 -2
View File
@@ -61,6 +61,8 @@ interface LiveEditorHost {
_renderBackdropFrame: (view: unknown) => unknown;
_renderTextFrame: (view: unknown) => unknown;
_renderLiveEditorMeasurements: (view: unknown) => unknown;
/** #521: guides for the gesture in flight; `nothing` while the runtime loads. */
_renderAlignGuides: () => unknown;
_livePos: (device: { id: string }) => { x: number; y: number };
_scenePoint: (point: number[]) => number[];
_renderProjection: string;
@@ -264,6 +266,9 @@ const planTemplate = (host: LiveEditorHost): TemplateResult => {
${measure ? host._renderOpeningDimensionGuides(measure) : nothing}
${measure?.guide ? host._renderOpeningCenterTick(measure.guide) : nothing}
${host._renderActiveChainInk()}
${''/* #521: the guides belong to whoever moves the point. The settled copy
of this layer is made transparent below, so exactly one is visible. */}
${host._renderAlignGuides()}
${host._tool === 'draw' ? nothing : host._renderPlanSnapOverlay()}
${host._renderWallThickUi()}
${host._opDrag ? host._renderOpenings(display) : nothing}
@@ -277,11 +282,18 @@ const editorTemplate = (host: LiveEditorHost): TemplateResult | typeof nothing =
const activeId = host._dtDrag?.id || host._decorMove?.id || null;
return svg`<g class="hp-live-decor" aria-hidden="true" pointer-events="none">
${host._renderDecorLayer(activeId)}
${host._renderAlignGuides()}
${host._renderBackdropFrame(view)}
${host._renderTextFrame(view)}
</g>`;
}
return nothing;
// #521: the device editor used to paint nothing here — `paintDevice` moved
// the marker element and the settled scene, which owns the guides, never ran
// again during the drag. The guides are the only thing this mode needs from
// a template: everything else about a dragged marker is an element style.
return svg`<g class="hp-live-devices" aria-hidden="true" pointer-events="none">
${host._renderAlignGuides()}
</g>`;
};
const paintDevice = (host: LiveEditorHost, root: ParentNode): void => {
@@ -339,8 +351,12 @@ export function paintHouseplanEditor(value: object): void {
paintDevice(host, root);
const target = root.querySelector<SVGElement>('[data-hp-live-editor]');
if (!target) return;
// #521: one visible guides layer per gesture, in every editor mode. The
// settled copy of `.hp-editor-only-layer` holds `_renderAlignGuides()`
// everywhere; plan mode already hid it because its markup layer lives here
// too, and devices/decor left it on screen with a stale point on it.
makeTransparent(state, root, '.hp-editor-only-layer:not(.hp-plan-snap-layer)');
if (host._mode === 'plan') {
makeTransparent(state, root, '.hp-editor-only-layer:not(.hp-plan-snap-layer)');
if (host._opDrag) hide(state, root, '.wallbodies');
if (host._resize?.dragging) {
dim(state, root, '.wallbodies');