fix: make room walls and slabs join cleanly (#497)

* fix(editor): wall endpoint move — detach/attach modifier sync and zero-move drop

- restore linked walls to original positions the moment alt-detach engages
- re-run the endpoint preview on alt keydown/keyup so re-attach doesn't wait for a mousemove; preview, HUD badge, and commit share one alt source
- second click at an unmoved position cleanly drops the endpoint (no history entry) instead of leaving the interaction stuck
- 2D floorplan parity: clear stale linked overrides on detach, re-apply drag sessions on modifier changes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(core): demote orphaned auto slabs/ceilings to manual instead of deleting

Deleting a wall that encloses a room no longer destroys the room's
slab/ceiling (paint, holes, elevation). Unmatched auto surfaces are only
deleted when >=60% of their footprint is still covered by a detected room
(rooms-merged case); otherwise they are demoted to manual nodes.

- demoted slab polygons are baked (inset by SLAB_OUTSET + AUTO_SLAB_INSET)
  so the rendered footprint doesn't jump between the auto and manual paths
- auto-creation suppression now also matches manual nodes by mutual
  footprint coverage, so re-closing the room doesn't stack a duplicate
  auto surface on the demoted one

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(editor): hide alt-to-detach hint when the moving endpoint has no linked walls

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(editor): split host walls in every snap mode, stop wall chain on existing walls

Splitting a room with a wall now splits the walls it lands on and migrates
hosted doors/windows/items — matching what already happened to the slab and
ceiling. The split machinery existed but was gated on magnetic snapping
('lines' mode) while the wall tool defaults to 'grid'; split resolution now
always runs, with the join radius scoped to the active mode (0.35 magnetic,
0.05 connect snap otherwise), and the whole commit lands as one undo step.

The drawing chain now terminates when a committed segment ends on a wall
outside the current chain (T-junction), like the room auto-close — users
don't draw overlapping walls. Applied in 3D and 2D, and the 2D-only path
gains the previously missing wallClosesRoom parity check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(core): per-edge build-time slab offsets — slabs tile at wall centerlines

Replaces the stored-polygon render offsets (auto: centroid inset 0.02,
manual: flat outset 0.05) with one uniform per-edge rule computed at
geometry build time, for auto and manual slabs alike:

- edge shared with a sibling slab (collinear-overlap test, T-junction
  sub-segments included) → small relief inset; adjacent rooms tile
  exactly and can never overlap
- edge on a wall centerline with no slab neighbor → expand outward by
  that wall's thickness/2, flush with the facade
- free edge → rendered exactly as drawn

Slab demotion no longer bakes polygons (offsets never live in node
data); no stored-data migration. New slab system marks level slabs
dirty when wall geometry/thickness or sibling slab footprints change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(viewer): import KTX2Loader from its deep path, not the jsm aggregate

The Addons.js aggregate re-exports LottieLoader/TTFLoader whose CDN URL
imports (lottie-web, opentype.js) abort bun test in every package that
transitively imports the viewer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(editor): split host wall when an endpoint move lands on its interior

Moving a wall endpoint onto another wall's mid-span now splits that wall
and migrates its doors/windows/items, matching the draw tool — previously
the room closed (detection planarizes internally) but the wall node stayed
whole. New resolveEndpointWallSplit reuses the draw path's split pipeline;
endpoint write + split commit as one undo step. Applied in 3D and 2D.
Corner drops still join without splitting; straddling openings skip the
split; alt-detached commits split the stationary former sibling correctly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(core): slab-wall adoption — absolute edge projection, band snapping, rendered-footprint wall elevation

Slab edges near a wall now render projected onto the wall's exact
geometry instead of getting relative offsets, healing legacy scenes
(face-aligned edges, old baked outsets, hand-drag slop) with no data
migration:

- adoption band: an edge collinear with a wall centerline within
  thickness/2 + 6cm (both sides) is wall-backed; nearest centerline
  wins, a sibling slab in the same band forces the interior seam
- wall-backed exterior edges project to the outer face; interior edges
  to the centerline minus the relief gap; free edges render as drawn
- slab edge resize: wall snapping is now edge-based, not cursor-based —
  fixes the drop landing short by the grab offset (0.34m arrow gap in
  3D, hit-stroke slop in 2D); the snap translates the edge onto the
  wall centerline (canonical stored position), beacon/preview/commit
  agree, full band in lines mode, 5cm stick otherwise; 2D shows a
  dashed stored-boundary skeleton when it diverges from the fill
- wall elevation tests the slab's rendered footprint instead of the
  stored polygon with a 0.1mm epsilon, so walls sit on legacy slabs
  and re-elevate when slabs are reshaped

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(core): subcut slab edges per backing span

A slab edge backed differently along its length (offset rooms sharing a
wall over part of the span, a wall shorter than the edge, collinear
walls of different thickness) is now subdivided at the backing-span
breakpoints; each sub-span classifies and projects independently, with
a perpendicular step connector at intra-edge transitions that lands
inside the crossing wall's footprint. Breakpoints closer than 5cm merge
so no sliver geometry reaches the ring, and same-target spans re-fuse
(curved-wall sampling doesn't balloon vertex counts; whole-edge cases
render bit-identically to before).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(core): wall elevation picks the slab that supports it, not the highest graze

A wall touching a raised slab only at an endpoint no longer lifts
entirely to that slab. Elevation selection is now coverage-based:
per-slab support is the wall's centerline+face length covered by the
slab's rendered footprint minus holes; slabs within 0.1mm of elevation
pool their support (party walls spanning two rooms still lift); the
wall sits on the highest elevation covering >=50% of its length, else
the best-covered group (ties prefer higher), else 0. Sub-5cm grazes
are ignored outright.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(core): interior slab edges meet exactly at the wall centerline

Removes the 2cm interior relief inset: both rooms' seam edges project
onto the same centerline (or the symmetric sibling midline when no wall
backs the seam), so adjacent slabs tile with a shared edge — the 4cm
slit under shared walls, visible at its open end on the facade, is
gone. Safe against z-fighting because slab side quads are single-sided
(FrontSide is enforced repo-wide for the MRT scene pass) and the
coincident seam faces have opposite normals; junction step pockets grow
to 5cm but remain strictly inside the intersecting wall footprints.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(core): elevation-aware interior slab seams — close the vertical band slit

With unequal room elevations, both slabs stopping at the shared wall's
centerline left the lower room's half of the wall band open between the
lower slab top and the wall base (the wall seats on the higher slab).
Interior seams across a wall are now elevation-aware: equal elevations
keep the exact centerline seam; unequal elevations project BOTH slabs
to the wall face on the lower side — the higher slab runs through the
band under the wall, the lower butts the same plane. Wall-less unequal
seams keep the sibling midline (a visible step face is correct there).
Slab elevation joins the level dependency signature so height edits
rebuild sibling slabs live.

Verified against real local scenes: all wall bands solid where the
seam rule applies (remaining pockets are the known flat-wall-base
limitation, tracked separately).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(plugin-trees): run wind displacement before the instance transform

The r184->r185 upgrade broke the plant wind: r185 fixed TSL's statement
emission order so a material's positionNode now runs *after* the
instancing transform (r184 emitted it before — verified in the compiled
WGSL of both versions). The wind nodes were tuned against the r184
order, reading positionLocal as geometry-local coordinates, so on r185
the displacement moved into level space: sway no longer scaled with the
per-instance scale (scaled-down trees thrashed like a storm, leaf cards
visibly detaching from branches), leaf phase followed world placement,
and STEM_BEND's height term read the floor elevation, so grass/flowers
on upper levels slid around rigidly.

Restore the r184 semantics explicitly: WindNodeMaterial assigns the wind
node to positionLocal inside setupPosition() before super applies the
instance transform, instead of using positionNode. The emitted WGSL is
statement-for-statement identical to r184's.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(core): resolve slab joins across floor elevations

* fix(editor): keep slab resize arrows visible

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-07-15 13:28:29 -04:00
committed by GitHub
co-authored by Claude Fable 5
parent a590747748
commit 4fca38a3ef
38 changed files with 4079 additions and 316 deletions
@@ -60,8 +60,26 @@ export type PolygonAffordanceSnapContext<N extends PolygonShape & { id: AnyNodeI
mode: PolygonAffordanceMode
}
export type PolygonEdgeSnapContext<N extends PolygonShape & { id: AnyNodeId }> = {
node: N
nodes: Record<AnyNodeId, AnyNode>
/** Candidate edge (after the perpendicular translation), in ring order. */
edge: [[number, number], [number, number]]
rawPoint: WallPlanPoint
modifiers: FloorplanAffordanceModifiers
holeIndex?: number
}
type PolygonAffordanceOptions<N extends PolygonShape & { id: AnyNodeId }> = {
resolvePlanPoint?: (context: PolygonAffordanceSnapContext<N>) => WallPlanPoint
/**
* `move-edge` only: absolute edge snap. The point-based resolver runs
* on the CURSOR, so any grab offset between the pointer and the edge
* line gets baked into a point snap; an edge that must land exactly on
* a target line (wall centerline) snaps here instead — return the
* translated edge, or `null` to keep the candidate.
*/
snapEdge?: (context: PolygonEdgeSnapContext<N>) => [[number, number], [number, number]] | null
}
type PolygonShape = {
@@ -334,8 +352,33 @@ export function createPolygonMoveEdgeAffordance<N extends PolygonShape & { id: A
holeIndex,
mode: 'move-edge',
})
const normalDistance =
let normalDistance =
(snappedPoint[0] - startX) * normalX + (snappedPoint[1] - startY) * normalY
if (options?.snapEdge) {
const candidate: [[number, number], [number, number]] = [
[
startVertex[0] + normalX * normalDistance,
startVertex[1] + normalY * normalDistance,
],
[endVertex[0] + normalX * normalDistance, endVertex[1] + normalY * normalDistance],
]
const snappedEdge = options.snapEdge({
node,
nodes,
edge: candidate,
rawPoint,
modifiers,
holeIndex,
})
if (snappedEdge) {
// Measure the final travel from the ORIGINAL edge so the
// stored edge lands exactly on the snapped line — grab
// offset and pointer position drop out entirely.
normalDistance =
(snappedEdge[0][0] - startVertex[0]) * normalX +
(snappedEdge[0][1] - startVertex[1]) * normalY
}
}
const nextRing: [number, number][] = originalRing.map((p, i) => {
if (i === edgeStartIndex || i === edgeEndIndex) {
return [p[0] + normalX * normalDistance, p[1] + normalY * normalDistance]
@@ -0,0 +1,118 @@
import { describe, expect, test } from 'bun:test'
import {
type AnyNode,
type AnyNodeId,
SlabNode,
type SlabNode as SlabNodeType,
useScene,
WallNode,
} from '@pascal-app/core'
import { slabMoveEdgeAffordance } from '../floorplan-affordances'
type RafFn = (cb: (t: number) => void) => number
;(globalThis as unknown as { requestAnimationFrame?: RafFn }).requestAnimationFrame ??= ((
cb: (t: number) => void,
) => {
cb(0)
return 0
}) as RafFn
;(globalThis as unknown as { cancelAnimationFrame?: (id: number) => void }).cancelAnimationFrame ??=
() => {}
const MODIFIERS = { shiftKey: false, altKey: false, ctrlKey: false, metaKey: false }
/**
* Level + one wall (centerline z=0, t=0.1) + one manual slab whose bottom
* edge starts 0.5m away from the wall.
*/
function seedScene() {
const levelId = 'level_slab-move-edge' as AnyNodeId
const wall = WallNode.parse({
start: [0, 0],
end: [4, 0],
thickness: 0.1,
parentId: levelId,
})
const slab = SlabNode.parse({
polygon: [
[0, 0.5],
[4, 0.5],
[4, 3],
[0, 3],
],
autoFromWalls: false,
parentId: levelId,
})
const level = {
id: levelId,
type: 'level',
object: 'node',
visible: true,
name: '',
metadata: {},
position: [0, 0, 0],
rotation: 0,
level: 0,
parentId: null,
children: [wall.id, slab.id],
} as unknown as AnyNode
useScene.setState({
nodes: { [levelId]: level, [wall.id]: wall, [slab.id]: slab } as never,
})
return { slab }
}
describe('slabMoveEdgeAffordance', () => {
test('commits the edge exactly on the wall centerline despite a grab offset', () => {
const { slab } = seedScene()
const nodes = useScene.getState().nodes
const session = slabMoveEdgeAffordance.start({
node: nodes[slab.id] as SlabNodeType,
payload: { edgeIndex: 0 },
nodes,
// Grabbed 0.15m off the stored edge line — inside the wide screen-px
// hit area. The old cursor-based snap baked this offset into the
// commit, leaving the edge short of the wall by exactly 0.15.
initialPlanPoint: [2, 0.65],
gridSnapStep: 0.1,
} as never)
// Drop the candidate edge just next to the centerline (z≈0.04 before
// any grid quantization) — inside the band / connect stick in every
// snapping mode.
session.apply({ planPoint: [2, 0.19], modifiers: MODIFIERS })
const updated = useScene.getState().nodes[slab.id] as SlabNodeType
expect(updated.polygon[0]![1]).toBeCloseTo(0, 5)
expect(updated.polygon[1]![1]).toBeCloseTo(0, 5)
// Tangential coordinates are untouched by the perpendicular edge drag.
expect(updated.polygon[0]![0]).toBeCloseTo(0, 5)
expect(updated.polygon[1]![0]).toBeCloseTo(4, 5)
// The far edge never moves.
expect(updated.polygon[2]![1]).toBeCloseTo(3, 5)
expect(session.canCommit()).toBe(true)
})
test('a drag far from any wall keeps pure delta semantics', () => {
const { slab } = seedScene()
const nodes = useScene.getState().nodes
const session = slabMoveEdgeAffordance.start({
node: nodes[slab.id] as SlabNodeType,
payload: { edgeIndex: 0 },
nodes,
initialPlanPoint: [2, 0.5],
gridSnapStep: 0.1,
} as never)
// Move up 1m — nowhere near the wall band; the edge follows the
// pointer delta (possibly grid-quantized, which 1.0 is invariant to).
session.apply({ planPoint: [2, 1.5], modifiers: MODIFIERS })
const updated = useScene.getState().nodes[slab.id] as SlabNodeType
expect(updated.polygon[0]![1]).toBeCloseTo(1.5, 5)
expect(updated.polygon[1]![1]).toBeCloseTo(1.5, 5)
})
})
+49 -3
View File
@@ -4,9 +4,12 @@ import { resolveLevelId, type SlabNode, useLiveNodeOverrides, useScene } from '@
import {
boundaryReshapeScope,
clearSlabSnapFeedback,
getSegmentGridStep,
PolygonEditor,
type PolygonEditorPlanPointSnapContext,
resolveSlabEdgeBandSnap,
resolveSlabPlanPointSnap,
snapScalarToGrid,
useInteractionScope,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
@@ -79,13 +82,56 @@ export const SlabBoundaryEditor: React.FC<{ slabId: SlabNode['id'] }> = ({ slabI
)
const resolvePolygonEditorPlanPoint = useCallback(
(context: PolygonEditorPlanPointSnapContext) =>
resolveSlabPlanPointSnap({
(context: PolygonEditorPlanPointSnapContext) => {
// Edge drags: `PolygonEditor` translates the edge by the pointer
// DELTA from `initialPosition` — the cursor at grab time, which sits
// on the edge ARROW ~0.34m outside the edge. A cursor-based wall
// snap here therefore commits the edge short of the wall by exactly
// that offset while the beacon shows a snap ON the wall. Snap the
// CANDIDATE EDGE onto the wall band instead (2D parity: the slab
// `move-edge` affordance's `snapEdge`), and hand back a point whose
// normal projection encodes the final travel.
if (context.mode === 'edge' && context.edgeIndex !== undefined) {
const a = context.initialPolygon[context.edgeIndex]
const b = context.initialPolygon[(context.edgeIndex + 1) % context.initialPolygon.length]
if (a && b) {
const dx = b[0] - a[0]
const dz = b[1] - a[1]
const length = Math.hypot(dx, dz)
if (length > 1e-6) {
// Same convention as PolygonEditor's getEdgeNormal.
const normalX = -dz / length
const normalZ = dx / length
const rawDelta =
(context.rawPoint[0] - context.initialPosition[0]) * normalX +
(context.rawPoint[1] - context.initialPosition[1]) * normalZ
const projection = snapScalarToGrid(rawDelta, getSegmentGridStep())
const candidate: [[number, number], [number, number]] = [
[a[0] + normalX * projection, a[1] + normalZ * projection],
[b[0] + normalX * projection, b[1] + normalZ * projection],
]
const snap = resolveSlabEdgeBandSnap({
edge: candidate,
levelId: slabLevelId,
referencePoint: context.rawPoint,
})
const distance = snap
? (snap.edge[0][0] - a[0]) * normalX + (snap.edge[0][1] - a[1]) * normalZ
: projection
return [
context.initialPosition[0] + normalX * distance,
context.initialPosition[1] + normalZ * distance,
] as [number, number]
}
}
}
return resolveSlabPlanPointSnap({
rawPoint: context.rawPoint,
fallbackPoint: context.gridPoint,
levelId: slabLevelId,
excludeId: slabId,
}).point,
}).point
},
[slabId, slabLevelId],
)
+9
View File
@@ -188,6 +188,15 @@ export const slabDefinition: NodeDefinition<typeof SlabNode> = {
// Stage B: pure geometry function.
geometry: buildSlabGeometry,
// Dependency tracker only — dirties level slabs when walls / sibling
// slabs change, since the renderable polygon derives from level context.
system: {
module: () => import('./system'),
priority: 4,
},
// The fill reads walls + sibling slabs via ctx (per-edge render offsets),
// so committed sibling edits must invalidate the cached floor-plan entry.
floorplanDependsOnSiblings: true,
// Stage C: floor-plan rendering. Legacy `slabPolygons` short-circuits
// to [] when slab is registered (see floorplan-panel.tsx).
floorplan: buildSlabFloorplan,
@@ -1,10 +1,11 @@
import { type AnyNode, resolveLevelId, type SlabNode } from '@pascal-app/core'
import { resolveSlabPlanPointSnap } from '@pascal-app/editor'
import { resolveSlabEdgeBandSnap, resolveSlabPlanPointSnap } from '@pascal-app/editor'
import {
createPolygonAddVertexAffordance,
createPolygonMoveEdgeAffordance,
createPolygonVertexAffordance,
type PolygonAffordanceSnapContext,
type PolygonEdgeSnapContext,
} from '../shared/polygon-vertex-affordance'
/**
@@ -27,7 +28,13 @@ const slabSnapOptions = {
nodes,
rawPoint,
fallbackPoint,
mode,
}: PolygonAffordanceSnapContext<SlabNode>) {
// Edge drags snap the EDGE, not the cursor (`snapEdge` below): a
// cursor-based wall snap here would bake the pointer's grab offset
// from the edge line into the commit — the beacon shows a snap on
// the wall while the released edge stops short by that offset.
if (mode === 'move-edge') return fallbackPoint
const sceneNodes = nodes as Record<string, AnyNode>
return resolveSlabPlanPointSnap({
rawPoint,
@@ -39,6 +46,17 @@ const slabSnapOptions = {
// `lines` mode), so no Shift or Alt snap bypass.
}).point
},
snapEdge({ node, nodes, edge, rawPoint }: PolygonEdgeSnapContext<SlabNode>) {
const sceneNodes = nodes as Record<string, AnyNode>
return (
resolveSlabEdgeBandSnap({
edge,
levelId: resolveLevelId(node, sceneNodes),
nodes: sceneNodes,
referencePoint: rawPoint,
})?.edge ?? null
)
},
}
export const slabMoveVertexAffordance = createPolygonVertexAffordance<SlabNode>(
+25 -3
View File
@@ -4,6 +4,7 @@ import {
type GeometryContext,
getRenderableSlabPolygon,
type SlabNode,
slabPolygonContextFromGeometry,
} from '@pascal-app/core'
/**
@@ -18,8 +19,8 @@ import {
* - Same three handle sets for every hole in `node.holes`, with the
* `holeIndex` carried in each handle's payload.
*
* Uses `getRenderableSlabPolygon` for the visible fill (auto-slabs
* generated from walls clip to wall footprints), but vertex / edge /
* Uses `getRenderableSlabPolygon` for the visible fill (per-edge render
* offsets against level walls + sibling slabs), but vertex / edge /
* midpoint handles live on the **raw** `node.polygon` — matches the
* legacy slab boundary editor which always operates on raw data.
*/
@@ -27,7 +28,7 @@ export function buildSlabFloorplan(node: SlabNode, ctx: GeometryContext): Floorp
const polygon = node.polygon
if (!polygon || polygon.length < 3) return null
const visualPolygon = getRenderableSlabPolygon(node)
const visualPolygon = getRenderableSlabPolygon(node, slabPolygonContextFromGeometry(ctx))
if (!visualPolygon || visualPolygon.length < 3) return null
const view = ctx.viewState
@@ -84,6 +85,27 @@ export function buildSlabFloorplan(node: SlabNode, ctx: GeometryContext): Floorp
// Boundary editor — visible only when the slab is the active selection.
if (isSelected) {
// Handles operate on the STORED polygon while the fill shows the
// band-healed render polygon; when the two diverge (edges projected
// onto wall faces / interior centerline seams), a dashed skeleton of the
// stored boundary shows what the handles actually grab.
const rawDiffersFromVisual =
polygon.length !== visualPolygon.length ||
polygon.some((point, index) => {
const visual = visualPolygon[index]!
return Math.abs(point[0] - visual[0]) > 0.005 || Math.abs(point[1] - visual[1]) > 0.005
})
if (rawDiffersFromVisual) {
children.push({
kind: 'path',
d: ring(polygon.map(([x, z]) => [x, z] as FloorplanPoint)),
fill: 'none',
stroke: palette ? palette.selectedStroke : '#475569',
strokeWidth: 0.015,
strokeOpacity: 0.55,
strokeDasharray: '0.08 0.06',
})
}
appendRingEditor(children, polygon, undefined)
holes.forEach((hole, holeIndex) => {
if (hole.length >= 3) appendRingEditor(children, hole, holeIndex)
+7 -2
View File
@@ -1,4 +1,9 @@
import { type GeometryContext, getMaterialPresetByRef, type SlabNode } from '@pascal-app/core'
import {
type GeometryContext,
getMaterialPresetByRef,
type SlabNode,
slabPolygonContextFromGeometry,
} from '@pascal-app/core'
import {
applyMaterialPresetToMaterials,
type ColorPreset,
@@ -177,7 +182,7 @@ export function buildSlabGeometry(
sceneTheme?: string,
): Group {
const group = new Group()
const merged = generateSlabGeometry(node)
const merged = generateSlabGeometry(node, slabPolygonContextFromGeometry(ctx))
const { top, side } = splitSlabFacesByFacing(merged)
merged.dispose()
+80
View File
@@ -0,0 +1,80 @@
'use client'
import {
type AnyNode,
type AnyNodeId,
type SlabNode,
useScene,
type WallNode,
} from '@pascal-app/core'
import { useEffect } from 'react'
/**
* Slab dependency tracker. The renderable slab polygon derives from level
* context — wall centerlines/thickness (exterior flush offsets) and sibling
* slab polygons (interior centerline seams) — none of which lives on the slab
* node itself. Store updates only dirty the node that changed, so a wall
* thickness edit or a neighbour slab add/remove/reshape would leave stale
* slab meshes. Watch a per-level signature of those inputs and dirty every
* slab on a level whose signature moved; `GeometrySystem` then rebuilds
* them through `def.geometry` as usual.
*/
function levelSlabContextSignatures(nodes: Record<string, AnyNode>): Map<string, string> {
const partsByLevel = new Map<string, string[]>()
const push = (levelId: string, part: string) => {
const parts = partsByLevel.get(levelId)
if (parts) parts.push(part)
else partsByLevel.set(levelId, [part])
}
for (const node of Object.values(nodes)) {
const levelId = node.parentId
if (!levelId) continue
if (node.type === 'wall') {
const wall = node as WallNode
push(
levelId,
`w|${wall.id}|${wall.start[0]},${wall.start[1]}|${wall.end[0]},${wall.end[1]}|${wall.thickness ?? ''}|${wall.curveOffset ?? ''}`,
)
} else if (node.type === 'slab') {
const slab = node as SlabNode
// Elevation is a seam input: an unequal-elevation seam projects to
// the lower side's wall face, so a height change reshapes siblings.
push(
levelId,
`s|${slab.id}|${slab.elevation ?? ''}|${slab.polygon.map(([x, z]) => `${x},${z}`).join(';')}`,
)
}
}
const signatures = new Map<string, string>()
for (const [levelId, parts] of partsByLevel.entries()) {
signatures.set(levelId, parts.sort().join('||'))
}
return signatures
}
const SlabSystems = () => {
useEffect(() => {
let previous = levelSlabContextSignatures(useScene.getState().nodes)
return useScene.subscribe((state) => {
const current = levelSlabContextSignatures(state.nodes)
for (const [levelId, signature] of current.entries()) {
if (previous.get(levelId) === signature) continue
for (const node of Object.values(state.nodes)) {
if (node.type === 'slab' && node.parentId === levelId) {
state.markDirty(node.id as AnyNodeId)
}
}
}
previous = current
})
}, [])
return null
}
export default SlabSystems
@@ -6,6 +6,7 @@ import {
getMaxWallCurveOffset,
getWallChordFrame,
normalizeWallCurveOffset,
runAsSingleSceneHistoryStep,
useLiveNodeOverrides,
useScene,
type WallNode,
@@ -17,6 +18,7 @@ import {
isAngleSnapActive,
isMagneticSnapActive,
isSegmentLongEnough,
resolveEndpointWallSplit,
snapBuildingLocalToWorldGrid,
snapScalarToGrid,
snapWallDraftPoint,
@@ -244,6 +246,17 @@ export const wallMoveEndpointAffordance: FloorplanAffordance<WallNode> = {
const sceneState = useScene.getState()
overrides.set(node.id as AnyNodeId, { start: primaryStart, end: primaryEnd })
sceneState.markDirty(node.id as AnyNodeId)
if (modifiers.altKey) {
// Attach→detach transition: linked walls dragged on earlier attached
// ticks still carry overrides — drop them so their corners snap back
// to the scene originals (untouched during the drag).
for (const linked of linkedWalls) {
if (overrides.get(linked.id)) {
overrides.clear(linked.id)
sceneState.markDirty(linked.id)
}
}
}
for (const upd of linkedUpdates) {
overrides.set(upd.id, { start: upd.start, end: upd.end })
sceneState.markDirty(upd.id)
@@ -261,14 +274,41 @@ export const wallMoveEndpointAffordance: FloorplanAffordance<WallNode> = {
commit() {
// Atomic tracked write of the final endpoints, then drop the
// overrides so the scene state is the single source of truth
// again.
useScene.getState().updateNodes([
{ id: node.id, data: { start: lastPrimaryStart, end: lastPrimaryEnd } },
...lastLinkedUpdates.map((u) => ({
id: u.id,
data: { start: u.start, end: u.end },
})),
])
// again. Parity with the 3D move-endpoint tool: a drop on another
// wall's interior splits that host (create halves, migrate
// attachments, delete host) inside the same single history step as
// the endpoint write. Linked walls updated here share the drop point
// as an endpoint (a corner join, not a split) so they're excluded
// with the dragged wall; a zero-move drop skips the resolution
// entirely.
const movingPoint = endpoint === 'start' ? lastPrimaryStart : lastPrimaryEnd
const originalMovingPoint = endpoint === 'start' ? originalStart : originalEnd
runAsSingleSceneHistoryStep(useScene, () => {
const resolved = pointsEqual(movingPoint, originalMovingPoint)
? null
: resolveEndpointWallSplit({
point: movingPoint,
levelId: (node.parentId ?? null) as string | null,
ignoreWallIds: [node.id, ...lastLinkedUpdates.map((u) => String(u.id))],
})
const finalPoint = resolved ?? movingPoint
useScene.getState().updateNodes([
{
id: node.id,
data: {
start: endpoint === 'start' ? finalPoint : lastPrimaryStart,
end: endpoint === 'end' ? finalPoint : lastPrimaryEnd,
},
},
...lastLinkedUpdates.map((u) => ({
id: u.id,
data: {
start: pointsEqual(u.start, movingPoint) ? finalPoint : u.start,
end: pointsEqual(u.end, movingPoint) ? finalPoint : u.end,
},
})),
])
})
const overrides = useLiveNodeOverrides.getState()
overrides.clear(node.id as AnyNodeId)
for (const upd of lastLinkedUpdates) overrides.clear(upd.id)
+129 -43
View File
@@ -11,6 +11,7 @@ import {
pauseSceneHistory,
resolveAlignment,
resumeSceneHistory,
runAsSingleSceneHistoryStep,
useLiveNodeOverrides,
useScene,
type WallNode,
@@ -26,6 +27,7 @@ import {
isSegmentLongEnough,
MeasurementPill,
markToolCancelConsumed,
resolveEndpointWallSplit,
snapWallDraftPointDetailed,
triggerSFX,
useAlignmentGuides,
@@ -207,6 +209,14 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
const [altPressed, setAltPressed] = useState(false)
const unit = useViewer((s) => s.unit)
// Alt-detach only affects walls sharing the moving endpoint; walls linked
// solely to the fixed endpoint never move, so the hint would be noise.
const movingOriginal =
target.endpoint === 'start' ? originalStartRef.current : originalEndRef.current
const canDetachCorner = linkedOriginalsRef.current.some(
(wall) => samePoint(wall.start, movingOriginal) || samePoint(wall.end, movingOriginal),
)
const exitMoveMode = useCallback(() => {
useInteractionScope
.getState()
@@ -231,6 +241,13 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
pauseSceneHistory(useScene)
let wasCommitted = false
// Last point handed to `applyPreview` — lets the Alt keydown/keyup
// handlers re-run the preview immediately on a modifier change instead of
// waiting for the next mousemove.
let lastMovedPoint: WallPlanPoint | null = null
// The first pointer-up is the *grab* of a click-to-move; later ones are
// drops. See the `!hasChanged` branch in `onPointerUp`.
let hasReleasedOnce = false
// Wall ids carrying a live position override during the drag. Mirrors the
// 3D/2D wall MOVE tools: preview via `useLiveNodeOverrides` (the wall
@@ -271,6 +288,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
}
const applyPreview = (movingPoint: WallPlanPoint, detachLinkedWalls = false) => {
lastMovedPoint = movingPoint
const nextStart = target.endpoint === 'start' ? movingPoint : fixedPoint
const nextEnd = target.endpoint === 'end' ? movingPoint : fixedPoint
const linkedUpdates = detachLinkedWalls
@@ -282,6 +300,20 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
nextStart,
nextEnd,
)
if (detachLinkedWalls) {
// Attach→detach transition: `setMany` only writes the ids it is
// handed, so linked walls dragged on earlier attached ticks would keep
// their stale overrides. Drop them so their corners snap back to the
// scene originals (untouched during the drag).
const overrides = useLiveNodeOverrides.getState()
const sceneState = useScene.getState()
for (const linked of linkedOriginalsRef.current) {
if (touchedWallIds.delete(linked.id as AnyNodeId)) {
overrides.clear(linked.id)
sceneState.markDirty(linked.id as AnyNodeId)
}
}
}
previewRef.current = { start: nextStart, end: nextEnd }
setCursorLocalPos([movingPoint[0], 0, movingPoint[1]])
setAngleLabel(
@@ -395,7 +427,14 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
: null,
)
applyPreview(alignedPoint, event.nativeEvent.altKey)
// The keydown listener can't observe an Alt press that predates the
// tool mounting; the pointer event can. Sync the shared ref (single Alt
// source for preview, HUD badge, and commit) before applying.
if (event.nativeEvent.altKey !== altPressedRef.current) {
altPressedRef.current = event.nativeEvent.altKey
setAltPressed(event.nativeEvent.altKey)
}
applyPreview(alignedPoint, altPressedRef.current)
}
const onPointerUp = () => {
@@ -413,14 +452,23 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
samePoint(preview.start, originalStart) && samePoint(preview.end, originalEnd)
)
// Endpoint still at its original spot: this release is the *grab* of a
// click-to-move (a tap on the handle, or a press that never dragged). Stay
// armed so the endpoint keeps following the cursor — the next release after
// an actual move commits. A press-drag and a click thus engage identically;
// previously the no-drag branch dismissed the tool, and whether it even ran
// raced the window pointer-up listener mounting (hence "works once, then
// needs a long press").
if (!hasChanged) return
// Endpoint still at its original spot. The FIRST release is the *grab*
// of a click-to-move (a tap on the handle, or a press that never
// dragged): stay armed so the endpoint keeps following the cursor — a
// press-drag and a click thus engage identically. Any LATER release at
// an unchanged position is a deliberate drop: end the interaction
// cleanly (previews restored, scope ended, no history entry) instead of
// leaving the user stuck until they move the mouse.
if (!hasChanged) {
if (!hasReleasedOnce) {
hasReleasedOnce = true
return
}
restoreOriginal()
useViewer.getState().setSelection({ selectedIds: [nodeId] })
exitMoveMode()
return
}
if (isSegmentLongEnough(preview.start, preview.end)) {
wasCommitted = true
@@ -438,20 +486,46 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
// Drop the live overrides; the store write below is the source of truth.
// The store sat at the pre-drag (original) values the whole drag — only
// overrides moved — so one resume+write records original→final as a
// single tracked change (one Ctrl-Z reverts to original).
// single tracked change (one Ctrl-Z reverts to original). The split
// ops (create halves, migrate attachments, delete host) would each
// push their own entry, so the whole commit runs as one history step.
clearPreviewOverrides()
resumeSceneHistory(useScene)
useScene.getState().updateNodes([
{ id: nodeId as AnyNodeId, data: { start: preview.start, end: preview.end } },
...linkedUpdates.map((u) => ({
id: u.id as AnyNodeId,
data: { start: u.start, end: u.end },
})),
])
useScene.getState().markDirty(nodeId as AnyNodeId)
for (const u of linkedUpdates) {
useScene.getState().markDirty(u.id as AnyNodeId)
}
runAsSingleSceneHistoryStep(useScene, () => {
// Dropping the endpoint on another wall's interior splits that host
// like the draw path does. Linked walls updated in this commit share
// the drop point as an endpoint (a corner join, not a split), so
// they're excluded along with the moved wall — in Alt-detach mode
// `linkedUpdates` is empty and a stationary former sibling can be
// split like any other host.
const movingPoint = target.endpoint === 'start' ? preview.start : preview.end
const resolved = resolveEndpointWallSplit({
point: movingPoint,
levelId: target.wall.parentId ?? null,
ignoreWallIds: [nodeId, ...linkedUpdates.map((u) => String(u.id))],
})
const finalPoint = resolved ?? movingPoint
useScene.getState().updateNodes([
{
id: nodeId as AnyNodeId,
data: {
start: target.endpoint === 'start' ? finalPoint : preview.start,
end: target.endpoint === 'end' ? finalPoint : preview.end,
},
},
...linkedUpdates.map((u) => ({
id: u.id as AnyNodeId,
data: {
start: samePoint(u.start, movingPoint) ? finalPoint : u.start,
end: samePoint(u.end, movingPoint) ? finalPoint : u.end,
},
})),
])
useScene.getState().markDirty(nodeId as AnyNodeId)
for (const u of linkedUpdates) {
useScene.getState().markDirty(u.id as AnyNodeId)
}
})
pauseSceneHistory(useScene)
triggerSFX('sfx:item-place')
}
@@ -472,26 +546,36 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
exitMoveMode()
}
// Single Alt writer for keyboard transitions. Re-running the preview on
// the flip keeps geometry and the HUD badge in lockstep — detach reverts
// the linked walls instantly, re-attach snaps them onto the dragged point
// — without waiting for the next mousemove.
const setAltState = (pressed: boolean) => {
if (altPressedRef.current === pressed) return
altPressedRef.current = pressed
setAltPressed(pressed)
if (lastMovedPoint) {
applyPreview(lastMovedPoint, pressed)
}
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
return
}
if (event.key === 'Alt') {
altPressedRef.current = true
setAltPressed(true)
setAltState(true)
}
}
const onKeyUp = (event: KeyboardEvent) => {
if (event.key === 'Alt') {
altPressedRef.current = false
setAltPressed(false)
setAltState(false)
}
}
const onWindowBlur = () => {
altPressedRef.current = false
setAltPressed(false)
setAltState(false)
}
emitter.on('grid:move', onGridMove)
@@ -550,23 +634,25 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
unit={unit}
/>
</Html>
<Html
position={[cursorLocalPos[0], 0, cursorLocalPos[2]]}
style={{ pointerEvents: 'none', touchAction: 'none' }}
zIndexRange={[100, 0]}
>
<div className="translate-y-10">
<div
className={`whitespace-nowrap rounded-full border px-2 py-1 font-medium text-[11px] shadow-lg backdrop-blur-md transition-colors ${
altPressed
? 'border-amber-500/80 bg-amber-500/15 text-amber-100'
: 'border-border bg-background/95 text-muted-foreground'
}`}
>
{altPressed ? 'Detaching corner' : 'Alt to detach'}
{canDetachCorner && (
<Html
position={[cursorLocalPos[0], 0, cursorLocalPos[2]]}
style={{ pointerEvents: 'none', touchAction: 'none' }}
zIndexRange={[100, 0]}
>
<div className="translate-y-10">
<div
className={`whitespace-nowrap rounded-full border px-2 py-1 font-medium text-[11px] shadow-lg backdrop-blur-md transition-colors ${
altPressed
? 'border-amber-500/80 bg-amber-500/15 text-amber-100'
: 'border-border bg-background/95 text-muted-foreground'
}`}
>
{altPressed ? 'Detaching corner' : 'Alt to detach'}
</div>
</div>
</div>
</Html>
</Html>
)}
{angleLabel && <EndpointAngleLabel label={angleLabel.label} position={angleLabel.position} />}
</group>
)
+1 -1
View File
@@ -7,7 +7,7 @@ import { WallCutout, WallSystem } from '@pascal-app/viewer'
*
* - **`WallSystem`** — reads `dirtyNodes`, batches by level, runs
* `calculateLevelMiters(levelWalls)`, rebuilds geometry via
* `generateExtrudedWall(node, children, miterData, slabElevation)`,
* `generateExtrudedWall(node, children, miterData, slabElevation, baseElevation, baseSegments)`,
* and cascades to adjacent walls that share a junction. This is the
* bulk of the wall runtime (~820 lines in viewer).
* - **`WallCutout`** — cutaway-mode hide/show logic based on camera
+17 -1
View File
@@ -16,6 +16,7 @@ import {
} from '@pascal-app/core'
import {
CursorSphere,
chainEndJoinsExistingWall,
createWallOnCurrentLevel,
EDITOR_LAYER,
formatAngleRadians,
@@ -529,6 +530,10 @@ export const WallTool: React.FC = () => {
const startingPoint = useRef(new Vector3(0, 0, 0))
const endingPoint = useRef(new Vector3(0, 0, 0))
const chainFirstVertex = useRef<Vector3 | null>(null)
// Ids of the walls committed by the current chain — the exclusion set for
// the "segment tees into an existing wall" chain-termination test, so
// snapping onto the chain's own segments never reads as a join.
const chainWallIds = useRef<string[]>([])
const buildingState = useRef(0)
const [draftMeasurement, setDraftMeasurement] = useState<DraftMeasurementState>(null)
const [axisGuide, setAxisGuide] = useState<DraftAxisGuideState>(null)
@@ -588,6 +593,7 @@ export const WallTool: React.FC = () => {
const stopDrafting = () => {
buildingState.current = 0
chainFirstVertex.current = null
chainWallIds.current = []
if (wallPreviewRef.current) {
wallPreviewRef.current.visible = false
}
@@ -735,6 +741,7 @@ export const WallTool: React.FC = () => {
snappedEnd,
)
if (!createdWall) return
chainWallIds.current.push(createdWall.id)
// The new segment is now a real node — make it an alignment target
// for the next segment, and drop the just-shown guide.
@@ -755,7 +762,16 @@ export const WallTool: React.FC = () => {
// existing wall network (e.g. a bay closed onto the middle of another
// wall), not just when the chain loops back to its own start. Shares the
// room graph with auto slab/ceiling detection so the two never disagree.
if (closedToChainStart || wallClosesRoom(getCurrentLevelWalls(), createdWall)) {
// A resolved end that tees into wall geometry outside the chain also
// terminates even without an enclosed room — nobody continues drawing
// from a T-junction into an existing wall; a dead end in free space
// keeps the chain going.
const levelWalls = getCurrentLevelWalls()
if (
closedToChainStart ||
chainEndJoinsExistingWall(createdWall.end, levelWalls, chainWallIds.current) ||
wallClosesRoom(levelWalls, createdWall)
) {
stopDrafting()
return
}