refactor: release-review cleanup for roof wall openings
Dual review pass (Claude multi-angle + Codex release-quality). One correctness fix and the agreed do-now cleanups: - fix: clone-scene-graph remaps roofSegmentId like wallId in both clone paths — duplicated scenes/levels kept pointing roof-hosted children at the original segments. - extract the settled, stateless roof target/cursor math shared by the four door/window tools into shared/roof-wall-opening-placement.ts (resolveRoofWallOpeningTarget + getRoofWallOpeningCursorPose + worldToSelectedBuildingLocal); tools keep the stateful lifecycle (drafts, undo/temporal, commit field lists). −199 net lines. - rename host-generic state: currentWallId→currentHostId, markWallDirty→markHostDirty (they hold segment ids too); capability cascadesViaHostSegment→dirtyHandledByOwnSystem (behavior-facing, before the public API hardens). - drop getRoofAccessoryKinds from core's public API — its only caller was the standalone Build tab, which now enumerates the registry inline with its app-specific filter. - window move-tool uses the shared stripPlacementMetadataFlags; stale "segment-local" comment fixed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
b8dfa90762
commit
aa3b0ef758
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { getRoofAccessoryKinds, nodeRegistry } from '@pascal-app/core'
|
import { nodeRegistry } from '@pascal-app/core'
|
||||||
import { MaterialPaintPanel, triggerSFX, useEditor } from '@pascal-app/editor'
|
import { MaterialPaintPanel, triggerSFX, useEditor } from '@pascal-app/editor'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
@@ -87,9 +87,10 @@ const ROOF_FEATURE_FALLBACK_ICON = '/icons/roof.png'
|
|||||||
/**
|
/**
|
||||||
* Roof accessories surfaced under the Roof tile (a "Features" group). Unlike
|
* Roof accessories surfaced under the Roof tile (a "Features" group). Unlike
|
||||||
* the community editor these aren't DB presets — each is a registry kind with
|
* the community editor these aren't DB presets — each is a registry kind with
|
||||||
* `capabilities.roofAccessory`, discovered via `getRoofAccessoryKinds()` and
|
* `capabilities.roofAccessory`, enumerated from the registry at render time
|
||||||
* activated like any structure tool (the kind's tool attaches it to the roof
|
* (it is populated by the app bootstrap — a module-scope const would race it)
|
||||||
* segment under the cursor). Label + icon come from the registry's
|
* and activated like any structure tool (the kind's tool attaches it to the
|
||||||
|
* roof segment under the cursor). Label + icon come from the registry's
|
||||||
* `presentation`; non-url icons fall back to the roof icon.
|
* `presentation`; non-url icons fall back to the roof icon.
|
||||||
*/
|
*/
|
||||||
function activateRoofFeatureTool(kind: string): void {
|
function activateRoofFeatureTool(kind: string): void {
|
||||||
@@ -116,23 +117,23 @@ export function BuildTab() {
|
|||||||
|
|
||||||
// Read at render time (not module scope): the registry is populated by the
|
// Read at render time (not module scope): the registry is populated by the
|
||||||
// app bootstrap, so enumerating earlier would race it and see no kinds.
|
// app bootstrap, so enumerating earlier would race it and see no kinds.
|
||||||
const roofFeatures = useMemo<RoofFeature[]>(
|
const roofFeatures = useMemo<RoofFeature[]>(() => {
|
||||||
() =>
|
const features: RoofFeature[] = []
|
||||||
getRoofAccessoryKinds()
|
for (const [kind, def] of nodeRegistry.entries()) {
|
||||||
|
if (def.capabilities.roofAccessory === undefined) continue
|
||||||
// Door / window declare `roofAccessory` for the wall-face cut but
|
// Door / window declare `roofAccessory` for the wall-face cut but
|
||||||
// already have their own Build tiles — listing them here too
|
// already have their own Build tiles — listing them here too
|
||||||
// would duplicate the entry under Roof → Features.
|
// would duplicate the entry under Roof → Features.
|
||||||
.filter((kind) => !nodeRegistry.get(kind)?.capabilities?.wallOpeningPlacement)
|
if (def.capabilities.wallOpeningPlacement) continue
|
||||||
.map((kind) => {
|
const icon = def.presentation?.icon
|
||||||
const icon = nodeRegistry.get(kind)?.presentation?.icon
|
features.push({
|
||||||
return {
|
|
||||||
kind,
|
kind,
|
||||||
label: nodeRegistry.get(kind)?.presentation?.label ?? kind,
|
label: def.presentation?.label ?? kind,
|
||||||
iconSrc: icon?.kind === 'url' ? icon.src : ROOF_FEATURE_FALLBACK_ICON,
|
iconSrc: icon?.kind === 'url' ? icon.src : ROOF_FEATURE_FALLBACK_ICON,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}),
|
return features
|
||||||
[],
|
}, [])
|
||||||
)
|
|
||||||
|
|
||||||
const isTypeActive = (type: BuildType) =>
|
const isTypeActive = (type: BuildType) =>
|
||||||
type.mode === 'material-paint' ? mode === 'material-paint' : selectedTypeId === type.id
|
type.mode === 'material-paint' ? mode === 'material-paint' : selectedTypeId === type.id
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ export type {
|
|||||||
export {
|
export {
|
||||||
discoverPlugins,
|
discoverPlugins,
|
||||||
getHostRefFields,
|
getHostRefFields,
|
||||||
getRoofAccessoryKinds,
|
|
||||||
getSelectableKinds,
|
getSelectableKinds,
|
||||||
isDrawnViaTool,
|
isDrawnViaTool,
|
||||||
isDrawnViaToolKind,
|
isDrawnViaToolKind,
|
||||||
|
|||||||
@@ -114,28 +114,6 @@ export function isRegistrySelectable(kind: string): boolean {
|
|||||||
return nodeRegistry.get(kind)?.capabilities.selectable !== undefined
|
return nodeRegistry.get(kind)?.capabilities.selectable !== undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Kinds whose definition declares the `roofAccessory` capability — the roof
|
|
||||||
* accessories (dormer, chimney, vents, gutter, …) that mount onto a roof
|
|
||||||
* segment via their own attach tool. Lets host UIs surface a "Features" group
|
|
||||||
* under the roof category without hardcoding the kind list (the standalone
|
|
||||||
* editor's Build tab; the roof inspector's add menu). Returned in builtin
|
|
||||||
* registration order (`packages/nodes/src/index.ts`), which is deterministic.
|
|
||||||
*
|
|
||||||
* Call at render time, not module-import time: the registry is populated by
|
|
||||||
* the host's bootstrap (`loadPlugin`), so a top-level `const` would race it
|
|
||||||
* and see an empty registry.
|
|
||||||
*/
|
|
||||||
export function getRoofAccessoryKinds(): string[] {
|
|
||||||
const result: string[] = []
|
|
||||||
for (const [kind, def] of nodeRegistry.entries()) {
|
|
||||||
if (def.capabilities.roofAccessory !== undefined) {
|
|
||||||
result.push(kind)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Kinds whose `def.floorplanScope` matches the requested scope. Used by
|
* Kinds whose `def.floorplanScope` matches the requested scope. Used by
|
||||||
* `FloorplanRegistryLayer` to discover building-scoped kinds (e.g.
|
* `FloorplanRegistryLayer` to discover building-scoped kinds (e.g.
|
||||||
|
|||||||
@@ -1214,14 +1214,14 @@ export type RoofAccessoryConfig = {
|
|||||||
*/
|
*/
|
||||||
cutScope?: 'all' | 'wall'
|
cutScope?: 'all' | 'wall'
|
||||||
/**
|
/**
|
||||||
* Set when the kind runs its own dirty-driven geometry system that
|
* The kind's own dirty-driven geometry system consumes its dirty
|
||||||
* already cascades to the host segment (door / window via the
|
* marks (door / window via DoorSystem / WindowSystem, which already
|
||||||
* DoorSystem / WindowSystem `parentId` cascade). The roof-merge loop
|
* cascade to the host segment through `parentId`). The roof-merge
|
||||||
* must then leave the kind's dirty marks alone — consuming them here
|
* loop must then leave those marks alone — consuming them would
|
||||||
* would starve that system whenever it defers a rebuild (mesh not
|
* starve that system whenever it defers a rebuild (mesh not mounted
|
||||||
* mounted yet, per-frame rebuild budget exhausted).
|
* yet, per-frame rebuild budget exhausted).
|
||||||
*/
|
*/
|
||||||
cascadesViaHostSegment?: boolean
|
dirtyHandledByOwnSystem?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -76,6 +76,13 @@ export function cloneSceneGraph(sceneGraph: SceneGraph): SceneGraph {
|
|||||||
| undefined
|
| undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remap roofSegmentId (doors/windows/items hosted on roof wall faces)
|
||||||
|
if ('roofSegmentId' in clonedNode && typeof clonedNode.roofSegmentId === 'string') {
|
||||||
|
;(clonedNode as Record<string, unknown>).roofSegmentId = idMap.get(
|
||||||
|
clonedNode.roofSegmentId,
|
||||||
|
) as string | undefined
|
||||||
|
}
|
||||||
|
|
||||||
clonedNodes[newId] = clonedNode
|
clonedNodes[newId] = clonedNode
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,6 +227,12 @@ export function cloneLevelSubtree(
|
|||||||
;(cloned as Record<string, unknown>).wallId = idMap.get(cloned.wallId) ?? cloned.wallId
|
;(cloned as Record<string, unknown>).wallId = idMap.get(cloned.wallId) ?? cloned.wallId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remap roofSegmentId (doors/windows/items hosted on roof wall faces)
|
||||||
|
if ('roofSegmentId' in cloned && typeof cloned.roofSegmentId === 'string') {
|
||||||
|
;(cloned as Record<string, unknown>).roofSegmentId =
|
||||||
|
idMap.get(cloned.roofSegmentId) ?? cloned.roofSegmentId
|
||||||
|
}
|
||||||
|
|
||||||
clonedNodes.push(cloned)
|
clonedNodes.push(cloned)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ import {
|
|||||||
NO_RAYCAST,
|
NO_RAYCAST,
|
||||||
} from './handles/handle-arrow'
|
} from './handles/handle-arrow'
|
||||||
import { type HandleDragControls, useHandleDrag } from './handles/use-handle-drag'
|
import { type HandleDragControls, useHandleDrag } from './handles/use-handle-drag'
|
||||||
|
|
||||||
// Pooled scratch for the handle rig's world-relative pose mapping.
|
// Pooled scratch for the handle rig's world-relative pose mapping.
|
||||||
const _rigRelative = new Matrix4()
|
const _rigRelative = new Matrix4()
|
||||||
const _rigScratchScale = new Vector3()
|
const _rigScratchScale = new Vector3()
|
||||||
|
|||||||
@@ -363,7 +363,7 @@ type RoofWallTarget = {
|
|||||||
segment: RoofSegmentNode
|
segment: RoofSegmentNode
|
||||||
faceId: RoofWallFaceId
|
faceId: RoofWallFaceId
|
||||||
faceYaw: number
|
faceYaw: number
|
||||||
/** Stored node position: segment-local, y = bottom edge. */
|
/** Stored node position: FACE-LOCAL, y = bottom edge. */
|
||||||
position: [number, number, number]
|
position: [number, number, number]
|
||||||
/** Face-coord center of the placed rect (for the overlap guard). */
|
/** Face-coord center of the placed rect (for the overlap guard). */
|
||||||
centerU: number
|
centerU: number
|
||||||
|
|||||||
@@ -1062,9 +1062,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
// push (z = thickness/2 off the face frame's mid-plane) so the
|
// push (z = thickness/2 off the face frame's mid-plane) so the
|
||||||
// drag preview doesn't sink into the wall until commit.
|
// drag preview doesn't sink into the wall until commit.
|
||||||
if (asset.attachTo === 'wall-side' && placementState.current.roofSegmentId) {
|
if (asset.attachTo === 'wall-side' && placementState.current.roofSegmentId) {
|
||||||
const segment = useScene.getState().nodes[
|
const segment =
|
||||||
placementState.current.roofSegmentId as AnyNodeId
|
useScene.getState().nodes[placementState.current.roofSegmentId as AnyNodeId]
|
||||||
]
|
|
||||||
if (segment?.type === 'roof-segment') {
|
if (segment?.type === 'roof-segment') {
|
||||||
mesh.position.z = (segment.wallThickness ?? 0.1) / 2
|
mesh.position.z = (segment.wallThickness ?? 0.1) / 2
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -160,14 +160,14 @@ export const doorDefinition: NodeDefinition<typeof DoorNode> = {
|
|||||||
wallOpeningPlacement: true,
|
wallOpeningPlacement: true,
|
||||||
// Doors also host on roof-segment wall faces (base walls under the
|
// Doors also host on roof-segment wall faces (base walls under the
|
||||||
// roof, gable ends). `buildCut` punches the opening into the
|
// roof, gable ends). `buildCut` punches the opening into the
|
||||||
// segment's wall brush; `cascadesViaHostSegment` keeps the roof-merge
|
// segment's wall brush; `dirtyHandledByOwnSystem` keeps the roof-merge
|
||||||
// loop from consuming door dirty marks (DoorSystem owns them and
|
// loop from consuming door dirty marks (DoorSystem owns them and
|
||||||
// already cascades to the host via parentId).
|
// already cascades to the host via parentId).
|
||||||
roofAccessory: {
|
roofAccessory: {
|
||||||
buildCut: (node, hostSegment) =>
|
buildCut: (node, hostSegment) =>
|
||||||
buildRoofWallOpeningCut(node as DoorNodeType, hostSegment as RoofSegmentNode),
|
buildRoofWallOpeningCut(node as DoorNodeType, hostSegment as RoofSegmentNode),
|
||||||
cutScope: 'wall',
|
cutScope: 'wall',
|
||||||
cascadesViaHostSegment: true,
|
dirtyHandledByOwnSystem: true,
|
||||||
},
|
},
|
||||||
// `wallId` / `roofSegmentId` tie the door to its host and are
|
// `wallId` / `roofSegmentId` tie the door to its host and are
|
||||||
// re-derived from the surface under the cursor when a preset is
|
// re-derived from the surface under the cursor when a preset is
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import {
|
import {
|
||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
clampRectToRoofWallFace,
|
|
||||||
collectAlignmentAnchors,
|
collectAlignmentAnchors,
|
||||||
DoorNode,
|
DoorNode,
|
||||||
emitter,
|
emitter,
|
||||||
isCurvedWall,
|
isCurvedWall,
|
||||||
type RoofEvent,
|
type RoofEvent,
|
||||||
type RoofNode,
|
type RoofNode,
|
||||||
roofFacePointToSegment,
|
|
||||||
sceneRegistry,
|
sceneRegistry,
|
||||||
spatialGridManager,
|
spatialGridManager,
|
||||||
useLiveTransforms,
|
useLiveTransforms,
|
||||||
@@ -19,9 +17,7 @@ import {
|
|||||||
calculateItemRotation,
|
calculateItemRotation,
|
||||||
EDITOR_LAYER,
|
EDITOR_LAYER,
|
||||||
getSideFromNormal,
|
getSideFromNormal,
|
||||||
hasRoofFaceChildOverlap,
|
|
||||||
isValidWallSideFace,
|
isValidWallSideFace,
|
||||||
resolveRoofWallHit,
|
|
||||||
stripPlacementMetadataFlags,
|
stripPlacementMetadataFlags,
|
||||||
triggerSFX,
|
triggerSFX,
|
||||||
useAlignmentGuides,
|
useAlignmentGuides,
|
||||||
@@ -29,8 +25,13 @@ import {
|
|||||||
} from '@pascal-app/editor'
|
} from '@pascal-app/editor'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||||
import { BoxGeometry, EdgesGeometry, type Group, Vector3 } from 'three'
|
import { BoxGeometry, EdgesGeometry, type Group } from 'three'
|
||||||
import { LineBasicNodeMaterial } from 'three/webgpu'
|
import { LineBasicNodeMaterial } from 'three/webgpu'
|
||||||
|
import {
|
||||||
|
getRoofWallOpeningCursorPose,
|
||||||
|
resolveRoofWallOpeningTarget,
|
||||||
|
type RoofWallOpeningTarget,
|
||||||
|
} from '../shared/roof-wall-opening-placement'
|
||||||
import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment'
|
import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment'
|
||||||
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math'
|
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math'
|
||||||
|
|
||||||
@@ -41,7 +42,6 @@ const edgeMaterial = new LineBasicNodeMaterial({
|
|||||||
depthWrite: false,
|
depthWrite: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const roofCursorPoint = new Vector3()
|
|
||||||
|
|
||||||
const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => {
|
const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => {
|
||||||
const cursorGroupRef = useRef<Group>(null!)
|
const cursorGroupRef = useRef<Group>(null!)
|
||||||
@@ -79,7 +79,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
let currentWallId: string | null = movingDoorNode.parentId
|
let currentHostId: string | null = movingDoorNode.parentId
|
||||||
let dragAnchor: { wallId: string; rawX: number; startX: number } | null = null
|
let dragAnchor: { wallId: string; rawX: number; startX: number } | null = null
|
||||||
let lastTarget: {
|
let lastTarget: {
|
||||||
wallNode: WallEvent['node']
|
wallNode: WallEvent['node']
|
||||||
@@ -93,18 +93,18 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
|||||||
event: WallEvent
|
event: WallEvent
|
||||||
} | null = null
|
} | null = null
|
||||||
|
|
||||||
const markWallDirty = (wallId: string | null) => {
|
const markHostDirty = (hostId: string | null) => {
|
||||||
if (wallId) useScene.getState().dirtyNodes.add(wallId as AnyNodeId)
|
if (hostId) useScene.getState().dirtyNodes.add(hostId as AnyNodeId)
|
||||||
}
|
}
|
||||||
const lastWallDirtyAt = new Map<string, number>()
|
const lastHostDirtyAt = new Map<string, number>()
|
||||||
const markWallDirtyThrottled = (wallId: string | null) => {
|
const markHostDirtyThrottled = (hostId: string | null) => {
|
||||||
if (!wallId) return
|
if (!hostId) return
|
||||||
const now = globalThis.performance?.now?.() ?? Date.now()
|
const now = globalThis.performance?.now?.() ?? Date.now()
|
||||||
const last = lastWallDirtyAt.get(wallId) ?? 0
|
const last = lastHostDirtyAt.get(hostId) ?? 0
|
||||||
// Wall rebuilds can trigger expensive CSG; throttle live previews to avoid FPS collapse.
|
// Wall rebuilds can trigger expensive CSG; throttle live previews to avoid FPS collapse.
|
||||||
if (now - last > 120) {
|
if (now - last > 120) {
|
||||||
lastWallDirtyAt.set(wallId, now)
|
lastHostDirtyAt.set(hostId, now)
|
||||||
markWallDirty(wallId)
|
markHostDirty(hostId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,7 +213,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
|||||||
}
|
}
|
||||||
|
|
||||||
const applyPreview = (target: NonNullable<typeof lastTarget>) => {
|
const applyPreview = (target: NonNullable<typeof lastTarget>) => {
|
||||||
if (currentWallId !== target.wallId) {
|
if (currentHostId !== target.wallId) {
|
||||||
useScene.getState().updateNode(movingDoorNode.id, {
|
useScene.getState().updateNode(movingDoorNode.id, {
|
||||||
position: [target.clampedX, target.clampedY, 0],
|
position: [target.clampedX, target.clampedY, 0],
|
||||||
rotation: [0, target.itemRotation, 0],
|
rotation: [0, target.itemRotation, 0],
|
||||||
@@ -223,8 +223,8 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
|||||||
roofSegmentId: undefined,
|
roofSegmentId: undefined,
|
||||||
roofFace: undefined,
|
roofFace: undefined,
|
||||||
})
|
})
|
||||||
markWallDirty(currentWallId)
|
markHostDirty(currentHostId)
|
||||||
currentWallId = target.wallId
|
currentHostId = target.wallId
|
||||||
} else {
|
} else {
|
||||||
const doorMesh = sceneRegistry.nodes.get(movingDoorNode.id as AnyNodeId)
|
const doorMesh = sceneRegistry.nodes.get(movingDoorNode.id as AnyNodeId)
|
||||||
if (doorMesh) {
|
if (doorMesh) {
|
||||||
@@ -237,7 +237,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
|||||||
position: [target.clampedX, target.clampedY, 0],
|
position: [target.clampedX, target.clampedY, 0],
|
||||||
rotation: target.itemRotation,
|
rotation: target.itemRotation,
|
||||||
})
|
})
|
||||||
markWallDirtyThrottled(target.wallId)
|
markHostDirtyThrottled(target.wallId)
|
||||||
|
|
||||||
updateCursor(
|
updateCursor(
|
||||||
wallLocalToWorld(
|
wallLocalToWorld(
|
||||||
@@ -328,12 +328,12 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (original.parentId && original.parentId !== target.wallId) {
|
if (original.parentId && original.parentId !== target.wallId) {
|
||||||
markWallDirty(original.parentId)
|
markHostDirty(original.parentId)
|
||||||
}
|
}
|
||||||
placedId = movingDoorNode.id
|
placedId = movingDoorNode.id
|
||||||
}
|
}
|
||||||
|
|
||||||
markWallDirty(target.wallId)
|
markHostDirty(target.wallId)
|
||||||
useLiveTransforms.getState().clear(movingDoorNode.id)
|
useLiveTransforms.getState().clear(movingDoorNode.id)
|
||||||
useScene.temporal.getState().pause()
|
useScene.temporal.getState().pause()
|
||||||
|
|
||||||
@@ -350,10 +350,10 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
|||||||
dragAnchor = null
|
dragAnchor = null
|
||||||
lastTarget = null
|
lastTarget = null
|
||||||
if (isNew) return
|
if (isNew) return
|
||||||
if (currentWallId && currentWallId !== original.parentId) {
|
if (currentHostId && currentHostId !== original.parentId) {
|
||||||
markWallDirty(currentWallId)
|
markHostDirty(currentHostId)
|
||||||
}
|
}
|
||||||
currentWallId = original.parentId
|
currentHostId = original.parentId
|
||||||
useScene.getState().updateNode(movingDoorNode.id, {
|
useScene.getState().updateNode(movingDoorNode.id, {
|
||||||
position: original.position,
|
position: original.position,
|
||||||
rotation: original.rotation,
|
rotation: original.rotation,
|
||||||
@@ -363,7 +363,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
|||||||
roofSegmentId: original.roofSegmentId,
|
roofSegmentId: original.roofSegmentId,
|
||||||
roofFace: original.roofFace,
|
roofFace: original.roofFace,
|
||||||
})
|
})
|
||||||
if (original.parentId) markWallDirty(original.parentId)
|
if (original.parentId) markHostDirty(original.parentId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Roof-segment wall faces ─────────────────────────────────────
|
// ── Roof-segment wall faces ─────────────────────────────────────
|
||||||
@@ -371,63 +371,18 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
|||||||
// walls under the roof + coplanar gable ends). This is also the
|
// walls under the roof + coplanar gable ends). This is also the
|
||||||
// placement path preset tiles take (`metadata.isNew` clones).
|
// placement path preset tiles take (`metadata.isNew` clones).
|
||||||
|
|
||||||
const worldToBuildingLocal = (point: Vector3): [number, number, number] => {
|
const resolveRoofMoveTarget = (event: RoofEvent) =>
|
||||||
const buildingId = useViewer.getState().selection.buildingId
|
resolveRoofWallOpeningTarget({
|
||||||
const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : undefined
|
event,
|
||||||
if (buildingObj) buildingObj.worldToLocal(point)
|
width: movingDoorNode.width,
|
||||||
return [point.x, point.y, point.z]
|
height: movingDoorNode.height,
|
||||||
}
|
ignoreId: movingDoorNode.id,
|
||||||
|
vertical: { kind: 'bottom-locked' },
|
||||||
|
})
|
||||||
|
|
||||||
const resolveRoofMoveTarget = (event: RoofEvent) => {
|
const updateRoofCursor = (target: RoofWallOpeningTarget, roof: RoofNode) => {
|
||||||
const hit = resolveRoofWallHit(
|
const pose = getRoofWallOpeningCursorPose(target, roof)
|
||||||
event.node as RoofNode,
|
if (pose) updateCursor(pose.position, pose.rotationY, target.valid)
|
||||||
event.position,
|
|
||||||
event.normal,
|
|
||||||
event.object,
|
|
||||||
)
|
|
||||||
if (!hit) return null
|
|
||||||
// Doors sit on the segment base: v locked to height/2, only u slides.
|
|
||||||
const clamped = clampRectToRoofWallFace(
|
|
||||||
hit.face,
|
|
||||||
hit.u,
|
|
||||||
movingDoorNode.height / 2,
|
|
||||||
movingDoorNode.width,
|
|
||||||
movingDoorNode.height,
|
|
||||||
{ lockV: true },
|
|
||||||
)
|
|
||||||
if (!clamped) return null
|
|
||||||
// FACE-LOCAL storage (u, v, z = 0 → wall mid-plane): the renderer
|
|
||||||
// mounts the node inside the live face frame, so it tracks segment
|
|
||||||
// resizes without any re-anchoring.
|
|
||||||
const position: [number, number, number] = [clamped.u, clamped.v, 0]
|
|
||||||
const valid = !hasRoofFaceChildOverlap(
|
|
||||||
hit.segment,
|
|
||||||
hit.face.id,
|
|
||||||
clamped.u,
|
|
||||||
clamped.v,
|
|
||||||
movingDoorNode.width,
|
|
||||||
movingDoorNode.height,
|
|
||||||
movingDoorNode.id,
|
|
||||||
)
|
|
||||||
return { hit, position, valid, roof: event.node as RoofNode }
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateRoofCursor = (target: NonNullable<ReturnType<typeof resolveRoofMoveTarget>>) => {
|
|
||||||
const segObj = sceneRegistry.nodes.get(target.hit.segment.id as AnyNodeId)
|
|
||||||
if (!segObj) return
|
|
||||||
segObj.updateWorldMatrix(true, false)
|
|
||||||
const segLocal = roofFacePointToSegment(
|
|
||||||
target.hit.segment,
|
|
||||||
target.hit.face.id,
|
|
||||||
target.position,
|
|
||||||
)
|
|
||||||
roofCursorPoint.set(segLocal[0], segLocal[1], segLocal[2])
|
|
||||||
segObj.localToWorld(roofCursorPoint)
|
|
||||||
updateCursor(
|
|
||||||
worldToBuildingLocal(roofCursorPoint),
|
|
||||||
(target.roof.rotation ?? 0) + (target.hit.segment.rotation ?? 0) + target.hit.face.yaw,
|
|
||||||
target.valid,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const onRoofHover = (event: RoofEvent) => {
|
const onRoofHover = (event: RoofEvent) => {
|
||||||
@@ -437,33 +392,33 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
|||||||
dragAnchor = null
|
dragAnchor = null
|
||||||
lastTarget = null
|
lastTarget = null
|
||||||
useLiveTransforms.getState().clear(movingDoorNode.id)
|
useLiveTransforms.getState().clear(movingDoorNode.id)
|
||||||
if (currentWallId !== target.hit.segment.id) {
|
if (currentHostId !== target.segment.id) {
|
||||||
useScene.getState().updateNode(movingDoorNode.id, {
|
useScene.getState().updateNode(movingDoorNode.id, {
|
||||||
position: target.position,
|
position: target.position,
|
||||||
rotation: [0, 0, 0],
|
rotation: [0, 0, 0],
|
||||||
side: 'front',
|
side: 'front',
|
||||||
parentId: target.hit.segment.id,
|
parentId: target.segment.id,
|
||||||
wallId: undefined,
|
wallId: undefined,
|
||||||
roofSegmentId: target.hit.segment.id,
|
roofSegmentId: target.segment.id,
|
||||||
roofFace: target.hit.face.id,
|
roofFace: target.face.id,
|
||||||
})
|
})
|
||||||
markWallDirty(currentWallId)
|
markHostDirty(currentHostId)
|
||||||
currentWallId = target.hit.segment.id
|
currentHostId = target.segment.id
|
||||||
} else {
|
} else {
|
||||||
useScene.getState().updateNode(movingDoorNode.id, {
|
useScene.getState().updateNode(movingDoorNode.id, {
|
||||||
position: target.position,
|
position: target.position,
|
||||||
rotation: [0, 0, 0],
|
rotation: [0, 0, 0],
|
||||||
roofFace: target.hit.face.id,
|
roofFace: target.face.id,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
updateRoofCursor(target)
|
updateRoofCursor(target, event.node as RoofNode)
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
}
|
}
|
||||||
|
|
||||||
const onRoofClick = (event: RoofEvent) => {
|
const onRoofClick = (event: RoofEvent) => {
|
||||||
const target = resolveRoofMoveTarget(event)
|
const target = resolveRoofMoveTarget(event)
|
||||||
if (!target?.valid) return
|
if (!target?.valid) return
|
||||||
const segmentId = target.hit.segment.id
|
const segmentId = target.segment.id
|
||||||
|
|
||||||
let placedId: string
|
let placedId: string
|
||||||
|
|
||||||
@@ -481,7 +436,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
|||||||
side: 'front',
|
side: 'front',
|
||||||
wallId: undefined,
|
wallId: undefined,
|
||||||
roofSegmentId: segmentId,
|
roofSegmentId: segmentId,
|
||||||
roofFace: target.hit.face.id,
|
roofFace: target.face.id,
|
||||||
parentId: segmentId,
|
parentId: segmentId,
|
||||||
})
|
})
|
||||||
useScene.getState().createNode(node, segmentId as AnyNodeId)
|
useScene.getState().createNode(node, segmentId as AnyNodeId)
|
||||||
@@ -506,17 +461,17 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
|||||||
parentId: segmentId,
|
parentId: segmentId,
|
||||||
wallId: undefined,
|
wallId: undefined,
|
||||||
roofSegmentId: segmentId,
|
roofSegmentId: segmentId,
|
||||||
roofFace: target.hit.face.id,
|
roofFace: target.face.id,
|
||||||
metadata: {},
|
metadata: {},
|
||||||
})
|
})
|
||||||
|
|
||||||
if (original.parentId && original.parentId !== segmentId) {
|
if (original.parentId && original.parentId !== segmentId) {
|
||||||
markWallDirty(original.parentId)
|
markHostDirty(original.parentId)
|
||||||
}
|
}
|
||||||
placedId = movingDoorNode.id
|
placedId = movingDoorNode.id
|
||||||
}
|
}
|
||||||
|
|
||||||
markWallDirty(segmentId)
|
markHostDirty(segmentId)
|
||||||
useLiveTransforms.getState().clear(movingDoorNode.id)
|
useLiveTransforms.getState().clear(movingDoorNode.id)
|
||||||
useScene.temporal.getState().pause()
|
useScene.temporal.getState().pause()
|
||||||
|
|
||||||
@@ -533,10 +488,10 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
|||||||
dragAnchor = null
|
dragAnchor = null
|
||||||
lastTarget = null
|
lastTarget = null
|
||||||
if (isNew) return
|
if (isNew) return
|
||||||
if (currentWallId && currentWallId !== original.parentId) {
|
if (currentHostId && currentHostId !== original.parentId) {
|
||||||
markWallDirty(currentWallId)
|
markHostDirty(currentHostId)
|
||||||
}
|
}
|
||||||
currentWallId = original.parentId
|
currentHostId = original.parentId
|
||||||
useScene.getState().updateNode(movingDoorNode.id, {
|
useScene.getState().updateNode(movingDoorNode.id, {
|
||||||
position: original.position,
|
position: original.position,
|
||||||
rotation: original.rotation,
|
rotation: original.rotation,
|
||||||
@@ -546,14 +501,14 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
|||||||
roofSegmentId: original.roofSegmentId,
|
roofSegmentId: original.roofSegmentId,
|
||||||
roofFace: original.roofFace,
|
roofFace: original.roofFace,
|
||||||
})
|
})
|
||||||
if (original.parentId) markWallDirty(original.parentId)
|
if (original.parentId) markHostDirty(original.parentId)
|
||||||
}
|
}
|
||||||
|
|
||||||
const onCancel = () => {
|
const onCancel = () => {
|
||||||
useLiveTransforms.getState().clear(movingDoorNode.id)
|
useLiveTransforms.getState().clear(movingDoorNode.id)
|
||||||
if (isNew) {
|
if (isNew) {
|
||||||
useScene.getState().deleteNode(movingDoorNode.id)
|
useScene.getState().deleteNode(movingDoorNode.id)
|
||||||
if (currentWallId) markWallDirty(currentWallId)
|
if (currentHostId) markHostDirty(currentHostId)
|
||||||
} else {
|
} else {
|
||||||
useScene.getState().updateNode(movingDoorNode.id, {
|
useScene.getState().updateNode(movingDoorNode.id, {
|
||||||
position: original.position,
|
position: original.position,
|
||||||
@@ -565,7 +520,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
|||||||
roofFace: original.roofFace,
|
roofFace: original.roofFace,
|
||||||
metadata: original.metadata,
|
metadata: original.metadata,
|
||||||
})
|
})
|
||||||
if (original.parentId) markWallDirty(original.parentId)
|
if (original.parentId) markHostDirty(original.parentId)
|
||||||
}
|
}
|
||||||
useScene.temporal.getState().resume()
|
useScene.temporal.getState().resume()
|
||||||
hideCursor()
|
hideCursor()
|
||||||
@@ -590,7 +545,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
|||||||
if (currentMeta?.isTransient) {
|
if (currentMeta?.isTransient) {
|
||||||
if (isNew) {
|
if (isNew) {
|
||||||
useScene.getState().deleteNode(movingDoorNode.id)
|
useScene.getState().deleteNode(movingDoorNode.id)
|
||||||
if (currentWallId) markWallDirty(currentWallId)
|
if (currentHostId) markHostDirty(currentHostId)
|
||||||
} else {
|
} else {
|
||||||
useScene.getState().updateNode(movingDoorNode.id, {
|
useScene.getState().updateNode(movingDoorNode.id, {
|
||||||
position: original.position,
|
position: original.position,
|
||||||
@@ -602,7 +557,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
|||||||
roofFace: original.roofFace,
|
roofFace: original.roofFace,
|
||||||
metadata: original.metadata,
|
metadata: original.metadata,
|
||||||
})
|
})
|
||||||
if (original.parentId) markWallDirty(original.parentId)
|
if (original.parentId) markHostDirty(original.parentId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
useLiveTransforms.getState().clear(movingDoorNode.id)
|
useLiveTransforms.getState().clear(movingDoorNode.id)
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import {
|
import {
|
||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
clampRectToRoofWallFace,
|
|
||||||
collectAlignmentAnchors,
|
collectAlignmentAnchors,
|
||||||
DoorNode,
|
DoorNode,
|
||||||
emitter,
|
emitter,
|
||||||
isCurvedWall,
|
isCurvedWall,
|
||||||
type RoofEvent,
|
type RoofEvent,
|
||||||
type RoofNode,
|
type RoofNode,
|
||||||
roofFacePointToSegment,
|
|
||||||
sceneRegistry,
|
sceneRegistry,
|
||||||
spatialGridManager,
|
spatialGridManager,
|
||||||
useScene,
|
useScene,
|
||||||
@@ -18,16 +16,19 @@ import {
|
|||||||
calculateItemRotation,
|
calculateItemRotation,
|
||||||
EDITOR_LAYER,
|
EDITOR_LAYER,
|
||||||
getSideFromNormal,
|
getSideFromNormal,
|
||||||
hasRoofFaceChildOverlap,
|
|
||||||
isValidWallSideFace,
|
isValidWallSideFace,
|
||||||
resolveRoofWallHit,
|
|
||||||
triggerSFX,
|
triggerSFX,
|
||||||
useAlignmentGuides,
|
useAlignmentGuides,
|
||||||
} from '@pascal-app/editor'
|
} from '@pascal-app/editor'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import { BoxGeometry, EdgesGeometry, type Group, type LineSegments, Vector3 } from 'three'
|
import { BoxGeometry, EdgesGeometry, type Group, type LineSegments } from 'three'
|
||||||
import { LineBasicNodeMaterial } from 'three/webgpu'
|
import { LineBasicNodeMaterial } from 'three/webgpu'
|
||||||
|
import {
|
||||||
|
getRoofWallOpeningCursorPose,
|
||||||
|
resolveRoofWallOpeningTarget,
|
||||||
|
type RoofWallOpeningTarget,
|
||||||
|
} from '../shared/roof-wall-opening-placement'
|
||||||
import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment'
|
import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment'
|
||||||
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math'
|
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math'
|
||||||
|
|
||||||
@@ -38,7 +39,6 @@ const edgeMaterial = new LineBasicNodeMaterial({
|
|||||||
depthWrite: false,
|
depthWrite: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const roofCursorPoint = new Vector3()
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Door tool — places DoorNodes on walls and on roof-segment wall faces
|
* Door tool — places DoorNodes on walls and on roof-segment wall faces
|
||||||
@@ -66,8 +66,8 @@ const DoorTool: React.FC = () => {
|
|||||||
wallEvent.node.end,
|
wallEvent.node.end,
|
||||||
)
|
)
|
||||||
|
|
||||||
const markWallDirty = (wallId: string) => {
|
const markHostDirty = (hostId: string) => {
|
||||||
useScene.getState().dirtyNodes.add(wallId as AnyNodeId)
|
useScene.getState().dirtyNodes.add(hostId as AnyNodeId)
|
||||||
}
|
}
|
||||||
|
|
||||||
const destroyDraft = () => {
|
const destroyDraft = () => {
|
||||||
@@ -75,7 +75,7 @@ const DoorTool: React.FC = () => {
|
|||||||
const wallId = draftRef.current.parentId
|
const wallId = draftRef.current.parentId
|
||||||
useScene.getState().deleteNode(draftRef.current.id)
|
useScene.getState().deleteNode(draftRef.current.id)
|
||||||
draftRef.current = null
|
draftRef.current = null
|
||||||
if (wallId) markWallDirty(wallId)
|
if (wallId) markHostDirty(wallId)
|
||||||
}
|
}
|
||||||
|
|
||||||
const hideCursor = () => {
|
const hideCursor = () => {
|
||||||
@@ -217,7 +217,7 @@ const DoorTool: React.FC = () => {
|
|||||||
rotation: [0, itemRotation, 0],
|
rotation: [0, itemRotation, 0],
|
||||||
side,
|
side,
|
||||||
})
|
})
|
||||||
markWallDirty(event.node.id)
|
markHostDirty(event.node.id)
|
||||||
} else {
|
} else {
|
||||||
useScene.getState().updateNode(draftRef.current.id, {
|
useScene.getState().updateNode(draftRef.current.id, {
|
||||||
position: [clampedX, clampedY, 0],
|
position: [clampedX, clampedY, 0],
|
||||||
@@ -352,65 +352,18 @@ const DoorTool: React.FC = () => {
|
|||||||
// The merged roof mesh emits `roof:*`; hits are resolved against the
|
// The merged roof mesh emits `roof:*`; hits are resolved against the
|
||||||
// segments' vertical wall faces (base walls + coplanar gable ends).
|
// segments' vertical wall faces (base walls + coplanar gable ends).
|
||||||
|
|
||||||
const worldToBuildingLocal = (point: Vector3): [number, number, number] => {
|
const resolveRoofTarget = (event: RoofEvent) =>
|
||||||
// The tool's cursor group renders in the building's local frame —
|
resolveRoofWallOpeningTarget({
|
||||||
// same conversion as the roof accessory tools (e.g. SkylightTool).
|
event,
|
||||||
const buildingId = useViewer.getState().selection.buildingId
|
width: draftRef.current?.width ?? 0.9,
|
||||||
const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : undefined
|
height: draftRef.current?.height ?? 2.1,
|
||||||
if (buildingObj) buildingObj.worldToLocal(point)
|
ignoreId: draftRef.current?.id,
|
||||||
return [point.x, point.y, point.z]
|
vertical: { kind: 'bottom-locked' },
|
||||||
}
|
|
||||||
|
|
||||||
const resolveRoofTarget = (event: RoofEvent) => {
|
|
||||||
const hit = resolveRoofWallHit(
|
|
||||||
event.node as RoofNode,
|
|
||||||
event.position,
|
|
||||||
event.normal,
|
|
||||||
event.object,
|
|
||||||
)
|
|
||||||
if (!hit) return null
|
|
||||||
const width = draftRef.current?.width ?? 0.9
|
|
||||||
const height = draftRef.current?.height ?? 2.1
|
|
||||||
// Doors sit on the segment base: v locked to height/2, only u slides.
|
|
||||||
const clamped = clampRectToRoofWallFace(hit.face, hit.u, height / 2, width, height, {
|
|
||||||
lockV: true,
|
|
||||||
})
|
})
|
||||||
if (!clamped) return null
|
|
||||||
// FACE-LOCAL storage (u, v, z = 0 → wall mid-plane): the renderer
|
|
||||||
// mounts the node inside the live face frame, so it tracks segment
|
|
||||||
// resizes without any re-anchoring.
|
|
||||||
const position: [number, number, number] = [clamped.u, clamped.v, 0]
|
|
||||||
const valid = !hasRoofFaceChildOverlap(
|
|
||||||
hit.segment,
|
|
||||||
hit.face.id,
|
|
||||||
clamped.u,
|
|
||||||
clamped.v,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
draftRef.current?.id,
|
|
||||||
)
|
|
||||||
return { hit, position, valid }
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateRoofCursor = (
|
const updateRoofCursor = (target: RoofWallOpeningTarget, roof: RoofNode) => {
|
||||||
target: NonNullable<ReturnType<typeof resolveRoofTarget>>,
|
const pose = getRoofWallOpeningCursorPose(target, roof)
|
||||||
roof: RoofNode,
|
if (pose) updateCursor(pose.position, pose.rotationY, target.valid)
|
||||||
) => {
|
|
||||||
const segObj = sceneRegistry.nodes.get(target.hit.segment.id as AnyNodeId)
|
|
||||||
if (!segObj) return
|
|
||||||
segObj.updateWorldMatrix(true, false)
|
|
||||||
const segLocal = roofFacePointToSegment(
|
|
||||||
target.hit.segment,
|
|
||||||
target.hit.face.id,
|
|
||||||
target.position,
|
|
||||||
)
|
|
||||||
roofCursorPoint.set(segLocal[0], segLocal[1], segLocal[2])
|
|
||||||
segObj.localToWorld(roofCursorPoint)
|
|
||||||
updateCursor(
|
|
||||||
worldToBuildingLocal(roofCursorPoint),
|
|
||||||
(roof.rotation ?? 0) + (target.hit.segment.rotation ?? 0) + target.hit.face.yaw,
|
|
||||||
target.valid,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const onRoofHover = (event: RoofEvent) => {
|
const onRoofHover = (event: RoofEvent) => {
|
||||||
@@ -424,26 +377,26 @@ const DoorTool: React.FC = () => {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const { hit, position } = target
|
const { segment, face, position } = target
|
||||||
|
|
||||||
if (draftRef.current && draftRef.current.parentId !== hit.segment.id) destroyDraft()
|
if (draftRef.current && draftRef.current.parentId !== segment.id) destroyDraft()
|
||||||
if (draftRef.current) {
|
if (draftRef.current) {
|
||||||
useScene.getState().updateNode(draftRef.current.id, {
|
useScene.getState().updateNode(draftRef.current.id, {
|
||||||
position,
|
position,
|
||||||
rotation: [0, 0, 0],
|
rotation: [0, 0, 0],
|
||||||
roofFace: hit.face.id,
|
roofFace: face.id,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
const node = DoorNode.parse({
|
const node = DoorNode.parse({
|
||||||
position,
|
position,
|
||||||
rotation: [0, 0, 0],
|
rotation: [0, 0, 0],
|
||||||
side: 'front',
|
side: 'front',
|
||||||
roofSegmentId: hit.segment.id,
|
roofSegmentId: segment.id,
|
||||||
roofFace: hit.face.id,
|
roofFace: face.id,
|
||||||
parentId: hit.segment.id,
|
parentId: segment.id,
|
||||||
metadata: { isTransient: true },
|
metadata: { isTransient: true },
|
||||||
})
|
})
|
||||||
useScene.getState().createNode(node, hit.segment.id as AnyNodeId)
|
useScene.getState().createNode(node, segment.id as AnyNodeId)
|
||||||
draftRef.current = node
|
draftRef.current = node
|
||||||
}
|
}
|
||||||
updateRoofCursor(target, event.node as RoofNode)
|
updateRoofCursor(target, event.node as RoofNode)
|
||||||
@@ -454,7 +407,7 @@ const DoorTool: React.FC = () => {
|
|||||||
if (!draftRef.current?.roofSegmentId) return
|
if (!draftRef.current?.roofSegmentId) return
|
||||||
const target = resolveRoofTarget(event)
|
const target = resolveRoofTarget(event)
|
||||||
if (!target?.valid) return
|
if (!target?.valid) return
|
||||||
const { hit, position } = target
|
const { segment, face, position } = target
|
||||||
|
|
||||||
const draft = draftRef.current
|
const draft = draftRef.current
|
||||||
draftRef.current = null
|
draftRef.current = null
|
||||||
@@ -472,9 +425,9 @@ const DoorTool: React.FC = () => {
|
|||||||
position,
|
position,
|
||||||
rotation: [0, 0, 0],
|
rotation: [0, 0, 0],
|
||||||
side: 'front',
|
side: 'front',
|
||||||
roofSegmentId: hit.segment.id,
|
roofSegmentId: segment.id,
|
||||||
roofFace: hit.face.id,
|
roofFace: face.id,
|
||||||
parentId: hit.segment.id,
|
parentId: segment.id,
|
||||||
width: draft.width,
|
width: draft.width,
|
||||||
height: draft.height,
|
height: draft.height,
|
||||||
doorCategory: draft.doorCategory,
|
doorCategory: draft.doorCategory,
|
||||||
@@ -499,10 +452,10 @@ const DoorTool: React.FC = () => {
|
|||||||
panicBarHeight: draft.panicBarHeight,
|
panicBarHeight: draft.panicBarHeight,
|
||||||
})
|
})
|
||||||
|
|
||||||
useScene.getState().createNode(node, hit.segment.id as AnyNodeId)
|
useScene.getState().createNode(node, segment.id as AnyNodeId)
|
||||||
// Rebuild the segment (and the merged roof) so the wall brush
|
// Rebuild the segment (and the merged roof) so the wall brush
|
||||||
// picks up the new opening cut.
|
// picks up the new opening cut.
|
||||||
useScene.getState().dirtyNodes.add(hit.segment.id as AnyNodeId)
|
useScene.getState().dirtyNodes.add(segment.id as AnyNodeId)
|
||||||
useViewer.getState().setSelection({ selectedIds: [node.id] })
|
useViewer.getState().setSelection({ selectedIds: [node.id] })
|
||||||
useScene.temporal.getState().pause()
|
useScene.temporal.getState().pause()
|
||||||
triggerSFX('sfx:structure-build')
|
triggerSFX('sfx:structure-build')
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import {
|
||||||
|
type AnyNodeId,
|
||||||
|
clampRectToRoofWallFace,
|
||||||
|
type RoofEvent,
|
||||||
|
type RoofNode,
|
||||||
|
type RoofSegmentNode,
|
||||||
|
type RoofSegmentWallFace,
|
||||||
|
roofFacePointToSegment,
|
||||||
|
sceneRegistry,
|
||||||
|
} from '@pascal-app/core'
|
||||||
|
import { hasRoofFaceChildOverlap, resolveRoofWallHit } from '@pascal-app/editor'
|
||||||
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
|
import { Vector3 } from 'three'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stateless target/cursor math shared by the door and window placement
|
||||||
|
* + move tools' roof flows. The tools keep ownership of everything
|
||||||
|
* stateful (draft lifecycle, undo/temporal sequencing, commit field
|
||||||
|
* lists, SFX/selection) — only the settled geometry lives here.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type RoofWallOpeningTarget = {
|
||||||
|
segment: RoofSegmentNode
|
||||||
|
face: RoofSegmentWallFace
|
||||||
|
/** FACE-LOCAL stored position: [u, v-center, 0] on the wall mid-plane. */
|
||||||
|
position: [number, number, number]
|
||||||
|
/** False when the rect overlaps a sibling on the same face. */
|
||||||
|
valid: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RoofWallOpeningVertical =
|
||||||
|
/** Doors: bottom on the segment base, only `u` slides. */
|
||||||
|
| { kind: 'bottom-locked' }
|
||||||
|
/** Windows: free height, optionally grid-snapped before the clamp. */
|
||||||
|
| { kind: 'free'; snap?: (v: number) => number }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a roof pointer event to an opening placement on a segment
|
||||||
|
* wall face: hit → vertical policy → profile clamp → overlap check.
|
||||||
|
* Null when the pointer isn't over a placeable face or the rect cannot
|
||||||
|
* fit at that spot.
|
||||||
|
*/
|
||||||
|
export function resolveRoofWallOpeningTarget(args: {
|
||||||
|
event: RoofEvent
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
ignoreId?: string
|
||||||
|
vertical: RoofWallOpeningVertical
|
||||||
|
}): RoofWallOpeningTarget | null {
|
||||||
|
const { event, width, height, ignoreId, vertical } = args
|
||||||
|
const hit = resolveRoofWallHit(event.node as RoofNode, event.position, event.normal, event.object)
|
||||||
|
if (!hit) return null
|
||||||
|
|
||||||
|
const centerV = vertical.kind === 'bottom-locked' ? height / 2 : (vertical.snap?.(hit.v) ?? hit.v)
|
||||||
|
const clamped = clampRectToRoofWallFace(
|
||||||
|
hit.face,
|
||||||
|
hit.u,
|
||||||
|
centerV,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
vertical.kind === 'bottom-locked' ? { lockV: true } : undefined,
|
||||||
|
)
|
||||||
|
if (!clamped) return null
|
||||||
|
|
||||||
|
const valid = !hasRoofFaceChildOverlap(
|
||||||
|
hit.segment,
|
||||||
|
hit.face.id,
|
||||||
|
clamped.u,
|
||||||
|
clamped.v,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
ignoreId,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
segment: hit.segment,
|
||||||
|
face: hit.face,
|
||||||
|
position: [clamped.u, clamped.v, 0],
|
||||||
|
valid,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cursorPoint = new Vector3()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* World → building-local. Tool cursor groups render inside the
|
||||||
|
* building's frame (same conversion as the roof accessory tools).
|
||||||
|
*/
|
||||||
|
export function worldToSelectedBuildingLocal(point: Vector3): [number, number, number] {
|
||||||
|
const buildingId = useViewer.getState().selection.buildingId
|
||||||
|
const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : undefined
|
||||||
|
if (buildingObj) buildingObj.worldToLocal(point)
|
||||||
|
return [point.x, point.y, point.z]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cursor pose for a resolved target: building-local position of the
|
||||||
|
* opening center + total yaw (roof ∘ segment ∘ face).
|
||||||
|
*/
|
||||||
|
export function getRoofWallOpeningCursorPose(
|
||||||
|
target: RoofWallOpeningTarget,
|
||||||
|
roof: RoofNode,
|
||||||
|
): { position: [number, number, number]; rotationY: number } | null {
|
||||||
|
const segObj = sceneRegistry.nodes.get(target.segment.id as AnyNodeId)
|
||||||
|
if (!segObj) return null
|
||||||
|
segObj.updateWorldMatrix(true, false)
|
||||||
|
const segLocal = roofFacePointToSegment(target.segment, target.face.id, target.position)
|
||||||
|
cursorPoint.set(segLocal[0], segLocal[1], segLocal[2])
|
||||||
|
segObj.localToWorld(cursorPoint)
|
||||||
|
return {
|
||||||
|
position: worldToSelectedBuildingLocal(cursorPoint),
|
||||||
|
rotationY: (roof.rotation ?? 0) + (target.segment.rotation ?? 0) + target.face.yaw,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -152,12 +152,12 @@ export const windowDefinition: NodeDefinition<typeof WindowNode> = {
|
|||||||
wallOpeningPlacement: true,
|
wallOpeningPlacement: true,
|
||||||
// Windows also host on roof-segment wall faces (base walls under the
|
// Windows also host on roof-segment wall faces (base walls under the
|
||||||
// roof, gable ends) — same wiring as door; see the door capability
|
// roof, gable ends) — same wiring as door; see the door capability
|
||||||
// for why `cascadesViaHostSegment` is required.
|
// for why `dirtyHandledByOwnSystem` is required.
|
||||||
roofAccessory: {
|
roofAccessory: {
|
||||||
buildCut: (node, hostSegment) =>
|
buildCut: (node, hostSegment) =>
|
||||||
buildRoofWallOpeningCut(node as WindowNodeType, hostSegment as RoofSegmentNode),
|
buildRoofWallOpeningCut(node as WindowNodeType, hostSegment as RoofSegmentNode),
|
||||||
cutScope: 'wall',
|
cutScope: 'wall',
|
||||||
cascadesViaHostSegment: true,
|
dirtyHandledByOwnSystem: true,
|
||||||
},
|
},
|
||||||
// `wallId` / `roofSegmentId` are re-derived from the surface under
|
// `wallId` / `roofSegmentId` are re-derived from the surface under
|
||||||
// the cursor at preset placement time — see door for the pattern.
|
// the cursor at preset placement time — see door for the pattern.
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import {
|
import {
|
||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
clampRectToRoofWallFace,
|
|
||||||
collectAlignmentAnchors,
|
collectAlignmentAnchors,
|
||||||
emitter,
|
emitter,
|
||||||
isCurvedWall,
|
isCurvedWall,
|
||||||
type RoofEvent,
|
type RoofEvent,
|
||||||
type RoofNode,
|
type RoofNode,
|
||||||
roofFacePointToSegment,
|
|
||||||
sceneRegistry,
|
sceneRegistry,
|
||||||
spatialGridManager,
|
spatialGridManager,
|
||||||
useLiveTransforms,
|
useLiveTransforms,
|
||||||
@@ -19,18 +17,22 @@ import {
|
|||||||
calculateItemRotation,
|
calculateItemRotation,
|
||||||
EDITOR_LAYER,
|
EDITOR_LAYER,
|
||||||
getSideFromNormal,
|
getSideFromNormal,
|
||||||
hasRoofFaceChildOverlap,
|
|
||||||
isValidWallSideFace,
|
isValidWallSideFace,
|
||||||
resolveRoofWallHit,
|
|
||||||
snapToHalf,
|
snapToHalf,
|
||||||
|
stripPlacementMetadataFlags,
|
||||||
triggerSFX,
|
triggerSFX,
|
||||||
useAlignmentGuides,
|
useAlignmentGuides,
|
||||||
useEditor,
|
useEditor,
|
||||||
} from '@pascal-app/editor'
|
} from '@pascal-app/editor'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||||
import { BoxGeometry, EdgesGeometry, type Group, Vector3 } from 'three'
|
import { BoxGeometry, EdgesGeometry, type Group } from 'three'
|
||||||
import { LineBasicNodeMaterial } from 'three/webgpu'
|
import { LineBasicNodeMaterial } from 'three/webgpu'
|
||||||
|
import {
|
||||||
|
getRoofWallOpeningCursorPose,
|
||||||
|
resolveRoofWallOpeningTarget,
|
||||||
|
type RoofWallOpeningTarget,
|
||||||
|
} from '../shared/roof-wall-opening-placement'
|
||||||
import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment'
|
import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment'
|
||||||
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './window-math'
|
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './window-math'
|
||||||
|
|
||||||
@@ -41,7 +43,6 @@ const edgeMaterial = new LineBasicNodeMaterial({
|
|||||||
depthWrite: false,
|
depthWrite: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const roofCursorPoint = new Vector3()
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Move/duplicate tool for WindowNodes — wall-only, same guardrails as WindowTool.
|
* Move/duplicate tool for WindowNodes — wall-only, same guardrails as WindowTool.
|
||||||
@@ -98,7 +99,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
let currentWallId: string | null = movingWindowNode.parentId
|
let currentHostId: string | null = movingWindowNode.parentId
|
||||||
let dragAnchor: {
|
let dragAnchor: {
|
||||||
wallId: string
|
wallId: string
|
||||||
rawX: number
|
rawX: number
|
||||||
@@ -118,18 +119,18 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
|||||||
event: WallEvent
|
event: WallEvent
|
||||||
} | null = null
|
} | null = null
|
||||||
|
|
||||||
const markWallDirty = (wallId: string | null) => {
|
const markHostDirty = (hostId: string | null) => {
|
||||||
if (wallId) useScene.getState().dirtyNodes.add(wallId as AnyNodeId)
|
if (hostId) useScene.getState().dirtyNodes.add(hostId as AnyNodeId)
|
||||||
}
|
}
|
||||||
const lastWallDirtyAt = new Map<string, number>()
|
const lastHostDirtyAt = new Map<string, number>()
|
||||||
const markWallDirtyThrottled = (wallId: string | null) => {
|
const markHostDirtyThrottled = (hostId: string | null) => {
|
||||||
if (!wallId) return
|
if (!hostId) return
|
||||||
const now = globalThis.performance?.now?.() ?? Date.now()
|
const now = globalThis.performance?.now?.() ?? Date.now()
|
||||||
const last = lastWallDirtyAt.get(wallId) ?? 0
|
const last = lastHostDirtyAt.get(hostId) ?? 0
|
||||||
// Wall rebuilds can trigger expensive CSG; throttle live previews to avoid FPS collapse.
|
// Wall rebuilds can trigger expensive CSG; throttle live previews to avoid FPS collapse.
|
||||||
if (now - last > 120) {
|
if (now - last > 120) {
|
||||||
lastWallDirtyAt.set(wallId, now)
|
lastHostDirtyAt.set(hostId, now)
|
||||||
markWallDirty(wallId)
|
markHostDirty(hostId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,7 +237,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
|||||||
}
|
}
|
||||||
|
|
||||||
const applyPreview = (target: NonNullable<typeof lastTarget>) => {
|
const applyPreview = (target: NonNullable<typeof lastTarget>) => {
|
||||||
if (currentWallId !== target.wallId) {
|
if (currentHostId !== target.wallId) {
|
||||||
useScene.getState().updateNode(movingWindowNode.id, {
|
useScene.getState().updateNode(movingWindowNode.id, {
|
||||||
position: [target.clampedX, target.clampedY, 0],
|
position: [target.clampedX, target.clampedY, 0],
|
||||||
rotation: [0, target.itemRotation, 0],
|
rotation: [0, target.itemRotation, 0],
|
||||||
@@ -246,8 +247,8 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
|||||||
roofSegmentId: undefined,
|
roofSegmentId: undefined,
|
||||||
roofFace: undefined,
|
roofFace: undefined,
|
||||||
})
|
})
|
||||||
markWallDirty(currentWallId)
|
markHostDirty(currentHostId)
|
||||||
currentWallId = target.wallId
|
currentHostId = target.wallId
|
||||||
} else {
|
} else {
|
||||||
const windowMesh = sceneRegistry.nodes.get(movingWindowNode.id as AnyNodeId)
|
const windowMesh = sceneRegistry.nodes.get(movingWindowNode.id as AnyNodeId)
|
||||||
if (windowMesh) {
|
if (windowMesh) {
|
||||||
@@ -260,7 +261,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
|||||||
position: [target.clampedX, target.clampedY, 0],
|
position: [target.clampedX, target.clampedY, 0],
|
||||||
rotation: target.itemRotation,
|
rotation: target.itemRotation,
|
||||||
})
|
})
|
||||||
markWallDirtyThrottled(target.wallId)
|
markHostDirtyThrottled(target.wallId)
|
||||||
|
|
||||||
updateCursor(
|
updateCursor(
|
||||||
wallLocalToWorld(
|
wallLocalToWorld(
|
||||||
@@ -318,10 +319,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
|||||||
|
|
||||||
const cloned = structuredClone(movingWindowNode) as any
|
const cloned = structuredClone(movingWindowNode) as any
|
||||||
delete cloned.id
|
delete cloned.id
|
||||||
if (cloned.metadata && typeof cloned.metadata === 'object') {
|
cloned.metadata = stripPlacementMetadataFlags(cloned.metadata)
|
||||||
delete cloned.metadata.isNew
|
|
||||||
delete cloned.metadata.isTransient
|
|
||||||
}
|
|
||||||
|
|
||||||
const node = WindowNode.parse({
|
const node = WindowNode.parse({
|
||||||
...cloned,
|
...cloned,
|
||||||
@@ -361,12 +359,12 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (original.parentId && original.parentId !== target.wallId) {
|
if (original.parentId && original.parentId !== target.wallId) {
|
||||||
markWallDirty(original.parentId)
|
markHostDirty(original.parentId)
|
||||||
}
|
}
|
||||||
placedId = movingWindowNode.id
|
placedId = movingWindowNode.id
|
||||||
}
|
}
|
||||||
|
|
||||||
markWallDirty(target.wallId)
|
markHostDirty(target.wallId)
|
||||||
useLiveTransforms.getState().clear(movingWindowNode.id)
|
useLiveTransforms.getState().clear(movingWindowNode.id)
|
||||||
useScene.temporal.getState().pause()
|
useScene.temporal.getState().pause()
|
||||||
|
|
||||||
@@ -384,10 +382,10 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
|||||||
lastTarget = null
|
lastTarget = null
|
||||||
if (isNew) return // No original to restore for duplicates
|
if (isNew) return // No original to restore for duplicates
|
||||||
// Move mode: restore to original position while off-wall
|
// Move mode: restore to original position while off-wall
|
||||||
if (currentWallId && currentWallId !== original.parentId) {
|
if (currentHostId && currentHostId !== original.parentId) {
|
||||||
markWallDirty(currentWallId)
|
markHostDirty(currentHostId)
|
||||||
}
|
}
|
||||||
currentWallId = original.parentId
|
currentHostId = original.parentId
|
||||||
useScene.getState().updateNode(movingWindowNode.id, {
|
useScene.getState().updateNode(movingWindowNode.id, {
|
||||||
position: original.position,
|
position: original.position,
|
||||||
rotation: original.rotation,
|
rotation: original.rotation,
|
||||||
@@ -397,7 +395,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
|||||||
roofSegmentId: original.roofSegmentId,
|
roofSegmentId: original.roofSegmentId,
|
||||||
roofFace: original.roofFace,
|
roofFace: original.roofFace,
|
||||||
})
|
})
|
||||||
if (original.parentId) markWallDirty(original.parentId)
|
if (original.parentId) markHostDirty(original.parentId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Roof-segment wall faces ─────────────────────────────────────
|
// ── Roof-segment wall faces ─────────────────────────────────────
|
||||||
@@ -406,64 +404,18 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
|||||||
// the gable pediment). This is also the placement path preset tiles
|
// the gable pediment). This is also the placement path preset tiles
|
||||||
// take (`metadata.isNew` clones).
|
// take (`metadata.isNew` clones).
|
||||||
|
|
||||||
const worldToBuildingLocal = (point: Vector3): [number, number, number] => {
|
const resolveRoofMoveTarget = (event: RoofEvent) =>
|
||||||
const buildingId = useViewer.getState().selection.buildingId
|
resolveRoofWallOpeningTarget({
|
||||||
const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : undefined
|
event,
|
||||||
if (buildingObj) buildingObj.worldToLocal(point)
|
width: movingWindowNode.width,
|
||||||
return [point.x, point.y, point.z]
|
height: movingWindowNode.height,
|
||||||
}
|
ignoreId: movingWindowNode.id,
|
||||||
|
vertical: { kind: 'free', snap: snapToHalf },
|
||||||
|
})
|
||||||
|
|
||||||
const resolveRoofMoveTarget = (event: RoofEvent) => {
|
const updateRoofCursor = (target: RoofWallOpeningTarget, roof: RoofNode) => {
|
||||||
const hit = resolveRoofWallHit(
|
const pose = getRoofWallOpeningCursorPose(target, roof)
|
||||||
event.node as RoofNode,
|
if (pose) updateCursor(pose.position, pose.rotationY, target.valid)
|
||||||
event.position,
|
|
||||||
event.normal,
|
|
||||||
event.object,
|
|
||||||
)
|
|
||||||
if (!hit) return null
|
|
||||||
// Free vertical placement (0.5m grid like walls); the clamp
|
|
||||||
// projects the window inside the face profile, sliding it down
|
|
||||||
// under the gable slopes when needed.
|
|
||||||
const clamped = clampRectToRoofWallFace(
|
|
||||||
hit.face,
|
|
||||||
hit.u,
|
|
||||||
snapToHalf(hit.v),
|
|
||||||
movingWindowNode.width,
|
|
||||||
movingWindowNode.height,
|
|
||||||
)
|
|
||||||
if (!clamped) return null
|
|
||||||
// FACE-LOCAL storage (u, v, z = 0 → wall mid-plane): the renderer
|
|
||||||
// mounts the node inside the live face frame, so it tracks segment
|
|
||||||
// resizes without any re-anchoring.
|
|
||||||
const position: [number, number, number] = [clamped.u, clamped.v, 0]
|
|
||||||
const valid = !hasRoofFaceChildOverlap(
|
|
||||||
hit.segment,
|
|
||||||
hit.face.id,
|
|
||||||
clamped.u,
|
|
||||||
clamped.v,
|
|
||||||
movingWindowNode.width,
|
|
||||||
movingWindowNode.height,
|
|
||||||
movingWindowNode.id,
|
|
||||||
)
|
|
||||||
return { hit, position, valid, roof: event.node as RoofNode }
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateRoofCursor = (target: NonNullable<ReturnType<typeof resolveRoofMoveTarget>>) => {
|
|
||||||
const segObj = sceneRegistry.nodes.get(target.hit.segment.id as AnyNodeId)
|
|
||||||
if (!segObj) return
|
|
||||||
segObj.updateWorldMatrix(true, false)
|
|
||||||
const segLocal = roofFacePointToSegment(
|
|
||||||
target.hit.segment,
|
|
||||||
target.hit.face.id,
|
|
||||||
target.position,
|
|
||||||
)
|
|
||||||
roofCursorPoint.set(segLocal[0], segLocal[1], segLocal[2])
|
|
||||||
segObj.localToWorld(roofCursorPoint)
|
|
||||||
updateCursor(
|
|
||||||
worldToBuildingLocal(roofCursorPoint),
|
|
||||||
(target.roof.rotation ?? 0) + (target.hit.segment.rotation ?? 0) + target.hit.face.yaw,
|
|
||||||
target.valid,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const onRoofHover = (event: RoofEvent) => {
|
const onRoofHover = (event: RoofEvent) => {
|
||||||
@@ -473,33 +425,33 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
|||||||
dragAnchor = null
|
dragAnchor = null
|
||||||
lastTarget = null
|
lastTarget = null
|
||||||
useLiveTransforms.getState().clear(movingWindowNode.id)
|
useLiveTransforms.getState().clear(movingWindowNode.id)
|
||||||
if (currentWallId !== target.hit.segment.id) {
|
if (currentHostId !== target.segment.id) {
|
||||||
useScene.getState().updateNode(movingWindowNode.id, {
|
useScene.getState().updateNode(movingWindowNode.id, {
|
||||||
position: target.position,
|
position: target.position,
|
||||||
rotation: [0, 0, 0],
|
rotation: [0, 0, 0],
|
||||||
side: 'front',
|
side: 'front',
|
||||||
parentId: target.hit.segment.id,
|
parentId: target.segment.id,
|
||||||
wallId: undefined,
|
wallId: undefined,
|
||||||
roofSegmentId: target.hit.segment.id,
|
roofSegmentId: target.segment.id,
|
||||||
roofFace: target.hit.face.id,
|
roofFace: target.face.id,
|
||||||
})
|
})
|
||||||
markWallDirty(currentWallId)
|
markHostDirty(currentHostId)
|
||||||
currentWallId = target.hit.segment.id
|
currentHostId = target.segment.id
|
||||||
} else {
|
} else {
|
||||||
useScene.getState().updateNode(movingWindowNode.id, {
|
useScene.getState().updateNode(movingWindowNode.id, {
|
||||||
position: target.position,
|
position: target.position,
|
||||||
rotation: [0, 0, 0],
|
rotation: [0, 0, 0],
|
||||||
roofFace: target.hit.face.id,
|
roofFace: target.face.id,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
updateRoofCursor(target)
|
updateRoofCursor(target, event.node as RoofNode)
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
}
|
}
|
||||||
|
|
||||||
const onRoofClick = (event: RoofEvent) => {
|
const onRoofClick = (event: RoofEvent) => {
|
||||||
const target = resolveRoofMoveTarget(event)
|
const target = resolveRoofMoveTarget(event)
|
||||||
if (!target?.valid) return
|
if (!target?.valid) return
|
||||||
const segmentId = target.hit.segment.id
|
const segmentId = target.segment.id
|
||||||
|
|
||||||
let placedId: string
|
let placedId: string
|
||||||
|
|
||||||
@@ -509,10 +461,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
|||||||
|
|
||||||
const cloned = structuredClone(movingWindowNode) as any
|
const cloned = structuredClone(movingWindowNode) as any
|
||||||
delete cloned.id
|
delete cloned.id
|
||||||
if (cloned.metadata && typeof cloned.metadata === 'object') {
|
cloned.metadata = stripPlacementMetadataFlags(cloned.metadata)
|
||||||
delete cloned.metadata.isNew
|
|
||||||
delete cloned.metadata.isTransient
|
|
||||||
}
|
|
||||||
|
|
||||||
const node = WindowNode.parse({
|
const node = WindowNode.parse({
|
||||||
...cloned,
|
...cloned,
|
||||||
@@ -521,7 +470,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
|||||||
side: 'front',
|
side: 'front',
|
||||||
wallId: undefined,
|
wallId: undefined,
|
||||||
roofSegmentId: segmentId,
|
roofSegmentId: segmentId,
|
||||||
roofFace: target.hit.face.id,
|
roofFace: target.face.id,
|
||||||
parentId: segmentId,
|
parentId: segmentId,
|
||||||
})
|
})
|
||||||
useScene.getState().createNode(node, segmentId as AnyNodeId)
|
useScene.getState().createNode(node, segmentId as AnyNodeId)
|
||||||
@@ -546,17 +495,17 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
|||||||
parentId: segmentId,
|
parentId: segmentId,
|
||||||
wallId: undefined,
|
wallId: undefined,
|
||||||
roofSegmentId: segmentId,
|
roofSegmentId: segmentId,
|
||||||
roofFace: target.hit.face.id,
|
roofFace: target.face.id,
|
||||||
metadata: {},
|
metadata: {},
|
||||||
})
|
})
|
||||||
|
|
||||||
if (original.parentId && original.parentId !== segmentId) {
|
if (original.parentId && original.parentId !== segmentId) {
|
||||||
markWallDirty(original.parentId)
|
markHostDirty(original.parentId)
|
||||||
}
|
}
|
||||||
placedId = movingWindowNode.id
|
placedId = movingWindowNode.id
|
||||||
}
|
}
|
||||||
|
|
||||||
markWallDirty(segmentId)
|
markHostDirty(segmentId)
|
||||||
useLiveTransforms.getState().clear(movingWindowNode.id)
|
useLiveTransforms.getState().clear(movingWindowNode.id)
|
||||||
useScene.temporal.getState().pause()
|
useScene.temporal.getState().pause()
|
||||||
|
|
||||||
@@ -573,10 +522,10 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
|||||||
dragAnchor = null
|
dragAnchor = null
|
||||||
lastTarget = null
|
lastTarget = null
|
||||||
if (isNew) return
|
if (isNew) return
|
||||||
if (currentWallId && currentWallId !== original.parentId) {
|
if (currentHostId && currentHostId !== original.parentId) {
|
||||||
markWallDirty(currentWallId)
|
markHostDirty(currentHostId)
|
||||||
}
|
}
|
||||||
currentWallId = original.parentId
|
currentHostId = original.parentId
|
||||||
useScene.getState().updateNode(movingWindowNode.id, {
|
useScene.getState().updateNode(movingWindowNode.id, {
|
||||||
position: original.position,
|
position: original.position,
|
||||||
rotation: original.rotation,
|
rotation: original.rotation,
|
||||||
@@ -586,14 +535,14 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
|||||||
roofSegmentId: original.roofSegmentId,
|
roofSegmentId: original.roofSegmentId,
|
||||||
roofFace: original.roofFace,
|
roofFace: original.roofFace,
|
||||||
})
|
})
|
||||||
if (original.parentId) markWallDirty(original.parentId)
|
if (original.parentId) markHostDirty(original.parentId)
|
||||||
}
|
}
|
||||||
|
|
||||||
const onCancel = () => {
|
const onCancel = () => {
|
||||||
useLiveTransforms.getState().clear(movingWindowNode.id)
|
useLiveTransforms.getState().clear(movingWindowNode.id)
|
||||||
if (isNew) {
|
if (isNew) {
|
||||||
useScene.getState().deleteNode(movingWindowNode.id)
|
useScene.getState().deleteNode(movingWindowNode.id)
|
||||||
if (currentWallId) markWallDirty(currentWallId)
|
if (currentHostId) markHostDirty(currentHostId)
|
||||||
} else {
|
} else {
|
||||||
useScene.getState().updateNode(movingWindowNode.id, {
|
useScene.getState().updateNode(movingWindowNode.id, {
|
||||||
position: original.position,
|
position: original.position,
|
||||||
@@ -605,7 +554,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
|||||||
roofFace: original.roofFace,
|
roofFace: original.roofFace,
|
||||||
metadata: original.metadata,
|
metadata: original.metadata,
|
||||||
})
|
})
|
||||||
if (original.parentId) markWallDirty(original.parentId)
|
if (original.parentId) markHostDirty(original.parentId)
|
||||||
}
|
}
|
||||||
useScene.temporal.getState().resume()
|
useScene.temporal.getState().resume()
|
||||||
hideCursor()
|
hideCursor()
|
||||||
@@ -631,7 +580,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
|||||||
if (currentMeta?.isTransient) {
|
if (currentMeta?.isTransient) {
|
||||||
if (isNew) {
|
if (isNew) {
|
||||||
useScene.getState().deleteNode(movingWindowNode.id)
|
useScene.getState().deleteNode(movingWindowNode.id)
|
||||||
if (currentWallId) markWallDirty(currentWallId)
|
if (currentHostId) markHostDirty(currentHostId)
|
||||||
} else {
|
} else {
|
||||||
useScene.getState().updateNode(movingWindowNode.id, {
|
useScene.getState().updateNode(movingWindowNode.id, {
|
||||||
position: original.position,
|
position: original.position,
|
||||||
@@ -643,7 +592,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
|||||||
roofFace: original.roofFace,
|
roofFace: original.roofFace,
|
||||||
metadata: original.metadata,
|
metadata: original.metadata,
|
||||||
})
|
})
|
||||||
if (original.parentId) markWallDirty(original.parentId)
|
if (original.parentId) markHostDirty(original.parentId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
useLiveTransforms.getState().clear(movingWindowNode.id)
|
useLiveTransforms.getState().clear(movingWindowNode.id)
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import {
|
import {
|
||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
clampRectToRoofWallFace,
|
|
||||||
collectAlignmentAnchors,
|
collectAlignmentAnchors,
|
||||||
emitter,
|
emitter,
|
||||||
isCurvedWall,
|
isCurvedWall,
|
||||||
type RoofEvent,
|
type RoofEvent,
|
||||||
type RoofNode,
|
type RoofNode,
|
||||||
roofFacePointToSegment,
|
|
||||||
sceneRegistry,
|
sceneRegistry,
|
||||||
spatialGridManager,
|
spatialGridManager,
|
||||||
useScene,
|
useScene,
|
||||||
@@ -18,17 +16,20 @@ import {
|
|||||||
calculateItemRotation,
|
calculateItemRotation,
|
||||||
EDITOR_LAYER,
|
EDITOR_LAYER,
|
||||||
getSideFromNormal,
|
getSideFromNormal,
|
||||||
hasRoofFaceChildOverlap,
|
|
||||||
isValidWallSideFace,
|
isValidWallSideFace,
|
||||||
resolveRoofWallHit,
|
|
||||||
snapToHalf,
|
snapToHalf,
|
||||||
triggerSFX,
|
triggerSFX,
|
||||||
useAlignmentGuides,
|
useAlignmentGuides,
|
||||||
} from '@pascal-app/editor'
|
} from '@pascal-app/editor'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import { BoxGeometry, EdgesGeometry, type Group, type LineSegments, Vector3 } from 'three'
|
import { BoxGeometry, EdgesGeometry, type Group, type LineSegments } from 'three'
|
||||||
import { LineBasicNodeMaterial } from 'three/webgpu'
|
import { LineBasicNodeMaterial } from 'three/webgpu'
|
||||||
|
import {
|
||||||
|
getRoofWallOpeningCursorPose,
|
||||||
|
resolveRoofWallOpeningTarget,
|
||||||
|
type RoofWallOpeningTarget,
|
||||||
|
} from '../shared/roof-wall-opening-placement'
|
||||||
import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment'
|
import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment'
|
||||||
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './window-math'
|
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './window-math'
|
||||||
|
|
||||||
@@ -40,7 +41,6 @@ const edgeMaterial = new LineBasicNodeMaterial({
|
|||||||
depthWrite: false,
|
depthWrite: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const roofCursorPoint = new Vector3()
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Window tool — places WindowNodes on walls and on roof-segment wall
|
* Window tool — places WindowNodes on walls and on roof-segment wall
|
||||||
@@ -68,8 +68,8 @@ const WindowTool: React.FC = () => {
|
|||||||
wallEvent.node.end,
|
wallEvent.node.end,
|
||||||
)
|
)
|
||||||
|
|
||||||
const markWallDirty = (wallId: string) => {
|
const markHostDirty = (hostId: string) => {
|
||||||
useScene.getState().dirtyNodes.add(wallId as AnyNodeId)
|
useScene.getState().dirtyNodes.add(hostId as AnyNodeId)
|
||||||
}
|
}
|
||||||
|
|
||||||
const destroyDraft = () => {
|
const destroyDraft = () => {
|
||||||
@@ -78,7 +78,7 @@ const WindowTool: React.FC = () => {
|
|||||||
useScene.getState().deleteNode(draftRef.current.id)
|
useScene.getState().deleteNode(draftRef.current.id)
|
||||||
draftRef.current = null
|
draftRef.current = null
|
||||||
// Rebuild wall so it removes the cutout from the deleted draft
|
// Rebuild wall so it removes the cutout from the deleted draft
|
||||||
if (wallId) markWallDirty(wallId)
|
if (wallId) markHostDirty(wallId)
|
||||||
}
|
}
|
||||||
|
|
||||||
const hideCursor = () => {
|
const hideCursor = () => {
|
||||||
@@ -225,7 +225,7 @@ const WindowTool: React.FC = () => {
|
|||||||
rotation: [0, itemRotation, 0],
|
rotation: [0, itemRotation, 0],
|
||||||
side,
|
side,
|
||||||
})
|
})
|
||||||
markWallDirty(event.node.id)
|
markHostDirty(event.node.id)
|
||||||
} else {
|
} else {
|
||||||
useScene.getState().updateNode(draftRef.current.id, {
|
useScene.getState().updateNode(draftRef.current.id, {
|
||||||
position: [clampedX, clampedY, 0],
|
position: [clampedX, clampedY, 0],
|
||||||
@@ -362,65 +362,18 @@ const WindowTool: React.FC = () => {
|
|||||||
// so a window can sit anywhere inside the face profile — including
|
// so a window can sit anywhere inside the face profile — including
|
||||||
// the gable pediment triangle.
|
// the gable pediment triangle.
|
||||||
|
|
||||||
const worldToBuildingLocal = (point: Vector3): [number, number, number] => {
|
const resolveRoofTarget = (event: RoofEvent) =>
|
||||||
// The tool's cursor group renders in the building's local frame —
|
resolveRoofWallOpeningTarget({
|
||||||
// same conversion as the roof accessory tools (e.g. SkylightTool).
|
event,
|
||||||
const buildingId = useViewer.getState().selection.buildingId
|
width: draftRef.current?.width ?? 1.5,
|
||||||
const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : undefined
|
height: draftRef.current?.height ?? 1.5,
|
||||||
if (buildingObj) buildingObj.worldToLocal(point)
|
ignoreId: draftRef.current?.id,
|
||||||
return [point.x, point.y, point.z]
|
vertical: { kind: 'free', snap: snapToHalf },
|
||||||
}
|
})
|
||||||
|
|
||||||
const resolveRoofTarget = (event: RoofEvent) => {
|
const updateRoofCursor = (target: RoofWallOpeningTarget, roof: RoofNode) => {
|
||||||
const hit = resolveRoofWallHit(
|
const pose = getRoofWallOpeningCursorPose(target, roof)
|
||||||
event.node as RoofNode,
|
if (pose) updateCursor(pose.position, pose.rotationY, target.valid)
|
||||||
event.position,
|
|
||||||
event.normal,
|
|
||||||
event.object,
|
|
||||||
)
|
|
||||||
if (!hit) return null
|
|
||||||
const width = draftRef.current?.width ?? 1.5
|
|
||||||
const height = draftRef.current?.height ?? 1.5
|
|
||||||
// Free vertical placement (snapped to the 0.5m grid like walls);
|
|
||||||
// the clamp projects the window inside the face profile, sliding
|
|
||||||
// it down under the gable slopes when needed.
|
|
||||||
const clamped = clampRectToRoofWallFace(hit.face, hit.u, snapToHalf(hit.v), width, height)
|
|
||||||
if (!clamped) return null
|
|
||||||
// FACE-LOCAL storage (u, v, z = 0 → wall mid-plane): the renderer
|
|
||||||
// mounts the node inside the live face frame, so it tracks segment
|
|
||||||
// resizes without any re-anchoring.
|
|
||||||
const position: [number, number, number] = [clamped.u, clamped.v, 0]
|
|
||||||
const valid = !hasRoofFaceChildOverlap(
|
|
||||||
hit.segment,
|
|
||||||
hit.face.id,
|
|
||||||
clamped.u,
|
|
||||||
clamped.v,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
draftRef.current?.id,
|
|
||||||
)
|
|
||||||
return { hit, position, valid }
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateRoofCursor = (
|
|
||||||
target: NonNullable<ReturnType<typeof resolveRoofTarget>>,
|
|
||||||
roof: RoofNode,
|
|
||||||
) => {
|
|
||||||
const segObj = sceneRegistry.nodes.get(target.hit.segment.id as AnyNodeId)
|
|
||||||
if (!segObj) return
|
|
||||||
segObj.updateWorldMatrix(true, false)
|
|
||||||
const segLocal = roofFacePointToSegment(
|
|
||||||
target.hit.segment,
|
|
||||||
target.hit.face.id,
|
|
||||||
target.position,
|
|
||||||
)
|
|
||||||
roofCursorPoint.set(segLocal[0], segLocal[1], segLocal[2])
|
|
||||||
segObj.localToWorld(roofCursorPoint)
|
|
||||||
updateCursor(
|
|
||||||
worldToBuildingLocal(roofCursorPoint),
|
|
||||||
(roof.rotation ?? 0) + (target.hit.segment.rotation ?? 0) + target.hit.face.yaw,
|
|
||||||
target.valid,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const onRoofHover = (event: RoofEvent) => {
|
const onRoofHover = (event: RoofEvent) => {
|
||||||
@@ -434,26 +387,26 @@ const WindowTool: React.FC = () => {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const { hit, position } = target
|
const { segment, face, position } = target
|
||||||
|
|
||||||
if (draftRef.current && draftRef.current.parentId !== hit.segment.id) destroyDraft()
|
if (draftRef.current && draftRef.current.parentId !== segment.id) destroyDraft()
|
||||||
if (draftRef.current) {
|
if (draftRef.current) {
|
||||||
useScene.getState().updateNode(draftRef.current.id, {
|
useScene.getState().updateNode(draftRef.current.id, {
|
||||||
position,
|
position,
|
||||||
rotation: [0, 0, 0],
|
rotation: [0, 0, 0],
|
||||||
roofFace: hit.face.id,
|
roofFace: face.id,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
const node = WindowNode.parse({
|
const node = WindowNode.parse({
|
||||||
position,
|
position,
|
||||||
rotation: [0, 0, 0],
|
rotation: [0, 0, 0],
|
||||||
side: 'front',
|
side: 'front',
|
||||||
roofSegmentId: hit.segment.id,
|
roofSegmentId: segment.id,
|
||||||
roofFace: hit.face.id,
|
roofFace: face.id,
|
||||||
parentId: hit.segment.id,
|
parentId: segment.id,
|
||||||
metadata: { isTransient: true },
|
metadata: { isTransient: true },
|
||||||
})
|
})
|
||||||
useScene.getState().createNode(node, hit.segment.id as AnyNodeId)
|
useScene.getState().createNode(node, segment.id as AnyNodeId)
|
||||||
draftRef.current = node
|
draftRef.current = node
|
||||||
}
|
}
|
||||||
updateRoofCursor(target, event.node as RoofNode)
|
updateRoofCursor(target, event.node as RoofNode)
|
||||||
@@ -464,7 +417,7 @@ const WindowTool: React.FC = () => {
|
|||||||
if (!draftRef.current?.roofSegmentId) return
|
if (!draftRef.current?.roofSegmentId) return
|
||||||
const target = resolveRoofTarget(event)
|
const target = resolveRoofTarget(event)
|
||||||
if (!target?.valid) return
|
if (!target?.valid) return
|
||||||
const { hit, position } = target
|
const { segment, face, position } = target
|
||||||
|
|
||||||
const draft = draftRef.current
|
const draft = draftRef.current
|
||||||
draftRef.current = null
|
draftRef.current = null
|
||||||
@@ -482,9 +435,9 @@ const WindowTool: React.FC = () => {
|
|||||||
position,
|
position,
|
||||||
rotation: [0, 0, 0],
|
rotation: [0, 0, 0],
|
||||||
side: 'front',
|
side: 'front',
|
||||||
roofSegmentId: hit.segment.id,
|
roofSegmentId: segment.id,
|
||||||
roofFace: hit.face.id,
|
roofFace: face.id,
|
||||||
parentId: hit.segment.id,
|
parentId: segment.id,
|
||||||
width: draft.width,
|
width: draft.width,
|
||||||
height: draft.height,
|
height: draft.height,
|
||||||
windowType: draft.windowType,
|
windowType: draft.windowType,
|
||||||
@@ -503,10 +456,10 @@ const WindowTool: React.FC = () => {
|
|||||||
sillThickness: draft.sillThickness,
|
sillThickness: draft.sillThickness,
|
||||||
})
|
})
|
||||||
|
|
||||||
useScene.getState().createNode(node, hit.segment.id as AnyNodeId)
|
useScene.getState().createNode(node, segment.id as AnyNodeId)
|
||||||
// Rebuild the segment (and the merged roof) so the wall brush
|
// Rebuild the segment (and the merged roof) so the wall brush
|
||||||
// picks up the new opening cut.
|
// picks up the new opening cut.
|
||||||
useScene.getState().dirtyNodes.add(hit.segment.id as AnyNodeId)
|
useScene.getState().dirtyNodes.add(segment.id as AnyNodeId)
|
||||||
useViewer.getState().setSelection({ selectedIds: [node.id] })
|
useViewer.getState().setSelection({ selectedIds: [node.id] })
|
||||||
useScene.temporal.getState().pause()
|
useScene.temporal.getState().pause()
|
||||||
triggerSFX('sfx:structure-build')
|
triggerSFX('sfx:structure-build')
|
||||||
|
|||||||
@@ -110,12 +110,12 @@ export const RoofSystem = () => {
|
|||||||
// previous cut shape (stale CSG) once the user exits segment
|
// previous cut shape (stale CSG) once the user exits segment
|
||||||
// edit mode. Registry-driven so the viewer stays kind-agnostic.
|
// edit mode. Registry-driven so the viewer stays kind-agnostic.
|
||||||
const def = nodeRegistry.get(node.type)
|
const def = nodeRegistry.get(node.type)
|
||||||
// Kinds with `cascadesViaHostSegment` (door / window) reach the roof
|
// Kinds with `dirtyHandledByOwnSystem` (door / window) reach the roof
|
||||||
// through their own geometry system's parentId cascade instead —
|
// through their own geometry system's parentId cascade instead —
|
||||||
// their dirty marks belong to that system, not to this loop.
|
// their dirty marks belong to that system, not to this loop.
|
||||||
if (
|
if (
|
||||||
def?.capabilities?.roofAccessory &&
|
def?.capabilities?.roofAccessory &&
|
||||||
!def.capabilities.roofAccessory.cascadesViaHostSegment
|
!def.capabilities.roofAccessory.dirtyHandledByOwnSystem
|
||||||
) {
|
) {
|
||||||
const segId = (node as { roofSegmentId?: string }).roofSegmentId
|
const segId = (node as { roofSegmentId?: string }).roofSegmentId
|
||||||
const seg = segId ? (nodes[segId as AnyNodeId] as RoofSegmentNode | undefined) : undefined
|
const seg = segId ? (nodes[segId as AnyNodeId] as RoofSegmentNode | undefined) : undefined
|
||||||
|
|||||||
Reference in New Issue
Block a user