diff --git a/packages/nodes/src/stair/renderer.test.ts b/packages/nodes/src/stair/renderer.test.ts
index 7bcdea2398..b72c1322d6 100644
--- a/packages/nodes/src/stair/renderer.test.ts
+++ b/packages/nodes/src/stair/renderer.test.ts
@@ -17,7 +17,13 @@ import {
useLiveNodeOverrides,
useScene,
} from '@pascal-app/core'
-import { StairEditSystem, ToolManager, useAlignmentGuides, useEditor } from '@pascal-app/editor'
+import {
+ preloadRegistryToolModules,
+ StairEditSystem,
+ ToolManager,
+ useAlignmentGuides,
+ useEditor,
+} from '@pascal-app/editor'
import { OVERLAY_LAYER, StairSystem, useViewer } from '@pascal-app/viewer'
import { act, create } from '@react-three/test-renderer'
import { createElement } from 'react'
@@ -286,6 +292,8 @@ test('stair placement preview follows raised support, survives level switches, a
camera.position.set(0, 10, 0)
camera.lookAt(0, 0, 0)
camera.updateMatrixWorld(true)
+ await loadPlugin(builtinPlugin)
+ await preloadRegistryToolModules('stair')
renderer = await create(createElement(ToolManager), { camera })
const move = { position: [0, 0, 0], localPosition: [0, 0, 0], nativeEvent: {} } as Parameters<
typeof emitter.emit<'grid:move'>
diff --git a/packages/nodes/src/stair/renderer.tsx b/packages/nodes/src/stair/renderer.tsx
index 6bfe9b787d..5ac82ec561 100644
--- a/packages/nodes/src/stair/renderer.tsx
+++ b/packages/nodes/src/stair/renderer.tsx
@@ -1,6 +1,7 @@
'use client'
import {
+ type AnyNodeId,
computeSegmentTransforms,
measureStairDetail,
resolveArcStairConstruction,
@@ -47,6 +48,7 @@ import {
resolveStairSlotMaterial,
} from './materials'
import { buildPostAndRailGuard, postAndRailGuardRails } from './post-and-rail-guard'
+import { surfaceTransitionProfile } from './surface-transition'
import { type StairRenderData, useStairRenderData } from './use-stair-render-data'
type StairRailPathSide = 'left' | 'right' | 'front'
@@ -163,6 +165,10 @@ export const StairRenderer = ({ node: rawNode }: { node: StairNode }) => {
[baseRailingMaterial, node, sceneMaterials, shading, textures],
)
+ const onLevel = useScene((state) =>
+ node.parentId ? state.nodes[node.parentId as AnyNodeId]?.type === 'level' : true,
+ )
+
// Each flight contributes tread and body material groups to the merged mesh.
const straightPlaceholderGeometry = useMemo(() => createPlaceholderGeometry(2), [])
@@ -175,6 +181,9 @@ export const StairRenderer = ({ node: rawNode }: { node: StairNode }) => {
return (
{
) : null}
>
)}
+ {isSegmentBasedStair && node.destinationSurfaceId && !detail.error ? (
+
+ ) : null}
{isSegmentBasedStair && !detail.error ? (
@@ -225,6 +237,169 @@ export const StairRenderer = ({ node: rawNode }: { node: StairNode }) => {
)
}
+function StairSurfaceTransition({
+ stair,
+ materials,
+}: {
+ stair: StairNode
+ materials: StairBodyMaterials
+}) {
+ const rawSurface = useScene((state) => state.nodes[stair.destinationSurfaceId as AnyNodeId])
+ const surfaceOverride = useLiveNodeOverrides((state) =>
+ state.overrides.get(stair.destinationSurfaceId as AnyNodeId),
+ )
+ const surface = useMemo(
+ () => (rawSurface && surfaceOverride ? { ...rawSurface, ...surfaceOverride } : rawSurface),
+ [rawSurface, surfaceOverride],
+ )
+ const flightId = stair.children[0]
+ const flight = useScene((state) => (flightId ? state.nodes[flightId] : undefined))
+ const flightOverride = useLiveNodeOverrides((state) =>
+ flightId ? state.overrides.get(flightId) : undefined,
+ )
+ const length =
+ (flightOverride?.length as number | undefined) ??
+ (flight as StairSegmentNode | undefined)?.length ??
+ 0
+ const segment = flight as StairSegmentNode | undefined
+ const fillToFloor =
+ (flightOverride?.fillToFloor as boolean | undefined) ??
+ segment?.fillToFloor ??
+ stair.fillToFloor ??
+ true
+ const thickness =
+ (flightOverride?.thickness as number | undefined) ??
+ segment?.thickness ??
+ stair.thickness ??
+ 0.25
+ const segmentHeight = (flightOverride?.height as number | undefined) ?? segment?.height ?? 0
+ const profile = useMemo(
+ () =>
+ surface
+ ? surfaceTransitionProfile(
+ stair,
+ surface as Parameters[1],
+ length,
+ )
+ : null,
+ [stair, surface, length],
+ )
+ const geometry = useMemo(() => {
+ if (!profile) return null
+ const vertices: number[] = []
+ const indices: number[] = []
+ const top = stair.totalRise ?? 0
+ const undersideOffset =
+ segment?.segmentType === 'landing'
+ ? thickness
+ : thickness / Math.cos(Math.atan2(segmentHeight, length || 1))
+ const bottom = fillToFloor ? 0 : top - undersideOffset
+ for (const [x, boundary] of profile.samples) {
+ vertices.push(
+ x,
+ top,
+ length + profile.frontZ,
+ x,
+ top,
+ length + boundary,
+ x,
+ bottom,
+ length + profile.frontZ,
+ x,
+ bottom,
+ length + boundary,
+ )
+ }
+ for (let i = 0; i < profile.samples.length - 1; i++) {
+ const a = i * 4,
+ b = (i + 1) * 4
+ indices.push(
+ a,
+ a + 1,
+ b,
+ b,
+ a + 1,
+ b + 1,
+ a + 2,
+ b + 2,
+ a + 3,
+ b + 2,
+ b + 3,
+ a + 3,
+ a,
+ b,
+ a + 2,
+ b,
+ b + 2,
+ a + 2,
+ a + 1,
+ a + 3,
+ b + 1,
+ b + 1,
+ a + 3,
+ b + 3,
+ )
+ }
+ const last = (profile.samples.length - 1) * 4
+ indices.push(0, 2, 1, 1, 2, 3, last, last + 1, last + 2, last + 1, last + 3, last + 2)
+ const indexed = new THREE.BufferGeometry()
+ indexed.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3))
+ indexed.setIndex(indices)
+ const result = indexed.toNonIndexed()
+ indexed.dispose()
+ result.computeVertexNormals()
+ const position = result.getAttribute('position')
+ const normals = result.getAttribute('normal')
+ const uvs: number[] = []
+ let currentMaterial = -1
+ let groupStart = 0
+ for (let i = 0; i < position.count; i += 3) {
+ const nx = Math.abs(normals.getX(i))
+ const ny = Math.abs(normals.getY(i))
+ const nz = Math.abs(normals.getZ(i))
+ const materialIndex =
+ normals.getY(i) > 0.75 ? STAIR_TREAD_MATERIAL_INDEX : STAIR_SIDE_MATERIAL_INDEX
+ if (materialIndex !== currentMaterial) {
+ if (i > groupStart) result.addGroup(groupStart, i - groupStart, currentMaterial)
+ groupStart = i
+ currentMaterial = materialIndex
+ }
+ for (let vertex = i; vertex < i + 3; vertex++) {
+ const x = position.getX(vertex),
+ y = position.getY(vertex),
+ z = position.getZ(vertex)
+ if (ny >= nx && ny >= nz) uvs.push(x, z)
+ else if (nx >= nz) uvs.push(z, y)
+ else uvs.push(x, y)
+ }
+ }
+ if (position.count > groupStart)
+ result.addGroup(groupStart, position.count - groupStart, currentMaterial)
+ result.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2))
+ result.setAttribute('uv2', new THREE.Float32BufferAttribute(uvs.slice(), 2))
+ return result
+ }, [
+ profile,
+ length,
+ stair.totalRise,
+ fillToFloor,
+ thickness,
+ segmentHeight,
+ segment?.segmentType,
+ ])
+ useEffect(() => () => geometry?.dispose(), [geometry])
+ return geometry ? (
+
+ ) : null
+}
+
function StairWalkingLine({
stair,
segments,
@@ -299,7 +474,7 @@ function StairRailings({
// reads as one coherent guard across straight, chained, winder, curved and
// spiral layouts.
- if ((stair.railingMode ?? 'none') === 'none') {
+ if (stair.destinationSurfaceId || (stair.railingMode ?? 'none') === 'none') {
return null
}
diff --git a/packages/editor/src/components/tools/stair/stair-click-guard.test.ts b/packages/nodes/src/stair/stair-click-guard.test.ts
similarity index 100%
rename from packages/editor/src/components/tools/stair/stair-click-guard.test.ts
rename to packages/nodes/src/stair/stair-click-guard.test.ts
diff --git a/packages/editor/src/components/tools/stair/stair-click-guard.ts b/packages/nodes/src/stair/stair-click-guard.ts
similarity index 100%
rename from packages/editor/src/components/tools/stair/stair-click-guard.ts
rename to packages/nodes/src/stair/stair-click-guard.ts
diff --git a/packages/nodes/src/stair/stair-type.ts b/packages/nodes/src/stair/stair-type.ts
index 718fd8a815..2a451c9568 100644
--- a/packages/nodes/src/stair/stair-type.ts
+++ b/packages/nodes/src/stair/stair-type.ts
@@ -2,11 +2,11 @@ import {
type AnyNode,
type AnyNodeId,
createStairFlightFromStair,
+ DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE,
type StairNode,
type StairSegmentNode,
type StairType,
} from '@pascal-app/core'
-import { DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE } from '@pascal-app/editor'
export type StairTypeChange = {
updates: Partial
diff --git a/packages/nodes/src/stair/surface-move-preview.test.ts b/packages/nodes/src/stair/surface-move-preview.test.ts
new file mode 100644
index 0000000000..4d80cf9429
--- /dev/null
+++ b/packages/nodes/src/stair/surface-move-preview.test.ts
@@ -0,0 +1,56 @@
+import { afterEach, expect, test } from 'bun:test'
+import {
+ getEffectiveNode,
+ StairNode,
+ StairSegmentNode,
+ useLiveNodeOverrides,
+} from '@pascal-app/core'
+import { clearStairMovePreview, publishStairMovePreview } from './surface-move-preview'
+
+const flight = StairSegmentNode.parse({
+ id: 'sseg_preview',
+ height: 0.35,
+ length: 0.84,
+ stepCount: 2,
+})
+const stair = StairNode.parse({
+ id: 'stair_preview',
+ children: [flight.id],
+ position: [0, 0, -2.34],
+ totalRise: 0.35,
+ stepCount: 2,
+ railingMode: 'both',
+})
+
+afterEach(() => useLiveNodeOverrides.getState().clearAll())
+
+test('the visible stair model receives live pose and flight geometry during a snap', () => {
+ const position: [number, number, number] = [1, 0, -3]
+ publishStairMovePreview(stair.id, flight.id, position, Math.PI / 2, {
+ position,
+ rotation: Math.PI / 2,
+ totalRise: 1.2,
+ stepCount: 8,
+ length: 2.24,
+ surfaceId: 'deck_1',
+ })
+
+ const visibleStair = getEffectiveNode(stair)
+ const visibleFlight = getEffectiveNode(flight)
+ expect(visibleStair.position).toEqual(position)
+ expect(visibleStair.rotation).toBeCloseTo(Math.PI / 2)
+ expect(visibleStair.totalRise).toBe(1.2)
+ expect(visibleStair.railingMode).toBe('none')
+ expect(visibleStair.destinationSurfaceId).toBe('deck_1')
+ expect(visibleFlight.height).toBe(1.2)
+ expect(visibleFlight.length).toBe(2.24)
+ expect(visibleFlight.stepCount).toBe(8)
+
+ publishStairMovePreview(stair.id, flight.id, [4, 0, -3], 0, null)
+ expect(getEffectiveNode(stair).position).toEqual([4, 0, -3])
+ expect(getEffectiveNode(stair).railingMode).toBe('both')
+ expect(getEffectiveNode(stair).destinationSurfaceId).toBeUndefined()
+ expect(getEffectiveNode(flight).height).toBe(0.35)
+ clearStairMovePreview(stair.id, flight.id)
+ expect(getEffectiveNode(stair).position).toEqual(stair.position)
+})
diff --git a/packages/nodes/src/stair/surface-move-preview.ts b/packages/nodes/src/stair/surface-move-preview.ts
new file mode 100644
index 0000000000..3dca1e8221
--- /dev/null
+++ b/packages/nodes/src/stair/surface-move-preview.ts
@@ -0,0 +1,62 @@
+import {
+ type AnyNodeId,
+ type StairSurfaceSnap,
+ useLiveNodeOverrides,
+ useScene,
+} from '@pascal-app/core'
+
+export function publishStairMovePreview(
+ stairId: AnyNodeId,
+ flightId: AnyNodeId | undefined,
+ position: [number, number, number],
+ rotation: number,
+ snap: StairSurfaceSnap | null,
+) {
+ const overrides = useLiveNodeOverrides.getState()
+ if (snap) {
+ overrides.setMany([
+ [
+ stairId,
+ {
+ position,
+ rotation,
+ totalRise: snap.totalRise,
+ stepCount: snap.stepCount,
+ destinationSurfaceId: snap.surfaceId,
+ railingMode: 'none',
+ },
+ ],
+ ...(flightId
+ ? [
+ [
+ flightId,
+ { height: snap.totalRise, length: snap.length, stepCount: snap.stepCount },
+ ] as const,
+ ]
+ : []),
+ ])
+ useScene.getState().markDirty(stairId)
+ if (flightId) useScene.getState().markDirty(flightId)
+ return
+ }
+
+ const hadSnap =
+ overrides.get(stairId)?.totalRise !== undefined ||
+ (flightId !== undefined && overrides.get(flightId) !== undefined)
+ overrides.clearFields(stairId, ['totalRise', 'stepCount', 'railingMode'])
+ if (flightId && overrides.get(flightId)) overrides.clear(flightId)
+ overrides.set(stairId, { position, rotation, destinationSurfaceId: undefined })
+ if (hadSnap) {
+ useScene.getState().markDirty(stairId)
+ if (flightId) useScene.getState().markDirty(flightId)
+ }
+}
+
+export function clearStairMovePreview(stairId: AnyNodeId, flightId: AnyNodeId | undefined) {
+ const overrides = useLiveNodeOverrides.getState()
+ if (!overrides.get(stairId) && (!flightId || !overrides.get(flightId))) return
+ overrides.clear(stairId)
+ if (flightId) overrides.clear(flightId)
+ useScene.getState().markDirty(stairId)
+ if (flightId) useScene.getState().markDirty(flightId)
+}
diff --git a/packages/nodes/src/stair/surface-transition.test.ts b/packages/nodes/src/stair/surface-transition.test.ts
new file mode 100644
index 0000000000..e57a3aa3b4
--- /dev/null
+++ b/packages/nodes/src/stair/surface-transition.test.ts
@@ -0,0 +1,103 @@
+import { expect, test } from 'bun:test'
+import {
+ type AnyNode,
+ type AnyNodeDefinition,
+ nodeRegistry,
+ registerNode,
+ StairNode,
+} from '@pascal-app/core'
+import { z } from 'zod'
+import { surfaceTransitionProfile } from './surface-transition'
+
+function surfaceFixture(
+ pose: { position: [number, number, number]; rotation?: [number, number, number] },
+ points: [number, number][],
+): AnyNode {
+ const type = 'test:curved-platform'
+ if (!nodeRegistry.has(type))
+ registerNode({
+ kind: type,
+ schemaVersion: 1,
+ schema: z.object({ type: z.literal(type) }),
+ defaults: () => ({}),
+ category: 'site',
+ capabilities: {
+ surfaces: {
+ top: {
+ height: 0.35,
+ boundary: (node: AnyNode) => {
+ const surface = node as AnyNode & {
+ edge: [number, number][]
+ position: [number, number, number]
+ rotation?: [number, number, number]
+ }
+ const c = Math.cos(surface.rotation?.[1] ?? 0),
+ s = Math.sin(surface.rotation?.[1] ?? 0)
+ return surface.edge.map(
+ ([x, z]) =>
+ [surface.position[0] + x * c + z * s, surface.position[2] - x * s + z * c] as [
+ number,
+ number,
+ ],
+ )
+ },
+ },
+ },
+ },
+ } as AnyNodeDefinition)
+ return { id: 'patio_1', type, ...pose, edge: points } as unknown as AnyNode
+}
+const circle = Array.from({ length: 128 }, (_, i): [number, number] => [
+ 2 * Math.cos((i * Math.PI) / 64),
+ 2 * Math.sin((i * Math.PI) / 64),
+])
+
+const stair = StairNode.parse({
+ position: [0, 0, -3.5],
+ rotation: 0,
+ width: 1,
+ totalRise: 0.35,
+ destinationSurfaceId: 'patio_1',
+})
+
+test('bridges the gap from a straight stair to a circular patio', () => {
+ const profile = surfaceTransitionProfile(
+ stair,
+ surfaceFixture({ position: [0, 0, 0] }, circle),
+ 1.5,
+ )
+ expect(profile).not.toBeNull()
+ expect(profile!.frontZ).toBe(0)
+ expect(profile!.samples[0]![1]).toBeGreaterThan(0)
+ expect(Math.abs(profile!.samples.find(([x]) => Math.abs(x) < 1e-6)![1])).toBeLessThan(0.01)
+ expect(profile!.samples.at(-1)![1]).toBeCloseTo(profile!.samples[0]![1])
+ expect(profile!.samples.length).toBeGreaterThan(3)
+})
+
+test('follows a custom polygon and skips a disconnected edge', () => {
+ const custom = surfaceFixture({ position: [0, 0, 0] }, [
+ [-2, -2],
+ [0, -1.8],
+ [2, -2],
+ [2, 2],
+ [-2, 2],
+ ])
+ expect(
+ surfaceTransitionProfile(stair, custom, 1.5)?.samples.find(([x]) => x === 0)?.[1],
+ ).toBeCloseTo(0.2)
+ expect(surfaceTransitionProfile(stair, custom, 0.5)).toBeNull()
+})
+
+test('keeps the same boundary after the stair becomes a rotated surface child', () => {
+ const surface = surfaceFixture({ position: [4, 0, 3], rotation: [0, Math.PI / 2, 0] }, circle)
+ const levelStair = StairNode.parse({ ...stair, position: [5, 0, 2], rotation: Math.PI / 2 })
+ const childStair = StairNode.parse({
+ ...levelStair,
+ parentId: 'patio_1',
+ position: [1, 0, 1],
+ rotation: 0,
+ })
+ expect(surfaceTransitionProfile(childStair, surface, 1.5)?.samples).toEqual(
+ surfaceTransitionProfile(levelStair, surface, 1.5)?.samples,
+ )
+})
diff --git a/packages/nodes/src/stair/surface-transition.ts b/packages/nodes/src/stair/surface-transition.ts
new file mode 100644
index 0000000000..1785dd1c52
--- /dev/null
+++ b/packages/nodes/src/stair/surface-transition.ts
@@ -0,0 +1,69 @@
+import { type AnyNode, nodeRegistry, type StairNode } from '@pascal-app/core'
+
+type Point = [number, number]
+export type TransitionProfile = { frontZ: number; samples: Point[] }
+
+export function surfaceTransitionProfile(
+ stair: StairNode,
+ surface: AnyNode,
+ length: number,
+): TransitionProfile | null {
+ if (!Number.isFinite(length)) return null
+ const boundary = nodeRegistry.get(surface.type)?.capabilities?.surfaces?.top?.boundary
+ if (!boundary) return null
+ const surfaceOutline = boundary(surface, 0)
+ if (surfaceOutline.length < 3) return null
+ const pose = surface as AnyNode & {
+ position?: [number, number, number]
+ rotation?: number | [number, number, number]
+ }
+ const surfacePosition = pose.position ?? [0, 0, 0]
+ const surfaceAngle = typeof pose.rotation === 'number' ? pose.rotation : (pose.rotation?.[1] ?? 0)
+ const attached = stair.parentId === stair.destinationSurfaceId
+ const parentCos = Math.cos(surfaceAngle),
+ parentSin = Math.sin(surfaceAngle)
+ const stairPosition: [number, number, number] = attached
+ ? [
+ surfacePosition[0] + parentCos * stair.position[0] + parentSin * stair.position[2],
+ surfacePosition[1] + stair.position[1],
+ surfacePosition[2] - parentSin * stair.position[0] + parentCos * stair.position[2],
+ ]
+ : stair.position
+ const stairRotation = stair.rotation + (attached ? surfaceAngle : 0)
+ const cc = Math.cos(stairRotation),
+ cs = Math.sin(stairRotation)
+ const highX = stairPosition[0] + cs * length
+ const highZ = stairPosition[2] + cc * length
+ const outline = surfaceOutline.map(([x, z]): Point => {
+ const dx = x - highX
+ const dz = z - highZ
+ return [dx * cc - dz * cs, dx * cs + dz * cc]
+ })
+
+ const halfWidth = stair.width / 2
+ const samples: Point[] = []
+ const sampleXs = [
+ -halfWidth,
+ 0,
+ halfWidth,
+ ...outline.map(([x]) => x).filter((x) => x > -halfWidth && x < halfWidth),
+ ]
+ .sort((a, b) => a - b)
+ .filter((x, index, values) => index === 0 || x - values[index - 1]! > 1e-6)
+ for (const x of sampleXs) {
+ const crossings: number[] = []
+ for (let edge = 0; edge < outline.length; edge++) {
+ const a = outline[edge]!,
+ b = outline[(edge + 1) % outline.length]!
+ const dx = b[0] - a[0]
+ if (Math.abs(dx) < 1e-8) continue
+ const t = (x - a[0]) / dx
+ if (t >= 0 && t <= 1) crossings.push(a[1] + (b[1] - a[1]) * t)
+ }
+ const boundary = crossings.sort((a, b) => Math.abs(a) - Math.abs(b))[0]
+ if (boundary === undefined || boundary < -0.12 || boundary > 0.7) return null
+ samples.push([x, boundary])
+ }
+ if (samples.every(([, z]) => Math.abs(z) < 0.015)) return null
+ return { frontZ: 0, samples }
+}
diff --git a/packages/editor/src/components/tools/stair/stair-tool.tsx b/packages/nodes/src/stair/tool.tsx
similarity index 80%
rename from packages/editor/src/components/tools/stair/stair-tool.tsx
rename to packages/nodes/src/stair/tool.tsx
index 8154ad22cd..3225ac9462 100644
--- a/packages/editor/src/components/tools/stair/stair-tool.tsx
+++ b/packages/nodes/src/stair/tool.tsx
@@ -1,9 +1,25 @@
+'use client'
+
import {
type AnyNode,
collectAlignmentAnchors,
createSizedStairFlight,
createSurfaceOpeningPreviewController,
+ DEFAULT_CURVED_STAIR_INNER_RADIUS,
+ DEFAULT_CURVED_STAIR_SWEEP_ANGLE,
DEFAULT_LEVEL_HEIGHT,
+ DEFAULT_SPIRAL_SHOW_CENTER_COLUMN,
+ DEFAULT_SPIRAL_SHOW_STEP_SUPPORTS,
+ DEFAULT_SPIRAL_TOP_LANDING_DEPTH,
+ DEFAULT_SPIRAL_TOP_LANDING_MODE,
+ DEFAULT_STAIR_ATTACHMENT_SIDE,
+ DEFAULT_STAIR_FILL_TO_FLOOR,
+ DEFAULT_STAIR_OPENING_OFFSET,
+ DEFAULT_STAIR_RAILING_HEIGHT,
+ DEFAULT_STAIR_RAILING_MODE,
+ DEFAULT_STAIR_THICKNESS,
+ DEFAULT_STAIR_TYPE,
+ DEFAULT_STAIR_WIDTH,
emitter,
type GridEvent,
getFloorStackedPosition,
@@ -15,54 +31,37 @@ import {
planStairSizing,
resolveAlignment,
resolveFrozenFloorPlacementPatch,
+ resolveStairSurfaceSnap,
resolveSupportSlabPatch,
StairNode,
type StairSegmentNode,
+ type StairSurfaceSnap,
syncAutoStairOpenings,
useScene,
} from '@pascal-app/core'
-import { useViewer } from '@pascal-app/viewer'
-import { useThree } from '@react-three/fiber'
-import { useEffect, useMemo, useRef } from 'react'
-import * as THREE from 'three'
-import { EDITOR_LAYER } from '../../../lib/constants'
-import { sfxEmitter } from '../../../lib/sfx-bus'
import {
- resolveStairDestinationLevel,
- resolveStairPlacementLevelId,
-} from '../../../lib/stair-levels'
-import { isTypingTarget } from '../../../lib/typing-target'
-import useAlignmentGuides from '../../../store/use-alignment-guides'
-import useEditor, {
+ CursorSphere,
+ EDITOR_LAYER,
+ getFloorStackPreviewPosition,
isAlignmentGuideActive,
isGridSnapActive,
isMagneticSnapActive,
-} from '../../../store/use-editor'
-import useFacingPose from '../../../store/use-facing-pose'
-import { useStairBuildPreview } from '../../../store/use-stair-build-preview'
-import { CursorSphere } from '../shared/cursor-sphere'
-import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview'
-import {
+ isTypingTarget,
type PointerSupportSurface,
resolvePointerSupportSurface,
-} from '../shared/pointer-support-cap'
+ resolveStairDestinationLevel,
+ resolveStairPlacementLevelId,
+ triggerSFX,
+ useAlignmentGuides,
+ useEditor,
+ useFacingPose,
+ useStairBuildPreview,
+} from '@pascal-app/editor'
+import { useViewer } from '@pascal-app/viewer'
+import { useThree } from '@react-three/fiber'
+import { useEffect, useMemo, useRef } from 'react'
+import * as THREE from 'three'
import { createStairCommitGate, swallowFollowUpBrowserClick } from './stair-click-guard'
-import {
- DEFAULT_CURVED_STAIR_INNER_RADIUS,
- DEFAULT_CURVED_STAIR_SWEEP_ANGLE,
- DEFAULT_SPIRAL_SHOW_CENTER_COLUMN,
- DEFAULT_SPIRAL_SHOW_STEP_SUPPORTS,
- DEFAULT_SPIRAL_TOP_LANDING_DEPTH,
- DEFAULT_SPIRAL_TOP_LANDING_MODE,
- DEFAULT_STAIR_ATTACHMENT_SIDE,
- DEFAULT_STAIR_FILL_TO_FLOOR,
- DEFAULT_STAIR_OPENING_OFFSET,
- DEFAULT_STAIR_RAILING_HEIGHT,
- DEFAULT_STAIR_RAILING_MODE,
- DEFAULT_STAIR_THICKNESS,
- DEFAULT_STAIR_TYPE,
- DEFAULT_STAIR_WIDTH,
-} from './stair-defaults'
const GRID_OFFSET = 0.02
/** Figma-style alignment-snap threshold (meters), matching the move tools. */
@@ -74,8 +73,16 @@ type MoveTriggerEvent = GridEvent | NodeEvent
* Generates the step-profile geometry for the ghost preview.
* Same algorithm as StairSystem's generateStairSegmentGeometry.
*/
-function createStairPreviewGeometry(rise: number): THREE.BufferGeometry {
- const { stepCount, length, riserHeight, going: treadDepth } = planStairSizing(rise)
+function createStairPreviewGeometry(
+ rise: number,
+ count?: number,
+ run?: number,
+): THREE.BufferGeometry {
+ const sizing = planStairSizing(rise)
+ const stepCount = count ?? sizing.stepCount
+ const length = run ?? sizing.length
+ const riserHeight = rise / stepCount
+ const treadDepth = length / stepCount
const shape = new THREE.Shape()
shape.moveTo(0, 0)
@@ -152,6 +159,7 @@ function createDefaultStairNode({
rotation: number
segmentId: StairSegmentNode['id']
}) {
+ const railingMode = useEditor.getState().toolDefaults.stair?.railingMode
return StairNode.parse({
name,
parentId: levelId,
@@ -173,7 +181,13 @@ function createDefaultStairNode({
showCenterColumn: DEFAULT_SPIRAL_SHOW_CENTER_COLUMN,
showStepSupports: DEFAULT_SPIRAL_SHOW_STEP_SUPPORTS,
railingHeight: DEFAULT_STAIR_RAILING_HEIGHT,
- railingMode: DEFAULT_STAIR_RAILING_MODE,
+ railingMode:
+ railingMode === 'none' ||
+ railingMode === 'left' ||
+ railingMode === 'right' ||
+ railingMode === 'both'
+ ? railingMode
+ : DEFAULT_STAIR_RAILING_MODE,
children: [segmentId],
})
}
@@ -186,6 +200,7 @@ function commitStairPlacement(
position: [number, number, number],
rotation: number,
supportSurface: PointerSupportSurface | null,
+ surfaceSnap: StairSurfaceSnap | null,
): void {
const { applyNodeChanges, nodes } = useScene.getState()
const placementLevelId = resolveStairPlacementLevelId(
@@ -201,11 +216,13 @@ function commitStairPlacement(
if (!(storeyRise > 0)) return
const seed = createSeedStairSegment(storeyRise)
- const destinationPlan = resolveStairDestinationLevel({
- createMissing: true,
- fromLevelId: placementLevelId,
- nodes,
- })
+ const destinationPlan = surfaceSnap
+ ? null
+ : resolveStairDestinationLevel({
+ createMissing: true,
+ fromLevelId: placementLevelId,
+ nodes,
+ })
const nextLevelId = destinationPlan?.toLevel.id ?? placementLevelId
const stair = StairNode.parse({
@@ -218,6 +235,15 @@ function commitStairPlacement(
segmentId: seed.id,
}),
parentId: placementLevelId,
+ ...(surfaceSnap
+ ? {
+ totalRise: surfaceSnap.totalRise,
+ stepCount: surfaceSnap.stepCount,
+ slabOpeningMode: 'none' as const,
+ destinationSurfaceId: surfaceSnap.surfaceId,
+ railingMode: 'none' as const,
+ }
+ : {}),
})
const base = resolvePlacedStairBase(nodes, placementLevelId, stair, supportSurface, seed)
let segment: StairSegmentNode
@@ -240,25 +266,34 @@ function commitStairPlacement(
if (error instanceof RangeError) return
throw error
}
+ if (surfaceSnap)
+ segment = {
+ ...segment,
+ height: surfaceSnap.totalRise,
+ length: surfaceSnap.length,
+ stepCount: surfaceSnap.stepCount,
+ }
stair.stepCount = segment.stepCount
const prospectiveNodes = {
...nodes,
[stair.id]: stair,
[segment.id]: { ...segment, parentId: stair.id },
} as Record
- const placementPatch = supportSurface?.sourceNodeId
- ? resolveFrozenFloorPlacementPatch(stair, prospectiveNodes, {
- position,
- rotation,
- elevation: supportSurface.elevation,
- preferredSlabId: supportSurface.supportSlabId,
- })
- : {
- position,
- ...resolveSupportSlabPatch(stair, prospectiveNodes, {
- maxElevation: supportSurface?.elevation,
- }),
- }
+ const placementPatch = surfaceSnap
+ ? { position }
+ : supportSurface?.sourceNodeId
+ ? resolveFrozenFloorPlacementPatch(stair, prospectiveNodes, {
+ position,
+ rotation,
+ elevation: supportSurface.elevation,
+ preferredSlabId: supportSurface.supportSlabId,
+ })
+ : {
+ position,
+ ...resolveSupportSlabPatch(stair, prospectiveNodes, {
+ maxElevation: supportSurface?.elevation,
+ }),
+ }
const committedStair = StairNode.parse({
...stair,
...placementPatch,
@@ -279,10 +314,10 @@ function commitStairPlacement(
update: destinationPlan?.liftedLevels ?? [],
})
- sfxEmitter.emit('sfx:structure-build')
+ triggerSFX('sfx:structure-build')
}
-export const StairTool: React.FC = () => {
+export default function StairTool() {
const camera = useThree((state) => state.camera)
const cameraRef = useRef(camera)
cameraRef.current = camera
@@ -291,6 +326,7 @@ export const StairTool: React.FC = () => {
const previewMeshRef = useRef(null)
const rotationRef = useRef(0)
const supportSurfaceRef = useRef(null)
+ const surfaceSnapRef = useRef(null)
const previousGridPosRef = useRef<[number, number] | null>(null)
const lastCanonicalPositionRef = useRef<[number, number, number] | null>(null)
const currentLevelId = useViewer((state) => state.selection.levelId)
@@ -302,8 +338,9 @@ export const StairTool: React.FC = () => {
previewRiseRef.current = previewRise
const previewGeometry = useMemo(() => createStairPreviewGeometry(DEFAULT_LEVEL_HEIGHT), [])
const ownedGeometryRef = useRef(previewGeometry)
- const geometryRiseRef = useRef(DEFAULT_LEVEL_HEIGHT)
+ const geometryRiseRef = useRef(DEFAULT_LEVEL_HEIGHT)
useEffect(() => () => ownedGeometryRef.current.dispose(), [])
+ useEffect(() => () => useEditor.getState().setToolDefaults('stair', null), [])
useEffect(() => {
if (!currentLevelId) return
@@ -322,11 +359,13 @@ export const StairTool: React.FC = () => {
}
lastCanonicalPositionRef.current = null
supportSurfaceRef.current = null
+ surfaceSnapRef.current = null
const buildPreviewScene = (
position: [number, number, number],
rotation: number,
supportSurface: PointerSupportSurface | null,
+ surfaceSnap: StairSurfaceSnap | null,
) => {
const nodes = useScene.getState().nodes
const placementLevelId = resolveStairPlacementLevelId(
@@ -336,11 +375,13 @@ export const StairTool: React.FC = () => {
)
if (!placementLevelId) return null
- const destinationPlan = resolveStairDestinationLevel({
- createMissing: true,
- fromLevelId: placementLevelId,
- nodes,
- })
+ const destinationPlan = surfaceSnap
+ ? null
+ : resolveStairDestinationLevel({
+ createMissing: true,
+ fromLevelId: placementLevelId,
+ nodes,
+ })
const nextLevelId = destinationPlan?.toLevel.id ?? placementLevelId
const storeyRise = getLevelFloorToFloorHeight(placementLevelId, nodes)
if (!(storeyRise > 0)) return null
@@ -374,6 +415,18 @@ export const StairTool: React.FC = () => {
if (error instanceof RangeError) return null
throw error
}
+ if (surfaceSnap) {
+ stair.totalRise = surfaceSnap.totalRise
+ stair.destinationSurfaceId = surfaceSnap.surfaceId
+ stair.slabOpeningMode = 'none'
+ stair.railingMode = 'none'
+ segment = {
+ ...segment,
+ height: surfaceSnap.totalRise,
+ length: surfaceSnap.length,
+ stepCount: surfaceSnap.stepCount,
+ }
+ }
stair.stepCount = segment.stepCount
const previewNodes = {
...nodes,
@@ -401,15 +454,23 @@ export const StairTool: React.FC = () => {
rotation: number,
supportSurface: PointerSupportSurface | null,
) => {
- const key = `${position[0].toFixed(3)},${position[2].toFixed(3)},${rotation.toFixed(4)},${supportSurface?.elevation.toFixed(3) ?? 'none'},${supportSurface?.sourceNodeId ?? 'floor'},${previewRiseRef.current}`
+ const surfaceSnap = surfaceSnapRef.current
+ rotation = surfaceSnap?.rotation ?? rotation
+ const key = `${position[0].toFixed(3)},${position[2].toFixed(3)},${rotation.toFixed(4)},${supportSurface?.elevation.toFixed(3) ?? 'none'},${supportSurface?.sourceNodeId ?? 'floor'},${surfaceSnapRef.current?.length.toFixed(3) ?? 'free'},${surfaceSnapRef.current?.stepCount ?? 0},${surfaceSnapRef.current?.totalRise.toFixed(3) ?? 'free'}`
if (key === lastPreviewKey) return
lastPreviewKey = key
- const preview = buildPreviewScene(position, rotation, supportSurface)
+ const preview = buildPreviewScene(position, rotation, supportSurface, surfaceSnap)
useStairBuildPreview
.getState()
- .setPreview(preview ? [position[0], position[2]] : null, rotation, preview?.rise ?? null)
+ .setPreview(
+ preview ? [position[0], position[2]] : null,
+ rotation,
+ preview?.rise ?? null,
+ surfaceSnap?.length,
+ surfaceSnap?.stepCount,
+ )
const frozenPatch =
- preview && supportSurface?.sourceNodeId
+ preview && supportSurface?.sourceNodeId && !surfaceSnap
? resolveFrozenFloorPlacementPatch(preview.stair, preview.previewNodes, {
position,
rotation,
@@ -444,12 +505,21 @@ export const StairTool: React.FC = () => {
previewRef.current.visible = Boolean(preview)
previewRef.current.position.set(...visualPosition)
previewRef.current.rotation.y = rotation
- if (preview && previewMeshRef.current && preview.rise !== geometryRiseRef.current) {
+ if (
+ preview &&
+ previewMeshRef.current &&
+ `${preview.rise}:${surfaceSnap?.stepCount}:${surfaceSnap?.length}` !==
+ String(geometryRiseRef.current)
+ ) {
const previous = previewMeshRef.current.geometry
- previewMeshRef.current.geometry = createStairPreviewGeometry(preview.rise)
+ previewMeshRef.current.geometry = createStairPreviewGeometry(
+ preview.rise,
+ surfaceSnap?.stepCount,
+ surfaceSnap?.length,
+ )
previous.dispose()
ownedGeometryRef.current = previewMeshRef.current.geometry
- geometryRiseRef.current = preview.rise
+ geometryRiseRef.current = `${preview.rise}:${surfaceSnap?.stepCount}:${surfaceSnap?.length}`
}
previewRef.current.scale.y = 1
}
@@ -486,7 +556,12 @@ export const StairTool: React.FC = () => {
z: number,
rotation: number,
): ReturnType | null => {
- const preview = buildPreviewScene([x, 0, z], rotation, supportSurfaceRef.current)
+ const preview = buildPreviewScene(
+ [x, 0, z],
+ rotation,
+ supportSurfaceRef.current,
+ surfaceSnapRef.current,
+ )
const moving = preview
? movingAlignmentAnchors(preview.stair, preview.previewNodes, x, z, rotation)
: []
@@ -556,7 +631,42 @@ export const StairTool: React.FC = () => {
!isAlignmentGuideActive(),
isMagneticSnapActive(),
)
- return [gridX, 0, gridZ]
+ const nodes = useScene.getState().nodes
+ const levelId = resolveStairPlacementLevelId(
+ nodes,
+ currentLevelId,
+ useViewer.getState().selection.buildingId,
+ )
+ const candidate: [number, number, number] = [gridX, 0, gridZ]
+ const draft = StairNode.parse({
+ parentId: levelId,
+ position: candidate,
+ width: DEFAULT_STAIR_WIDTH,
+ stairType: 'straight',
+ rotation: rotationRef.current,
+ })
+ const baseElevation = levelId
+ ? getFloorStackedPosition({
+ node: draft,
+ nodes,
+ position: candidate,
+ rotation: rotationRef.current,
+ levelId,
+ })[1]
+ : candidate[1]
+ const snap = !isMagneticSnapActive()
+ ? null
+ : resolveStairSurfaceSnap(
+ draft,
+ nodes,
+ candidate,
+ planStairSizing(previewRiseRef.current).length,
+ baseElevation,
+ useScene.getState().installedPlugins,
+ )
+ surfaceSnapRef.current = snap
+ if (snap) return snap.position
+ return candidate
}
const onPointerMove = (event: MoveTriggerEvent) => {
@@ -571,7 +681,7 @@ export const StairTool: React.FC = () => {
previousGridPosRef.current &&
(gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])
) {
- sfxEmitter.emit('sfx:grid-snap')
+ triggerSFX('sfx:grid-snap')
}
previousGridPosRef.current = [gridX, gridZ]
@@ -598,7 +708,13 @@ export const StairTool: React.FC = () => {
const position = resolveStairPosition(event)
if (!position) return
- commitStairPlacement(currentLevelId, position, rotationRef.current, supportSurfaceRef.current)
+ commitStairPlacement(
+ currentLevelId,
+ position,
+ surfaceSnapRef.current?.rotation ?? rotationRef.current,
+ supportSurfaceRef.current,
+ surfaceSnapRef.current,
+ )
openingPreview.clear()
// Commit cleared the opening preview, so force the next hover (even on the
// same cell) to rebuild rather than dedupe against the just-placed key.
@@ -632,7 +748,7 @@ export const StairTool: React.FC = () => {
if (rotationDelta !== 0) {
event.preventDefault()
- sfxEmitter.emit('sfx:item-rotate')
+ triggerSFX('sfx:item-rotate')
rotationRef.current += rotationDelta
if (lastCanonicalPositionRef.current) {
applyDraftPreview(
diff --git a/packages/nodes/src/wall/tool.tsx b/packages/nodes/src/wall/tool.tsx
index a83c70d9b7..0444638f2a 100644
--- a/packages/nodes/src/wall/tool.tsx
+++ b/packages/nodes/src/wall/tool.tsx
@@ -1046,7 +1046,14 @@ function PolygonDraftGhosts({
export const WallTool: React.FC = () => {
// Clear preset-seeded defaults on deactivation so a later manual wall draw
// isn't built with a stale preset's parameters. Unmount-only.
- useEffect(() => () => useEditor.getState().setToolDefaults('wall', null), [])
+ useEffect(
+ () => () => {
+ const editor = useEditor.getState()
+ // Remounts keep the active preset; leaving the wall tool releases it.
+ if (editor.tool !== 'wall') editor.setToolDefaults('wall', null)
+ },
+ [],
+ )
// The drawing variant is picked in the Build panel's Rooms group.
const variant = useWallDrawVariant()
diff --git a/packages/viewer/src/lib/merged-outline-node.test.ts b/packages/viewer/src/lib/merged-outline-node.test.ts
index d5adb0335c..8a17dca74c 100644
--- a/packages/viewer/src/lib/merged-outline-node.test.ts
+++ b/packages/viewer/src/lib/merged-outline-node.test.ts
@@ -16,6 +16,7 @@ import {
SkinnedMesh,
Sprite,
SpriteMaterial,
+ Texture,
type Vector2,
} from 'three'
import RenderObject from 'three/src/renderers/common/RenderObject.js'
@@ -435,3 +436,21 @@ describe('merged outline passes', () => {
f.dispose()
})
})
+
+test('selection masks retain cutout coverage and release it when alpha testing is removed', () => {
+ const f = makeOutlineFixture()
+ const map = new Texture()
+ f.material.map = map
+ f.material.alphaTest = 0.5
+ f.tick()
+ const mask = f.renderer.draws[0].material
+ expect(mask.alphaTest).toBe(0.5)
+ expect(mask.opacityNode).toBeDefined()
+ expect(f.mesh.material).toBe(f.material)
+ f.renderer.draws.length = 0
+ f.material.alphaTest = 0
+ f.tick()
+ expect(f.renderer.draws[0].material.alphaTest).toBe(0)
+ map.dispose()
+ f.dispose()
+})
diff --git a/packages/viewer/src/lib/merged-outline-node.ts b/packages/viewer/src/lib/merged-outline-node.ts
index cf57382e78..89abbfe947 100644
--- a/packages/viewer/src/lib/merged-outline-node.ts
+++ b/packages/viewer/src/lib/merged-outline-node.ts
@@ -180,6 +180,10 @@ export class MergedOutlineNode extends TempNode {
private readonly _proxiesA = new Map()
private readonly _proxiesB = new Map()
private readonly _proxyMaskMaterials = new WeakMap()
+ private readonly _cutoutMasksA = new Map()
+ private readonly _cutoutMasksB = new Map()
+ private readonly _usedCutoutMasksA = new Set()
+ private readonly _usedCutoutMasksB = new Set()
private readonly _maskSceneA = new Scene()
private readonly _maskSceneB = new Scene()
@@ -370,8 +374,21 @@ export class MergedOutlineNode extends TempNode {
this._buildCache(this.secondaryObjects, this._cacheB)
const useProxiesA = this._syncProxies(this._cacheA, this._proxiesA, this._maskSceneA)
const useProxiesB = this._syncProxies(this._cacheB, this._proxiesB, this._maskSceneB)
+ this._usedCutoutMasksA.clear()
+ this._usedCutoutMasksB.clear()
+ for (const [cache, used] of [
+ [this._cacheA, this._usedCutoutMasksA],
+ [this._cacheB, this._usedCutoutMasksB],
+ ] as const) {
+ for (const object of cache) {
+ if (!(object instanceof Mesh)) continue
+ const materials = Array.isArray(object.material) ? object.material : [object.material]
+ for (const source of materials) if (source?.alphaTest > 0) used.add(source)
+ }
+ }
if (!hasAny) {
+ this._pruneCutoutMasks()
RendererUtils.restoreRendererAndSceneState(renderer, scene, _rendererState)
return
}
@@ -408,6 +425,7 @@ export class MergedOutlineNode extends TempNode {
if (hasPrimary) this._runEdgePipeline(renderer, 'A')
if (hasSecondary) this._runEdgePipeline(renderer, 'B')
+ this._pruneCutoutMasks()
RendererUtils.restoreRendererAndSceneState(renderer, scene, _rendererState)
}
@@ -418,6 +436,8 @@ export class MergedOutlineNode extends TempNode {
const maskScene = isA ? this._maskSceneA : this._maskSceneB
const material = isA ? this._prepareMaskMatA : this._prepareMaskMatB
const spriteMaterial = isA ? this._prepareMaskSpriteMatA : this._prepareMaskSpriteMatB
+ const cutoutMasks = isA ? this._cutoutMasksA : this._cutoutMasksB
+ const usedCutoutMasks = isA ? this._usedCutoutMasksA : this._usedCutoutMasksB
if (useProxies) {
for (const [source, proxy] of proxies) proxy.matrixWorld.copy(source.matrixWorld)
}
@@ -425,10 +445,48 @@ export class MergedOutlineNode extends TempNode {
// Keep source materials on proxies for material visibility and geometry
// groups; substitute only at submission, just like the full-scene path.
renderer.setRenderObjectFunction(
- (obj: any, sc: any, cam: any, geo: any, _mat: any, grp: any, lights: any, clip: any) => {
+ (
+ obj: any,
+ sc: any,
+ cam: any,
+ geo: any,
+ sourceMaterial: any,
+ grp: any,
+ lights: any,
+ clip: any,
+ ) => {
if (!hasDrawableGeometry(geo)) return
if (useProxies || cache.has(obj)) {
let maskMaterial = obj.isSprite ? spriteMaterial : material
+ if (!obj.isSprite && sourceMaterial?.alphaTest > 0) {
+ let cutoutMask = cutoutMasks.get(sourceMaterial)
+ const opacityNode =
+ sourceMaterial.opacityNode ??
+ (sourceMaterial.map
+ ? cutoutMask?.userData.sourceMap === sourceMaterial.map
+ ? cutoutMask.opacityNode
+ : texture(sourceMaterial.map).a
+ : null)
+ if (opacityNode) {
+ if (!cutoutMask) {
+ cutoutMask = material.clone()
+ cutoutMasks.set(sourceMaterial, cutoutMask)
+ }
+ const positionNode = sourceMaterial.positionNode ?? null
+ const changed =
+ cutoutMask.opacityNode !== opacityNode ||
+ cutoutMask.positionNode !== positionNode ||
+ cutoutMask.side !== sourceMaterial.side
+ cutoutMask.opacityNode = opacityNode
+ cutoutMask.positionNode = positionNode
+ cutoutMask.alphaTest = sourceMaterial.alphaTest
+ cutoutMask.side = sourceMaterial.side
+ cutoutMask.userData.sourceMap = sourceMaterial.map
+ if (changed) cutoutMask.needsUpdate = true
+ maskMaterial = cutoutMask
+ usedCutoutMasks.add(sourceMaterial)
+ }
+ }
if (useProxies) {
let ownedMaterial = this._proxyMaskMaterials.get(obj)
if (!ownedMaterial) {
@@ -437,8 +495,18 @@ export class MergedOutlineNode extends TempNode {
ownedMaterial = maskMaterial.clone()
this._proxyMaskMaterials.set(obj, ownedMaterial)
}
- if (ownedMaterial.colorNode !== maskMaterial.colorNode) {
+ if (
+ ownedMaterial.colorNode !== maskMaterial.colorNode ||
+ ownedMaterial.opacityNode !== maskMaterial.opacityNode ||
+ ownedMaterial.positionNode !== maskMaterial.positionNode ||
+ ownedMaterial.alphaTest !== maskMaterial.alphaTest ||
+ ownedMaterial.side !== maskMaterial.side
+ ) {
ownedMaterial.colorNode = maskMaterial.colorNode
+ ownedMaterial.opacityNode = maskMaterial.opacityNode
+ ownedMaterial.positionNode = maskMaterial.positionNode
+ ownedMaterial.alphaTest = maskMaterial.alphaTest
+ ownedMaterial.side = maskMaterial.side
ownedMaterial.needsUpdate = true
}
maskMaterial = ownedMaterial
@@ -525,6 +593,19 @@ export class MergedOutlineNode extends TempNode {
this._proxyMaskMaterials.delete(proxy)
}
+ private _pruneCutoutMasks() {
+ for (const [materials, used] of [
+ [this._cutoutMasksA, this._usedCutoutMasksA],
+ [this._cutoutMasksB, this._usedCutoutMasksB],
+ ] as const) {
+ for (const [source, mask] of materials) {
+ if (used.has(source)) continue
+ mask.dispose()
+ materials.delete(source)
+ }
+ }
+ }
+
private _runEdgePipeline(renderer: any, group: 'A' | 'B') {
const isA = group === 'A'
const g = isA ? this._groupA : this._groupB
@@ -759,6 +840,10 @@ export class MergedOutlineNode extends TempNode {
this._proxiesB.clear()
this._cacheA.clear()
this._cacheB.clear()
+ for (const mask of this._cutoutMasksA.values()) mask.dispose()
+ for (const mask of this._cutoutMasksB.values()) mask.dispose()
+ this._cutoutMasksA.clear()
+ this._cutoutMasksB.clear()
this._depthRT.dispose()
this._groupA.dispose()
this._groupB.dispose()
diff --git a/packages/viewer/src/systems/stair/stair-system.test.tsx b/packages/viewer/src/systems/stair/stair-system.test.tsx
new file mode 100644
index 0000000000..da59203e4d
--- /dev/null
+++ b/packages/viewer/src/systems/stair/stair-system.test.tsx
@@ -0,0 +1,64 @@
+import { afterEach, expect, test } from 'bun:test'
+import {
+ StairNode,
+ StairSegmentNode,
+ sceneRegistry,
+ useLiveNodeOverrides,
+ useScene,
+} from '@pascal-app/core'
+import ReactThreeTestRenderer from '@react-three/test-renderer'
+import { BufferGeometry, Group, Mesh } from 'three'
+import { StairSystem } from './stair-system'
+
+const initialScene = useScene.getState()
+
+afterEach(() => {
+ useLiveNodeOverrides.getState().clearAll()
+ sceneRegistry.clear()
+ useScene.setState({
+ nodes: initialScene.nodes,
+ rootNodeIds: initialScene.rootNodeIds,
+ dirtyNodes: initialScene.dirtyNodes,
+ })
+})
+
+test('rebuilds the visible stair body from a live snapped flight', async () => {
+ const segment = StairSegmentNode.parse({
+ id: 'sseg_live_body',
+ parentId: 'stair_live_body',
+ height: 0.35,
+ length: 0.84,
+ stepCount: 2,
+ })
+ const stair = StairNode.parse({
+ id: 'stair_live_body',
+ children: [segment.id],
+ totalRise: 0.35,
+ stepCount: 2,
+ })
+ const group = new Group()
+ const body = new Mesh(new BufferGeometry())
+ body.name = 'merged-stair'
+ group.add(body)
+ sceneRegistry.nodes.set(stair.id, group)
+ sceneRegistry.nodes.set(segment.id, new Mesh(new BufferGeometry()))
+ useScene.setState({
+ nodes: { [stair.id]: stair, [segment.id]: segment },
+ rootNodeIds: [stair.id],
+ dirtyNodes: new Set([stair.id, segment.id]),
+ } as never)
+
+ const renderer = await ReactThreeTestRenderer.create()
+ await renderer.advanceFrames(1, 1 / 60)
+ body.geometry.computeBoundingBox()
+ const original = body.geometry.boundingBox!.clone()
+
+ useLiveNodeOverrides.getState().set(segment.id, { height: 1.2, length: 2.24, stepCount: 8 })
+ useScene.getState().markDirty(segment.id)
+ useScene.getState().markDirty(stair.id)
+ await renderer.advanceFrames(1, 1 / 60)
+ body.geometry.computeBoundingBox()
+ expect(body.geometry.boundingBox!.max.y).toBeGreaterThan(original.max.y + 0.8)
+ expect(body.geometry.boundingBox!.max.z).toBeGreaterThan(original.max.z + 1)
+ await renderer.unmount()
+})
diff --git a/wiki/architecture/plugin-authoring.md b/wiki/architecture/plugin-authoring.md
index ab1f85bc13..6746c3bdea 100644
--- a/wiki/architecture/plugin-authoring.md
+++ b/wiki/architecture/plugin-authoring.md
@@ -402,3 +402,57 @@ The boundary stays narrow on purpose so the contract is shippable. Each "not yet
3. The dev-mode `[pascal:registry]` console log shows the loaded plugin id + node count — that's the verification anchor.
The host's own parity test (`packages/nodes/src/index.test.ts`) asserts every `AnyNode` discriminator has a registered kind. Plugin-contributed kinds don't participate in that test (they're not in `AnyNode`); add an equivalent test on your own side if you maintain a hand-typed union elsewhere.
+
+## Surface connections and editing contributions
+
+These optional fields extend node definitions, not the `Plugin` v1 manifest. See
+DECISIONS.md E-017. Framework packages never import a node pack to implement them.
+
+- `capabilities.surfaces.top.boundary(node, inset)` returns an ordered polygon in
+ the node's parent XZ frame, in metres. The polygon can be clockwise or
+ counterclockwise. A positive inset moves the boundary inward. Return at least
+ three finite points; an unavailable boundary returns an empty array. `top.height`
+ is relative to `node.position[1]`, before floor construction lift, and its point
+ context is in the same parent frame. Snapping and railings consult the registry.
+- `connectedMove(node, preview, nodes)` returns data patches for connected nodes.
+ It is pure: it must not mutate `nodes` or write the store. The editor applies its
+ patches to live overrides during a drag and includes them in the one commit on
+ release. It may return an empty list when there is no connection to move.
+- `InspectorExtension.primaryWhen(node)` selects an extension as the card's main
+ content. Registered order breaks ties. It must be pure and cheap. The wrapper
+ subscribes to the resulting extension ID, so changes unrelated to that choice
+ do not rerender the whole card. The extension's own controls subscribe to the
+ fields they display.
+- `capabilities.movable.preserveLevelAndElevation` keeps an existing floor-placed
+ object on its original level and visual elevation during a move. Fresh placement
+ still follows the chosen support. Stored Y remains an offset from the support,
+ not an absolute world height.
+- `capabilities.movable.parentFrame.independent` keeps selection/manipulation on
+ the child instead of promoting its parent. The frame's conversion hooks still
+ own cursor and rotation conversion in 2D and 3D.
+- `node.metadata.roomBoundary === false` excludes a wall-like node from room
+ topology. Omitted or true retains ordinary room-boundary behavior. This flag
+ does not change the node's visible geometry or paint surfaces.
+
+### Shared run tools
+
+Use `@pascal-app/editor/distribution` for `useDistributionRunTool`,
+`DistributionRunCursor`, port queries, connection compatibility and screen/angle
+snapping. It depends on the framework packages and never on `@pascal-app/nodes`.
+External plugins must not depend on the built-in node pack.
+
+### Stair destination surfaces
+
+Stairs use `destinationSurfaceId` and `autoSurfaceSnap`. Their source support is
+still `supportSlabId` and the stored Y is the offset above that support. Both edge
+snapping and transition geometry read `surfaces.top.boundary`; they do not inspect
+another plugin's width, shape, or private polygon fields. The scene loader migrates
+old `landscapeSurfaceId` / `autoLandscapeSnap` values to these generic fields.
+
+### Stable package revisions
+
+Pin immutable commits reachable from a maintained publishing branch or release tag.
+Bath Space uses `pascal-cluster`'s `packages/bath-space` branch; Landscape uses
+`packages/landscape`. Do not use a temporary PR branch as the only reference to a
+published revision. Streetscape is maintained in `pascalorg/plugin-streetscape`
+with the existing package name and `pascal:streetscape` plugin ID.