Add two-sided wall material targeting
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
Reference in New Issue
Block a user