Add click-targeted stair materials and UV mapping

This commit is contained in:
sudhir
2026-04-20 12:01:23 +05:30
parent df9c4e499f
commit 4f9b822e54
10 changed files with 698 additions and 58 deletions
@@ -7,6 +7,9 @@ import {
type NodeEvent,
resolveLevelId,
sceneRegistry,
type StairEvent,
type StairNode,
type StairSegmentEvent,
useScene,
type WallEvent,
type WallSurfaceSide,
@@ -16,7 +19,7 @@ import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useRef } from 'react'
import { Color, type Material, type Mesh, type Object3D } from 'three'
import { sfxEmitter } from '../../lib/sfx-bus'
import useEditor, { type Phase, type StructureLayer } from './../../store/use-editor'
import useEditor, { type Phase, type StairMaterialTargetRole, type StructureLayer } from './../../store/use-editor'
import { boxSelectHandled } from '../tools/select/box-select-tool'
const isNodeInCurrentLevel = (node: AnyNode): boolean => {
@@ -96,6 +99,35 @@ function resolveWallMaterialTarget(event: WallEvent): WallSurfaceSide | null {
return hitFace === 'front' ? 'interior' : 'exterior'
}
function resolveStairMaterialTarget(
event: StairEvent | StairSegmentEvent,
): StairMaterialTargetRole | null {
const hitObjectName = event.nativeEvent.object?.name ?? ''
if (hitObjectName.startsWith('stair-railing')) {
return 'railing'
}
if (event.materialIndex === 0) {
return 'tread'
}
if (event.materialIndex === 1) {
return 'side'
}
const normalY = event.normal?.[1]
if (normalY !== undefined && normalY > 0.75) {
return 'tread'
}
if (normalY !== undefined && Math.abs(normalY) <= 0.75) {
return 'side'
}
return null
}
const HIGHLIGHT_PROFILES = {
delete: {
color: new Color('#dc2626'),
@@ -484,6 +516,28 @@ export const SelectionManager = () => {
useEditor.getState().setSelectedWallMaterialTarget(null)
}
if (
(node.type === 'stair' || node.type === 'stair-segment') &&
nodeToSelect.type === 'stair'
) {
const nextStairMaterialTarget = 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)
}
// Reset the handled flag after a short delay to allow grid:click to be ignored
setTimeout(() => {
clickHandledRef.current = false
@@ -517,6 +571,7 @@ export const SelectionManager = () => {
const activeStrategy = SELECTION_STRATEGIES[phase]
if (activeStrategy) activeStrategy.handleDeselect()
useEditor.getState().setSelectedWallMaterialTarget(null)
useEditor.getState().setSelectedStairMaterialTarget(null)
// When deselecting from zone mode, return to structure select
if (phase === 'structure' && structureLayer === 'zones') {
@@ -752,6 +807,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 singleSelectedId = useViewer((s) =>
s.selection.selectedIds.length === 1 ? s.selection.selectedIds[0] : null,
)
@@ -803,6 +860,25 @@ const SelectionStateSync = () => {
}
}, [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])
return null
}
@@ -3,10 +3,12 @@
import {
type AnyNode,
type AnyNodeId,
getEffectiveStairSurfaceMaterial,
type LevelNode,
type MaterialSchema,
type StairNode,
type StairRailingMode,
type StairSurfaceMaterialRole,
type StairSlabOpeningMode,
type StairTopLandingMode,
type StairType,
@@ -31,6 +33,32 @@ import { SliderControl } from '../controls/slider-control'
import { ToggleControl } from '../controls/toggle-control'
import { PanelWrapper } from './panel-wrapper'
function buildStairSurfaceMaterialPatch(
node: StairNode,
targetRole: StairSurfaceMaterialRole,
material: MaterialSchema | undefined,
materialPreset: string | undefined,
): Partial<StairNode> {
const nextSurfaceMaterial = { material, materialPreset }
const nextRailing =
targetRole === 'railing' ? nextSurfaceMaterial : getEffectiveStairSurfaceMaterial(node, 'railing')
const nextTread =
targetRole === 'tread' ? nextSurfaceMaterial : getEffectiveStairSurfaceMaterial(node, 'tread')
const nextSide =
targetRole === 'side' ? nextSurfaceMaterial : getEffectiveStairSurfaceMaterial(node, 'side')
return {
railingMaterial: nextRailing.material,
railingMaterialPreset: nextRailing.materialPreset,
treadMaterial: nextTread.material,
treadMaterialPreset: nextTread.materialPreset,
sideMaterial: nextSide.material,
sideMaterialPreset: nextSide.materialPreset,
material: undefined,
materialPreset: undefined,
}
}
const RAILING_MODE_OPTIONS: { label: string; value: StairRailingMode }[] = [
{ label: 'None', value: 'none' },
{ label: 'Left', value: 'left' },
@@ -62,6 +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 node = useScene((s) =>
selectedId ? (s.nodes[selectedId as AnyNode['id']] as StairNode | undefined) : undefined,
@@ -92,18 +121,27 @@ export function StairPanel() {
[selectedId, updateNode],
)
const handleMaterialChange = useCallback(
const materialTargetRole =
selectedStairMaterialTarget && selectedStairMaterialTarget.stairId === node?.id
? selectedStairMaterialTarget.role
: null
const materialPickerValue =
node && materialTargetRole ? getEffectiveStairSurfaceMaterial(node, materialTargetRole) : {}
const handleTargetedMaterialChange = useCallback(
(material: MaterialSchema) => {
handleUpdate({ material, materialPreset: undefined })
if (!node || !materialTargetRole) return
handleUpdate(buildStairSurfaceMaterialPatch(node, materialTargetRole, material, undefined))
},
[handleUpdate],
[handleUpdate, materialTargetRole, node],
)
const handleMaterialPresetChange = useCallback(
const handleTargetedMaterialPresetChange = useCallback(
(materialPreset: string) => {
handleUpdate({ materialPreset, material: undefined })
if (!node || !materialTargetRole) return
handleUpdate(buildStairSurfaceMaterialPatch(node, materialTargetRole, undefined, materialPreset))
},
[handleUpdate],
[handleUpdate, materialTargetRole, node],
)
const handleClose = useCallback(() => {
@@ -569,13 +607,21 @@ export function StairPanel() {
</ActionGroup>
</PanelSection>
<PanelSection title="Material">
<MaterialPicker
nodeType="stair"
onChange={handleMaterialChange}
onSelectMaterialPreset={handleMaterialPresetChange}
selectedMaterialPreset={node.materialPreset}
value={node.material}
/>
{!materialTargetRole ? (
<div className="mb-3 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 text-[11px] text-muted-foreground">
Click the stair surface you want to edit. Materials apply to one target at a time.
</div>
) : null}
{materialTargetRole ? (
<MaterialPicker
hideSideControl
nodeType="stair"
onChange={handleTargetedMaterialChange}
onSelectMaterialPreset={handleTargetedMaterialPresetChange}
selectedMaterialPreset={materialPickerValue.materialPreset}
value={materialPickerValue.material}
/>
) : null}
</PanelSection>
</PanelWrapper>
)
+11
View File
@@ -86,6 +86,13 @@ export type SelectedWallMaterialTarget = {
side: WallSurfaceSide
}
export type StairMaterialTargetRole = 'railing' | 'tread' | 'side'
export type SelectedStairMaterialTarget = {
stairId: StairNode['id']
role: StairMaterialTargetRole
}
type EditorState = {
phase: Phase
setPhase: (phase: Phase) => void
@@ -135,6 +142,8 @@ type EditorState = {
setCurvingWall: (wall: WallNode | null) => void
selectedWallMaterialTarget: SelectedWallMaterialTarget | null
setSelectedWallMaterialTarget: (target: SelectedWallMaterialTarget | null) => void
selectedStairMaterialTarget: SelectedStairMaterialTarget | null
setSelectedStairMaterialTarget: (target: SelectedStairMaterialTarget | null) => void
selectedReferenceId: string | null
setSelectedReferenceId: (id: string | null) => void
// Space detection for cutaway mode
@@ -512,6 +521,8 @@ const useEditor = create<EditorState>()(
setCurvingWall: (wall) => set({ curvingWall: wall }),
selectedWallMaterialTarget: null,
setSelectedWallMaterialTarget: (target) => set({ selectedWallMaterialTarget: target }),
selectedStairMaterialTarget: null,
setSelectedStairMaterialTarget: (target) => set({ selectedStairMaterialTarget: target }),
selectedReferenceId: null,
setSelectedReferenceId: (id) => set({ selectedReferenceId: id }),
spaces: {},