Add two-sided wall material targeting

This commit is contained in:
sudhir
2026-04-20 11:18:20 +05:30
parent 783b2e0c33
commit 71ddb7052b
14 changed files with 824 additions and 292 deletions
@@ -8,6 +8,8 @@ import {
resolveLevelId,
sceneRegistry,
useScene,
type WallEvent,
type WallSurfaceSide,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
@@ -68,6 +70,32 @@ export const resolveBuildingId = (
return null
}
function resolveWallMaterialTarget(event: WallEvent): WallSurfaceSide | null {
if (event.materialIndex === 1) return 'interior'
if (event.materialIndex === 2) return 'exterior'
const normalZ = event.normal?.[2]
const localZ = event.localPosition[2]
const thickness = event.node.thickness ?? 0.1
if (
normalZ === undefined ||
Math.abs(normalZ) < 0.65 ||
Math.abs(localZ) < Math.max(thickness * 0.2, 0.01)
) {
return null
}
const hitFace = localZ >= 0 ? 'front' : 'back'
const semantic = hitFace === 'front' ? event.node.frontSide : event.node.backSide
if (semantic === 'interior' || semantic === 'exterior') {
return semantic
}
return hitFace === 'front' ? 'interior' : 'exterior'
}
const HIGHLIGHT_PROFILES = {
delete: {
color: new Color('#dc2626'),
@@ -439,6 +467,23 @@ export const SelectionManager = () => {
activeStrategy.handleSelect(nodeToSelect, event.nativeEvent, modifierKeysRef.current)
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)
}
// Reset the handled flag after a short delay to allow grid:click to be ignored
setTimeout(() => {
clickHandledRef.current = false
@@ -471,6 +516,7 @@ export const SelectionManager = () => {
const { phase, structureLayer } = useEditor.getState()
const activeStrategy = SELECTION_STRATEGIES[phase]
if (activeStrategy) activeStrategy.handleDeselect()
useEditor.getState().setSelectedWallMaterialTarget(null)
// When deselecting from zone mode, return to structure select
if (phase === 'structure' && structureLayer === 'zones') {
@@ -704,6 +750,12 @@ export const SelectionManager = () => {
}
const SelectionStateSync = () => {
const selectedWallMaterialTarget = useEditor((s) => s.selectedWallMaterialTarget)
const setSelectedWallMaterialTarget = useEditor((s) => s.setSelectedWallMaterialTarget)
const singleSelectedId = useViewer((s) =>
s.selection.selectedIds.length === 1 ? s.selection.selectedIds[0] : null,
)
useEffect(() => {
return useScene.subscribe((state) => {
const { buildingId, levelId, zoneId, selectedIds } = useViewer.getState().selection
@@ -732,6 +784,25 @@ const SelectionStateSync = () => {
})
}, [])
useEffect(() => {
if (!selectedWallMaterialTarget) return
if (!singleSelectedId) {
setSelectedWallMaterialTarget(null)
return
}
const selectedNode = useScene.getState().nodes[singleSelectedId as AnyNodeId]
if (!(selectedNode?.type === 'wall')) {
setSelectedWallMaterialTarget(null)
return
}
if (selectedWallMaterialTarget.wallId !== selectedNode.id) {
setSelectedWallMaterialTarget(null)
}
}, [selectedWallMaterialTarget, setSelectedWallMaterialTarget, singleSelectedId])
return null
}
@@ -6,7 +6,7 @@ import {
type MaterialSchema,
type MaterialTarget,
} from '@pascal-app/core'
import { useState } from 'react'
import { useEffect, useState } from 'react'
type MaterialPickerProps = {
nodeType?: MaterialTarget
@@ -14,6 +14,7 @@ type MaterialPickerProps = {
selectedMaterialPreset?: string
onChange?: (material: MaterialSchema) => void
onSelectMaterialPreset?: (materialPreset: string) => void
hideSideControl?: boolean
}
export function MaterialPicker({
@@ -22,10 +23,15 @@ export function MaterialPicker({
selectedMaterialPreset,
onChange,
onSelectMaterialPreset,
hideSideControl = false,
}: MaterialPickerProps) {
const [showCustom, setShowCustom] = useState<boolean>(!!value?.properties)
const catalogItems = nodeType ? getMaterialsForTarget(nodeType) : []
useEffect(() => {
setShowCustom(!!value?.properties && !selectedMaterialPreset)
}, [selectedMaterialPreset, value?.properties])
const currentProps = value?.properties || {
color: '#ffffff',
roughness: 0.5,
@@ -193,20 +199,22 @@ export function MaterialPicker({
</span>
</div>
<div className="flex items-center gap-2">
<label className="w-16 text-gray-500 text-xs">Side</label>
<select
className="h-7 flex-1 rounded border border-gray-300 px-2 text-xs"
onChange={(e) =>
handlePropertyChange('side', e.target.value as 'front' | 'back' | 'double')
}
value={currentProps.side}
>
<option value="front">Front</option>
<option value="back">Back</option>
<option value="double">Double</option>
</select>
</div>
{!hideSideControl && (
<div className="flex items-center gap-2">
<label className="w-16 text-gray-500 text-xs">Side</label>
<select
className="h-7 flex-1 rounded border border-gray-300 px-2 text-xs"
onChange={(e) =>
handlePropertyChange('side', e.target.value as 'front' | 'back' | 'double')
}
value={currentProps.side}
>
<option value="front">Front</option>
<option value="back">Back</option>
<option value="double">Double</option>
</select>
</div>
)}
</div>
)}
</div>
@@ -3,17 +3,20 @@
import {
type AnyNode,
type AnyNodeId,
getEffectiveWallSurfaceMaterial,
getClampedWallCurveOffset,
getMaxWallCurveOffset,
getWallCurveLength,
getWallSurfaceMaterialSignature,
normalizeWallCurveOffset,
type MaterialSchema,
useScene,
type WallSurfaceSide,
type WallNode,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Move, Spline } from 'lucide-react'
import { useCallback } from 'react'
import { useCallback, useMemo } from 'react'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button'
@@ -22,12 +25,39 @@ import { PanelSection } from '../controls/panel-section'
import { SliderControl } from '../controls/slider-control'
import { PanelWrapper } from './panel-wrapper'
function buildWallSurfaceMaterialPatch(
node: WallNode,
targetSide: WallSurfaceSide | null,
material: MaterialSchema | undefined,
materialPreset: string | undefined,
): Partial<WallNode> {
const nextSurfaceMaterial = { material, materialPreset }
const nextInterior =
targetSide === null || targetSide === 'interior'
? nextSurfaceMaterial
: getEffectiveWallSurfaceMaterial(node, 'interior')
const nextExterior =
targetSide === null || targetSide === 'exterior'
? nextSurfaceMaterial
: getEffectiveWallSurfaceMaterial(node, 'exterior')
return {
interiorMaterial: nextInterior.material,
interiorMaterialPreset: nextInterior.materialPreset,
exteriorMaterial: nextExterior.material,
exteriorMaterialPreset: nextExterior.materialPreset,
material: undefined,
materialPreset: undefined,
}
}
export function WallPanel() {
const selectedId = useViewer((s) => s.selection.selectedIds[0])
const setSelection = useViewer((s) => s.setSelection)
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 node = useScene((s) =>
selectedId ? (s.nodes[selectedId as AnyNode['id']] as WallNode | undefined) : undefined,
@@ -58,6 +88,33 @@ export function WallPanel() {
[selectedId, updateNode],
)
const effectiveInteriorMaterial = useMemo(
() => (node ? getEffectiveWallSurfaceMaterial(node, 'interior') : {}),
[node],
)
const effectiveExteriorMaterial = useMemo(
() => (node ? getEffectiveWallSurfaceMaterial(node, 'exterior') : {}),
[node],
)
const surfaceMaterialsMatch = useMemo(
() =>
getWallSurfaceMaterialSignature(effectiveInteriorMaterial) ===
getWallSurfaceMaterialSignature(effectiveExteriorMaterial),
[effectiveExteriorMaterial, effectiveInteriorMaterial],
)
const materialTargetSide =
selectedWallMaterialTarget && selectedWallMaterialTarget.wallId === node?.id
? selectedWallMaterialTarget.side
: null
const materialPickerValue =
materialTargetSide === 'interior'
? effectiveInteriorMaterial
: materialTargetSide === 'exterior'
? effectiveExteriorMaterial
: surfaceMaterialsMatch
? effectiveInteriorMaterial
: {}
const handleUpdateLength = useCallback(
(newLength: number) => {
if (!node || newLength <= 0) return
@@ -83,16 +140,18 @@ export function WallPanel() {
const handleMaterialPresetChange = useCallback(
(materialPreset: string) => {
handleUpdate({ materialPreset, material: undefined })
if (!node || !materialTargetSide) return
handleUpdate(buildWallSurfaceMaterialPatch(node, materialTargetSide, undefined, materialPreset))
},
[handleUpdate],
[handleUpdate, materialTargetSide, node],
)
const handleCustomMaterialChange = useCallback(
(material: MaterialSchema) => {
handleUpdate({ material, materialPreset: undefined })
if (!node || !materialTargetSide) return
handleUpdate(buildWallSurfaceMaterialPatch(node, materialTargetSide, material, undefined))
},
[handleUpdate],
[handleUpdate, materialTargetSide, node],
)
const handleClose = useCallback(() => {
@@ -177,25 +236,35 @@ export function WallPanel() {
</PanelSection>
<PanelSection title="Material">
<MaterialPicker
nodeType="wall"
onChange={handleCustomMaterialChange}
onSelectMaterialPreset={handleMaterialPresetChange}
selectedMaterialPreset={node.materialPreset}
value={node.material}
/>
{!materialTargetSide ? (
<div className="mb-3 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 text-[11px] text-muted-foreground">
Click the wall face you want to edit. Materials now apply to one side at a time.
</div>
) : null}
{materialTargetSide ? (
<MaterialPicker
hideSideControl
nodeType="wall"
onChange={handleCustomMaterialChange}
onSelectMaterialPreset={handleMaterialPresetChange}
selectedMaterialPreset={materialPickerValue.materialPreset}
value={materialPickerValue.material}
/>
) : null}
</PanelSection>
<ActionGroup>
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
{!hasWallChildrenBlockingCurve && (
<ActionButton
icon={<Spline className="h-3.5 w-3.5" />}
label="Curve"
onClick={handleCurve}
/>
)}
</ActionGroup>
<PanelSection title="Actions">
<ActionGroup>
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
{!hasWallChildrenBlockingCurve && (
<ActionButton
icon={<Spline className="h-3.5 w-3.5" />}
label="Curve"
onClick={handleCurve}
/>
)}
</ActionGroup>
</PanelSection>
</PanelWrapper>
)
}
+10
View File
@@ -16,6 +16,7 @@ import {
type StairSegmentNode,
useScene,
type WallNode,
type WallSurfaceSide,
type WindowNode,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
@@ -80,6 +81,11 @@ export type MovingWallEndpoint = {
endpoint: 'start' | 'end'
}
export type SelectedWallMaterialTarget = {
wallId: WallNode['id']
side: WallSurfaceSide
}
type EditorState = {
phase: Phase
setPhase: (phase: Phase) => void
@@ -127,6 +133,8 @@ type EditorState = {
setMovingWallEndpoint: (value: MovingWallEndpoint | null) => void
curvingWall: WallNode | null
setCurvingWall: (wall: WallNode | null) => void
selectedWallMaterialTarget: SelectedWallMaterialTarget | null
setSelectedWallMaterialTarget: (target: SelectedWallMaterialTarget | null) => void
selectedReferenceId: string | null
setSelectedReferenceId: (id: string | null) => void
// Space detection for cutaway mode
@@ -502,6 +510,8 @@ const useEditor = create<EditorState>()(
setMovingWallEndpoint: (value) => set({ movingWallEndpoint: value }),
curvingWall: null,
setCurvingWall: (wall) => set({ curvingWall: wall }),
selectedWallMaterialTarget: null,
setSelectedWallMaterialTarget: (target) => set({ selectedWallMaterialTarget: target }),
selectedReferenceId: null,
setSelectedReferenceId: (id) => set({ selectedReferenceId: id }),
spaces: {},