Add click-targeted stair materials and UV mapping
This commit is contained in:
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
Reference in New Issue
Block a user