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:
co-authored by
Claude Fable 5
parent
a590747748
commit
4fca38a3ef
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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],
|
||||
)
|
||||
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user