diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index ca70e049..6f61b2f7 100644 --- a/packages/core/src/events/bus.ts +++ b/packages/core/src/events/bus.ts @@ -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 { localPosition: [number, number, number] normal?: [number, number, number] faceIndex?: number - materialIndex?: number + object: Object3D stopPropagation: () => void nativeEvent: ThreeEvent } diff --git a/packages/editor/src/components/editor/selection-manager.tsx b/packages/editor/src/components/editor/selection-manager.tsx index 047481ab..7122ff1a 100755 --- a/packages/editor/src/components/editor/selection-manager.tsx +++ b/packages/editor/src/components/editor/selection-manager.tsx @@ -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) - } - } - } else if (useEditor.getState().selectedRoofMaterialTarget) { - useEditor.getState().setSelectedRoofMaterialTarget(null) + nextMaterialTargetHandled = true + } + + 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 } diff --git a/packages/editor/src/components/ui/controls/material-picker.tsx b/packages/editor/src/components/ui/controls/material-picker.tsx index 0974ea50..72b1b5e9 100755 --- a/packages/editor/src/components/ui/controls/material-picker.tsx +++ b/packages/editor/src/components/ui/controls/material-picker.tsx @@ -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(!!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 ( -
+
{(catalogItems.length > 0 || onChange) && (
{catalogItems.length > 0 ? ( diff --git a/packages/editor/src/components/ui/panels/roof-panel.tsx b/packages/editor/src/components/ui/panels/roof-panel.tsx index 06a2973f..69cdba15 100755 --- a/packages/editor/src/components/ui/panels/roof-panel.tsx +++ b/packages/editor/src/components/ui/panels/roof-panel.tsx @@ -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,16 +316,15 @@ export function RoofPanel() { Click the roof surface you want to edit. Materials apply to one target at a time.
) : null} - {materialTargetRole ? ( - - ) : null} + ) diff --git a/packages/editor/src/components/ui/panels/stair-panel.tsx b/packages/editor/src/components/ui/panels/stair-panel.tsx index e9477ac7..8756cc0b 100644 --- a/packages/editor/src/components/ui/panels/stair-panel.tsx +++ b/packages/editor/src/components/ui/panels/stair-panel.tsx @@ -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,16 +616,15 @@ export function StairPanel() { Click the stair surface you want to edit. Materials apply to one target at a time.
) : null} - {materialTargetRole ? ( - - ) : null} + ) diff --git a/packages/editor/src/components/ui/panels/wall-panel.tsx b/packages/editor/src/components/ui/panels/wall-panel.tsx index 65f2d7d8..35bd8e5f 100755 --- a/packages/editor/src/components/ui/panels/wall-panel.tsx +++ b/packages/editor/src/components/ui/panels/wall-panel.tsx @@ -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,16 +243,15 @@ export function WallPanel() { Click the wall face you want to edit. Materials now apply to one side at a time.
) : null} - {materialTargetSide ? ( - - ) : null} + diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index 6dd55c73..03d8a0e9 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -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()( 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: {}, diff --git a/packages/viewer/src/hooks/use-node-events.ts b/packages/viewer/src/hooks/use-node-events.ts index cc3eae2f..133ba34b 100644 --- a/packages/viewer/src/hooks/use-node-events.ts +++ b/packages/viewer/src/hooks/use-node-events.ts @@ -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['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(node: NodeConfig[T]['node'], type: T) { const emit = (suffix: EventSuffix, e: ThreeEvent) => { const eventKey = `${type}:${suffix}` as `${T}:${EventSuffix}` @@ -83,7 +65,7 @@ export function useNodeEvents(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']