Unify material target selection across wall stair and roof

This commit is contained in:
sudhir
2026-04-20 22:45:56 +05:30
parent dd5b6e5e83
commit 8cf2df1483
8 changed files with 152 additions and 195 deletions
+2 -1
View File
@@ -1,4 +1,5 @@
import type { ThreeEvent } from '@react-three/fiber'
import type { Object3D } from 'three'
import mitt from 'mitt'
import type {
BuildingNode,
@@ -39,7 +40,7 @@ export interface NodeEvent<T extends AnyNode = AnyNode> {
localPosition: [number, number, number]
normal?: [number, number, number]
faceIndex?: number
materialIndex?: number
object: Object3D
stopPropagation: () => void
nativeEvent: ThreeEvent<PointerEvent>
}
@@ -11,6 +11,7 @@ import {
sceneRegistry,
type StairEvent,
type StairNode,
type StairSurfaceMaterialRole,
type StairSegmentEvent,
useScene,
type WallEvent,
@@ -19,9 +20,9 @@ import {
import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useRef } from 'react'
import { Color, type Material, type Mesh, type Object3D } from 'three'
import { Color, type BufferGeometry, type Material, type Mesh, type Object3D } from 'three'
import { sfxEmitter } from '../../lib/sfx-bus'
import useEditor, { type Phase, type StairMaterialTargetRole, type StructureLayer } from './../../store/use-editor'
import useEditor, { type MaterialTargetRole, type Phase, type StructureLayer } from './../../store/use-editor'
import { boxSelectHandled } from '../tools/select/box-select-tool'
const isNodeInCurrentLevel = (node: AnyNode): boolean => {
@@ -76,8 +77,9 @@ export const resolveBuildingId = (
}
function resolveWallMaterialTarget(event: WallEvent): WallSurfaceSide | null {
if (event.materialIndex === 1) return 'interior'
if (event.materialIndex === 2) return 'exterior'
const materialIndex = getIntersectionMaterialIndex(getEventObject(event), event.faceIndex)
if (materialIndex === 1) return 'interior'
if (materialIndex === 2) return 'exterior'
const normalZ = event.normal?.[2]
const localZ = event.localPosition[2]
@@ -103,8 +105,9 @@ function resolveWallMaterialTarget(event: WallEvent): WallSurfaceSide | null {
function resolveStairMaterialTarget(
event: StairEvent | StairSegmentEvent,
): StairMaterialTargetRole | null {
): StairSurfaceMaterialRole | null {
const hitObjectName = event.nativeEvent.object?.name ?? ''
const materialIndex = getIntersectionMaterialIndex(getEventObject(event), event.faceIndex)
if (hitObjectName.startsWith('stair-railing')) {
return 'railing'
@@ -114,11 +117,11 @@ function resolveStairMaterialTarget(
return 'side'
}
if (event.materialIndex === 0) {
if (materialIndex === 0) {
return 'tread'
}
if (event.materialIndex === 1) {
if (materialIndex === 1) {
return 'side'
}
@@ -137,9 +140,10 @@ function resolveStairMaterialTarget(
function resolveRoofMaterialTarget(
event: RoofEvent | RoofSegmentEvent,
): 'top' | 'edge' | 'wall' | null {
if (event.materialIndex === 3) return 'top'
if (event.materialIndex === 0) return 'edge'
if (event.materialIndex === 1 || event.materialIndex === 2) return 'wall'
const materialIndex = getIntersectionMaterialIndex(getEventObject(event), event.faceIndex)
if (materialIndex === 3) return 'top'
if (materialIndex === 0) return 'edge'
if (materialIndex === 1 || materialIndex === 2) return 'wall'
const normalY = event.normal?.[1]
if (normalY !== undefined && normalY > 0.35) return 'top'
@@ -149,6 +153,46 @@ function resolveRoofMaterialTarget(
return null
}
function getEventObject(event: NodeEvent): Object3D {
const eventWithObject = event as NodeEvent & { object?: Object3D }
return eventWithObject.object ?? event.nativeEvent.object
}
function getIntersectionMaterialIndex(
object: Object3D,
faceIndex: number | undefined,
): number | undefined {
if (faceIndex === undefined) return undefined
const geometry = (object as Mesh).geometry as BufferGeometry | undefined
if (!geometry || geometry.groups.length === 0) return undefined
const triangleStart = faceIndex * 3
const group = geometry.groups.find(
(entry) => triangleStart >= entry.start && triangleStart < entry.start + entry.count,
)
return group?.materialIndex
}
function setSelectedMaterialTargetForNode(
node: AnyNode,
role: MaterialTargetRole | null,
) {
if (!role) {
const currentTarget = useEditor.getState().selectedMaterialTarget
if (currentTarget?.nodeId !== node.id) {
useEditor.getState().setSelectedMaterialTarget(null)
}
return
}
useEditor.getState().setSelectedMaterialTarget({
nodeId: node.id as AnyNodeId,
role,
})
}
const HIGHLIGHT_PROFILES = {
delete: {
color: new Color('#dc2626'),
@@ -520,65 +564,40 @@ export const SelectionManager = () => {
activeStrategy.handleSelect(nodeToSelect, event.nativeEvent, modifierKeysRef.current)
let nextMaterialTargetHandled = false
if (node.type === 'wall' && nodeToSelect.type === 'wall') {
const nextWallMaterialTarget = resolveWallMaterialTarget(event as WallEvent)
if (nextWallMaterialTarget) {
useEditor.getState().setSelectedWallMaterialTarget({
wallId: nodeToSelect.id,
side: nextWallMaterialTarget,
})
} else {
const currentWallMaterialTarget = useEditor.getState().selectedWallMaterialTarget
if (currentWallMaterialTarget?.wallId !== nodeToSelect.id) {
useEditor.getState().setSelectedWallMaterialTarget(null)
}
}
} else if (useEditor.getState().selectedWallMaterialTarget) {
useEditor.getState().setSelectedWallMaterialTarget(null)
setSelectedMaterialTargetForNode(
nodeToSelect,
resolveWallMaterialTarget(event as WallEvent),
)
nextMaterialTargetHandled = true
}
if (
(node.type === 'stair' || node.type === 'stair-segment') &&
nodeToSelect.type === 'stair'
) {
const nextStairMaterialTarget = resolveStairMaterialTarget(
event as StairEvent | StairSegmentEvent,
setSelectedMaterialTargetForNode(
nodeToSelect,
resolveStairMaterialTarget(event as StairEvent | StairSegmentEvent),
)
if (nextStairMaterialTarget) {
useEditor.getState().setSelectedStairMaterialTarget({
stairId: nodeToSelect.id,
role: nextStairMaterialTarget,
})
} else {
const currentStairMaterialTarget = useEditor.getState().selectedStairMaterialTarget
if (currentStairMaterialTarget?.stairId !== nodeToSelect.id) {
useEditor.getState().setSelectedStairMaterialTarget(null)
}
}
} else if (useEditor.getState().selectedStairMaterialTarget) {
useEditor.getState().setSelectedStairMaterialTarget(null)
nextMaterialTargetHandled = true
}
if (
(node.type === 'roof' || node.type === 'roof-segment') &&
nodeToSelect.type === 'roof'
) {
const nextRoofMaterialTarget = resolveRoofMaterialTarget(
event as RoofEvent | RoofSegmentEvent,
setSelectedMaterialTargetForNode(
nodeToSelect,
resolveRoofMaterialTarget(event as RoofEvent | RoofSegmentEvent),
)
if (nextRoofMaterialTarget) {
useEditor.getState().setSelectedRoofMaterialTarget({
roofId: nodeToSelect.id,
role: nextRoofMaterialTarget,
})
} else {
const currentRoofMaterialTarget = useEditor.getState().selectedRoofMaterialTarget
if (currentRoofMaterialTarget?.roofId !== nodeToSelect.id) {
useEditor.getState().setSelectedRoofMaterialTarget(null)
nextMaterialTargetHandled = true
}
}
} else if (useEditor.getState().selectedRoofMaterialTarget) {
useEditor.getState().setSelectedRoofMaterialTarget(null)
if (!nextMaterialTargetHandled && useEditor.getState().selectedMaterialTarget) {
useEditor.getState().setSelectedMaterialTarget(null)
}
// Reset the handled flag after a short delay to allow grid:click to be ignored
@@ -613,9 +632,7 @@ export const SelectionManager = () => {
const { phase, structureLayer } = useEditor.getState()
const activeStrategy = SELECTION_STRATEGIES[phase]
if (activeStrategy) activeStrategy.handleDeselect()
useEditor.getState().setSelectedWallMaterialTarget(null)
useEditor.getState().setSelectedStairMaterialTarget(null)
useEditor.getState().setSelectedRoofMaterialTarget(null)
useEditor.getState().setSelectedMaterialTarget(null)
// When deselecting from zone mode, return to structure select
if (phase === 'structure' && structureLayer === 'zones') {
@@ -849,12 +866,8 @@ export const SelectionManager = () => {
}
const SelectionStateSync = () => {
const selectedWallMaterialTarget = useEditor((s) => s.selectedWallMaterialTarget)
const setSelectedWallMaterialTarget = useEditor((s) => s.setSelectedWallMaterialTarget)
const selectedStairMaterialTarget = useEditor((s) => s.selectedStairMaterialTarget)
const setSelectedStairMaterialTarget = useEditor((s) => s.setSelectedStairMaterialTarget)
const selectedRoofMaterialTarget = useEditor((s) => s.selectedRoofMaterialTarget)
const setSelectedRoofMaterialTarget = useEditor((s) => s.setSelectedRoofMaterialTarget)
const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget)
const setSelectedMaterialTarget = useEditor((s) => s.setSelectedMaterialTarget)
const singleSelectedId = useViewer((s) =>
s.selection.selectedIds.length === 1 ? s.selection.selectedIds[0] : null,
)
@@ -888,61 +901,26 @@ const SelectionStateSync = () => {
}, [])
useEffect(() => {
if (!selectedWallMaterialTarget) return
if (!selectedMaterialTarget) return
if (!singleSelectedId) {
setSelectedWallMaterialTarget(null)
setSelectedMaterialTarget(null)
return
}
const selectedNode = useScene.getState().nodes[singleSelectedId as AnyNodeId]
if (!(selectedNode?.type === 'wall')) {
setSelectedWallMaterialTarget(null)
if (
!selectedNode ||
(selectedNode.type !== 'wall' && selectedNode.type !== 'stair' && selectedNode.type !== 'roof')
) {
setSelectedMaterialTarget(null)
return
}
if (selectedWallMaterialTarget.wallId !== selectedNode.id) {
setSelectedWallMaterialTarget(null)
if (selectedMaterialTarget.nodeId !== selectedNode.id) {
setSelectedMaterialTarget(null)
}
}, [selectedWallMaterialTarget, setSelectedWallMaterialTarget, singleSelectedId])
useEffect(() => {
if (!selectedStairMaterialTarget) return
if (!singleSelectedId) {
setSelectedStairMaterialTarget(null)
return
}
const selectedNode = useScene.getState().nodes[singleSelectedId as AnyNodeId]
if (!(selectedNode?.type === 'stair')) {
setSelectedStairMaterialTarget(null)
return
}
if (selectedStairMaterialTarget.stairId !== selectedNode.id) {
setSelectedStairMaterialTarget(null)
}
}, [selectedStairMaterialTarget, setSelectedStairMaterialTarget, singleSelectedId])
useEffect(() => {
if (!selectedRoofMaterialTarget) return
if (!singleSelectedId) {
setSelectedRoofMaterialTarget(null)
return
}
const selectedNode = useScene.getState().nodes[singleSelectedId as AnyNodeId]
if (!(selectedNode?.type === 'roof')) {
setSelectedRoofMaterialTarget(null)
return
}
if (selectedRoofMaterialTarget.roofId !== selectedNode.id) {
setSelectedRoofMaterialTarget(null)
}
}, [selectedRoofMaterialTarget, setSelectedRoofMaterialTarget, singleSelectedId])
}, [selectedMaterialTarget, setSelectedMaterialTarget, singleSelectedId])
return null
}
@@ -15,6 +15,7 @@ type MaterialPickerProps = {
onChange?: (material: MaterialSchema) => void
onSelectMaterialPreset?: (materialPreset: string) => void
hideSideControl?: boolean
disabled?: boolean
}
export function MaterialPicker({
@@ -24,6 +25,7 @@ export function MaterialPicker({
onChange,
onSelectMaterialPreset,
hideSideControl = false,
disabled = false,
}: MaterialPickerProps) {
const [showCustom, setShowCustom] = useState<boolean>(!!value?.properties)
const catalogItems = nodeType ? getMaterialsForTarget(nodeType) : []
@@ -44,11 +46,13 @@ export function MaterialPicker({
selectedMaterialPreset ?? (value?.id ? toLibraryMaterialRef(value.id) : undefined)
const handleCatalogSelect = (materialId: string) => {
if (disabled) return
setShowCustom(false)
onSelectMaterialPreset?.(toLibraryMaterialRef(materialId))
}
const handleCustomOpen = () => {
if (disabled) return
setShowCustom(true)
onChange?.({
preset: 'custom',
@@ -67,6 +71,7 @@ export function MaterialPicker({
prop: keyof typeof currentProps,
val: (typeof currentProps)[keyof typeof currentProps],
) => {
if (disabled) return
onChange?.({
preset: 'custom',
properties: {
@@ -77,7 +82,7 @@ export function MaterialPicker({
}
return (
<div className="space-y-3">
<div className={`space-y-3 ${disabled ? 'pointer-events-none opacity-50' : ''}`}>
{(catalogItems.length > 0 || onChange) && (
<div className="space-y-2">
{catalogItems.length > 0 ? (
@@ -56,7 +56,7 @@ export function RoofPanel() {
const updateNode = useScene((s) => s.updateNode)
const createNode = useScene((s) => s.createNode)
const setMovingNode = useEditor((s) => s.setMovingNode)
const selectedRoofMaterialTarget = useEditor((s) => s.selectedRoofMaterialTarget)
const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget)
const node = useScene((s) =>
selectedId ? (s.nodes[selectedId as AnyNode['id']] as RoofNode | undefined) : undefined,
@@ -80,8 +80,12 @@ export function RoofPanel() {
)
const materialTargetRole =
selectedRoofMaterialTarget && selectedRoofMaterialTarget.roofId === node?.id
? selectedRoofMaterialTarget.role
selectedMaterialTarget &&
selectedMaterialTarget.nodeId === node?.id &&
(selectedMaterialTarget.role === 'top' ||
selectedMaterialTarget.role === 'edge' ||
selectedMaterialTarget.role === 'wall')
? selectedMaterialTarget.role
: null
const materialPickerValue =
node && materialTargetRole ? getEffectiveRoofSurfaceMaterial(node, materialTargetRole) : {}
@@ -312,8 +316,8 @@ export function RoofPanel() {
Click the roof surface you want to edit. Materials apply to one target at a time.
</div>
) : null}
{materialTargetRole ? (
<MaterialPicker
disabled={!materialTargetRole}
hideSideControl
nodeType="roof"
onChange={handleTargetedMaterialChange}
@@ -321,7 +325,6 @@ export function RoofPanel() {
selectedMaterialPreset={materialPickerValue.materialPreset}
value={materialPickerValue.material}
/>
) : null}
</PanelSection>
</PanelWrapper>
)
@@ -90,7 +90,7 @@ export function StairPanel() {
const createNode = useScene((s) => s.createNode)
const createNodes = useScene((s) => s.createNodes)
const setMovingNode = useEditor((s) => s.setMovingNode)
const selectedStairMaterialTarget = useEditor((s) => s.selectedStairMaterialTarget)
const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget)
const node = useScene((s) =>
selectedId ? (s.nodes[selectedId as AnyNode['id']] as StairNode | undefined) : undefined,
@@ -122,8 +122,12 @@ export function StairPanel() {
)
const materialTargetRole =
selectedStairMaterialTarget && selectedStairMaterialTarget.stairId === node?.id
? selectedStairMaterialTarget.role
selectedMaterialTarget &&
selectedMaterialTarget.nodeId === node?.id &&
(selectedMaterialTarget.role === 'railing' ||
selectedMaterialTarget.role === 'tread' ||
selectedMaterialTarget.role === 'side')
? selectedMaterialTarget.role
: null
const materialPickerValue =
node && materialTargetRole ? getEffectiveStairSurfaceMaterial(node, materialTargetRole) : {}
@@ -612,8 +616,8 @@ export function StairPanel() {
Click the stair surface you want to edit. Materials apply to one target at a time.
</div>
) : null}
{materialTargetRole ? (
<MaterialPicker
disabled={!materialTargetRole}
hideSideControl
nodeType="stair"
onChange={handleTargetedMaterialChange}
@@ -621,7 +625,6 @@ export function StairPanel() {
selectedMaterialPreset={materialPickerValue.materialPreset}
value={materialPickerValue.material}
/>
) : null}
</PanelSection>
</PanelWrapper>
)
@@ -57,7 +57,7 @@ export function WallPanel() {
const updateNode = useScene((s) => s.updateNode)
const setMovingNode = useEditor((s) => s.setMovingNode)
const setCurvingWall = useEditor((s) => s.setCurvingWall)
const selectedWallMaterialTarget = useEditor((s) => s.selectedWallMaterialTarget)
const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget)
const node = useScene((s) =>
selectedId ? (s.nodes[selectedId as AnyNode['id']] as WallNode | undefined) : undefined,
@@ -103,8 +103,10 @@ export function WallPanel() {
[effectiveExteriorMaterial, effectiveInteriorMaterial],
)
const materialTargetSide =
selectedWallMaterialTarget && selectedWallMaterialTarget.wallId === node?.id
? selectedWallMaterialTarget.side
selectedMaterialTarget &&
selectedMaterialTarget.nodeId === node?.id &&
(selectedMaterialTarget.role === 'interior' || selectedMaterialTarget.role === 'exterior')
? selectedMaterialTarget.role
: null
const materialPickerValue =
materialTargetSide === 'interior'
@@ -241,8 +243,8 @@ export function WallPanel() {
Click the wall face you want to edit. Materials now apply to one side at a time.
</div>
) : null}
{materialTargetSide ? (
<MaterialPicker
disabled={!materialTargetSide}
hideSideControl
nodeType="wall"
onChange={handleCustomMaterialChange}
@@ -250,7 +252,6 @@ export function WallPanel() {
selectedMaterialPreset={materialPickerValue.materialPreset}
value={materialPickerValue.material}
/>
) : null}
</PanelSection>
<PanelSection title="Actions">
+11 -27
View File
@@ -1,7 +1,8 @@
'use client'
import type { AssetInput } from '@pascal-app/core'
import {
type AnyNodeId,
type AssetInput,
type BuildingNode,
type CeilingNode,
type DoorNode,
@@ -13,6 +14,7 @@ import {
type RoofSegmentNode,
type SlabNode,
type Space,
type StairSurfaceMaterialRole,
type StairNode,
type StairSegmentNode,
useScene,
@@ -82,21 +84,11 @@ export type MovingWallEndpoint = {
endpoint: 'start' | 'end'
}
export type SelectedWallMaterialTarget = {
wallId: WallNode['id']
side: WallSurfaceSide
}
export type MaterialTargetRole = WallSurfaceSide | StairSurfaceMaterialRole | RoofSurfaceMaterialRole
export type StairMaterialTargetRole = 'railing' | 'tread' | 'side'
export type SelectedStairMaterialTarget = {
stairId: StairNode['id']
role: StairMaterialTargetRole
}
export type SelectedRoofMaterialTarget = {
roofId: RoofNode['id']
role: RoofSurfaceMaterialRole
export type SelectedMaterialTarget = {
nodeId: AnyNodeId
role: MaterialTargetRole
}
type EditorState = {
@@ -146,12 +138,8 @@ type EditorState = {
setMovingWallEndpoint: (value: MovingWallEndpoint | null) => void
curvingWall: WallNode | null
setCurvingWall: (wall: WallNode | null) => void
selectedWallMaterialTarget: SelectedWallMaterialTarget | null
setSelectedWallMaterialTarget: (target: SelectedWallMaterialTarget | null) => void
selectedStairMaterialTarget: SelectedStairMaterialTarget | null
setSelectedStairMaterialTarget: (target: SelectedStairMaterialTarget | null) => void
selectedRoofMaterialTarget: SelectedRoofMaterialTarget | null
setSelectedRoofMaterialTarget: (target: SelectedRoofMaterialTarget | null) => void
selectedMaterialTarget: SelectedMaterialTarget | null
setSelectedMaterialTarget: (target: SelectedMaterialTarget | null) => void
selectedReferenceId: string | null
setSelectedReferenceId: (id: string | null) => void
// Space detection for cutaway mode
@@ -527,12 +515,8 @@ const useEditor = create<EditorState>()(
setMovingWallEndpoint: (value) => set({ movingWallEndpoint: value }),
curvingWall: null,
setCurvingWall: (wall) => set({ curvingWall: wall }),
selectedWallMaterialTarget: null,
setSelectedWallMaterialTarget: (target) => set({ selectedWallMaterialTarget: target }),
selectedStairMaterialTarget: null,
setSelectedStairMaterialTarget: (target) => set({ selectedStairMaterialTarget: target }),
selectedRoofMaterialTarget: null,
setSelectedRoofMaterialTarget: (target) => set({ selectedRoofMaterialTarget: target }),
selectedMaterialTarget: null,
setSelectedMaterialTarget: (target) => set({ selectedMaterialTarget: target }),
selectedReferenceId: null,
setSelectedReferenceId: (id) => set({ selectedReferenceId: id }),
spaces: {},
+1 -19
View File
@@ -33,7 +33,6 @@ import {
type ZoneNode,
} from '@pascal-app/core'
import type { ThreeEvent } from '@react-three/fiber'
import type { BufferGeometry, Mesh } from 'three'
import useViewer from '../store/use-viewer'
type NodeConfig = {
@@ -56,23 +55,6 @@ type NodeConfig = {
type NodeType = keyof NodeConfig
function getIntersectionMaterialIndex(
object: ThreeEvent<PointerEvent>['object'],
faceIndex: number | undefined,
): number | undefined {
if (faceIndex === undefined) return undefined
const geometry = (object as Mesh).geometry as BufferGeometry | undefined
if (!geometry || geometry.groups.length === 0) return undefined
const triangleStart = faceIndex * 3
const group = geometry.groups.find(
(entry) => triangleStart >= entry.start && triangleStart < entry.start + entry.count,
)
return group?.materialIndex
}
export function useNodeEvents<T extends NodeType>(node: NodeConfig[T]['node'], type: T) {
const emit = (suffix: EventSuffix, e: ThreeEvent<PointerEvent>) => {
const eventKey = `${type}:${suffix}` as `${T}:${EventSuffix}`
@@ -83,7 +65,7 @@ export function useNodeEvents<T extends NodeType>(node: NodeConfig[T]['node'], t
localPosition: [localPoint.x, localPoint.y, localPoint.z],
normal: e.face ? [e.face.normal.x, e.face.normal.y, e.face.normal.z] : undefined,
faceIndex: e.faceIndex ?? undefined,
materialIndex: getIntersectionMaterialIndex(e.object, e.faceIndex ?? undefined),
object: e.object,
stopPropagation: () => e.stopPropagation(),
nativeEvent: e,
} as NodeConfig[T]['event']