From 71ddb7052b9e6849136a23d503dc2151e4d9588b Mon Sep 17 00:00:00 2001 From: sudhir Date: Mon, 20 Apr 2026 11:18:20 +0530 Subject: [PATCH 01/31] Add two-sided wall material targeting --- packages/core/src/events/bus.ts | 2 + packages/core/src/schema/index.ts | 23 +- packages/core/src/schema/nodes/wall.ts | 65 +++++ .../core/src/store/actions/node-actions.ts | 50 ++-- packages/core/src/store/use-scene.ts | 47 ++++ .../core/src/systems/wall/wall-system.tsx | 218 ++++++++++++++++- .../components/editor/selection-manager.tsx | 71 ++++++ .../ui/controls/material-picker.tsx | 38 +-- .../src/components/ui/panels/wall-panel.tsx | 113 +++++++-- packages/editor/src/store/use-editor.tsx | 10 + .../renderers/wall/wall-renderer.tsx | 23 +- packages/viewer/src/hooks/use-node-events.ts | 20 ++ .../viewer/src/systems/wall/wall-cutout.tsx | 210 +--------------- .../viewer/src/systems/wall/wall-materials.ts | 226 ++++++++++++++++++ 14 files changed, 824 insertions(+), 292 deletions(-) create mode 100644 packages/viewer/src/systems/wall/wall-materials.ts diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index bb6d2b7b..ca70e049 100644 --- a/packages/core/src/events/bus.ts +++ b/packages/core/src/events/bus.ts @@ -38,6 +38,8 @@ export interface NodeEvent { position: [number, number, number] localPosition: [number, number, number] normal?: [number, number, number] + faceIndex?: number + materialIndex?: number stopPropagation: () => void nativeEvent: ThreeEvent } diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 82086641..c1df74ef 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -4,6 +4,13 @@ export { BaseNode, generateId, Material, nodeType, objectId } from './base' export { CameraSchema } from './camera' // Collections export { type Collection, type CollectionId, generateCollectionId } from './collections' +export type { + MaterialMapProperties, + MaterialMaps, + MaterialPresetPayload, + MaterialTarget as MaterialTargetValue, + TextureWrapMode as TextureWrapModeValue, +} from './material' // Material export { DEFAULT_MATERIALS, @@ -14,15 +21,8 @@ export { MaterialProperties, MaterialSchema, MaterialTarget, - TextureWrapMode, resolveMaterial, -} from './material' -export type { - MaterialMapProperties, - MaterialMaps, - MaterialPresetPayload, - MaterialTarget as MaterialTargetValue, - TextureWrapMode as TextureWrapModeValue, + TextureWrapMode, } from './material' export { BuildingNode } from './nodes/building' export { CeilingNode } from './nodes/ceiling' @@ -58,7 +58,12 @@ export { } from './nodes/stair' export { AttachmentSide, StairSegmentNode, StairSegmentType } from './nodes/stair-segment' export { SurfaceHoleMetadata } from './nodes/surface-hole-metadata' -export { WallNode } from './nodes/wall' +export type { WallSurfaceMaterialSpec, WallSurfaceSide } from './nodes/wall' +export { + getEffectiveWallSurfaceMaterial, + getWallSurfaceMaterialSignature, + WallNode, +} from './nodes/wall' export { WindowNode } from './nodes/window' export { ZoneNode } from './nodes/zone' export type { AnyNodeId, AnyNodeType } from './types' diff --git a/packages/core/src/schema/nodes/wall.ts b/packages/core/src/schema/nodes/wall.ts index ccbec814..ebce121c 100644 --- a/packages/core/src/schema/nodes/wall.ts +++ b/packages/core/src/schema/nodes/wall.ts @@ -12,8 +12,14 @@ export const WallNode = BaseNode.extend({ children: z .array(z.union([ItemNode.shape.id, DoorNode.shape.id, WindowNode.shape.id])) .default([]), + // Legacy single-material wall finish. Read for backward compatibility only. material: MaterialSchema.optional(), + // Legacy single-material wall finish preset. Read for backward compatibility only. materialPreset: z.string().optional(), + interiorMaterial: MaterialSchema.optional(), + interiorMaterialPreset: z.string().optional(), + exteriorMaterial: MaterialSchema.optional(), + exteriorMaterialPreset: z.string().optional(), thickness: z.number().optional(), height: z.number().optional(), curveOffset: z.number().optional(), @@ -37,3 +43,62 @@ export const WallNode = BaseNode.extend({ `, ) export type WallNode = z.infer + +export type WallSurfaceSide = 'interior' | 'exterior' + +export type WallSurfaceMaterialSpec = { + material?: z.infer + materialPreset?: string +} + +type WallSurfaceMaterialSource = { + material?: z.infer + materialPreset?: string + interiorMaterial?: z.infer + interiorMaterialPreset?: string + exteriorMaterial?: z.infer + exteriorMaterialPreset?: string +} + +function getConfiguredWallSurfaceMaterial( + wall: WallSurfaceMaterialSource, + side: WallSurfaceSide, +): WallSurfaceMaterialSpec { + if (side === 'interior') { + return { + material: wall.interiorMaterial, + materialPreset: wall.interiorMaterialPreset, + } + } + + return { + material: wall.exteriorMaterial, + materialPreset: wall.exteriorMaterialPreset, + } +} + +function hasSurfaceMaterial(spec: WallSurfaceMaterialSpec): boolean { + return spec.material !== undefined || typeof spec.materialPreset === 'string' +} + +export function getEffectiveWallSurfaceMaterial( + wall: WallSurfaceMaterialSource, + side: WallSurfaceSide, +): WallSurfaceMaterialSpec { + const configured = getConfiguredWallSurfaceMaterial(wall, side) + if (hasSurfaceMaterial(configured)) { + return configured + } + + return { + material: wall.material, + materialPreset: wall.materialPreset, + } +} + +export function getWallSurfaceMaterialSignature(spec: WallSurfaceMaterialSpec): string { + return JSON.stringify({ + material: spec.material ?? null, + materialPreset: spec.materialPreset ?? null, + }) +} diff --git a/packages/core/src/store/actions/node-actions.ts b/packages/core/src/store/actions/node-actions.ts index f6e64a7d..a855871d 100644 --- a/packages/core/src/store/actions/node-actions.ts +++ b/packages/core/src/store/actions/node-actions.ts @@ -1,4 +1,10 @@ -import type { AnyNode, AnyNodeId, WallNode } from '../../schema' +import { + type AnyNode, + type AnyNodeId, + getEffectiveWallSurfaceMaterial, + getWallSurfaceMaterialSignature, + type WallNode, +} from '../../schema' import type { CollectionId } from '../../schema/collections' import type { SceneState } from '../use-scene' @@ -17,11 +23,7 @@ type WallMergePlan = { let pendingRafId: number | null = null let pendingUpdates: Set = new Set() -function pointsEqual( - a: [number, number], - b: [number, number], - tolerance = 1e-6, -) { +function pointsEqual(a: [number, number], b: [number, number], tolerance = 1e-6) { const dx = a[0] - b[0] const dz = a[1] - b[1] return dx * dx + dz * dz <= tolerance * tolerance @@ -40,32 +42,30 @@ function getWallEndpointAtPoint( return null } -function getWallFreeEndpoint( - wall: Pick, - sharedPoint: [number, number], -) { +function getWallFreeEndpoint(wall: Pick, sharedPoint: [number, number]) { return pointsEqual(wall.start, sharedPoint) ? wall.end : wall.start } function areWallStylesCompatible(a: WallNode, b: WallNode) { + const aInterior = getWallSurfaceMaterialSignature(getEffectiveWallSurfaceMaterial(a, 'interior')) + const bInterior = getWallSurfaceMaterialSignature(getEffectiveWallSurfaceMaterial(b, 'interior')) + const aExterior = getWallSurfaceMaterialSignature(getEffectiveWallSurfaceMaterial(a, 'exterior')) + const bExterior = getWallSurfaceMaterialSignature(getEffectiveWallSurfaceMaterial(b, 'exterior')) + return ( (a.parentId ?? null) === (b.parentId ?? null) && Math.abs((a.curveOffset ?? 0) - (b.curveOffset ?? 0)) <= 1e-6 && Math.abs((a.thickness ?? 0.2) - (b.thickness ?? 0.2)) <= 1e-6 && Math.abs((a.height ?? 2.5) - (b.height ?? 2.5)) <= 1e-6 && - a.materialPreset === b.materialPreset && - JSON.stringify(a.material ?? null) === JSON.stringify(b.material ?? null) && + aInterior === bInterior && + aExterior === bExterior && a.frontSide === b.frontSide && a.backSide === b.backSide && a.visible === b.visible ) } -function areWallsCollinearAcrossPoint( - a: WallNode, - b: WallNode, - sharedPoint: [number, number], -) { +function areWallsCollinearAcrossPoint(a: WallNode, b: WallNode, sharedPoint: [number, number]) { const freeA = getWallFreeEndpoint(a, sharedPoint) const freeB = getWallFreeEndpoint(b, sharedPoint) const ax = freeA[0] - sharedPoint[0] @@ -111,7 +111,10 @@ function buildMergedWallAttachmentUpdates( mergedEnd: [number, number], nodes: Record, ): WallAttachmentUpdate[] { - const mergedLength = Math.max(Math.hypot(mergedEnd[0] - mergedStart[0], mergedEnd[1] - mergedStart[1]), 1e-6) + const mergedLength = Math.max( + Math.hypot(mergedEnd[0] - mergedStart[0], mergedEnd[1] - mergedStart[1]), + 1e-6, + ) const tangentX = (mergedEnd[0] - mergedStart[0]) / mergedLength const tangentZ = (mergedEnd[1] - mergedStart[1]) / mergedLength const updates: WallAttachmentUpdate[] = [] @@ -126,11 +129,16 @@ function buildMergedWallAttachmentUpdates( const sourceWall = child.parentId === secondary.id ? secondary : primary const sourceLength = Math.max(wallLength(sourceWall), 1e-6) const localX = typeof child.position[0] === 'number' ? child.position[0] : 0 - const worldX = sourceWall.start[0] + ((sourceWall.end[0] - sourceWall.start[0]) * localX) / sourceLength - const worldZ = sourceWall.start[1] + ((sourceWall.end[1] - sourceWall.start[1]) * localX) / sourceLength + const worldX = + sourceWall.start[0] + ((sourceWall.end[0] - sourceWall.start[0]) * localX) / sourceLength + const worldZ = + sourceWall.start[1] + ((sourceWall.end[1] - sourceWall.start[1]) * localX) / sourceLength const nextLocalX = Math.max( 0, - Math.min(mergedLength, (worldX - mergedStart[0]) * tangentX + (worldZ - mergedStart[1]) * tangentZ), + Math.min( + mergedLength, + (worldX - mergedStart[0]) * tangentX + (worldZ - mergedStart[1]) * tangentZ, + ), ) updates.push({ diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index 543ac30a..a3ba6cf8 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -100,6 +100,49 @@ function normalizeStairSegmentNode(node: Record) { return parsed.success ? parsed.data : null } +function migrateWallSurfaceMaterials(node: Record) { + const hasInterior = + node.interiorMaterial !== undefined || typeof node.interiorMaterialPreset === 'string' + const hasExterior = + node.exteriorMaterial !== undefined || typeof node.exteriorMaterialPreset === 'string' + const legacyFinish = { + material: node.material, + materialPreset: typeof node.materialPreset === 'string' ? node.materialPreset : undefined, + } + + if (!hasInterior && !hasExterior) { + if (legacyFinish.material === undefined && legacyFinish.materialPreset === undefined) { + return node + } + + return { + ...node, + interiorMaterial: legacyFinish.material, + interiorMaterialPreset: legacyFinish.materialPreset, + exteriorMaterial: legacyFinish.material, + exteriorMaterialPreset: legacyFinish.materialPreset, + } + } + + if (!hasInterior) { + return { + ...node, + interiorMaterial: node.exteriorMaterial, + interiorMaterialPreset: node.exteriorMaterialPreset, + } + } + + if (!hasExterior) { + return { + ...node, + exteriorMaterial: node.interiorMaterial, + exteriorMaterialPreset: node.interiorMaterialPreset, + } + } + + return node +} + function migrateNodes(nodes: Record): Record { const patchedNodes = { ...nodes } for (const [id, node] of Object.entries(patchedNodes)) { @@ -153,6 +196,10 @@ function migrateNodes(nodes: Record): Record { patchedNodes[id] = normalized } } + + if (node.type === 'wall') { + patchedNodes[id] = migrateWallSurfaceMaterials(patchedNodes[id]) + } } return patchedNodes as Record } diff --git a/packages/core/src/systems/wall/wall-system.tsx b/packages/core/src/systems/wall/wall-system.tsx index 062eb1f6..5a40c09a 100644 --- a/packages/core/src/systems/wall/wall-system.tsx +++ b/packages/core/src/systems/wall/wall-system.tsx @@ -7,20 +7,30 @@ import { spatialGridManager } from '../../hooks/spatial-grid/spatial-grid-manage import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync' import type { AnyNode, AnyNodeId, WallNode } from '../../schema' import useScene from '../../store/use-scene' -import { DEFAULT_WALL_HEIGHT, getWallPlanFootprint, getWallThickness } from './wall-footprint' import { getWallCurveFrameAt, getWallSurfacePolygon, isCurvedWall } from './wall-curve' +import { DEFAULT_WALL_HEIGHT, getWallPlanFootprint, getWallThickness } from './wall-footprint' import { calculateLevelMiters, getAdjacentWallIds, getWallMiterBoundaryPoints, type Point2D, - type WallMiterData, pointToKey, + type WallMiterData, } from './wall-mitering' // Reusable CSG evaluator for better performance const csgEvaluator = new Evaluator() const CURVED_WALL_3D_ENDPOINT_INSET = 0.0015 +const WALL_FACE_NORMAL_Y_EPSILON = 0.6 +const WALL_FACE_EDGE_DISTANCE_EPSILON = 0.003 + +type WallBoundaryEdgeTag = 'front' | 'back' | 'base' + +type TaggedWallBoundaryEdge = { + start: THREE.Vector2 + end: THREE.Vector2 + tag: WallBoundaryEdgeTag +} function ensureUv2Attribute(geometry: THREE.BufferGeometry) { const uv = geometry.getAttribute('uv') @@ -78,6 +88,207 @@ function insetCurvedWallBoundaryPointsFor3D( return next } +function addTaggedWallBoundaryEdge( + edges: TaggedWallBoundaryEdge[], + points: { x: number; z: number }[], + startIndex: number, + endIndex: number, + tag: WallBoundaryEdgeTag, +) { + const start = points[startIndex] + const end = points[endIndex] + if (!(start && end)) return + if (Math.hypot(end.x - start.x, end.z - start.z) < 1e-6) return + + edges.push({ + start: new THREE.Vector2(start.x, start.z), + end: new THREE.Vector2(end.x, end.z), + tag, + }) +} + +function buildTaggedWallBoundaryEdges( + wall: WallNode, + localPoints: { x: number; z: number }[], + miterData: WallMiterData, +): TaggedWallBoundaryEdge[] { + if (localPoints.length < 2) return [] + + const edges: TaggedWallBoundaryEdge[] = [] + + if (isCurvedWall(wall)) { + const sidePointCount = Math.floor(localPoints.length / 2) + if (sidePointCount < 2) return edges + + for (let index = 0; index < sidePointCount - 1; index += 1) { + addTaggedWallBoundaryEdge(edges, localPoints, index, index + 1, 'back') + } + + addTaggedWallBoundaryEdge(edges, localPoints, sidePointCount - 1, sidePointCount, 'base') + + for (let index = sidePointCount; index < localPoints.length - 1; index += 1) { + addTaggedWallBoundaryEdge(edges, localPoints, index, index + 1, 'front') + } + + addTaggedWallBoundaryEdge(edges, localPoints, localPoints.length - 1, 0, 'base') + return edges + } + + const startKey = pointToKey({ x: wall.start[0], y: wall.start[1] }) + const startJunction = miterData.junctionData.get(startKey)?.get(wall.id) + const startLeftIndex = startJunction ? localPoints.length - 2 : localPoints.length - 1 + const endLeftIndex = startJunction ? localPoints.length - 3 : localPoints.length - 2 + + addTaggedWallBoundaryEdge(edges, localPoints, 0, 1, 'back') + + for (let index = 1; index < endLeftIndex; index += 1) { + addTaggedWallBoundaryEdge(edges, localPoints, index, index + 1, 'base') + } + + addTaggedWallBoundaryEdge(edges, localPoints, endLeftIndex, startLeftIndex, 'front') + + for (let index = startLeftIndex; index < localPoints.length - 1; index += 1) { + addTaggedWallBoundaryEdge(edges, localPoints, index, index + 1, 'base') + } + + addTaggedWallBoundaryEdge(edges, localPoints, localPoints.length - 1, 0, 'base') + + return edges +} + +function distanceToWallBoundaryEdge(point: THREE.Vector2, edge: TaggedWallBoundaryEdge): number { + const edgeDx = edge.end.x - edge.start.x + const edgeDz = edge.end.y - edge.start.y + const pointDx = point.x - edge.start.x + const pointDz = point.y - edge.start.y + const edgeLengthSq = edgeDx * edgeDx + edgeDz * edgeDz + + if (edgeLengthSq < 1e-12) { + return point.distanceTo(edge.start) + } + + const t = THREE.MathUtils.clamp((pointDx * edgeDx + pointDz * edgeDz) / edgeLengthSq, 0, 1) + const closestX = edge.start.x + edgeDx * t + const closestZ = edge.start.y + edgeDz * t + + return Math.hypot(point.x - closestX, point.y - closestZ) +} + +function getWallFaceMaterialIndex( + wall: Pick, + face: 'front' | 'back', +): 0 | 1 | 2 { + const semantic = face === 'front' ? wall.frontSide : wall.backSide + const fallback = face === 'front' ? 1 : 2 + + if (semantic === 'interior') return 1 + if (semantic === 'exterior') return 2 + return fallback +} + +function assignWallMaterialGroups( + geometry: THREE.BufferGeometry, + wall: WallNode, + boundaryEdges: TaggedWallBoundaryEdge[], +) { + const position = geometry.getAttribute('position') + if (!position) return + + const index = geometry.getIndex() + const triangleCount = index ? Math.floor(index.count / 3) : Math.floor(position.count / 3) + if (triangleCount === 0) { + geometry.clearGroups() + return + } + + const triangleMaterials = new Array(triangleCount).fill(0) + const a = new THREE.Vector3() + const b = new THREE.Vector3() + const c = new THREE.Vector3() + const ab = new THREE.Vector3() + const ac = new THREE.Vector3() + const normal = new THREE.Vector3() + const centroid = new THREE.Vector3() + const projectedCentroid = new THREE.Vector2() + const maxBoundaryDistance = Math.max( + getWallThickness(wall) * 0.02, + WALL_FACE_EDGE_DISTANCE_EPSILON, + ) + + for (let triangleIndex = 0; triangleIndex < triangleCount; triangleIndex += 1) { + const baseIndex = triangleIndex * 3 + const ia = index ? index.getX(baseIndex) : baseIndex + const ib = index ? index.getX(baseIndex + 1) : baseIndex + 1 + const ic = index ? index.getX(baseIndex + 2) : baseIndex + 2 + + a.fromBufferAttribute(position, ia) + b.fromBufferAttribute(position, ib) + c.fromBufferAttribute(position, ic) + + ab.subVectors(b, a) + ac.subVectors(c, a) + normal.crossVectors(ab, ac) + + if (normal.lengthSq() < 1e-12) { + triangleMaterials[triangleIndex] = 0 + continue + } + + normal.normalize() + + if (Math.abs(normal.y) >= WALL_FACE_NORMAL_Y_EPSILON) { + triangleMaterials[triangleIndex] = 0 + continue + } + + centroid + .copy(a) + .add(b) + .add(c) + .multiplyScalar(1 / 3) + projectedCentroid.set(centroid.x, centroid.z) + + let nearestTag: WallBoundaryEdgeTag | null = null + let nearestDistance = Number.POSITIVE_INFINITY + + for (const edge of boundaryEdges) { + const distance = distanceToWallBoundaryEdge(projectedCentroid, edge) + if (distance < nearestDistance) { + nearestDistance = distance + nearestTag = edge.tag + } + } + + if (!nearestTag || nearestDistance > maxBoundaryDistance) { + triangleMaterials[triangleIndex] = 0 + continue + } + + if (nearestTag === 'base') { + triangleMaterials[triangleIndex] = 0 + continue + } + + triangleMaterials[triangleIndex] = getWallFaceMaterialIndex(wall, nearestTag) + } + + geometry.clearGroups() + + let currentMaterial = triangleMaterials[0] ?? 0 + let groupStart = 0 + + for (let triangleIndex = 1; triangleIndex < triangleCount; triangleIndex += 1) { + const materialIndex = triangleMaterials[triangleIndex] ?? 0 + if (materialIndex === currentMaterial) continue + + geometry.addGroup(groupStart * 3, (triangleIndex - groupStart) * 3, currentMaterial) + groupStart = triangleIndex + currentMaterial = materialIndex + } + + geometry.addGroup(groupStart * 3, (triangleCount - groupStart) * 3, currentMaterial) +} + // ============================================================================ // WALL SYSTEM // ============================================================================ @@ -252,6 +463,7 @@ export function generateExtrudedWall( // Convert polygon to local coordinates const localPoints = polyPoints.map(worldToLocal) + const boundaryEdges = buildTaggedWallBoundaryEdges(wallNode, localPoints, miterData) // Build THREE.js shape // Shape uses (x, y) where we map: shape.x = local.x, shape.y = -local.z @@ -272,6 +484,7 @@ export function generateExtrudedWall( // Rotate so extrusion direction (Z) becomes height direction (Y) geometry.rotateX(-Math.PI / 2) geometry.computeVertexNormals() + assignWallMaterialGroups(geometry, wallNode, boundaryEdges) ensureUv2Attribute(geometry) // Apply CSG subtraction for cutouts (doors/windows) @@ -307,6 +520,7 @@ export function generateExtrudedWall( const resultGeometry = resultBrush.geometry resultGeometry.computeVertexNormals() + assignWallMaterialGroups(resultGeometry, wallNode, boundaryEdges) ensureUv2Attribute(resultGeometry) return resultGeometry diff --git a/packages/editor/src/components/editor/selection-manager.tsx b/packages/editor/src/components/editor/selection-manager.tsx index 3d9ef80a..e9cfcea4 100755 --- a/packages/editor/src/components/editor/selection-manager.tsx +++ b/packages/editor/src/components/editor/selection-manager.tsx @@ -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 } diff --git a/packages/editor/src/components/ui/controls/material-picker.tsx b/packages/editor/src/components/ui/controls/material-picker.tsx index ec74cbf1..0974ea50 100755 --- a/packages/editor/src/components/ui/controls/material-picker.tsx +++ b/packages/editor/src/components/ui/controls/material-picker.tsx @@ -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(!!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({ -
- - -
+ {!hideSideControl && ( +
+ + +
+ )} )} diff --git a/packages/editor/src/components/ui/panels/wall-panel.tsx b/packages/editor/src/components/ui/panels/wall-panel.tsx index 066a9059..96cd697b 100755 --- a/packages/editor/src/components/ui/panels/wall-panel.tsx +++ b/packages/editor/src/components/ui/panels/wall-panel.tsx @@ -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 { + 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() { - + {!materialTargetSide ? ( +
+ Click the wall face you want to edit. Materials now apply to one side at a time. +
+ ) : null} + {materialTargetSide ? ( + + ) : null}
- - } label="Move" onClick={handleMove} /> - {!hasWallChildrenBlockingCurve && ( - } - label="Curve" - onClick={handleCurve} - /> - )} - + + + } label="Move" onClick={handleMove} /> + {!hasWallChildrenBlockingCurve && ( + } + label="Curve" + onClick={handleCurve} + /> + )} + + ) } diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index 6fa9bb4b..c5e5e833 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -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()( 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: {}, diff --git a/packages/viewer/src/components/renderers/wall/wall-renderer.tsx b/packages/viewer/src/components/renderers/wall/wall-renderer.tsx index ef6f70cf..9ff38006 100644 --- a/packages/viewer/src/components/renderers/wall/wall-renderer.tsx +++ b/packages/viewer/src/components/renderers/wall/wall-renderer.tsx @@ -1,12 +1,8 @@ import { useRegistry, useScene, type WallNode } from '@pascal-app/core' -import { useLayoutEffect, useMemo, useRef } from 'react' +import { useLayoutEffect, useRef } from 'react' import type { Mesh } from 'three' import { useNodeEvents } from '../../../hooks/use-node-events' -import { - createMaterial, - createMaterialFromPresetRef, - DEFAULT_WALL_MATERIAL, -} from '../../../lib/materials' +import { getVisibleWallMaterials } from '../../../systems/wall/wall-materials' import { NodeRenderer } from '../node-renderer' export const WallRenderer = ({ node }: { node: WallNode }) => { @@ -19,20 +15,7 @@ export const WallRenderer = ({ node }: { node: WallNode }) => { }, [node.id]) const handlers = useNodeEvents(node, 'wall') - - const material = useMemo(() => { - const presetMaterial = createMaterialFromPresetRef(node.materialPreset) - if (presetMaterial) return presetMaterial - const mat = node.material - if (!mat) return DEFAULT_WALL_MATERIAL - return createMaterial(mat) - }, [ - node.material, - node.material?.preset, - node.material?.properties, - node.material?.texture, - node.materialPreset, - ]) + const material = getVisibleWallMaterials(node) return ( diff --git a/packages/viewer/src/hooks/use-node-events.ts b/packages/viewer/src/hooks/use-node-events.ts index d247ece1..cc3eae2f 100644 --- a/packages/viewer/src/hooks/use-node-events.ts +++ b/packages/viewer/src/hooks/use-node-events.ts @@ -33,6 +33,7 @@ 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 = { @@ -55,6 +56,23 @@ 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}` @@ -64,6 +82,8 @@ export function useNodeEvents(node: NodeConfig[T]['node'], t position: [e.point.x, e.point.y, e.point.z], 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), stopPropagation: () => e.stopPropagation(), nativeEvent: e, } as NodeConfig[T]['event'] diff --git a/packages/viewer/src/systems/wall/wall-cutout.tsx b/packages/viewer/src/systems/wall/wall-cutout.tsx index b80613d1..cec20d01 100644 --- a/packages/viewer/src/systems/wall/wall-cutout.tsx +++ b/packages/viewer/src/systems/wall/wall-cutout.tsx @@ -1,210 +1,14 @@ -import { - type AnyNodeId, - baseMaterial, - emitter, - getMaterialPresetByRef, - sceneRegistry, - useScene, - type WallNode, -} from '@pascal-app/core' +import { type AnyNodeId, emitter, sceneRegistry, useScene, type WallNode } from '@pascal-app/core' import { useFrame } from '@react-three/fiber' import { useEffect, useRef } from 'react' import type { Material } from 'three' -import { Color } from 'three' -import { Fn, float, fract, length, mix, positionLocal, smoothstep, step, vec2 } from 'three/tsl' -import { type Mesh, MeshStandardNodeMaterial, Vector3 } from 'three/webgpu' +import { type Mesh, Vector3 } from 'three/webgpu' import useViewer from '../../store/use-viewer' -import { createMaterial, createMaterialFromPresetRef } from '../../lib/materials' +import { getMaterialsForWall } from './wall-materials' const tmpVec = new Vector3() const u = new Vector3() const v = new Vector3() -const DEFAULT_WALL_COLOR = '#f2f0ed' -const WALL_HIGHLIGHT_PROFILES = { - delete: { - color: new Color('#dc2626'), - blend: 0.76, - emissiveBlend: 0.92, - emissiveIntensity: 0.46, - }, - selection: { - color: new Color('#818cf8'), - blend: 0.32, - emissiveBlend: 0.7, - emissiveIntensity: 0.42, - }, -} as const - -type WallHighlightKind = keyof typeof WALL_HIGHLIGHT_PROFILES - -const dotPattern = Fn(() => { - const scale = float(0.1) - const dotSize = float(0.3) - - const uv = vec2(positionLocal.x, positionLocal.y).div(scale) - const gridUV = fract(uv) - - const dist = length(gridUV.sub(0.5)) - - const dots = step(dist, dotSize.mul(0.5)) - - const fadeHeight = float(2.5) - const yFade = float(1).sub(smoothstep(float(0), fadeHeight, positionLocal.y)) - - return dots.mul(yFade) -}) - -interface WallMaterials { - visible: Material - invisible: MeshStandardNodeMaterial - deleteVisible: Material - deleteInvisible: MeshStandardNodeMaterial - highlightedVisible: Material - highlightedInvisible: MeshStandardNodeMaterial - materialHash: string -} - -const wallMaterialCache = new Map() - -const presetColors = { - white: '#ffffff', - brick: '#8b4513', - concrete: '#808080', - wood: '#deb887', - glass: '#87ceeb', - metal: '#c0c0c0', - plaster: '#f5f5dc', - tile: '#dcdcdc', - marble: '#f5f5f5', -} as const - -function getMaterialHash(wallNode: WallNode): string { - if (wallNode.materialPreset) return `preset-ref-${wallNode.materialPreset}` - if (!wallNode.material) return 'none' - const mat = wallNode.material - if (mat.preset && mat.preset !== 'custom') { - return `preset-${mat.preset}` - } - if (mat.properties) { - return `props-${mat.properties.color}-${mat.properties.roughness}-${mat.properties.metalness}` - } - return 'default' -} - -function getPresetColor(preset: string): string { - return presetColors[preset as keyof typeof presetColors] ?? '#ffffff' -} - -function getHighlightedColor(color: Color, kind: WallHighlightKind): Color { - const profile = WALL_HIGHLIGHT_PROFILES[kind] - return color.clone().lerp(profile.color, profile.blend) -} - -function createHighlightedWallMaterial( - material: Material, - kind: WallHighlightKind, -): Material { - const highlightedMaterial = material.clone() as Material & { - color?: Color - emissive?: Color - emissiveIntensity?: number - needsUpdate?: boolean - } - const profile = WALL_HIGHLIGHT_PROFILES[kind] - - if ('color' in highlightedMaterial && highlightedMaterial.color) { - highlightedMaterial.color = getHighlightedColor(highlightedMaterial.color, kind) - } - if ('emissive' in highlightedMaterial && highlightedMaterial.emissive) { - highlightedMaterial.emissive = highlightedMaterial.emissive - .clone() - .lerp(profile.color, profile.emissiveBlend) - } - if ('emissiveIntensity' in highlightedMaterial) { - highlightedMaterial.emissiveIntensity = Math.max( - highlightedMaterial.emissiveIntensity ?? 0, - profile.emissiveIntensity, - ) - } - highlightedMaterial.needsUpdate = true - - return highlightedMaterial -} - -function createBaseVisibleWallMaterial(wallNode: WallNode): Material { - if (wallNode.materialPreset) { - return createMaterialFromPresetRef(wallNode.materialPreset) ?? baseMaterial - } - - if (wallNode.material) { - return createMaterial(wallNode.material) - } - - return baseMaterial -} - -function getMaterialsForWall(wallNode: WallNode): WallMaterials { - const cacheKey = wallNode.id - const materialHash = getMaterialHash(wallNode) - - const existing = wallMaterialCache.get(cacheKey) - if (existing && existing.materialHash === materialHash) { - return existing - } - - if (existing) { - existing.visible.dispose() - existing.invisible.dispose() - existing.deleteVisible.dispose() - existing.deleteInvisible.dispose() - existing.highlightedVisible.dispose() - existing.highlightedInvisible.dispose() - } - - let userColor = DEFAULT_WALL_COLOR - const preset = getMaterialPresetByRef(wallNode.materialPreset) - if (preset?.mapProperties?.color) { - userColor = preset.mapProperties.color - } else if (wallNode.material?.properties?.color) { - userColor = wallNode.material.properties.color - } else if (wallNode.material?.preset && wallNode.material.preset !== 'custom') { - userColor = getPresetColor(wallNode.material.preset) - } - - const visibleMat = createBaseVisibleWallMaterial(wallNode) - - const invisibleMat = new MeshStandardNodeMaterial({ - transparent: true, - opacityNode: mix(float(0.0), float(0.24), dotPattern()), - color: userColor, - depthWrite: false, - emissive: userColor, - }) - - const highlightedVisible = createHighlightedWallMaterial(visibleMat, 'selection') - const highlightedInvisible = createHighlightedWallMaterial( - invisibleMat, - 'selection', - ) as MeshStandardNodeMaterial - const deleteVisible = createHighlightedWallMaterial(visibleMat, 'delete') - const deleteInvisible = createHighlightedWallMaterial(invisibleMat, 'delete') as MeshStandardNodeMaterial - - const result: WallMaterials = { - visible: visibleMat, - invisible: invisibleMat, - deleteVisible, - deleteInvisible, - highlightedVisible, - highlightedInvisible, - materialHash, - } - wallMaterialCache.set(cacheKey, result) - return result -} - -function getVisibleWallMaterial(wallNode: WallNode): Material { - return createBaseVisibleWallMaterial(wallNode) -} function getWallHideState( wallNode: WallNode, @@ -301,7 +105,7 @@ export const WallCutout = () => { ? materials.deleteVisible : isSelectionHighlighted ? materials.highlightedVisible - : getVisibleWallMaterial(wallNode) + : materials.visible } }) lastWallMode.current = wallMode @@ -311,7 +115,7 @@ export const WallCutout = () => { }) useEffect(() => { - const snapshot = new Map() + const snapshot = new Map() const restoreForCapture = () => { sceneRegistry.byType.wall.forEach((wallId) => { @@ -320,10 +124,10 @@ export const WallCutout = () => { const wallNode = useScene.getState().nodes[wallId as AnyNodeId] as WallNode | undefined if (!wallNode || wallNode.type !== 'wall') return const mats = getMaterialsForWall(wallNode) - const current = wallMesh.material as Material + const current = wallMesh.material as Material | Material[] snapshot.set(wallMesh, current) if (current === mats.highlightedVisible || current === mats.deleteVisible) { - wallMesh.material = getVisibleWallMaterial(wallNode) + wallMesh.material = mats.visible } else if (current === mats.highlightedInvisible || current === mats.deleteInvisible) { wallMesh.material = mats.invisible } diff --git a/packages/viewer/src/systems/wall/wall-materials.ts b/packages/viewer/src/systems/wall/wall-materials.ts new file mode 100644 index 00000000..5edccfea --- /dev/null +++ b/packages/viewer/src/systems/wall/wall-materials.ts @@ -0,0 +1,226 @@ +import { + baseMaterial, + getEffectiveWallSurfaceMaterial, + getMaterialPresetByRef, + getWallSurfaceMaterialSignature, + resolveMaterial, + type WallNode, + type WallSurfaceMaterialSpec, +} from '@pascal-app/core' +import { Color, type Material } from 'three' +import { Fn, float, fract, length, mix, positionLocal, smoothstep, step, vec2 } from 'three/tsl' +import { MeshStandardNodeMaterial } from 'three/webgpu' +import { createMaterial, createMaterialFromPresetRef } from '../../lib/materials' + +const DEFAULT_WALL_COLOR = '#f2f0ed' + +const WALL_HIGHLIGHT_PROFILES = { + delete: { + color: new Color('#dc2626'), + blend: 0.76, + emissiveBlend: 0.92, + emissiveIntensity: 0.46, + }, + selection: { + color: new Color('#818cf8'), + blend: 0.32, + emissiveBlend: 0.7, + emissiveIntensity: 0.42, + }, +} as const + +type WallHighlightKind = keyof typeof WALL_HIGHLIGHT_PROFILES + +export type WallMaterialArray = [Material, Material, Material] + +export interface WallMaterials { + visible: WallMaterialArray + invisible: WallMaterialArray + deleteVisible: WallMaterialArray + deleteInvisible: WallMaterialArray + highlightedVisible: WallMaterialArray + highlightedInvisible: WallMaterialArray + materialHash: string +} + +const wallMaterialCache = new Map() + +const dotPattern = Fn(() => { + const scale = float(0.1) + const dotSize = float(0.3) + + const uv = vec2(positionLocal.x, positionLocal.y).div(scale) + const gridUV = fract(uv) + + const dist = length(gridUV.sub(0.5)) + + const dots = step(dist, dotSize.mul(0.5)) + + const fadeHeight = float(2.5) + const yFade = float(1).sub(smoothstep(float(0), fadeHeight, positionLocal.y)) + + return dots.mul(yFade) +}) + +function getSurfaceVisibleMaterial(spec: WallSurfaceMaterialSpec): Material { + if (spec.materialPreset) { + return createMaterialFromPresetRef(spec.materialPreset) ?? baseMaterial + } + + if (spec.material) { + return createMaterial(spec.material) + } + + return baseMaterial +} + +function getSurfaceColor(spec: WallSurfaceMaterialSpec, fallback = DEFAULT_WALL_COLOR): string { + const preset = getMaterialPresetByRef(spec.materialPreset) + if (preset?.mapProperties?.color) { + return preset.mapProperties.color + } + + if (spec.material) { + return resolveMaterial(spec.material).color + } + + return fallback +} + +function getHighlightedColor(color: Color, kind: WallHighlightKind): Color { + const profile = WALL_HIGHLIGHT_PROFILES[kind] + return color.clone().lerp(profile.color, profile.blend) +} + +function createHighlightedWallMaterial(material: Material, kind: WallHighlightKind): Material { + const highlightedMaterial = material.clone() as Material & { + color?: Color + emissive?: Color + emissiveIntensity?: number + needsUpdate?: boolean + } + const profile = WALL_HIGHLIGHT_PROFILES[kind] + + if ('color' in highlightedMaterial && highlightedMaterial.color) { + highlightedMaterial.color = getHighlightedColor(highlightedMaterial.color, kind) + } + if ('emissive' in highlightedMaterial && highlightedMaterial.emissive) { + highlightedMaterial.emissive = highlightedMaterial.emissive + .clone() + .lerp(profile.color, profile.emissiveBlend) + } + if ('emissiveIntensity' in highlightedMaterial) { + highlightedMaterial.emissiveIntensity = Math.max( + highlightedMaterial.emissiveIntensity ?? 0, + profile.emissiveIntensity, + ) + } + highlightedMaterial.needsUpdate = true + + return highlightedMaterial +} + +function createInvisibleWallMaterial(color: string): MeshStandardNodeMaterial { + return new MeshStandardNodeMaterial({ + transparent: true, + opacityNode: mix(float(0.0), float(0.24), dotPattern()), + color, + depthWrite: false, + emissive: color, + }) +} + +function mapWallMaterialArray( + materials: WallMaterialArray, + iteratee: (material: Material, index: number) => Material, +): WallMaterialArray { + return materials.map(iteratee) as WallMaterialArray +} + +function disposeOwnedMaterials(materials: WallMaterialArray[]) { + const owned = new Set() + materials.forEach((entry) => { + entry.forEach((material) => { + owned.add(material) + }) + }) + owned.forEach((material) => { + material.dispose() + }) +} + +export function getWallMaterialHash(wallNode: WallNode): string { + return JSON.stringify({ + interior: getWallSurfaceMaterialSignature( + getEffectiveWallSurfaceMaterial(wallNode, 'interior'), + ), + exterior: getWallSurfaceMaterialSignature( + getEffectiveWallSurfaceMaterial(wallNode, 'exterior'), + ), + }) +} + +export function getMaterialsForWall(wallNode: WallNode): WallMaterials { + const cacheKey = wallNode.id + const materialHash = getWallMaterialHash(wallNode) + + const existing = wallMaterialCache.get(cacheKey) + if (existing && existing.materialHash === materialHash) { + return existing + } + + if (existing) { + disposeOwnedMaterials([ + existing.invisible, + existing.deleteVisible, + existing.deleteInvisible, + existing.highlightedVisible, + existing.highlightedInvisible, + ]) + } + + const interiorSpec = getEffectiveWallSurfaceMaterial(wallNode, 'interior') + const exteriorSpec = getEffectiveWallSurfaceMaterial(wallNode, 'exterior') + + const visible: WallMaterialArray = [ + baseMaterial, + getSurfaceVisibleMaterial(interiorSpec), + getSurfaceVisibleMaterial(exteriorSpec), + ] + + const invisible: WallMaterialArray = [ + createInvisibleWallMaterial(DEFAULT_WALL_COLOR), + createInvisibleWallMaterial(getSurfaceColor(interiorSpec, DEFAULT_WALL_COLOR)), + createInvisibleWallMaterial(getSurfaceColor(exteriorSpec, DEFAULT_WALL_COLOR)), + ] + + const highlightedVisible = mapWallMaterialArray(visible, (material) => + createHighlightedWallMaterial(material, 'selection'), + ) + const highlightedInvisible = mapWallMaterialArray(invisible, (material) => + createHighlightedWallMaterial(material, 'selection'), + ) + const deleteVisible = mapWallMaterialArray(visible, (material) => + createHighlightedWallMaterial(material, 'delete'), + ) + const deleteInvisible = mapWallMaterialArray(invisible, (material) => + createHighlightedWallMaterial(material, 'delete'), + ) + + const result: WallMaterials = { + visible, + invisible, + deleteVisible, + deleteInvisible, + highlightedVisible, + highlightedInvisible, + materialHash, + } + + wallMaterialCache.set(cacheKey, result) + return result +} + +export function getVisibleWallMaterials(wallNode: WallNode): WallMaterialArray { + return getMaterialsForWall(wallNode).visible +} From df9c4e499f7db18fb03238e166690a9925209cec Mon Sep 17 00:00:00 2001 From: sudhir Date: Mon, 20 Apr 2026 11:25:36 +0530 Subject: [PATCH 02/31] Restrict ground occluder to recessed slabs --- .../src/components/renderers/site/site-renderer.tsx | 11 ++++++++++- .../viewer/src/components/viewer/ground-occluder.tsx | 10 +++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/viewer/src/components/renderers/site/site-renderer.tsx b/packages/viewer/src/components/renderers/site/site-renderer.tsx index e52b6c4c..c16e056a 100644 --- a/packages/viewer/src/components/renderers/site/site-renderer.tsx +++ b/packages/viewer/src/components/renderers/site/site-renderer.tsx @@ -55,7 +55,16 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => { }) const next = nodeList - .filter((n): n is SlabNode => n.type === 'slab' && n.visible && n.polygon.length >= 3) + .filter( + (n): n is SlabNode => + n.type === 'slab' && + n.visible && + n.polygon.length >= 3 && + // Only recessed slabs should punch through the site ground. + // Positive slabs are real floor geometry and should not create a + // ghost footprint in the background ground fill. + (n.elevation ?? 0.05) < 0, + ) .filter((n) => { if (!Number.isFinite(lowestLevelIndex)) return true const parentLevel = n.parentId ? levelIndexById.get(n.parentId as string) : undefined diff --git a/packages/viewer/src/components/viewer/ground-occluder.tsx b/packages/viewer/src/components/viewer/ground-occluder.tsx index c04e9d81..54be8b51 100644 --- a/packages/viewer/src/components/viewer/ground-occluder.tsx +++ b/packages/viewer/src/components/viewer/ground-occluder.tsx @@ -38,7 +38,15 @@ export const GroundOccluder = () => { const polygons: [number, number][][] = [] Object.values(nodes).forEach((node) => { - if (!(node.type === 'slab' && node.visible && node.polygon.length >= 3)) { + if ( + !( + node.type === 'slab' && + node.visible && + node.polygon.length >= 3 && + // Only recessed slabs should punch through the ground plane. + (node.elevation ?? 0.05) < 0 + ) + ) { return } From 4f9b822e5412d14039fc885c5b9c3f1f25abecdb Mon Sep 17 00:00:00 2001 From: sudhir Date: Mon, 20 Apr 2026 12:01:23 +0530 Subject: [PATCH 03/31] Add click-targeted stair materials and UV mapping --- packages/core/src/schema/index.ts | 2 + packages/core/src/schema/nodes/stair.ts | 86 +++++++- packages/core/src/store/use-scene.ts | 83 +++++++- .../core/src/systems/stair/stair-system.tsx | 123 ++++++++++- .../components/editor/selection-manager.tsx | 78 ++++++- .../src/components/ui/panels/stair-panel.tsx | 72 +++++-- packages/editor/src/store/use-editor.tsx | 11 + .../stair-segment/stair-segment-renderer.tsx | 41 ++-- .../renderers/stair/stair-renderer.tsx | 201 +++++++++++++++--- .../src/systems/stair/stair-materials.ts | 59 +++++ 10 files changed, 698 insertions(+), 58 deletions(-) create mode 100644 packages/viewer/src/systems/stair/stair-materials.ts diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index c1df74ef..5599bc46 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -50,12 +50,14 @@ export { ScanNode } from './nodes/scan' export { SiteNode } from './nodes/site' export { SlabNode } from './nodes/slab' export { + getEffectiveStairSurfaceMaterial, StairNode, StairRailingMode, StairSlabOpeningMode, StairTopLandingMode, StairType, } from './nodes/stair' +export type { StairSurfaceMaterialRole, StairSurfaceMaterialSpec } from './nodes/stair' export { AttachmentSide, StairSegmentNode, StairSegmentType } from './nodes/stair-segment' export { SurfaceHoleMetadata } from './nodes/surface-hole-metadata' export type { WallSurfaceMaterialSpec, WallSurfaceSide } from './nodes/wall' diff --git a/packages/core/src/schema/nodes/stair.ts b/packages/core/src/schema/nodes/stair.ts index f6390b34..81969828 100644 --- a/packages/core/src/schema/nodes/stair.ts +++ b/packages/core/src/schema/nodes/stair.ts @@ -1,7 +1,7 @@ import dedent from 'dedent' import { z } from 'zod' import { BaseNode, nodeType, objectId } from '../base' -import { MaterialSchema } from '../material' +import { type MaterialSchema, MaterialSchema as MaterialSchemaSchema } from '../material' import { StairSegmentNode } from './stair-segment' export const StairRailingMode = z.enum(['none', 'left', 'right', 'both']) @@ -13,12 +13,23 @@ export type StairRailingMode = z.infer export type StairType = z.infer export type StairTopLandingMode = z.infer export type StairSlabOpeningMode = z.infer +export type StairSurfaceMaterialRole = 'railing' | 'tread' | 'side' +export type StairSurfaceMaterialSpec = { + material?: MaterialSchema + materialPreset?: string +} export const StairNode = BaseNode.extend({ id: objectId('stair'), type: nodeType('stair'), - material: MaterialSchema.optional(), + material: MaterialSchemaSchema.optional(), materialPreset: z.string().optional(), + railingMaterial: MaterialSchemaSchema.optional(), + railingMaterialPreset: z.string().optional(), + treadMaterial: MaterialSchemaSchema.optional(), + treadMaterialPreset: z.string().optional(), + sideMaterial: MaterialSchemaSchema.optional(), + sideMaterialPreset: z.string().optional(), position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), // Rotation around Y axis in radians rotation: z.number().default(0), @@ -71,3 +82,74 @@ export const StairNode = BaseNode.extend({ ) export type StairNode = z.infer + +function getLegacyStairSurfaceMaterial(node: StairNode): StairSurfaceMaterialSpec { + return { + material: node.material, + materialPreset: node.materialPreset, + } +} + +export function getEffectiveStairSurfaceMaterial( + node: StairNode, + role: StairSurfaceMaterialRole, +): StairSurfaceMaterialSpec { + if (role === 'railing') { + if (node.railingMaterial !== undefined || typeof node.railingMaterialPreset === 'string') { + return { + material: node.railingMaterial, + materialPreset: + typeof node.railingMaterialPreset === 'string' ? node.railingMaterialPreset : undefined, + } + } + } + + if (role === 'tread') { + if (node.treadMaterial !== undefined || typeof node.treadMaterialPreset === 'string') { + return { + material: node.treadMaterial, + materialPreset: + typeof node.treadMaterialPreset === 'string' ? node.treadMaterialPreset : undefined, + } + } + } + + if (role === 'side') { + if (node.sideMaterial !== undefined || typeof node.sideMaterialPreset === 'string') { + return { + material: node.sideMaterial, + materialPreset: + typeof node.sideMaterialPreset === 'string' ? node.sideMaterialPreset : undefined, + } + } + } + + const treadFallback = { + material: node.treadMaterial, + materialPreset: typeof node.treadMaterialPreset === 'string' ? node.treadMaterialPreset : undefined, + } + const sideFallback = { + material: node.sideMaterial, + materialPreset: typeof node.sideMaterialPreset === 'string' ? node.sideMaterialPreset : undefined, + } + + if (role === 'tread' && (sideFallback.material !== undefined || sideFallback.materialPreset !== undefined)) { + return sideFallback + } + + if (role === 'side' && (treadFallback.material !== undefined || treadFallback.materialPreset !== undefined)) { + return treadFallback + } + + if (role === 'railing') { + if (treadFallback.material !== undefined || treadFallback.materialPreset !== undefined) { + return treadFallback + } + + if (sideFallback.material !== undefined || sideFallback.materialPreset !== undefined) { + return sideFallback + } + } + + return getLegacyStairSurfaceMaterial(node) +} diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index a3ba6cf8..1ce4974c 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -143,6 +143,87 @@ function migrateWallSurfaceMaterials(node: Record) { return node } +function migrateStairSurfaceMaterials(node: Record) { + const hasRailing = + node.railingMaterial !== undefined || typeof node.railingMaterialPreset === 'string' + const hasTread = node.treadMaterial !== undefined || typeof node.treadMaterialPreset === 'string' + const hasSide = node.sideMaterial !== undefined || typeof node.sideMaterialPreset === 'string' + const legacyFinish = { + material: node.material, + materialPreset: typeof node.materialPreset === 'string' ? node.materialPreset : undefined, + } + + const resolveBodyFallback = () => { + if (node.treadMaterial !== undefined || typeof node.treadMaterialPreset === 'string') { + return { + material: node.treadMaterial, + materialPreset: typeof node.treadMaterialPreset === 'string' ? node.treadMaterialPreset : undefined, + } + } + + if (node.sideMaterial !== undefined || typeof node.sideMaterialPreset === 'string') { + return { + material: node.sideMaterial, + materialPreset: typeof node.sideMaterialPreset === 'string' ? node.sideMaterialPreset : undefined, + } + } + + return legacyFinish + } + + if (!hasRailing && !hasTread && !hasSide) { + if (legacyFinish.material === undefined && legacyFinish.materialPreset === undefined) { + return node + } + + return { + ...node, + railingMaterial: legacyFinish.material, + railingMaterialPreset: legacyFinish.materialPreset, + treadMaterial: legacyFinish.material, + treadMaterialPreset: legacyFinish.materialPreset, + sideMaterial: legacyFinish.material, + sideMaterialPreset: legacyFinish.materialPreset, + } + } + + const next = { ...node } + + if (!hasTread) { + const fallback = + node.sideMaterial !== undefined || typeof node.sideMaterialPreset === 'string' + ? { + material: node.sideMaterial, + materialPreset: + typeof node.sideMaterialPreset === 'string' ? node.sideMaterialPreset : undefined, + } + : resolveBodyFallback() + next.treadMaterial = fallback.material + next.treadMaterialPreset = fallback.materialPreset + } + + if (!hasSide) { + const fallback = + node.treadMaterial !== undefined || typeof node.treadMaterialPreset === 'string' + ? { + material: node.treadMaterial, + materialPreset: + typeof node.treadMaterialPreset === 'string' ? node.treadMaterialPreset : undefined, + } + : resolveBodyFallback() + next.sideMaterial = fallback.material + next.sideMaterialPreset = fallback.materialPreset + } + + if (!hasRailing) { + const fallback = resolveBodyFallback() + next.railingMaterial = fallback.material + next.railingMaterialPreset = fallback.materialPreset + } + + return next +} + function migrateNodes(nodes: Record): Record { const patchedNodes = { ...nodes } for (const [id, node] of Object.entries(patchedNodes)) { @@ -184,7 +265,7 @@ function migrateNodes(nodes: Record): Record { } if (node.type === 'stair') { - const normalized = normalizeStairNode(node) + const normalized = normalizeStairNode(migrateStairSurfaceMaterials(node)) if (normalized) { patchedNodes[id] = normalized } diff --git a/packages/core/src/systems/stair/stair-system.tsx b/packages/core/src/systems/stair/stair-system.tsx index db0bc3d2..3d0964e6 100644 --- a/packages/core/src/systems/stair/stair-system.tsx +++ b/packages/core/src/systems/stair/stair-system.tsx @@ -12,6 +12,10 @@ import { syncAutoStairOpenings } from './stair-opening-sync' const pendingStairUpdates = new Set() const MAX_STAIRS_PER_FRAME = 2 const MAX_SEGMENTS_PER_FRAME = 4 +const STAIR_TREAD_MATERIAL_INDEX = 0 +const STAIR_SIDE_MATERIAL_INDEX = 1 +const _uvPosition = new THREE.Vector3() +const _uvNormal = new THREE.Vector3() // ============================================================================ // STAIR SYSTEM @@ -198,7 +202,7 @@ function generateStairSegmentGeometry( shape.lineTo(0, 0) - const geometry = new THREE.ExtrudeGeometry(shape, { + const extrudedGeometry = new THREE.ExtrudeGeometry(shape, { steps: 1, depth: width, bevelEnabled: false, @@ -209,7 +213,16 @@ function generateStairSegmentGeometry( const matrix = new THREE.Matrix4() matrix.makeRotationY(-Math.PI / 2) matrix.setPosition(width / 2, 0, 0) - geometry.applyMatrix4(matrix) + extrudedGeometry.applyMatrix4(matrix) + extrudedGeometry.computeVertexNormals() + + const geometry = extrudedGeometry.toNonIndexed() ?? extrudedGeometry + if (geometry !== extrudedGeometry) { + extrudedGeometry.dispose() + } + + applyStairSegmentUvs(geometry) + ensureUv2Attribute(geometry) return geometry } @@ -219,6 +232,7 @@ function updateStairSegmentGeometry(node: StairSegmentNode, mesh: THREE.Mesh) { const absoluteHeight = computeAbsoluteHeight(node) const newGeometry = generateStairSegmentGeometry(node, absoluteHeight) + applyStraightStairMaterialGroups(newGeometry) mesh.geometry.dispose() mesh.geometry = newGeometry @@ -363,6 +377,7 @@ function updateMergedStairGeometry( } const merged = mergeGeometries(geometries, false) ?? createEmptyGeometry() + applyStraightStairMaterialGroups(merged) replaceMeshGeometry(mergedMesh, merged) // Dispose individual geometries @@ -371,6 +386,108 @@ function updateMergedStairGeometry( } } +function applyStraightStairMaterialGroups(geometry: THREE.BufferGeometry) { + const position = geometry.getAttribute('position') + if (!position || position.count < 3) { + geometry.clearGroups() + return + } + + const index = geometry.getIndex() + const triangleCount = index ? index.count / 3 : position.count / 3 + + if (!Number.isFinite(triangleCount) || triangleCount <= 0) { + geometry.clearGroups() + return + } + + const triangleMaterials: number[] = new Array(triangleCount) + const v0 = new THREE.Vector3() + const v1 = new THREE.Vector3() + const v2 = new THREE.Vector3() + const edge1 = new THREE.Vector3() + const edge2 = new THREE.Vector3() + const normal = new THREE.Vector3() + + for (let triangleIndex = 0; triangleIndex < triangleCount; triangleIndex++) { + const vertexOffset = triangleIndex * 3 + const a = index ? index.getX(vertexOffset) : vertexOffset + const b = index ? index.getX(vertexOffset + 1) : vertexOffset + 1 + const c = index ? index.getX(vertexOffset + 2) : vertexOffset + 2 + + v0.fromBufferAttribute(position, a) + v1.fromBufferAttribute(position, b) + v2.fromBufferAttribute(position, c) + + edge1.subVectors(v1, v0) + edge2.subVectors(v2, v0) + normal.crossVectors(edge1, edge2) + + triangleMaterials[triangleIndex] = + normal.lengthSq() > 0 && normal.normalize().y > 0.75 + ? STAIR_TREAD_MATERIAL_INDEX + : STAIR_SIDE_MATERIAL_INDEX + } + + geometry.clearGroups() + + let currentMaterial = triangleMaterials[0] + let groupStart = 0 + + for (let triangleIndex = 1; triangleIndex < triangleMaterials.length; triangleIndex++) { + const materialIndex = triangleMaterials[triangleIndex] + if (materialIndex === currentMaterial) continue + + geometry.addGroup(groupStart * 3, (triangleIndex - groupStart) * 3, currentMaterial) + groupStart = triangleIndex + currentMaterial = materialIndex + } + + geometry.addGroup( + groupStart * 3, + (triangleMaterials.length - groupStart) * 3, + currentMaterial ?? STAIR_SIDE_MATERIAL_INDEX, + ) +} + +function applyStairSegmentUvs(geometry: THREE.BufferGeometry) { + const position = geometry.getAttribute('position') + const normal = geometry.getAttribute('normal') + + if (!position || !normal || position.count === 0) { + geometry.deleteAttribute('uv') + return + } + + const uv: number[] = [] + + for (let index = 0; index < position.count; index++) { + _uvPosition.fromBufferAttribute(position, index) + _uvNormal.fromBufferAttribute(normal, index).normalize() + + const absX = Math.abs(_uvNormal.x) + const absY = Math.abs(_uvNormal.y) + const absZ = Math.abs(_uvNormal.z) + + if (absY >= absX && absY >= absZ) { + uv.push(_uvPosition.x, _uvPosition.z) + } else if (absX >= absZ) { + uv.push(_uvPosition.z, _uvPosition.y) + } else { + uv.push(_uvPosition.x, _uvPosition.y) + } + } + + geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uv, 2)) +} + +function ensureUv2Attribute(geometry: THREE.BufferGeometry) { + const uv = geometry.getAttribute('uv') + if (!uv) return + + geometry.setAttribute('uv2', new THREE.Float32BufferAttribute(Array.from(uv.array), 2)) +} + // ============================================================================ // SEGMENT CHAINING // ============================================================================ @@ -441,6 +558,8 @@ function rotateXZ(x: number, z: number, angle: number): [number, number] { function createEmptyGeometry(): THREE.BufferGeometry { const geometry = new THREE.BufferGeometry() geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3)) + geometry.addGroup(0, 0, STAIR_TREAD_MATERIAL_INDEX) + geometry.addGroup(0, 0, STAIR_SIDE_MATERIAL_INDEX) return geometry } diff --git a/packages/editor/src/components/editor/selection-manager.tsx b/packages/editor/src/components/editor/selection-manager.tsx index e9cfcea4..76d15055 100755 --- a/packages/editor/src/components/editor/selection-manager.tsx +++ b/packages/editor/src/components/editor/selection-manager.tsx @@ -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 } diff --git a/packages/editor/src/components/ui/panels/stair-panel.tsx b/packages/editor/src/components/ui/panels/stair-panel.tsx index cfe51504..e9477ac7 100644 --- a/packages/editor/src/components/ui/panels/stair-panel.tsx +++ b/packages/editor/src/components/ui/panels/stair-panel.tsx @@ -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 { + 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() { - + {!materialTargetRole ? ( +
+ 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/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index c5e5e833..91c2158a 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -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()( 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: {}, diff --git a/packages/viewer/src/components/renderers/stair-segment/stair-segment-renderer.tsx b/packages/viewer/src/components/renderers/stair-segment/stair-segment-renderer.tsx index 9c326dc1..830520fa 100644 --- a/packages/viewer/src/components/renderers/stair-segment/stair-segment-renderer.tsx +++ b/packages/viewer/src/components/renderers/stair-segment/stair-segment-renderer.tsx @@ -1,8 +1,8 @@ import { type AnyNodeId, type StairNode, type StairSegmentNode, useRegistry, useScene } from '@pascal-app/core' -import { useLayoutEffect, useMemo, useRef } from 'react' -import type * as THREE from 'three' +import { useEffect, useLayoutEffect, useMemo, useRef } from 'react' +import * as THREE from 'three' import { useNodeEvents } from '../../../hooks/use-node-events' -import { createMaterial, createMaterialFromPresetRef, DEFAULT_STAIR_MATERIAL } from '../../../lib/materials' +import { getStraightStairSegmentBodyMaterials } from '../../../systems/stair/stair-materials' export const StairSegmentRenderer = ({ node }: { node: StairSegmentNode }) => { const ref = useRef(null!) @@ -19,14 +19,7 @@ export const StairSegmentRenderer = ({ node }: { node: StairSegmentNode }) => { node.parentId ? (nodes[node.parentId as AnyNodeId] as StairNode | undefined) : undefined const material = useMemo(() => { - const effectiveMaterialPreset = node.materialPreset ?? parentNode?.materialPreset - const effectiveMaterial = node.material ?? parentNode?.material - - const presetMaterial = createMaterialFromPresetRef(effectiveMaterialPreset) - if (presetMaterial) return presetMaterial - const mat = effectiveMaterial - if (!mat) return DEFAULT_STAIR_MATERIAL - return createMaterial(mat) + return getStraightStairSegmentBodyMaterials(node, parentNode) }, [ node.materialPreset, node.material, @@ -38,19 +31,37 @@ export const StairSegmentRenderer = ({ node }: { node: StairSegmentNode }) => { parentNode?.material?.preset, parentNode?.material?.properties, parentNode?.material?.texture, + parentNode?.railingMaterialPreset, + parentNode?.railingMaterial, + parentNode?.sideMaterialPreset, + parentNode?.sideMaterial, + parentNode?.treadMaterialPreset, + parentNode?.treadMaterial, ]) + const placeholderGeometry = useMemo(() => { + const geometry = new THREE.BufferGeometry() + geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3)) + geometry.addGroup(0, 0, 0) + geometry.addGroup(0, 0, 1) + return geometry + }, []) + + useEffect(() => { + return () => { + placeholderGeometry.dispose() + } + }, [placeholderGeometry]) + return ( - {/* StairSystem will replace this geometry in the next frame */} - - + /> ) } diff --git a/packages/viewer/src/components/renderers/stair/stair-renderer.tsx b/packages/viewer/src/components/renderers/stair/stair-renderer.tsx index 0eb5795f..b79ecec2 100644 --- a/packages/viewer/src/components/renderers/stair/stair-renderer.tsx +++ b/packages/viewer/src/components/renderers/stair/stair-renderer.tsx @@ -5,14 +5,15 @@ import { useRegistry, useScene, } from '@pascal-app/core' -import { useLayoutEffect, useMemo, useRef } from 'react' +import { useEffect, useLayoutEffect, useMemo, useRef } from 'react' import * as THREE from 'three' import { useNodeEvents } from '../../../hooks/use-node-events' +import { createMaterial, createMaterialFromPresetRef, DEFAULT_STAIR_MATERIAL } from '../../../lib/materials' import { - createMaterial, - createMaterialFromPresetRef, - DEFAULT_STAIR_MATERIAL, -} from '../../../lib/materials' + getStairRailingMaterial, + getStairBodyMaterials, + type StairBodyMaterials, +} from '../../../systems/stair/stair-materials' import { NodeRenderer } from '../node-renderer' type SegmentTransform = { @@ -71,6 +72,48 @@ export const StairRenderer = ({ node }: { node: StairNode }) => { node.material?.texture, ]) + const straightBodyMaterials = useMemo( + () => getStairBodyMaterials(node), + [ + node.material, + node.materialPreset, + node.railingMaterial, + node.railingMaterialPreset, + node.sideMaterial, + node.sideMaterialPreset, + node.treadMaterial, + node.treadMaterialPreset, + ], + ) + + const railingMaterial = useMemo( + () => getStairRailingMaterial(node), + [ + node.material, + node.materialPreset, + node.railingMaterial, + node.railingMaterialPreset, + node.sideMaterial, + node.sideMaterialPreset, + node.treadMaterial, + node.treadMaterialPreset, + ], + ) + + const straightPlaceholderGeometry = useMemo(() => { + const geometry = new THREE.BufferGeometry() + geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3)) + geometry.addGroup(0, 0, 0) + geometry.addGroup(0, 0, 1) + return geometry + }, []) + + useEffect(() => { + return () => { + straightPlaceholderGeometry.dispose() + } + }, [straightPlaceholderGeometry]) + return ( { {...handlers} > {isSegmentBasedStair ? ( - - - + ) : null} - {!isSegmentBasedStair ? : null} - + {!isSegmentBasedStair ? : null} + {isSegmentBasedStair ? ( {(node.children ?? []).map((childId) => ( @@ -170,6 +217,7 @@ function StairRailings({ stair, material }: { stair: StairNode; material: THREE. geometry={BALUSTER_GEOMETRY} key={`${stair.id}-curved-baluster-${sideIndex}-${pointIndex}`} material={material} + name="stair-railing-baluster" position={[point[0], point[1] + railHeight / 2, point[2]]} receiveShadow scale={[balusterRadius, railHeight, balusterRadius]} @@ -227,6 +275,7 @@ function StairRailings({ stair, material }: { stair: StairNode; material: THREE. geometry={BALUSTER_GEOMETRY} key={`${segmentPath.layout.segment.id}-${sidePath.side}-baluster-${pointIndex}`} material={material} + name="stair-railing-baluster" position={[point[2], point[1] + railHeight / 2, point[0]]} receiveShadow scale={[balusterRadius, railHeight, balusterRadius]} @@ -333,6 +382,8 @@ function StairRailings({ stair, material }: { stair: StairNode; material: THREE. const BALUSTER_GEOMETRY = new THREE.CylinderGeometry(1, 1, 1, 8) const RAIL_GEOMETRY = new THREE.CylinderGeometry(1, 1, 1, 8) +const STAIR_TREAD_MATERIAL_INDEX = 0 +const STAIR_SIDE_MATERIAL_INDEX = 1 function RailSegment({ start, @@ -367,6 +418,7 @@ function RailSegment({ castShadow geometry={RAIL_GEOMETRY} material={material} + name="stair-railing-rail" position={[midpoint.x, midpoint.y, midpoint.z]} quaternion={quaternion} receiveShadow @@ -375,7 +427,14 @@ function RailSegment({ ) } -function CurvedStairBody({ stair, material }: { stair: StairNode; material: THREE.Material }) { +function CurvedStairBody({ + stair, + bodyMaterials, +}: { + stair: StairNode + bodyMaterials: StairBodyMaterials +}) { + const sideMaterial = bodyMaterials[1] const stepCount = Math.max(2, Math.round(stair.stepCount ?? 10)) const totalRise = Math.max(stair.totalRise ?? 2.5, 0.1) const stepHeight = totalRise / stepCount @@ -411,7 +470,8 @@ function CurvedStairBody({ stair, material }: { stair: StairNode; material: THRE @@ -556,15 +617,39 @@ function buildCurvedStepGeometry( const positions: number[] = [] const normals: number[] = [] + const uvs: number[] = [] + const triangleMaterialIndices: number[] = [] const pointOnArc = (radius: number, angle: number, y: number) => new THREE.Vector3(Math.cos(angle) * radius, y, Math.sin(angle) * radius) + const pushUv = (point: THREE.Vector3, normal: THREE.Vector3, materialIndex: number) => { + if (materialIndex === STAIR_TREAD_MATERIAL_INDEX) { + const angle = Math.atan2(point.z, point.x) + const arcOffset = (angle - startAngle) * Math.max((innerRadius + outerRadius) * 0.5, 0.01) + uvs.push(arcOffset, Math.sqrt(point.x * point.x + point.z * point.z) - innerRadius) + return + } + + const absX = Math.abs(normal.x) + const absY = Math.abs(normal.y) + const absZ = Math.abs(normal.z) + + if (absY >= absX && absY >= absZ) { + uvs.push(point.x, point.z) + } else if (absX >= absZ) { + uvs.push(point.z, point.y) + } else { + uvs.push(point.x, point.y) + } + } + const pushTriangle = ( a: THREE.Vector3, b: THREE.Vector3, c: THREE.Vector3, normal: THREE.Vector3, + materialIndex: number, ) => { const edgeAB = b.clone().sub(a) const edgeAC = c.clone().sub(a) @@ -573,7 +658,9 @@ function buildCurvedStepGeometry( for (const point of ordered) { positions.push(point.x, point.y, point.z) normals.push(normal.x, normal.y, normal.z) + pushUv(point, normal, materialIndex) } + triangleMaterialIndices.push(materialIndex) } const pushQuad = ( @@ -582,9 +669,10 @@ function buildCurvedStepGeometry( c: THREE.Vector3, d: THREE.Vector3, normal: THREE.Vector3, + materialIndex: number, ) => { - pushTriangle(a, b, c, normal) - pushTriangle(a, c, d, normal) + pushTriangle(a, b, c, normal, materialIndex) + pushTriangle(a, c, d, normal, materialIndex) } const upNormal = new THREE.Vector3(0, 1, 0) @@ -609,10 +697,38 @@ function buildCurvedStepGeometry( const outerNormal = new THREE.Vector3(Math.cos(midAngle), 0, Math.sin(midAngle)).normalize() const innerNormal = new THREE.Vector3(-Math.cos(midAngle), 0, -Math.sin(midAngle)).normalize() - pushQuad(innerStartTop, outerStartTop, outerEndTop, innerEndTop, upNormal) - pushQuad(innerStartBottom, innerEndBottom, outerEndBottom, outerStartBottom, downNormal) - pushQuad(innerStartBottom, innerStartTop, innerEndTop, innerEndBottom, innerNormal) - pushQuad(outerStartBottom, outerEndBottom, outerEndTop, outerStartTop, outerNormal) + pushQuad( + innerStartTop, + outerStartTop, + outerEndTop, + innerEndTop, + upNormal, + STAIR_TREAD_MATERIAL_INDEX, + ) + pushQuad( + innerStartBottom, + innerEndBottom, + outerEndBottom, + outerStartBottom, + downNormal, + STAIR_SIDE_MATERIAL_INDEX, + ) + pushQuad( + innerStartBottom, + innerStartTop, + innerEndTop, + innerEndBottom, + innerNormal, + STAIR_SIDE_MATERIAL_INDEX, + ) + pushQuad( + outerStartBottom, + outerEndBottom, + outerEndTop, + outerStartTop, + outerNormal, + STAIR_SIDE_MATERIAL_INDEX, + ) } const startInnerBottom = pointOnArc(innerRadius, startAngle, y0) @@ -634,12 +750,49 @@ function buildCurvedStepGeometry( sweepDirection * Math.cos(endAngle), ).normalize() - pushQuad(startInnerBottom, startOuterBottom, startOuterTop, startInnerTop, startNormal) - pushQuad(endInnerBottom, endInnerTop, endOuterTop, endOuterBottom, endNormal) + pushQuad( + startInnerBottom, + startOuterBottom, + startOuterTop, + startInnerTop, + startNormal, + STAIR_SIDE_MATERIAL_INDEX, + ) + pushQuad( + endInnerBottom, + endInnerTop, + endOuterTop, + endOuterBottom, + endNormal, + STAIR_SIDE_MATERIAL_INDEX, + ) const geometry = new THREE.BufferGeometry() geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)) geometry.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3)) + geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2)) + geometry.clearGroups() + + let currentMaterial = triangleMaterialIndices[0] + let groupStart = 0 + + for (let triangleIndex = 1; triangleIndex < triangleMaterialIndices.length; triangleIndex++) { + const materialIndex = triangleMaterialIndices[triangleIndex] + if (materialIndex === currentMaterial) continue + + geometry.addGroup(groupStart * 3, (triangleIndex - groupStart) * 3, currentMaterial) + groupStart = triangleIndex + currentMaterial = materialIndex + } + + if (triangleMaterialIndices.length > 0) { + geometry.addGroup( + groupStart * 3, + (triangleMaterialIndices.length - groupStart) * 3, + currentMaterial ?? STAIR_SIDE_MATERIAL_INDEX, + ) + } + geometry.setAttribute('uv2', new THREE.Float32BufferAttribute(uvs.slice(), 2)) geometry.computeVertexNormals() return geometry } diff --git a/packages/viewer/src/systems/stair/stair-materials.ts b/packages/viewer/src/systems/stair/stair-materials.ts new file mode 100644 index 00000000..81983479 --- /dev/null +++ b/packages/viewer/src/systems/stair/stair-materials.ts @@ -0,0 +1,59 @@ +import { + getEffectiveStairSurfaceMaterial, + type StairNode, + type StairSegmentNode, +} from '@pascal-app/core' +import type * as THREE from 'three' +import { + createMaterial, + createMaterialFromPresetRef, + DEFAULT_STAIR_MATERIAL, +} from '../../lib/materials' + +export type StairBodyMaterials = [THREE.Material, THREE.Material] + +function createResolvedMaterial( + material: StairNode['material'] | StairSegmentNode['material'] | undefined, + materialPreset: string | undefined, +): THREE.Material { + if (materialPreset) { + return createMaterialFromPresetRef(materialPreset) ?? DEFAULT_STAIR_MATERIAL + } + + if (material) { + return createMaterial(material) + } + + return DEFAULT_STAIR_MATERIAL +} + +export function getStairBodyMaterials(stair: StairNode): StairBodyMaterials { + const tread = getEffectiveStairSurfaceMaterial(stair, 'tread') + const side = getEffectiveStairSurfaceMaterial(stair, 'side') + + return [ + createResolvedMaterial(tread.material, tread.materialPreset), + createResolvedMaterial(side.material, side.materialPreset), + ] +} + +export function getStairRailingMaterial(stair: StairNode): THREE.Material { + const railing = getEffectiveStairSurfaceMaterial(stair, 'railing') + return createResolvedMaterial(railing.material, railing.materialPreset) +} + +export function getStraightStairSegmentBodyMaterials( + segment: StairSegmentNode, + parentNode?: StairNode, +): StairBodyMaterials { + if (segment.material !== undefined || typeof segment.materialPreset === 'string') { + const override = createResolvedMaterial(segment.material, segment.materialPreset) + return [override, override] + } + + if (parentNode) { + return getStairBodyMaterials(parentNode) + } + + return [DEFAULT_STAIR_MATERIAL, DEFAULT_STAIR_MATERIAL] +} From 05002a78655abf8390d5ca77806e06a459bc13f8 Mon Sep 17 00:00:00 2001 From: sudhir Date: Mon, 20 Apr 2026 12:04:43 +0530 Subject: [PATCH 04/31] Allow stair side clicks to target side materials --- packages/editor/src/components/editor/selection-manager.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/editor/src/components/editor/selection-manager.tsx b/packages/editor/src/components/editor/selection-manager.tsx index 76d15055..0680f44b 100755 --- a/packages/editor/src/components/editor/selection-manager.tsx +++ b/packages/editor/src/components/editor/selection-manager.tsx @@ -108,6 +108,10 @@ function resolveStairMaterialTarget( return 'railing' } + if (hitObjectName.startsWith('stair-side')) { + return 'side' + } + if (event.materialIndex === 0) { return 'tread' } From bbc351d3a4b6f0f1f0c7edbd1733049cfa77c9fc Mon Sep 17 00:00:00 2001 From: sudhir Date: Mon, 20 Apr 2026 12:57:06 +0530 Subject: [PATCH 05/31] Add roof material targets and UV mapping --- packages/core/src/material-library.ts | 6 +- packages/core/src/schema/index.ts | 3 +- packages/core/src/schema/nodes/roof.ts | 79 ++++++++++++++++++- packages/core/src/store/use-scene.ts | 61 ++++++++++++++ .../core/src/systems/roof/roof-system.tsx | 39 ++++++++- .../components/editor/selection-manager.tsx | 61 ++++++++++++++ .../src/components/ui/panels/roof-panel.tsx | 72 ++++++++++++++--- packages/editor/src/store/use-editor.tsx | 10 +++ .../roof-segment/roof-segment-renderer.tsx | 44 +++++++---- .../renderers/roof/roof-renderer.tsx | 54 ++++++++++--- .../viewer/src/systems/roof/roof-materials.ts | 46 +++++++++++ 11 files changed, 428 insertions(+), 47 deletions(-) create mode 100644 packages/viewer/src/systems/roof/roof-materials.ts diff --git a/packages/core/src/material-library.ts b/packages/core/src/material-library.ts index 06218708..f5455c96 100644 --- a/packages/core/src/material-library.ts +++ b/packages/core/src/material-library.ts @@ -51,7 +51,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ id: 'wall-wood1', label: 'Wood', description: 'Warm wood finish', - targets: [...WALL_TARGETS, ...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS], + targets: [...WALL_TARGETS, ...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS, ...ROOF_TARGETS], previewThumbnailUrl: '/material/wood1/wood1_thumbnail.webp', preset: { maps: { @@ -86,7 +86,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ id: 'wall-wood2', label: 'Wood', description: 'Textured wood finish', - targets: [...WALL_TARGETS, ...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS], + targets: [...WALL_TARGETS, ...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS, ...ROOF_TARGETS], previewThumbnailUrl: '/material/wood2/wood2_thumbnail.webp', preset: { maps: { @@ -122,7 +122,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ id: 'wall-wood3', label: 'Wood', description: 'Knotted timber finish', - targets: [...WALL_TARGETS, ...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS], + targets: [...WALL_TARGETS, ...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS, ...ROOF_TARGETS], previewThumbnailUrl: '/material/wood3/wood3_thumbnail.webp', preset: { maps: { diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 5599bc46..1383216d 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -43,7 +43,8 @@ export type { } from './nodes/item' export { getScaledDimensions, ItemNode } from './nodes/item' export { LevelNode } from './nodes/level' -export { RoofNode } from './nodes/roof' +export { getEffectiveRoofSurfaceMaterial, RoofNode } from './nodes/roof' +export type { RoofSurfaceMaterialRole, RoofSurfaceMaterialSpec } from './nodes/roof' export { RoofSegmentNode, RoofType } from './nodes/roof-segment' export { ScanNode } from './nodes/scan' // Nodes diff --git a/packages/core/src/schema/nodes/roof.ts b/packages/core/src/schema/nodes/roof.ts index d6529b4e..944cca04 100644 --- a/packages/core/src/schema/nodes/roof.ts +++ b/packages/core/src/schema/nodes/roof.ts @@ -1,14 +1,26 @@ import dedent from 'dedent' import { z } from 'zod' import { BaseNode, nodeType, objectId } from '../base' -import { MaterialSchema } from '../material' +import { type MaterialSchema, MaterialSchema as MaterialSchemaSchema } from '../material' import { RoofSegmentNode } from './roof-segment' +export type RoofSurfaceMaterialRole = 'top' | 'edge' | 'wall' +export type RoofSurfaceMaterialSpec = { + material?: MaterialSchema + materialPreset?: string +} + export const RoofNode = BaseNode.extend({ id: objectId('roof'), type: nodeType('roof'), - material: MaterialSchema.optional(), + material: MaterialSchemaSchema.optional(), materialPreset: z.string().optional(), + topMaterial: MaterialSchemaSchema.optional(), + topMaterialPreset: z.string().optional(), + edgeMaterial: MaterialSchemaSchema.optional(), + edgeMaterialPreset: z.string().optional(), + wallMaterial: MaterialSchemaSchema.optional(), + wallMaterialPreset: z.string().optional(), position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), // Rotation around Y axis in radians rotation: z.number().default(0), @@ -26,3 +38,66 @@ export const RoofNode = BaseNode.extend({ ) export type RoofNode = z.infer + +function getLegacyRoofSurfaceMaterial(node: RoofNode): RoofSurfaceMaterialSpec { + return { + material: node.material, + materialPreset: node.materialPreset, + } +} + +export function getEffectiveRoofSurfaceMaterial( + node: RoofNode, + role: RoofSurfaceMaterialRole, +): RoofSurfaceMaterialSpec { + if (role === 'top') { + if (node.topMaterial !== undefined || typeof node.topMaterialPreset === 'string') { + return { + material: node.topMaterial, + materialPreset: typeof node.topMaterialPreset === 'string' ? node.topMaterialPreset : undefined, + } + } + } + + if (role === 'edge') { + if (node.edgeMaterial !== undefined || typeof node.edgeMaterialPreset === 'string') { + return { + material: node.edgeMaterial, + materialPreset: + typeof node.edgeMaterialPreset === 'string' ? node.edgeMaterialPreset : undefined, + } + } + } + + if (role === 'wall') { + if (node.wallMaterial !== undefined || typeof node.wallMaterialPreset === 'string') { + return { + material: node.wallMaterial, + materialPreset: + typeof node.wallMaterialPreset === 'string' ? node.wallMaterialPreset : undefined, + } + } + } + + if (role === 'edge') { + if (node.wallMaterial !== undefined || typeof node.wallMaterialPreset === 'string') { + return { + material: node.wallMaterial, + materialPreset: + typeof node.wallMaterialPreset === 'string' ? node.wallMaterialPreset : undefined, + } + } + } + + if (role === 'wall') { + if (node.edgeMaterial !== undefined || typeof node.edgeMaterialPreset === 'string') { + return { + material: node.edgeMaterial, + materialPreset: + typeof node.edgeMaterialPreset === 'string' ? node.edgeMaterialPreset : undefined, + } + } + } + + return getLegacyRoofSurfaceMaterial(node) +} diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index 1ce4974c..ca310349 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -224,6 +224,63 @@ function migrateStairSurfaceMaterials(node: Record) { return next } +function migrateRoofSurfaceMaterials(node: Record) { + const hasTop = node.topMaterial !== undefined || typeof node.topMaterialPreset === 'string' + const hasEdge = node.edgeMaterial !== undefined || typeof node.edgeMaterialPreset === 'string' + const hasWall = node.wallMaterial !== undefined || typeof node.wallMaterialPreset === 'string' + const legacyFinish = { + material: node.material, + materialPreset: typeof node.materialPreset === 'string' ? node.materialPreset : undefined, + } + + if (!hasTop && !hasEdge && !hasWall) { + if (legacyFinish.material === undefined && legacyFinish.materialPreset === undefined) { + return node + } + + return { + ...node, + topMaterial: legacyFinish.material, + topMaterialPreset: legacyFinish.materialPreset, + edgeMaterial: legacyFinish.material, + edgeMaterialPreset: legacyFinish.materialPreset, + wallMaterial: legacyFinish.material, + wallMaterialPreset: legacyFinish.materialPreset, + } + } + + const next = { ...node } + + if (!hasTop) { + next.topMaterial = legacyFinish.material + next.topMaterialPreset = legacyFinish.materialPreset + } + + if (!hasEdge) { + if (node.wallMaterial !== undefined || typeof node.wallMaterialPreset === 'string') { + next.edgeMaterial = node.wallMaterial + next.edgeMaterialPreset = + typeof node.wallMaterialPreset === 'string' ? node.wallMaterialPreset : undefined + } else { + next.edgeMaterial = legacyFinish.material + next.edgeMaterialPreset = legacyFinish.materialPreset + } + } + + if (!hasWall) { + if (node.edgeMaterial !== undefined || typeof node.edgeMaterialPreset === 'string') { + next.wallMaterial = node.edgeMaterial + next.wallMaterialPreset = + typeof node.edgeMaterialPreset === 'string' ? node.edgeMaterialPreset : undefined + } else { + next.wallMaterial = legacyFinish.material + next.wallMaterialPreset = legacyFinish.materialPreset + } + } + + return next +} + function migrateNodes(nodes: Record): Record { const patchedNodes = { ...nodes } for (const [id, node] of Object.entries(patchedNodes)) { @@ -281,6 +338,10 @@ function migrateNodes(nodes: Record): Record { if (node.type === 'wall') { patchedNodes[id] = migrateWallSurfaceMaterials(patchedNodes[id]) } + + if (node.type === 'roof') { + patchedNodes[id] = migrateRoofSurfaceMaterials(patchedNodes[id]) + } } return patchedNodes as Record } diff --git a/packages/core/src/systems/roof/roof-system.tsx b/packages/core/src/systems/roof/roof-system.tsx index 0e23364b..5a1f06d1 100644 --- a/packages/core/src/systems/roof/roof-system.tsx +++ b/packages/core/src/systems/roof/roof-system.tsx @@ -11,7 +11,7 @@ import useScene from '../../store/use-scene' const csgEvaluator = new Evaluator() csgEvaluator.useGroups = true ;(csgEvaluator as any).consolidateGroups = false // shared dummyMats across brushes causes consolidation to misalign groupIndices vs groupOrder indices → crash -csgEvaluator.attributes = ['position', 'normal'] +csgEvaluator.attributes = ['position', 'normal', 'uv'] function prepareBrushForCSG(brush: Brush) { brush.geometry.computeBoundsTree = computeBoundsTree @@ -25,6 +25,7 @@ const _position = new THREE.Vector3() const _quaternion = new THREE.Quaternion() const _scale = new THREE.Vector3(1, 1, 1) const _yAxis = new THREE.Vector3(0, 1, 0) +const _uvFaceNormal = new THREE.Vector3() // Pending merged-roof updates carried across frames (for throttling) const pendingRoofUpdates = new Set() @@ -251,6 +252,7 @@ function updateMergedRoofGeometry( g.materialIndex = mapRoofGroupMaterialIndex(g.materialIndex, resultMaterials, matToIndex) } + ensureUv2Attribute(resultGeo) resultGeo.computeVertexNormals() mergedMesh.geometry.dispose() mergedMesh.geometry = resultGeo @@ -641,6 +643,7 @@ export function generateRoofSegmentGeometry(node: RoofSegmentNode): THREE.Buffer wallBrush.geometry.dispose() innerBrush.geometry.dispose() + ensureUv2Attribute(resultGeo) resultGeo.computeVertexNormals() return resultGeo } @@ -936,6 +939,7 @@ function createGeometryFromFaces( ): THREE.BufferGeometry { const positions: number[] = [] const normals: number[] = [] + const uvs: number[] = [] const indices: number[] = [] const groups: { start: number; count: number; materialIndex: number }[] = [] let vertexCount = 0 @@ -974,6 +978,10 @@ function createGeometryFromFaces( normals.push(normal.x, normal.y, normal.z) normals.push(normal.x, normal.y, normal.z) + pushRoofUv(uvs, p0, normal) + pushRoofUv(uvs, fi, normal) + pushRoofUv(uvs, fi1, normal) + indices.push(vertexCount, vertexCount + 1, vertexCount + 2) faceVertexCount += 3 @@ -990,6 +998,7 @@ function createGeometryFromFaces( const geometry = new THREE.BufferGeometry() geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)) geometry.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3)) + geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2)) geometry.setIndex(indices) for (const g of groups) { @@ -999,6 +1008,34 @@ function createGeometryFromFaces( // Merge identical vertices to optimize geometry for CSG and create clean topology const mergedGeo = mergeVertices(geometry, 1e-4) geometry.dispose() + ensureUv2Attribute(mergedGeo) return mergedGeo } + +function pushRoofUv(uvs: number[], point: THREE.Vector3, normal: THREE.Vector3) { + _uvFaceNormal.copy(normal).normalize() + + const absX = Math.abs(_uvFaceNormal.x) + const absY = Math.abs(_uvFaceNormal.y) + const absZ = Math.abs(_uvFaceNormal.z) + + if (absY >= absX && absY >= absZ) { + uvs.push(point.x, point.z) + return + } + + if (absX >= absZ) { + uvs.push(point.z, point.y) + return + } + + uvs.push(point.x, point.y) +} + +function ensureUv2Attribute(geometry: THREE.BufferGeometry) { + const uv = geometry.getAttribute('uv') + if (!uv) return + + geometry.setAttribute('uv2', new THREE.Float32BufferAttribute(Array.from(uv.array), 2)) +} diff --git a/packages/editor/src/components/editor/selection-manager.tsx b/packages/editor/src/components/editor/selection-manager.tsx index 0680f44b..047481ab 100755 --- a/packages/editor/src/components/editor/selection-manager.tsx +++ b/packages/editor/src/components/editor/selection-manager.tsx @@ -5,6 +5,8 @@ import { emitter, type ItemNode, type NodeEvent, + type RoofEvent, + type RoofSegmentEvent, resolveLevelId, sceneRegistry, type StairEvent, @@ -132,6 +134,21 @@ function resolveStairMaterialTarget( return null } +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 normalY = event.normal?.[1] + if (normalY !== undefined && normalY > 0.35) return 'top' + if (normalY !== undefined && Math.abs(normalY) <= 0.35) return 'edge' + if (normalY !== undefined && normalY < -0.35) return 'wall' + + return null +} + const HIGHLIGHT_PROFILES = { delete: { color: new Color('#dc2626'), @@ -542,6 +559,28 @@ export const SelectionManager = () => { useEditor.getState().setSelectedStairMaterialTarget(null) } + if ( + (node.type === 'roof' || node.type === 'roof-segment') && + nodeToSelect.type === 'roof' + ) { + const nextRoofMaterialTarget = 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) + } + // Reset the handled flag after a short delay to allow grid:click to be ignored setTimeout(() => { clickHandledRef.current = false @@ -576,6 +615,7 @@ export const SelectionManager = () => { if (activeStrategy) activeStrategy.handleDeselect() useEditor.getState().setSelectedWallMaterialTarget(null) useEditor.getState().setSelectedStairMaterialTarget(null) + useEditor.getState().setSelectedRoofMaterialTarget(null) // When deselecting from zone mode, return to structure select if (phase === 'structure' && structureLayer === 'zones') { @@ -813,6 +853,8 @@ const SelectionStateSync = () => { 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 singleSelectedId = useViewer((s) => s.selection.selectedIds.length === 1 ? s.selection.selectedIds[0] : null, ) @@ -883,6 +925,25 @@ const SelectionStateSync = () => { } }, [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]) + return null } diff --git a/packages/editor/src/components/ui/panels/roof-panel.tsx b/packages/editor/src/components/ui/panels/roof-panel.tsx index db344ef3..5fbc573f 100755 --- a/packages/editor/src/components/ui/panels/roof-panel.tsx +++ b/packages/editor/src/components/ui/panels/roof-panel.tsx @@ -3,8 +3,10 @@ import { type AnyNode, type AnyNodeId, + getEffectiveRoofSurfaceMaterial, type MaterialSchema, type RoofNode, + type RoofSurfaceMaterialRole, RoofNode as RoofNodeSchema, type RoofSegmentNode, RoofSegmentNode as RoofSegmentNodeSchema, @@ -22,12 +24,39 @@ import { PanelSection } from '../controls/panel-section' import { SliderControl } from '../controls/slider-control' import { PanelWrapper } from './panel-wrapper' +function buildRoofSurfaceMaterialPatch( + node: RoofNode, + targetRole: RoofSurfaceMaterialRole, + material: MaterialSchema | undefined, + materialPreset: string | undefined, +): Partial { + const nextSurfaceMaterial = { material, materialPreset } + const nextTop = + targetRole === 'top' ? nextSurfaceMaterial : getEffectiveRoofSurfaceMaterial(node, 'top') + const nextEdge = + targetRole === 'edge' ? nextSurfaceMaterial : getEffectiveRoofSurfaceMaterial(node, 'edge') + const nextWall = + targetRole === 'wall' ? nextSurfaceMaterial : getEffectiveRoofSurfaceMaterial(node, 'wall') + + return { + topMaterial: nextTop.material, + topMaterialPreset: nextTop.materialPreset, + edgeMaterial: nextEdge.material, + edgeMaterialPreset: nextEdge.materialPreset, + wallMaterial: nextWall.material, + wallMaterialPreset: nextWall.materialPreset, + material: undefined, + materialPreset: undefined, + } +} + export function RoofPanel() { const selectedId = useViewer((s) => s.selection.selectedIds[0]) const setSelection = useViewer((s) => s.setSelection) 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 node = useScene((s) => selectedId ? (s.nodes[selectedId as AnyNode['id']] as RoofNode | undefined) : undefined, @@ -50,18 +79,27 @@ export function RoofPanel() { [selectedId, updateNode], ) - const handleMaterialChange = useCallback( + const materialTargetRole = + selectedRoofMaterialTarget && selectedRoofMaterialTarget.roofId === node?.id + ? selectedRoofMaterialTarget.role + : null + const materialPickerValue = + node && materialTargetRole ? getEffectiveRoofSurfaceMaterial(node, materialTargetRole) : {} + + const handleTargetedMaterialChange = useCallback( (material: MaterialSchema) => { - handleUpdate({ material, materialPreset: undefined }) + if (!node || !materialTargetRole) return + handleUpdate(buildRoofSurfaceMaterialPatch(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(buildRoofSurfaceMaterialPatch(node, materialTargetRole, undefined, materialPreset)) }, - [handleUpdate], + [handleUpdate, materialTargetRole, node], ) const handleClose = useCallback(() => { @@ -267,13 +305,21 @@ export function RoofPanel() { - + {!materialTargetRole ? ( +
+ 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/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index 91c2158a..6dd55c73 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -8,6 +8,7 @@ import { type FenceNode, type ItemNode, type LevelNode, + type RoofSurfaceMaterialRole, type RoofNode, type RoofSegmentNode, type SlabNode, @@ -93,6 +94,11 @@ export type SelectedStairMaterialTarget = { role: StairMaterialTargetRole } +export type SelectedRoofMaterialTarget = { + roofId: RoofNode['id'] + role: RoofSurfaceMaterialRole +} + type EditorState = { phase: Phase setPhase: (phase: Phase) => void @@ -144,6 +150,8 @@ type EditorState = { setSelectedWallMaterialTarget: (target: SelectedWallMaterialTarget | null) => void selectedStairMaterialTarget: SelectedStairMaterialTarget | null setSelectedStairMaterialTarget: (target: SelectedStairMaterialTarget | null) => void + selectedRoofMaterialTarget: SelectedRoofMaterialTarget | null + setSelectedRoofMaterialTarget: (target: SelectedRoofMaterialTarget | null) => void selectedReferenceId: string | null setSelectedReferenceId: (id: string | null) => void // Space detection for cutaway mode @@ -523,6 +531,8 @@ const useEditor = create()( setSelectedWallMaterialTarget: (target) => set({ selectedWallMaterialTarget: target }), selectedStairMaterialTarget: null, setSelectedStairMaterialTarget: (target) => set({ selectedStairMaterialTarget: target }), + selectedRoofMaterialTarget: null, + setSelectedRoofMaterialTarget: (target) => set({ selectedRoofMaterialTarget: target }), selectedReferenceId: null, setSelectedReferenceId: (id) => set({ selectedReferenceId: id }), spaces: {}, diff --git a/packages/viewer/src/components/renderers/roof-segment/roof-segment-renderer.tsx b/packages/viewer/src/components/renderers/roof-segment/roof-segment-renderer.tsx index b24d5b9c..cd0fbd09 100644 --- a/packages/viewer/src/components/renderers/roof-segment/roof-segment-renderer.tsx +++ b/packages/viewer/src/components/renderers/roof-segment/roof-segment-renderer.tsx @@ -1,9 +1,9 @@ import { type AnyNodeId, type RoofNode, type RoofSegmentNode, useRegistry, useScene } from '@pascal-app/core' -import { useMemo, useRef } from 'react' -import type * as THREE from 'three' +import { useEffect, useMemo, useRef } from 'react' +import * as THREE from 'three' import { useNodeEvents } from '../../../hooks/use-node-events' -import { createMaterial, createMaterialFromPresetRef } from '../../../lib/materials' import useViewer from '../../../store/use-viewer' +import { getRoofMaterialArray } from '../../../systems/roof/roof-materials' import { roofDebugMaterials, roofMaterials } from '../roof/roof-materials' export const RoofSegmentRenderer = ({ node }: { node: RoofSegmentNode }) => { @@ -16,16 +16,22 @@ export const RoofSegmentRenderer = ({ node }: { node: RoofSegmentNode }) => { const debugColors = useViewer((s) => s.debugColors) const parentNode = node.parentId ? (nodes[node.parentId as AnyNodeId] as RoofNode | undefined) : undefined + const placeholderGeometry = useMemo(() => { + const geometry = new THREE.BufferGeometry() + geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3)) + geometry.addGroup(0, 0, 0) + geometry.addGroup(0, 0, 1) + geometry.addGroup(0, 0, 2) + geometry.addGroup(0, 0, 3) + return geometry + }, []) const customMaterial = useMemo(() => { - const effectiveMaterialPreset = node.materialPreset ?? parentNode?.materialPreset - const effectiveMaterial = node.material ?? parentNode?.material + if (node.material !== undefined || typeof node.materialPreset === 'string') { + return null + } - const presetMaterial = createMaterialFromPresetRef(effectiveMaterialPreset) - if (presetMaterial) return presetMaterial - const mat = effectiveMaterial - if (!mat) return null - return createMaterial(mat) + return parentNode ? getRoofMaterialArray(parentNode) : null }, [ node.materialPreset, node.material, @@ -37,21 +43,31 @@ export const RoofSegmentRenderer = ({ node }: { node: RoofSegmentNode }) => { parentNode?.material?.preset, parentNode?.material?.properties, parentNode?.material?.texture, + parentNode?.topMaterial, + parentNode?.topMaterialPreset, + parentNode?.edgeMaterial, + parentNode?.edgeMaterialPreset, + parentNode?.wallMaterial, + parentNode?.wallMaterialPreset, ]) const material = debugColors ? roofDebugMaterials : customMaterial || roofMaterials + useEffect(() => { + return () => { + placeholderGeometry.dispose() + } + }, [placeholderGeometry]) + return ( - {/* RoofSystem will replace this geometry in the next frame */} - - + /> ) } diff --git a/packages/viewer/src/components/renderers/roof/roof-renderer.tsx b/packages/viewer/src/components/renderers/roof/roof-renderer.tsx index 776fcbe7..a5fd4ee6 100644 --- a/packages/viewer/src/components/renderers/roof/roof-renderer.tsx +++ b/packages/viewer/src/components/renderers/roof/roof-renderer.tsx @@ -1,9 +1,9 @@ import { type RoofNode, useRegistry } from '@pascal-app/core' -import { useMemo, useRef } from 'react' -import type * as THREE from 'three' +import { useEffect, useMemo, useRef } from 'react' +import * as THREE from 'three' import { useNodeEvents } from '../../../hooks/use-node-events' -import { createMaterial, createMaterialFromPresetRef } from '../../../lib/materials' import useViewer from '../../../store/use-viewer' +import { getRoofMaterialArray } from '../../../systems/roof/roof-materials' import { NodeRenderer } from '../node-renderer' import { roofDebugMaterials, roofMaterials } from './roof-materials' @@ -14,17 +14,41 @@ export const RoofRenderer = ({ node }: { node: RoofNode }) => { const handlers = useNodeEvents(node, 'roof') const debugColors = useViewer((s) => s.debugColors) + const placeholderGeometry = useMemo(() => { + const geometry = new THREE.BufferGeometry() + geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3)) + geometry.addGroup(0, 0, 0) + geometry.addGroup(0, 0, 1) + geometry.addGroup(0, 0, 2) + geometry.addGroup(0, 0, 3) + return geometry + }, []) - const customMaterial = useMemo(() => { - const presetMaterial = createMaterialFromPresetRef(node.materialPreset) - if (presetMaterial) return presetMaterial - const mat = node.material - if (!mat) return null - return createMaterial(mat) - }, [node.materialPreset, node.material, node.material?.preset, node.material?.properties, node.material?.texture]) + const customMaterial = useMemo( + () => getRoofMaterialArray(node), + [ + node.materialPreset, + node.material, + node.material?.preset, + node.material?.properties, + node.material?.texture, + node.topMaterial, + node.topMaterialPreset, + node.edgeMaterial, + node.edgeMaterialPreset, + node.wallMaterial, + node.wallMaterialPreset, + ], + ) const material = debugColors ? roofDebugMaterials : customMaterial || roofMaterials + useEffect(() => { + return () => { + placeholderGeometry.dispose() + } + }, [placeholderGeometry]) + return ( { visible={node.visible} {...handlers} > - - - + {(node.children ?? []).map((childId) => ( diff --git a/packages/viewer/src/systems/roof/roof-materials.ts b/packages/viewer/src/systems/roof/roof-materials.ts new file mode 100644 index 00000000..3991050a --- /dev/null +++ b/packages/viewer/src/systems/roof/roof-materials.ts @@ -0,0 +1,46 @@ +import { + getEffectiveRoofSurfaceMaterial, + type RoofNode, + type RoofSegmentNode, +} from '@pascal-app/core' +import * as THREE from 'three' +import { createMaterial, createMaterialFromPresetRef } from '../../lib/materials' + +export type RoofMaterialArray = [THREE.Material, THREE.Material, THREE.Material, THREE.Material] + +function createResolvedMaterial( + material: RoofNode['material'] | RoofSegmentNode['material'] | undefined, + materialPreset: string | undefined, +): THREE.Material | null { + if (materialPreset) { + return createMaterialFromPresetRef(materialPreset) + } + + if (material) { + return createMaterial(material) + } + + return null +} + +export function getRoofMaterialArray(node: RoofNode): RoofMaterialArray | null { + const top = getEffectiveRoofSurfaceMaterial(node, 'top') + const edge = getEffectiveRoofSurfaceMaterial(node, 'edge') + const wall = getEffectiveRoofSurfaceMaterial(node, 'wall') + + const topMaterial = createResolvedMaterial(top.material, top.materialPreset) + const edgeMaterial = createResolvedMaterial(edge.material, edge.materialPreset) + const wallMaterial = createResolvedMaterial(wall.material, wall.materialPreset) + + if (!(topMaterial || edgeMaterial || wallMaterial)) { + return null + } + + return [ + edgeMaterial ?? wallMaterial ?? topMaterial ?? new THREE.MeshStandardMaterial(), + wallMaterial ?? edgeMaterial ?? topMaterial ?? new THREE.MeshStandardMaterial(), + wallMaterial ?? edgeMaterial ?? topMaterial ?? new THREE.MeshStandardMaterial(), + topMaterial ?? wallMaterial ?? edgeMaterial ?? new THREE.MeshStandardMaterial(), + ] +} + From dfdeca4aae11bdbf93ca0c8cfeb35e06500f0d83 Mon Sep 17 00:00:00 2001 From: sudhir Date: Mon, 20 Apr 2026 13:08:45 +0530 Subject: [PATCH 06/31] Refine roof edit selection behavior and panel actions --- .../src/components/systems/roof/roof-edit-system.tsx | 10 +++++----- .../editor/src/components/ui/panels/roof-panel.tsx | 12 +++++++----- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/packages/editor/src/components/systems/roof/roof-edit-system.tsx b/packages/editor/src/components/systems/roof/roof-edit-system.tsx index 3a7f0b01..933416a5 100644 --- a/packages/editor/src/components/systems/roof/roof-edit-system.tsx +++ b/packages/editor/src/components/systems/roof/roof-edit-system.tsx @@ -6,7 +6,7 @@ import { useEffect, useRef } from 'react' * Imperatively toggles the Three.js visibility of roof objects based on the * editor selection — without causing React re-renders in RoofRenderer. * - * When a roof (or one of its segments) is selected: + * When a roof-segment is selected: * - merged-roof mesh is hidden * - segments-wrapper group is shown (individual segments visible for editing) * - all children are marked dirty so RoofSystem rebuilds their geometry @@ -22,14 +22,14 @@ export const RoofEditSystem = () => { useEffect(() => { const nodes = useScene.getState().nodes - // Collect which roof nodes should be in "edit mode" + // Collect which roof nodes should be in "edit mode". + // Selecting the roof itself should keep the merged visual intact so + // material appearance does not jump between merged and per-segment meshes. const activeRoofIds = new Set() for (const id of selectedIds) { const node = nodes[id as AnyNodeId] if (!node) continue - if (node.type === 'roof') { - activeRoofIds.add(id) - } else if (node.type === 'roof-segment' && node.parentId) { + if (node.type === 'roof-segment' && node.parentId) { activeRoofIds.add(node.parentId) } } diff --git a/packages/editor/src/components/ui/panels/roof-panel.tsx b/packages/editor/src/components/ui/panels/roof-panel.tsx index 5fbc573f..06a2973f 100755 --- a/packages/editor/src/components/ui/panels/roof-panel.tsx +++ b/packages/editor/src/components/ui/panels/roof-panel.tsx @@ -208,11 +208,13 @@ export function RoofPanel() { ))} - } - label="Add Segment" - onClick={handleAddSegment} - /> + + } + label="Add Segment" + onClick={handleAddSegment} + /> + From b715e96038ae872398fe7b9efd9983c2153da9c7 Mon Sep 17 00:00:00 2001 From: sudhir Date: Mon, 20 Apr 2026 13:12:20 +0530 Subject: [PATCH 07/31] Fix fence UV generation before geometry merge --- .../core/src/systems/fence/fence-system.tsx | 37 +++++++------------ 1 file changed, 14 insertions(+), 23 deletions(-) diff --git a/packages/core/src/systems/fence/fence-system.tsx b/packages/core/src/systems/fence/fence-system.tsx index 70d29b74..8a6d38c8 100644 --- a/packages/core/src/systems/fence/fence-system.tsx +++ b/packages/core/src/systems/fence/fence-system.tsx @@ -20,26 +20,13 @@ function applyFenceUVs(geometry: THREE.BufferGeometry) { let minX = Number.POSITIVE_INFINITY let minY = Number.POSITIVE_INFINITY let minZ = Number.POSITIVE_INFINITY - let maxX = Number.NEGATIVE_INFINITY - let maxY = Number.NEGATIVE_INFINITY - let maxZ = Number.NEGATIVE_INFINITY for (let index = 0; index < position.count; index += 1) { - const px = position.getX(index) - const py = position.getY(index) - const pz = position.getZ(index) - minX = Math.min(minX, px) - minY = Math.min(minY, py) - minZ = Math.min(minZ, pz) - maxX = Math.max(maxX, px) - maxY = Math.max(maxY, py) - maxZ = Math.max(maxZ, pz) + minX = Math.min(minX, position.getX(index)) + minY = Math.min(minY, position.getY(index)) + minZ = Math.min(minZ, position.getZ(index)) } - const width = Math.max(maxX - minX, 0.001) - const height = Math.max(maxY - minY, 0.001) - const depth = Math.max(maxZ - minZ, 0.001) - for (let index = 0; index < position.count; index += 1) { const px = position.getX(index) const py = position.getY(index) @@ -52,14 +39,14 @@ function applyFenceUVs(geometry: THREE.BufferGeometry) { let v = 0 if (ny >= nx && ny >= nz) { - u = (px - minX) / width - v = (pz - minZ) / depth + u = px - minX + v = pz - minZ } else if (nx >= nz) { - u = (pz - minZ) / depth - v = (py - minY) / height + u = pz - minZ + v = py - minY } else { - u = (px - minX) / width - v = (py - minY) / height + u = px - minX + v = py - minY } uvs[index * 2] = u @@ -157,13 +144,17 @@ function generateFenceGeometry(fence: FenceNode) { const geometries = parts.map((part) => { const geometry = new THREE.BoxGeometry(1, 1, 1) geometry.scale(part.scale[0], part.scale[1], part.scale[2]) + applyFenceUVs(geometry) geometry.translate(part.position[0], part.position[1], part.position[2]) return geometry }) const merged = mergeGeometries(geometries, false) ?? new THREE.BufferGeometry() geometries.forEach((geometry) => geometry.dispose()) - applyFenceUVs(merged) + const mergedUv = merged.getAttribute('uv') + if (mergedUv) { + merged.setAttribute('uv2', new THREE.Float32BufferAttribute(Array.from(mergedUv.array), 2)) + } merged.computeVertexNormals() return merged } From dd5b6e5e835f7944874884bf09bffac0c5583171 Mon Sep 17 00:00:00 2001 From: sudhir Date: Mon, 20 Apr 2026 14:13:47 +0530 Subject: [PATCH 08/31] Fix slab material reload and wall curve step --- .../components/ui/controls/slider-control.tsx | 42 ++++++++++++------- .../src/components/ui/panels/wall-panel.tsx | 2 +- .../renderers/slab/slab-renderer.tsx | 19 ++++++--- 3 files changed, 43 insertions(+), 20 deletions(-) diff --git a/packages/editor/src/components/ui/controls/slider-control.tsx b/packages/editor/src/components/ui/controls/slider-control.tsx index 1746cca3..937f637d 100644 --- a/packages/editor/src/components/ui/controls/slider-control.tsx +++ b/packages/editor/src/components/ui/controls/slider-control.tsx @@ -21,6 +21,20 @@ function stepPrecision(s: number): number { return Math.max(0, Math.ceil(-Math.log10(s))) } +function getAdjustedStep( + baseStep: number, + modifiers: { + shiftKey?: boolean + metaKey?: boolean + ctrlKey?: boolean + altKey?: boolean + }, +): number { + if (modifiers.shiftKey) return baseStep * 10 + if (modifiers.metaKey || modifiers.ctrlKey || modifiers.altKey) return baseStep * 0.1 + return baseStep +} + export function SliderControl({ label, value, @@ -58,16 +72,14 @@ export function SliderControl({ if (isEditing) return e.preventDefault() const direction = e.deltaY < 0 ? 1 : -1 - let s = step - if (e.shiftKey) s = step * 10 - else if (e.altKey) s = step * 0.1 + const s = getAdjustedStep(step, e) const newValue = clamp(valueRef.current + direction * s) const final = Number.parseFloat(newValue.toFixed(stepPrecision(s))) if (final !== valueRef.current) onChange(final) } el.addEventListener('wheel', handleWheel, { passive: false }) return () => el.removeEventListener('wheel', handleWheel) - }, [isEditing, step, clamp, onChange, precision]) + }, [isEditing, step, clamp, onChange]) // Arrow key support while hovered useEffect(() => { @@ -78,9 +90,7 @@ export function SliderControl({ else if (e.key === 'ArrowDown' || e.key === 'ArrowLeft') direction = -1 if (direction !== 0) { e.preventDefault() - let s = step - if (e.shiftKey) s = step * 10 - else if (e.metaKey || e.ctrlKey) s = step * 0.1 + const s = getAdjustedStep(step, e) const newValue = clamp(valueRef.current + direction * s) const final = Number.parseFloat(newValue.toFixed(stepPrecision(s))) if (final !== valueRef.current) onChange(final) @@ -88,7 +98,7 @@ export function SliderControl({ } window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) - }, [isHovered, isEditing, step, clamp, onChange, precision]) + }, [isHovered, isEditing, step, clamp, onChange]) const handleLabelPointerDown = useCallback( (e: React.PointerEvent) => { @@ -107,16 +117,14 @@ export function SliderControl({ if (!dragRef.current) return const { startX, startValue } = dragRef.current const dx = e.clientX - startX - let s = step - if (e.shiftKey) s = step * 10 - else if (e.metaKey || e.ctrlKey) s = step * 0.1 + const s = getAdjustedStep(step, e) // 4 px per step at default sensitivity const newValue = clamp( Number.parseFloat((startValue + (dx / 4) * s).toFixed(stepPrecision(s))), ) onChange(newValue) }, - [step, precision, clamp, onChange], + [step, clamp, onChange], ) const handleLabelPointerUp = useCallback( @@ -163,12 +171,18 @@ export function SliderControl({ setIsEditing(false) } else if (e.key === 'ArrowUp') { e.preventDefault() - const newV = clamp(value + step) + const adjustedStep = getAdjustedStep(step, e) + const newV = clamp( + Number.parseFloat((value + adjustedStep).toFixed(stepPrecision(adjustedStep))), + ) onChange(newV) setInputValue(newV.toFixed(precision)) } else if (e.key === 'ArrowDown') { e.preventDefault() - const newV = clamp(value - step) + const adjustedStep = getAdjustedStep(step, e) + const newV = clamp( + Number.parseFloat((value - adjustedStep).toFixed(stepPrecision(adjustedStep))), + ) onChange(newV) setInputValue(newV.toFixed(precision)) } diff --git a/packages/editor/src/components/ui/panels/wall-panel.tsx b/packages/editor/src/components/ui/panels/wall-panel.tsx index 96cd697b..65f2d7d8 100755 --- a/packages/editor/src/components/ui/panels/wall-panel.tsx +++ b/packages/editor/src/components/ui/panels/wall-panel.tsx @@ -228,7 +228,7 @@ export function WallPanel() { min={-Math.max(0.01, maxCurveOffset)} onChange={(v) => handleUpdate({ curveOffset: normalizeWallCurveOffset(node, v) })} precision={2} - step={0.01} + step={0.1} unit="m" value={Math.round(curveOffset * 100) / 100} /> diff --git a/packages/viewer/src/components/renderers/slab/slab-renderer.tsx b/packages/viewer/src/components/renderers/slab/slab-renderer.tsx index 537433fe..f491cef2 100644 --- a/packages/viewer/src/components/renderers/slab/slab-renderer.tsx +++ b/packages/viewer/src/components/renderers/slab/slab-renderer.tsx @@ -1,11 +1,11 @@ -import { type SlabNode, useRegistry } from '@pascal-app/core' +import { getMaterialPresetByRef, type SlabNode, useRegistry } from '@pascal-app/core' import { useEffect, useMemo, useRef } from 'react' import * as THREE from 'three' import type { Mesh } from 'three' import { useNodeEvents } from '../../../hooks/use-node-events' import { + applyMaterialPresetToMaterials, createMaterial, - createMaterialFromPresetRef, DEFAULT_SLAB_MATERIAL, } from '../../../lib/materials' @@ -17,9 +17,18 @@ export const SlabRenderer = ({ node }: { node: SlabNode }) => { const handlers = useNodeEvents(node, 'slab') const material = useMemo(() => { - const presetMaterial = createMaterialFromPresetRef(node.materialPreset) - const sourceMaterial = presetMaterial ?? (node.material ? createMaterial(node.material) : DEFAULT_SLAB_MATERIAL) - const slabMaterial = sourceMaterial.clone() + const preset = getMaterialPresetByRef(node.materialPreset) + const slabMaterial = preset + ? new THREE.MeshStandardMaterial() + : node.material + ? createMaterial(node.material).clone() + : DEFAULT_SLAB_MATERIAL.clone() + + if (preset) { + // Apply the preset to the slab-owned material so async texture loads update + // the instance we actually render after refresh as well. + applyMaterialPresetToMaterials(slabMaterial, preset) + } // Slabs participate in the WebGPU MRT scene pass. Keeping them opaque avoids // pipeline variants that can fail when geometry is regenerated while a From 8cf2df14832e7847fc5db9cfd2b71b0ad2466d08 Mon Sep 17 00:00:00 2001 From: sudhir Date: Mon, 20 Apr 2026 22:45:56 +0530 Subject: [PATCH 09/31] Unify material target selection across wall stair and roof --- packages/core/src/events/bus.ts | 3 +- .../components/editor/selection-manager.tsx | 194 ++++++++---------- .../ui/controls/material-picker.tsx | 7 +- .../src/components/ui/panels/roof-panel.tsx | 29 +-- .../src/components/ui/panels/stair-panel.tsx | 29 +-- .../src/components/ui/panels/wall-panel.tsx | 27 +-- packages/editor/src/store/use-editor.tsx | 38 +--- packages/viewer/src/hooks/use-node-events.ts | 20 +- 8 files changed, 152 insertions(+), 195 deletions(-) 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'] From ffa78f1344b9254a59bf4e0231340b91661b913a Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 21 Apr 2026 11:12:04 +0530 Subject: [PATCH 10/31] Fix realtime undo updates for wall and fence moves --- packages/core/src/index.ts | 6 ++++ packages/core/src/lib/space-detection.ts | 10 ++++-- packages/core/src/store/history-control.ts | 36 +++++++++++++++++++ packages/core/src/store/use-scene.ts | 7 ++-- .../src/components/editor/floorplan-panel.tsx | 10 ++++-- .../tools/fence/move-fence-tool.tsx | 8 +++++ .../tools/wall/move-wall-endpoint-tool.tsx | 20 +++++++---- .../components/tools/wall/move-wall-tool.tsx | 24 ++++++++----- .../ui/command-palette/editor-commands.tsx | 5 +-- packages/editor/src/hooks/use-keyboard.ts | 5 +-- packages/editor/src/lib/history.ts | 20 +++++++++++ 11 files changed, 127 insertions(+), 24 deletions(-) create mode 100644 packages/core/src/store/history-control.ts create mode 100644 packages/editor/src/lib/history.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 134770e2..8c7778ba 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -54,6 +54,12 @@ export { type ItemInteractiveState, useInteractive, } from './store/use-interactive' +export { + getSceneHistoryPauseDepth, + pauseSceneHistory, + resetSceneHistoryPauseDepth, + resumeSceneHistory, +} from './store/history-control' export { default as useLiveTransforms, type LiveTransform } from './store/use-live-transforms' export { FenceSystem } from './systems/fence/fence-system' export { clearSceneHistory, default as useScene } from './store/use-scene' diff --git a/packages/core/src/lib/space-detection.ts b/packages/core/src/lib/space-detection.ts index 9aaed406..eb7ef6cc 100644 --- a/packages/core/src/lib/space-detection.ts +++ b/packages/core/src/lib/space-detection.ts @@ -4,6 +4,11 @@ import { isCurvedWall, } from '../systems/wall/wall-curve' import { CeilingNode, SlabNode, type CeilingNode as CeilingNodeType, type SlabNode as SlabNodeType, type WallNode } from '../schema' +import { + getSceneHistoryPauseDepth, + pauseSceneHistory, + resumeSceneHistory, +} from '../store/history-control' import { simplifyClosedPolygon } from './polygon-geometry' type Point2D = { x: number; y: number } @@ -855,6 +860,7 @@ export function initSpaceDetectionSync(sceneStore: any, editorStore: any): () => const unsubscribe = sceneStore.subscribe((state: any) => { if (isProcessing) return + if (getSceneHistoryPauseDepth() > 0) return const nodes = state.nodes const wallsByLevel = new Map() @@ -889,11 +895,11 @@ export function initSpaceDetectionSync(sceneStore: any, editorStore: any): () => } isProcessing = true - sceneStore.temporal.getState().pause() + pauseSceneHistory(sceneStore) try { runSpaceDetection([...levelsToUpdate], sceneStore, editorStore, nodes) } finally { - sceneStore.temporal.getState().resume() + resumeSceneHistory(sceneStore) previousSnapshots.clear() for (const [levelId, snapshot] of currentSnapshots.entries()) { previousSnapshots.set(levelId, snapshot) diff --git a/packages/core/src/store/history-control.ts b/packages/core/src/store/history-control.ts new file mode 100644 index 00000000..51e191b6 --- /dev/null +++ b/packages/core/src/store/history-control.ts @@ -0,0 +1,36 @@ +let sceneHistoryPauseDepth = 0 + +type TemporalStoreLike = { + temporal: { + getState(): { + pause(): void + resume(): void + } + } +} + +export function pauseSceneHistory(sceneStore: TemporalStoreLike): void { + if (sceneHistoryPauseDepth === 0) { + sceneStore.temporal.getState().pause() + } + sceneHistoryPauseDepth += 1 +} + +export function resumeSceneHistory(sceneStore: TemporalStoreLike): void { + if (sceneHistoryPauseDepth === 0) { + return + } + + sceneHistoryPauseDepth -= 1 + if (sceneHistoryPauseDepth === 0) { + sceneStore.temporal.getState().resume() + } +} + +export function getSceneHistoryPauseDepth(): number { + return sceneHistoryPauseDepth +} + +export function resetSceneHistoryPauseDepth(): void { + sceneHistoryPauseDepth = 0 +} diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index ca310349..28441f69 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -11,6 +11,7 @@ import { SiteNode } from '../schema/nodes/site' import { StairNode as StairNodeSchema } from '../schema/nodes/stair' import { StairSegmentNode as StairSegmentNodeSchema } from '../schema/nodes/stair-segment' import type { AnyNode, AnyNodeId } from '../schema/types' +import { resetSceneHistoryPauseDepth } from './history-control' import * as nodeActions from './actions/node-actions' function getFiniteNumber(value: unknown, fallback: number) { @@ -628,6 +629,7 @@ let prevNodesSnapshot: Record | null = null export function clearSceneHistory() { useScene.temporal.getState().clear() + resetSceneHistoryPauseDepth() prevPastLength = 0 prevFutureLength = 0 prevNodesSnapshot = null @@ -647,8 +649,9 @@ useScene.temporal.subscribe((state) => { // Capture the previous snapshot before RAF fires const snapshotBefore = prevNodesSnapshot - // Use RAF to ensure all middleware and store updates are complete - requestAnimationFrame(() => { + // Defer to a microtask so the scene store has settled before we diff, + // but still mark walls/items dirty before the next paint. + queueMicrotask(() => { const currentNodes = useScene.getState().nodes const { markDirty } = useScene.getState() diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index 826740d8..c8966a76 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -159,6 +159,12 @@ type FloorplanViewport = { width: number } +function floorplanViewportEquals(a: FloorplanViewport | null, b: FloorplanViewport | null) { + if (a === b) return true + if (!(a && b)) return false + return a.centerX === b.centerX && a.centerY === b.centerY && a.width === b.width +} + type SvgPoint = { x: number y: number @@ -6155,12 +6161,12 @@ export function FloorplanPanel() { if (levelChanged) { previousLevelIdRef.current = levelId ?? null hasUserAdjustedViewportRef.current = false - setViewport(fittedViewport) + setViewport((current) => (floorplanViewportEquals(current, fittedViewport) ? current : fittedViewport)) return } if (!hasUserAdjustedViewportRef.current) { - setViewport(fittedViewport) + setViewport((current) => (floorplanViewportEquals(current, fittedViewport) ? current : fittedViewport)) } }, [fittedViewport, levelId]) diff --git a/packages/editor/src/components/tools/fence/move-fence-tool.tsx b/packages/editor/src/components/tools/fence/move-fence-tool.tsx index 4fb36354..10dd688b 100644 --- a/packages/editor/src/components/tools/fence/move-fence-tool.tsx +++ b/packages/editor/src/components/tools/fence/move-fence-tool.tsx @@ -167,6 +167,14 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => { const preview = previewRef.current ?? { start: originalStart, end: originalEnd } wasCommitted = true + + // Restore original baseline while paused so the next resume+update + // registers as a single tracked change (undo reverts to original). + applyNodePreview([ + { id: nodeId, start: originalStart, end: originalEnd }, + ...linkedOriginalsRef.current, + ]) + useScene.temporal.getState().resume() applyNodePreview([ { id: nodeId, start: preview.start, end: preview.end }, diff --git a/packages/editor/src/components/tools/wall/move-wall-endpoint-tool.tsx b/packages/editor/src/components/tools/wall/move-wall-endpoint-tool.tsx index eae0e656..031f433e 100644 --- a/packages/editor/src/components/tools/wall/move-wall-endpoint-tool.tsx +++ b/packages/editor/src/components/tools/wall/move-wall-endpoint-tool.tsx @@ -1,6 +1,14 @@ 'use client' -import { type AnyNodeId, emitter, type GridEvent, useScene, type WallNode } from '@pascal-app/core' +import { + type AnyNodeId, + emitter, + type GridEvent, + pauseSceneHistory, + resumeSceneHistory, + useScene, + type WallNode, +} from '@pascal-app/core' import { Html } from '@react-three/drei' import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useRef, useState } from 'react' @@ -127,7 +135,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ node?.type === 'wall' && (node.parentId ?? null) === (target.wall.parentId ?? null), ) - useScene.temporal.getState().pause() + pauseSceneHistory(useScene) let wasCommitted = false const applyNodePreview = ( @@ -209,7 +217,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ ...linkedOriginalsRef.current, ]) - useScene.temporal.getState().resume() + resumeSceneHistory(useScene) applyNodePreview([ { id: nodeId, start: preview.start, end: preview.end }, ...(altPressedRef.current @@ -222,7 +230,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ preview.end, )), ]) - useScene.temporal.getState().pause() + pauseSceneHistory(useScene) sfxEmitter.emit('sfx:item-place') } @@ -234,7 +242,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ const onCancel = () => { restoreOriginal() useViewer.getState().setSelection({ selectedIds: [nodeId] }) - useScene.temporal.getState().resume() + resumeSceneHistory(useScene) markToolCancelConsumed() exitMoveMode() } @@ -279,7 +287,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ if (!wasCommitted) { restoreOriginal() } - useScene.temporal.getState().resume() + resumeSceneHistory(useScene) emitter.off('grid:move', onGridMove) emitter.off('grid:click', onGridClick) emitter.off('tool:cancel', onCancel) diff --git a/packages/editor/src/components/tools/wall/move-wall-tool.tsx b/packages/editor/src/components/tools/wall/move-wall-tool.tsx index 733e9e38..cd82aa34 100644 --- a/packages/editor/src/components/tools/wall/move-wall-tool.tsx +++ b/packages/editor/src/components/tools/wall/move-wall-tool.tsx @@ -1,6 +1,14 @@ 'use client' -import { type AnyNodeId, emitter, type GridEvent, useScene, type WallNode } from '@pascal-app/core' +import { + type AnyNodeId, + emitter, + type GridEvent, + pauseSceneHistory, + resumeSceneHistory, + useScene, + type WallNode, +} from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useRef, useState } from 'react' import { markToolCancelConsumed } from '../../../hooks/use-keyboard' @@ -24,9 +32,9 @@ function stripWallIsNewMetadata(meta: WallNode['metadata']): WallNode['metadata' return meta } - const nextMeta = { ...(meta as Record) } + const nextMeta = { ...(meta as Record) } as Record delete nextMeta.isNew - return nextMeta + return nextMeta as WallNode['metadata'] } type LinkedWallSnapshot = { @@ -146,7 +154,7 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { const originalCenter = originalCenterRef.current const originalHalfVector = originalHalfVectorRef.current - useScene.temporal.getState().pause() + pauseSceneHistory(useScene) let wasCommitted = false const applyNodePreview = ( @@ -237,7 +245,7 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { ...linkedOriginalsRef.current, ]) - useScene.temporal.getState().resume() + resumeSceneHistory(useScene) const commitUpdates = [ { @@ -266,7 +274,7 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { useScene.getState().markDirty(id) } - useScene.temporal.getState().pause() + pauseSceneHistory(useScene) sfxEmitter.emit('sfx:item-place') useViewer.getState().setSelection({ selectedIds: [nodeId] }) @@ -315,7 +323,7 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { const onCancel = () => { restoreOriginal() useViewer.getState().setSelection({ selectedIds: [nodeId] }) - useScene.temporal.getState().resume() + resumeSceneHistory(useScene) markToolCancelConsumed() exitMoveMode() } @@ -331,7 +339,7 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { restoreOriginal() } shiftPressedRef.current = false - useScene.temporal.getState().resume() + resumeSceneHistory(useScene) emitter.off('grid:move', onGridMove) emitter.off('grid:click', onGridClick) emitter.off('tool:cancel', onCancel) diff --git a/packages/editor/src/components/ui/command-palette/editor-commands.tsx b/packages/editor/src/components/ui/command-palette/editor-commands.tsx index 42d4bc9f..edba21cc 100644 --- a/packages/editor/src/components/ui/command-palette/editor-commands.tsx +++ b/packages/editor/src/components/ui/command-palette/editor-commands.tsx @@ -35,6 +35,7 @@ import { } from 'lucide-react' import { useEffect } from 'react' import { deleteLevelWithFallbackSelection } from '../../../lib/level-selection' +import { runRedo, runUndo } from '../../../lib/history' import { useCommandRegistry } from '../../../store/use-command-registry' import type { StructureTool } from '../../../store/use-editor' import useEditor from '../../../store/use-editor' @@ -313,7 +314,7 @@ export function EditorCommands() { group: 'History', icon: , keywords: ['undo', 'revert', 'back'], - execute: () => run(() => useScene.temporal.getState().undo()), + execute: () => run(() => runUndo()), }, { id: 'editor.history.redo', @@ -321,7 +322,7 @@ export function EditorCommands() { group: 'History', icon: , keywords: ['redo', 'forward', 'repeat'], - execute: () => run(() => useScene.temporal.getState().redo()), + execute: () => run(() => runRedo()), }, // ── Export & Share ─────────────────────────────────────────────────── diff --git a/packages/editor/src/hooks/use-keyboard.ts b/packages/editor/src/hooks/use-keyboard.ts index 511075d9..ccdbfd84 100755 --- a/packages/editor/src/hooks/use-keyboard.ts +++ b/packages/editor/src/hooks/use-keyboard.ts @@ -1,6 +1,7 @@ import { type AnyNodeId, emitter, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { useEffect } from 'react' +import { runRedo, runUndo } from '../lib/history' import { sfxEmitter } from '../lib/sfx-bus' import useEditor from '../store/use-editor' @@ -91,11 +92,11 @@ export const useKeyboard = ({ isVersionPreviewMode = false } = {}) => { } else if (e.key === 'z' && (e.metaKey || e.ctrlKey)) { if (isVersionPreviewMode) return e.preventDefault() - useScene.temporal.getState().undo() + runUndo() } else if (e.key === 'Z' && e.shiftKey && (e.metaKey || e.ctrlKey)) { if (isVersionPreviewMode) return e.preventDefault() - useScene.temporal.getState().redo() + runRedo() } else if (e.key === 'ArrowUp' && (e.metaKey || e.ctrlKey)) { e.preventDefault() const { buildingId, levelId } = useViewer.getState().selection diff --git a/packages/editor/src/lib/history.ts b/packages/editor/src/lib/history.ts new file mode 100644 index 00000000..bdd37fbb --- /dev/null +++ b/packages/editor/src/lib/history.ts @@ -0,0 +1,20 @@ +import { useLiveTransforms, useScene } from '@pascal-app/core' + +function refreshSceneAfterHistoryJump() { + useLiveTransforms.getState().clearAll() + + const state = useScene.getState() + for (const node of Object.values(state.nodes)) { + state.markDirty(node.id) + } +} + +export function runUndo() { + useScene.temporal.getState().undo() + refreshSceneAfterHistoryJump() +} + +export function runRedo() { + useScene.temporal.getState().redo() + refreshSceneAfterHistoryJump() +} From 5b5b80be612101538160687d435e665c2f84ac73 Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 21 Apr 2026 11:18:54 +0530 Subject: [PATCH 11/31] Support fence endpoint moves and drafting snap --- .../editor/floating-action-menu.tsx | 50 ++- .../components/tools/fence/fence-drafting.ts | 9 +- .../tools/fence/move-fence-endpoint-tool.tsx | 327 ++++++++++++++++++ .../src/components/tools/tool-manager.tsx | 3 + packages/editor/src/store/use-editor.tsx | 9 + 5 files changed, 383 insertions(+), 15 deletions(-) create mode 100644 packages/editor/src/components/tools/fence/move-fence-endpoint-tool.tsx diff --git a/packages/editor/src/components/editor/floating-action-menu.tsx b/packages/editor/src/components/editor/floating-action-menu.tsx index 3fc7853f..d06771b5 100755 --- a/packages/editor/src/components/editor/floating-action-menu.tsx +++ b/packages/editor/src/components/editor/floating-action-menu.tsx @@ -49,8 +49,10 @@ export function FloatingActionMenu() { const mode = useEditor((s) => s.mode) const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered) const movingWallEndpoint = useEditor((s) => s.movingWallEndpoint) + const movingFenceEndpoint = useEditor((s) => s.movingFenceEndpoint) const setMovingNode = useEditor((s) => s.setMovingNode) const setMovingWallEndpoint = useEditor((s) => s.setMovingWallEndpoint) + const setMovingFenceEndpoint = useEditor((s) => s.setMovingFenceEndpoint) const setCurvingWall = useEditor((s) => s.setCurvingWall) const setSelection = useViewer((s) => s.setSelection) const setEditingHole = useEditor((s) => s.setEditingHole) @@ -128,12 +130,17 @@ export function FloatingActionMenu() { groupRef.current.position.set(center.x, box.max.y + yOffset, center.z) } - if (node?.type === 'wall') { - const wall = node as WallNode - const wallLength = Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1]) + if (node?.type === 'wall' || node?.type === 'fence') { + const segment = node as WallNode | FenceNode + const segmentLength = Math.hypot( + segment.end[0] - segment.start[0], + segment.end[1] - segment.start[1], + ) const endpointYOffset = 0.35 - const startWorld = obj.localToWorld(new THREE.Vector3(0, 0, 0)) - const endWorld = obj.localToWorld(new THREE.Vector3(wallLength, 0, 0)) + const startLocalX = node.type === 'wall' ? 0 : -segmentLength / 2 + const endLocalX = node.type === 'wall' ? segmentLength : segmentLength / 2 + const startWorld = obj.localToWorld(new THREE.Vector3(startLocalX, 0, 0)) + const endWorld = obj.localToWorld(new THREE.Vector3(endLocalX, 0, 0)) if (startEndpointGroupRef.current) { startEndpointGroupRef.current.position.set( @@ -190,12 +197,18 @@ export function FloatingActionMenu() { const handleEndpointMove = useCallback( (endpoint: 'start' | 'end', e: React.MouseEvent) => { e.stopPropagation() - if (!(node && node.type === 'wall')) return + if (!node) return sfxEmitter.emit('sfx:item-pick') - setMovingWallEndpoint({ wall: node, endpoint }) + if (node.type === 'wall') { + setMovingWallEndpoint({ wall: node, endpoint }) + } else if (node.type === 'fence') { + setMovingFenceEndpoint({ fence: node, endpoint }) + } else { + return + } setSelection({ selectedIds: [] }) }, - [node, setMovingWallEndpoint, setSelection], + [node, setMovingFenceEndpoint, setMovingWallEndpoint, setSelection], ) const handleDuplicate = useCallback( @@ -396,7 +409,8 @@ export function FloatingActionMenu() { if ( !(selectedId && node && isValidType && !isFloorplanHovered && mode !== 'delete') || - movingWallEndpoint + movingWallEndpoint || + movingFenceEndpoint ) return null @@ -426,7 +440,7 @@ export function FloatingActionMenu() { />
- {node?.type === 'wall' && ( + {(node?.type === 'wall' || node?.type === 'fence') && ( <> - ))} - {onChange ? ( - - ) : null} +
+
+ {catalogItems.map((item) => ( + + ))} + {onChange ? ( + + ) : null} +
)} diff --git a/packages/editor/src/components/ui/panels/roof-panel.tsx b/packages/editor/src/components/ui/panels/roof-panel.tsx index 69cdba15..f546a8f3 100755 --- a/packages/editor/src/components/ui/panels/roof-panel.tsx +++ b/packages/editor/src/components/ui/panels/roof-panel.tsx @@ -6,7 +6,6 @@ import { getEffectiveRoofSurfaceMaterial, type MaterialSchema, type RoofNode, - type RoofSurfaceMaterialRole, RoofNode as RoofNodeSchema, type RoofSegmentNode, RoofSegmentNode as RoofSegmentNodeSchema, @@ -16,6 +15,7 @@ import { useViewer } from '@pascal-app/viewer' import { Copy, Move, Plus, Trash2 } from 'lucide-react' import { useCallback } from 'react' import { useShallow } from 'zustand/react/shallow' +import { buildRoofSurfaceMaterialPatch } from '../../../lib/material-paint' import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' import { ActionButton, ActionGroup } from '../controls/action-button' @@ -24,32 +24,6 @@ import { PanelSection } from '../controls/panel-section' import { SliderControl } from '../controls/slider-control' import { PanelWrapper } from './panel-wrapper' -function buildRoofSurfaceMaterialPatch( - node: RoofNode, - targetRole: RoofSurfaceMaterialRole, - material: MaterialSchema | undefined, - materialPreset: string | undefined, -): Partial { - const nextSurfaceMaterial = { material, materialPreset } - const nextTop = - targetRole === 'top' ? nextSurfaceMaterial : getEffectiveRoofSurfaceMaterial(node, 'top') - const nextEdge = - targetRole === 'edge' ? nextSurfaceMaterial : getEffectiveRoofSurfaceMaterial(node, 'edge') - const nextWall = - targetRole === 'wall' ? nextSurfaceMaterial : getEffectiveRoofSurfaceMaterial(node, 'wall') - - return { - topMaterial: nextTop.material, - topMaterialPreset: nextTop.materialPreset, - edgeMaterial: nextEdge.material, - edgeMaterialPreset: nextEdge.materialPreset, - wallMaterial: nextWall.material, - wallMaterialPreset: nextWall.materialPreset, - material: undefined, - materialPreset: undefined, - } -} - export function RoofPanel() { const selectedId = useViewer((s) => s.selection.selectedIds[0]) const setSelection = useViewer((s) => s.setSelection) @@ -57,6 +31,8 @@ export function RoofPanel() { const createNode = useScene((s) => s.createNode) const setMovingNode = useEditor((s) => s.setMovingNode) const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget) + const setActivePaintMaterial = useEditor((s) => s.setActivePaintMaterial) + const setActivePaintTarget = useEditor((s) => s.setActivePaintTarget) const node = useScene((s) => selectedId ? (s.nodes[selectedId as AnyNode['id']] as RoofNode | undefined) : undefined, @@ -93,17 +69,21 @@ export function RoofPanel() { const handleTargetedMaterialChange = useCallback( (material: MaterialSchema) => { if (!node || !materialTargetRole) return + setActivePaintTarget('roof') + setActivePaintMaterial({ material, sourceTarget: 'roof' }) handleUpdate(buildRoofSurfaceMaterialPatch(node, materialTargetRole, material, undefined)) }, - [handleUpdate, materialTargetRole, node], + [handleUpdate, materialTargetRole, node, setActivePaintMaterial, setActivePaintTarget], ) const handleTargetedMaterialPresetChange = useCallback( (materialPreset: string) => { if (!node || !materialTargetRole) return + setActivePaintTarget('roof') + setActivePaintMaterial({ materialPreset, sourceTarget: 'roof' }) handleUpdate(buildRoofSurfaceMaterialPatch(node, materialTargetRole, undefined, materialPreset)) }, - [handleUpdate, materialTargetRole, node], + [handleUpdate, materialTargetRole, node, setActivePaintMaterial, setActivePaintTarget], ) const handleClose = useCallback(() => { diff --git a/packages/editor/src/components/ui/panels/stair-panel.tsx b/packages/editor/src/components/ui/panels/stair-panel.tsx index 8756cc0b..ea3f1d26 100644 --- a/packages/editor/src/components/ui/panels/stair-panel.tsx +++ b/packages/editor/src/components/ui/panels/stair-panel.tsx @@ -8,7 +8,6 @@ import { type MaterialSchema, type StairNode, type StairRailingMode, - type StairSurfaceMaterialRole, type StairSlabOpeningMode, type StairTopLandingMode, type StairType, @@ -21,6 +20,7 @@ import { useViewer } from '@pascal-app/viewer' import { Copy, Move, Plus, Trash2 } from 'lucide-react' import { useCallback } from 'react' import { useShallow } from 'zustand/react/shallow' +import { buildStairSurfaceMaterialPatch } from '../../../lib/material-paint' import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' import { DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE } from '../../tools/stair/stair-defaults' @@ -33,32 +33,6 @@ 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 { - 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' }, @@ -91,6 +65,8 @@ export function StairPanel() { const createNodes = useScene((s) => s.createNodes) const setMovingNode = useEditor((s) => s.setMovingNode) const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget) + const setActivePaintMaterial = useEditor((s) => s.setActivePaintMaterial) + const setActivePaintTarget = useEditor((s) => s.setActivePaintTarget) const node = useScene((s) => selectedId ? (s.nodes[selectedId as AnyNode['id']] as StairNode | undefined) : undefined, @@ -135,17 +111,21 @@ export function StairPanel() { const handleTargetedMaterialChange = useCallback( (material: MaterialSchema) => { if (!node || !materialTargetRole) return + setActivePaintTarget('stair') + setActivePaintMaterial({ material, sourceTarget: 'stair' }) handleUpdate(buildStairSurfaceMaterialPatch(node, materialTargetRole, material, undefined)) }, - [handleUpdate, materialTargetRole, node], + [handleUpdate, materialTargetRole, node, setActivePaintMaterial, setActivePaintTarget], ) const handleTargetedMaterialPresetChange = useCallback( (materialPreset: string) => { if (!node || !materialTargetRole) return + setActivePaintTarget('stair') + setActivePaintMaterial({ materialPreset, sourceTarget: 'stair' }) handleUpdate(buildStairSurfaceMaterialPatch(node, materialTargetRole, undefined, materialPreset)) }, - [handleUpdate, materialTargetRole, node], + [handleUpdate, materialTargetRole, node, setActivePaintMaterial, setActivePaintTarget], ) const handleClose = useCallback(() => { diff --git a/packages/editor/src/components/ui/panels/wall-panel.tsx b/packages/editor/src/components/ui/panels/wall-panel.tsx index 35bd8e5f..899c92fc 100755 --- a/packages/editor/src/components/ui/panels/wall-panel.tsx +++ b/packages/editor/src/components/ui/panels/wall-panel.tsx @@ -11,13 +11,13 @@ import { 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, useMemo } from 'react' import { sfxEmitter } from '../../../lib/sfx-bus' +import { buildWallSurfaceMaterialPatch } from '../../../lib/material-paint' import useEditor from '../../../store/use-editor' import { ActionButton, ActionGroup } from '../controls/action-button' import { MaterialPicker } from '../controls/material-picker' @@ -25,32 +25,6 @@ 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 { - 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) @@ -58,6 +32,8 @@ export function WallPanel() { const setMovingNode = useEditor((s) => s.setMovingNode) const setCurvingWall = useEditor((s) => s.setCurvingWall) const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget) + const setActivePaintMaterial = useEditor((s) => s.setActivePaintMaterial) + const setActivePaintTarget = useEditor((s) => s.setActivePaintTarget) const node = useScene((s) => selectedId ? (s.nodes[selectedId as AnyNode['id']] as WallNode | undefined) : undefined, @@ -143,17 +119,21 @@ export function WallPanel() { const handleMaterialPresetChange = useCallback( (materialPreset: string) => { if (!node || !materialTargetSide) return + setActivePaintTarget('wall') + setActivePaintMaterial({ materialPreset, sourceTarget: 'wall' }) handleUpdate(buildWallSurfaceMaterialPatch(node, materialTargetSide, undefined, materialPreset)) }, - [handleUpdate, materialTargetSide, node], + [handleUpdate, materialTargetSide, node, setActivePaintMaterial, setActivePaintTarget], ) const handleCustomMaterialChange = useCallback( (material: MaterialSchema) => { if (!node || !materialTargetSide) return + setActivePaintTarget('wall') + setActivePaintMaterial({ material, sourceTarget: 'wall' }) handleUpdate(buildWallSurfaceMaterialPatch(node, materialTargetSide, material, undefined)) }, - [handleUpdate, materialTargetSide, node], + [handleUpdate, materialTargetSide, node, setActivePaintMaterial, setActivePaintTarget], ) const handleClose = useCallback(() => { diff --git a/packages/editor/src/hooks/use-keyboard.ts b/packages/editor/src/hooks/use-keyboard.ts index 511075d9..24b53bed 100755 --- a/packages/editor/src/hooks/use-keyboard.ts +++ b/packages/editor/src/hooks/use-keyboard.ts @@ -88,6 +88,13 @@ export const useKeyboard = ({ isVersionPreviewMode = false } = {}) => { if (isVersionPreviewMode) return e.preventDefault() useEditor.getState().setMode('delete') + } else if (e.key === 'p' && !e.metaKey && !e.ctrlKey) { + if (isVersionPreviewMode) return + e.preventDefault() + useEditor.getState().primeMaterialPaintFromSelection() + useEditor.getState().setPhase('structure') + useEditor.getState().setStructureLayer('elements') + useEditor.getState().setMode('material-paint') } else if (e.key === 'z' && (e.metaKey || e.ctrlKey)) { if (isVersionPreviewMode) return e.preventDefault() diff --git a/packages/editor/src/lib/material-paint.ts b/packages/editor/src/lib/material-paint.ts new file mode 100644 index 00000000..7c7fed68 --- /dev/null +++ b/packages/editor/src/lib/material-paint.ts @@ -0,0 +1,237 @@ +'use client' + +import { + getCatalogMaterialById, + getEffectiveRoofSurfaceMaterial, + getEffectiveStairSurfaceMaterial, + getEffectiveWallSurfaceMaterial, + getLibraryMaterialIdFromRef, + type MaterialSchema, + type MaterialTarget, + type RoofNode, + type RoofSurfaceMaterialRole, + type StairNode, + type StairSurfaceMaterialRole, + type WallNode, + type WallSurfaceSide, +} from '@pascal-app/core' + +export type PaintableMaterialTarget = Extract + +export type ActivePaintMaterial = { + material?: MaterialSchema + materialPreset?: string + sourceTarget: PaintableMaterialTarget +} + +export function hasActivePaintMaterial( + material: ActivePaintMaterial | null | undefined, +): material is ActivePaintMaterial { + return Boolean( + material && (material.material !== undefined || material.materialPreset !== undefined), + ) +} + +function getCatalogEntryForActivePaintMaterial(material: ActivePaintMaterial | null | undefined) { + const catalogId = + getLibraryMaterialIdFromRef(material?.materialPreset) ?? material?.material?.id ?? undefined + + return getCatalogMaterialById(catalogId) +} + +export function getActivePaintMaterialLabel(material: ActivePaintMaterial | null | undefined) { + return getCatalogEntryForActivePaintMaterial(material)?.label ?? 'Custom' +} + +export function isActivePaintMaterialCompatible( + material: ActivePaintMaterial | null | undefined, + target: PaintableMaterialTarget, +) { + if (!hasActivePaintMaterial(material)) return false + + const catalogEntry = getCatalogEntryForActivePaintMaterial(material) + if (!catalogEntry) return true + + return catalogEntry.targets.includes(target) +} + +export function buildWallSurfaceMaterialPatch( + node: WallNode, + targetSide: WallSurfaceSide, + material: MaterialSchema | undefined, + materialPreset: string | undefined, +): Partial { + const nextSurfaceMaterial = { material, materialPreset } + const nextInterior = + targetSide === 'interior' + ? nextSurfaceMaterial + : getEffectiveWallSurfaceMaterial(node, 'interior') + const nextExterior = + 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 buildRoofSurfaceMaterialPatch( + node: RoofNode, + targetRole: RoofSurfaceMaterialRole, + material: MaterialSchema | undefined, + materialPreset: string | undefined, +): Partial { + const nextSurfaceMaterial = { material, materialPreset } + const nextTop = + targetRole === 'top' ? nextSurfaceMaterial : getEffectiveRoofSurfaceMaterial(node, 'top') + const nextEdge = + targetRole === 'edge' ? nextSurfaceMaterial : getEffectiveRoofSurfaceMaterial(node, 'edge') + const nextWall = + targetRole === 'wall' ? nextSurfaceMaterial : getEffectiveRoofSurfaceMaterial(node, 'wall') + + return { + topMaterial: nextTop.material, + topMaterialPreset: nextTop.materialPreset, + edgeMaterial: nextEdge.material, + edgeMaterialPreset: nextEdge.materialPreset, + wallMaterial: nextWall.material, + wallMaterialPreset: nextWall.materialPreset, + material: undefined, + materialPreset: undefined, + } +} + +export function buildStairSurfaceMaterialPatch( + node: StairNode, + targetRole: StairSurfaceMaterialRole, + material: MaterialSchema | undefined, + materialPreset: string | undefined, +): Partial { + 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, + } +} + +export function resolveActivePaintMaterialFromSelection(params: { + nodes: Record + selectedId: string | null + selectedMaterialTarget: { + nodeId: string + role: WallSurfaceSide | StairSurfaceMaterialRole | RoofSurfaceMaterialRole + } | null +}): ActivePaintMaterial | null { + const { nodes, selectedId, selectedMaterialTarget } = params + if (!selectedId || !selectedMaterialTarget || selectedMaterialTarget.nodeId !== selectedId) + return null + + const selectedNode = nodes[selectedId] + if (!selectedNode) return null + + if ( + selectedNode.type === 'wall' && + (selectedMaterialTarget.role === 'interior' || selectedMaterialTarget.role === 'exterior') + ) { + const surface = getEffectiveWallSurfaceMaterial(selectedNode, selectedMaterialTarget.role) + return hasActivePaintMaterial({ + material: surface.material, + materialPreset: surface.materialPreset, + sourceTarget: 'wall', + }) + ? { + material: surface.material, + materialPreset: surface.materialPreset, + sourceTarget: 'wall', + } + : null + } + + if ( + selectedNode.type === 'roof' && + (selectedMaterialTarget.role === 'top' || + selectedMaterialTarget.role === 'edge' || + selectedMaterialTarget.role === 'wall') + ) { + const surface = getEffectiveRoofSurfaceMaterial(selectedNode, selectedMaterialTarget.role) + return hasActivePaintMaterial({ + material: surface.material, + materialPreset: surface.materialPreset, + sourceTarget: 'roof', + }) + ? { + material: surface.material, + materialPreset: surface.materialPreset, + sourceTarget: 'roof', + } + : null + } + + if ( + selectedNode.type === 'stair' && + (selectedMaterialTarget.role === 'railing' || + selectedMaterialTarget.role === 'tread' || + selectedMaterialTarget.role === 'side') + ) { + const surface = getEffectiveStairSurfaceMaterial(selectedNode, selectedMaterialTarget.role) + return hasActivePaintMaterial({ + material: surface.material, + materialPreset: surface.materialPreset, + sourceTarget: 'stair', + }) + ? { + material: surface.material, + materialPreset: surface.materialPreset, + sourceTarget: 'stair', + } + : null + } + + return null +} + +export function resolvePaintTargetFromSelection(params: { + nodes: Record + selectedId: string | null +}): PaintableMaterialTarget | null { + const { nodes, selectedId } = params + if (!selectedId) return null + + const selectedNode = nodes[selectedId] + if (!selectedNode) return null + + if (selectedNode.type === 'wall') { + return 'wall' + } + + if (selectedNode.type === 'roof' || selectedNode.type === 'roof-segment') { + return 'roof' + } + + if (selectedNode.type === 'stair' || selectedNode.type === 'stair-segment') { + return 'stair' + } + + return null +} diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index 03d8a0e9..d323f12f 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -9,14 +9,14 @@ import { type FenceNode, type ItemNode, type LevelNode, - type RoofSurfaceMaterialRole, type RoofNode, type RoofSegmentNode, + type RoofSurfaceMaterialRole, type SlabNode, type Space, - type StairSurfaceMaterialRole, type StairNode, type StairSegmentNode, + type StairSurfaceMaterialRole, useScene, type WallNode, type WallSurfaceSide, @@ -26,6 +26,12 @@ import { useViewer } from '@pascal-app/viewer' import { create } from 'zustand' import { persist } from 'zustand/middleware' import { getDefaultCatalogItem } from '../components/ui/item-catalog/catalog-items' +import { + type ActivePaintMaterial, + type PaintableMaterialTarget, + resolveActivePaintMaterialFromSelection, + resolvePaintTargetFromSelection, +} from '../lib/material-paint' const DEFAULT_ACTIVE_SIDEBAR_PANEL = 'site' const DEFAULT_FLOORPLAN_PANE_RATIO = 0.5 @@ -37,7 +43,7 @@ export type SplitOrientation = 'horizontal' | 'vertical' export type Phase = 'site' | 'structure' | 'furnish' -export type Mode = 'select' | 'edit' | 'delete' | 'build' +export type Mode = 'select' | 'edit' | 'delete' | 'build' | 'material-paint' // Structure mode tools (building elements) export type StructureTool = @@ -84,13 +90,22 @@ export type MovingWallEndpoint = { endpoint: 'start' | 'end' } -export type MaterialTargetRole = WallSurfaceSide | StairSurfaceMaterialRole | RoofSurfaceMaterialRole +export type MaterialTargetRole = + | WallSurfaceSide + | StairSurfaceMaterialRole + | RoofSurfaceMaterialRole export type SelectedMaterialTarget = { nodeId: AnyNodeId role: MaterialTargetRole } +type MaterialPaintSelectionSnapshot = { + selectedId: string | null + activePaintTarget: PaintableMaterialTarget + activePaintMaterial: ActivePaintMaterial | null +} + type EditorState = { phase: Phase setPhase: (phase: Phase) => void @@ -140,6 +155,15 @@ type EditorState = { setCurvingWall: (wall: WallNode | null) => void selectedMaterialTarget: SelectedMaterialTarget | null setSelectedMaterialTarget: (target: SelectedMaterialTarget | null) => void + activePaintMaterial: ActivePaintMaterial | null + setActivePaintMaterial: (material: ActivePaintMaterial | null) => void + activePaintTarget: PaintableMaterialTarget + setActivePaintTarget: (target: PaintableMaterialTarget) => void + primeMaterialPaintFromSelection: () => MaterialPaintSelectionSnapshot + paintDisabledFeedbackTick: number + triggerPaintDisabledFeedback: () => void + hoveredPaintTarget: PaintableMaterialTarget | null + setHoveredPaintTarget: (target: PaintableMaterialTarget | null) => void selectedReferenceId: string | null setSelectedReferenceId: (id: string | null) => void // Space detection for cutaway mode @@ -219,7 +243,7 @@ function normalizeModeForPhase(phase: Phase, mode: Mode | undefined): Mode { return 'select' } - return mode === 'build' || mode === 'delete' ? mode : 'select' + return mode === 'build' || mode === 'delete' || mode === 'material-paint' ? mode : 'select' } function normalizeFloorplanPaneRatio(value: unknown): number { @@ -457,6 +481,8 @@ const useEditor = create()( const category = get().catalogCategory ?? 'furniture' set({ selectedItem: getDefaultSelectedItemForCategory(category) }) } + } else if (mode === 'material-paint') { + get().primeMaterialPaintFromSelection() } // When leaving build mode, clear tool else if (tool) { @@ -517,6 +543,48 @@ const useEditor = create()( setCurvingWall: (wall) => set({ curvingWall: wall }), selectedMaterialTarget: null, setSelectedMaterialTarget: (target) => set({ selectedMaterialTarget: target }), + activePaintMaterial: null, + setActivePaintMaterial: (material) => set({ activePaintMaterial: material }), + activePaintTarget: 'wall', + setActivePaintTarget: (target) => + set((state) => + state.activePaintTarget === target ? state : { activePaintTarget: target }, + ), + primeMaterialPaintFromSelection: () => { + const selectedId = + useViewer.getState().selection.selectedIds.length === 1 + ? (useViewer.getState().selection.selectedIds[0] ?? null) + : null + const activePaintTarget = + resolvePaintTargetFromSelection({ + nodes: useScene.getState().nodes, + selectedId, + }) ?? get().activePaintTarget + const activePaintMaterial = resolveActivePaintMaterialFromSelection({ + nodes: useScene.getState().nodes, + selectedId, + selectedMaterialTarget: get().selectedMaterialTarget, + }) + + set({ + activePaintTarget, + ...(activePaintMaterial ? { activePaintMaterial } : {}), + }) + + return { + selectedId, + activePaintTarget, + activePaintMaterial: activePaintMaterial ?? get().activePaintMaterial, + } + }, + paintDisabledFeedbackTick: 0, + triggerPaintDisabledFeedback: () => + set((state) => ({ paintDisabledFeedbackTick: state.paintDisabledFeedbackTick + 1 })), + hoveredPaintTarget: null, + setHoveredPaintTarget: (target) => + set((state) => + state.hoveredPaintTarget === target ? state : { hoveredPaintTarget: target }, + ), selectedReferenceId: null, setSelectedReferenceId: (id) => set({ selectedReferenceId: id }), spaces: {}, diff --git a/packages/viewer/src/components/viewer/post-processing.tsx b/packages/viewer/src/components/viewer/post-processing.tsx index a0b3022e..63217975 100644 --- a/packages/viewer/src/components/viewer/post-processing.tsx +++ b/packages/viewer/src/components/viewer/post-processing.tsx @@ -249,15 +249,33 @@ const PostProcessingPasses = () => { // Hovered: blue visible, yellow hidden, pulsing const hoverVisibleColor = uniform( - new Color(hoverHighlightMode === 'delete' ? 0xef_44_44 : 0x00_aa_ff), + new Color( + hoverHighlightMode === 'delete' + ? 0xef_44_44 + : hoverHighlightMode === 'paint-ready' + ? 0xf59e_0b + : hoverHighlightMode === 'paint-disabled' + ? 0x94_a3_b8 + : 0x00_aa_ff, + ), ) const hoverHiddenColor = uniform( - new Color(hoverHighlightMode === 'delete' ? 0x99_1b_1b : 0xf3_ff_47), + new Color( + hoverHighlightMode === 'delete' + ? 0x99_1b_1b + : hoverHighlightMode === 'paint-ready' + ? 0xfde0_68 + : hoverHighlightMode === 'paint-disabled' + ? 0x47_55_69 + : 0xf3_ff_47, + ), + ) + const hoverStrength = uniform( + hoverHighlightMode === 'delete' ? 6 : hoverHighlightMode === 'paint-disabled' ? 4 : 5, ) - const hoverStrength = uniform(hoverHighlightMode === 'delete' ? 6 : 5) const pulsePeriod = uniform(3) const osc = - hoverHighlightMode === 'delete' + hoverHighlightMode === 'delete' || hoverHighlightMode === 'paint-disabled' ? float(1) : oscSine(time.div(pulsePeriod).mul(2)).mul(0.5).add(0.5) // [ 0.5, 1.0 ] const hoverOutline = outlineNode.secondaryVisibleEdge diff --git a/packages/viewer/src/store/use-viewer.ts b/packages/viewer/src/store/use-viewer.ts index 070ec71d..f390edd6 100644 --- a/packages/viewer/src/store/use-viewer.ts +++ b/packages/viewer/src/store/use-viewer.ts @@ -22,8 +22,8 @@ type ViewerState = { selection: SelectionPath previewSelectedIds: BaseNode['id'][] setPreviewSelectedIds: (ids: BaseNode['id'][]) => void - hoverHighlightMode: 'default' | 'delete' - setHoverHighlightMode: (mode: 'default' | 'delete') => void + hoverHighlightMode: 'default' | 'delete' | 'paint-ready' | 'paint-disabled' + setHoverHighlightMode: (mode: 'default' | 'delete' | 'paint-ready' | 'paint-disabled') => void hoveredId: AnyNode['id'] | ZoneNode['id'] | null setHoveredId: (id: AnyNode['id'] | ZoneNode['id'] | null) => void @@ -85,9 +85,10 @@ const useViewer = create()( previewSelectedIds: [], setPreviewSelectedIds: (ids) => set({ previewSelectedIds: ids }), hoverHighlightMode: 'default', - setHoverHighlightMode: (mode) => set({ hoverHighlightMode: mode }), + setHoverHighlightMode: (mode) => + set((state) => (state.hoverHighlightMode === mode ? state : { hoverHighlightMode: mode })), hoveredId: null, - setHoveredId: (id) => set({ hoveredId: id }), + setHoveredId: (id) => set((state) => (state.hoveredId === id ? state : { hoveredId: id })), cameraMode: 'perspective', setCameraMode: (mode) => set({ cameraMode: mode }), From b925cf1e7a47b16531454b20f0483492012e0a82 Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 21 Apr 2026 15:11:46 +0530 Subject: [PATCH 16/31] Show paint cursor on incompatible clicks --- packages/editor/src/components/editor/index.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index 20e5669e..08ee4eaf 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -686,6 +686,7 @@ function PaintCursorLayer({ const activePaintTarget = useEditor((s) => s.activePaintTarget) const paintDisabledFeedbackTick = useEditor((s) => s.paintDisabledFeedbackTick) const badgeRef = useRef(null) + const lastPointerPositionRef = useRef<{ x: number; y: number } | null>(null) const [showDisabledFeedback, setShowDisabledFeedback] = useState(false) const active = mode === 'material-paint' && !isVersionPreviewMode @@ -714,6 +715,7 @@ function PaintCursorLayer({ const rect = el.getBoundingClientRect() nextX = e.clientX - rect.left nextY = e.clientY - rect.top + lastPointerPositionRef.current = { x: nextX, y: nextY } if (frame === 0) { frame = window.requestAnimationFrame(flushPosition) @@ -755,10 +757,16 @@ function PaintCursorLayer({ if (!paintDisabledFeedbackTick) return const el = containerRef.current + const badge = badgeRef.current + const lastPointerPosition = lastPointerPositionRef.current setShowDisabledFeedback(true) if (el) { el.style.cursor = 'not-allowed' } + if (badge && lastPointerPosition) { + badge.style.display = 'block' + badge.style.transform = `translate(${lastPointerPosition.x + PAINT_CURSOR_BADGE_OFFSET_X}px, ${lastPointerPosition.y + PAINT_CURSOR_BADGE_OFFSET_Y}px)` + } const timeoutId = window.setTimeout(() => { setShowDisabledFeedback(false) if (containerRef.current) { From 65399731f72ae05561e33a53f3a8b1a505fc5b93 Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 21 Apr 2026 15:19:46 +0530 Subject: [PATCH 17/31] Enable material paint for fence slab and ceiling nodes --- .../components/editor/selection-manager.tsx | 40 ++++++++++++- packages/editor/src/lib/material-paint.ts | 58 ++++++++++++++++++- packages/editor/src/store/use-editor.tsx | 2 + 3 files changed, 97 insertions(+), 3 deletions(-) diff --git a/packages/editor/src/components/editor/selection-manager.tsx b/packages/editor/src/components/editor/selection-manager.tsx index 92b1d2f1..a1dd3157 100755 --- a/packages/editor/src/components/editor/selection-manager.tsx +++ b/packages/editor/src/components/editor/selection-manager.tsx @@ -2,13 +2,16 @@ import { type AnyNode, type AnyNodeId, type BuildingNode, + type CeilingNode, emitter, + type FenceNode, type ItemNode, type NodeEvent, type RoofEvent, type RoofNode, type RoofSegmentEvent, resolveLevelId, + type SlabNode, type StairEvent, type StairNode, type StairSegmentEvent, @@ -25,6 +28,7 @@ import { useCallback, useEffect, useRef } from 'react' import { type BufferGeometry, Color, type Material, type Mesh, type Object3D } from 'three' import { buildRoofSurfaceMaterialPatch, + buildSingleSurfaceMaterialPatch, buildStairSurfaceMaterialPatch, buildWallSurfaceMaterialPatch, hasActivePaintMaterial, @@ -609,7 +613,30 @@ export const SelectionManager = () => { } } - const disabledNodeTypes = ['fence', 'item', 'slab', 'ceiling', 'window', 'door', 'zone'] + if (node.type === 'fence' || node.type === 'slab' || node.type === 'ceiling') { + const target = node.type + const compatible = + isActivePaintMaterialCompatible(activePaintMaterial, target) && + hasActivePaintMaterial(activePaintMaterial) + + return { + apply: compatible + ? () => { + useScene + .getState() + .updateNode( + node.id as AnyNodeId, + buildSingleSurfaceMaterialPatch( + activePaintMaterial.material, + activePaintMaterial.materialPreset, + ), + ) + } + : null, + } + } + + const disabledNodeTypes = ['item', 'window', 'door', 'zone'] if (disabledNodeTypes.includes(node.type)) { return { apply: null, @@ -776,6 +803,14 @@ export const SelectionManager = () => { nextMaterialTargetHandled = true } + if ( + (node.type === 'fence' || node.type === 'slab' || node.type === 'ceiling') && + nodeToSelect.type === node.type + ) { + setSelectedMaterialTargetForNode(nodeToSelect, 'surface') + nextMaterialTargetHandled = true + } + if (!nextMaterialTargetHandled && useEditor.getState().selectedMaterialTarget) { useEditor.getState().setSelectedMaterialTarget(null) } @@ -1092,6 +1127,9 @@ const SelectionStateSync = () => { if ( !selectedNode || (selectedNode.type !== 'wall' && + selectedNode.type !== 'fence' && + selectedNode.type !== 'slab' && + selectedNode.type !== 'ceiling' && selectedNode.type !== 'stair' && selectedNode.type !== 'roof') ) { diff --git a/packages/editor/src/lib/material-paint.ts b/packages/editor/src/lib/material-paint.ts index 7c7fed68..88bce034 100644 --- a/packages/editor/src/lib/material-paint.ts +++ b/packages/editor/src/lib/material-paint.ts @@ -1,6 +1,8 @@ 'use client' import { + type CeilingNode, + type FenceNode, getCatalogMaterialById, getEffectiveRoofSurfaceMaterial, getEffectiveStairSurfaceMaterial, @@ -10,13 +12,19 @@ import { type MaterialTarget, type RoofNode, type RoofSurfaceMaterialRole, + type SlabNode, type StairNode, type StairSurfaceMaterialRole, type WallNode, type WallSurfaceSide, } from '@pascal-app/core' -export type PaintableMaterialTarget = Extract +export type PaintableMaterialTarget = Extract< + MaterialTarget, + 'wall' | 'roof' | 'stair' | 'fence' | 'slab' | 'ceiling' +> + +export type SingleSurfaceMaterialRole = 'surface' export type ActivePaintMaterial = { material?: MaterialSchema @@ -135,12 +143,26 @@ export function buildStairSurfaceMaterialPatch( } } +export function buildSingleSurfaceMaterialPatch( + material: MaterialSchema | undefined, + materialPreset: string | undefined, +): Partial { + return { + material, + materialPreset, + } as Partial +} + export function resolveActivePaintMaterialFromSelection(params: { nodes: Record selectedId: string | null selectedMaterialTarget: { nodeId: string - role: WallSurfaceSide | StairSurfaceMaterialRole | RoofSurfaceMaterialRole + role: + | WallSurfaceSide + | StairSurfaceMaterialRole + | RoofSurfaceMaterialRole + | SingleSurfaceMaterialRole } | null }): ActivePaintMaterial | null { const { nodes, selectedId, selectedMaterialTarget } = params @@ -208,6 +230,26 @@ export function resolveActivePaintMaterialFromSelection(params: { : null } + if ( + (selectedNode.type === 'fence' || + selectedNode.type === 'slab' || + selectedNode.type === 'ceiling') && + selectedMaterialTarget.role === 'surface' + ) { + const target = selectedNode.type + return hasActivePaintMaterial({ + material: selectedNode.material, + materialPreset: selectedNode.materialPreset, + sourceTarget: target, + }) + ? { + material: selectedNode.material, + materialPreset: selectedNode.materialPreset, + sourceTarget: target, + } + : null + } + return null } @@ -233,5 +275,17 @@ export function resolvePaintTargetFromSelection(params: { return 'stair' } + if (selectedNode.type === 'fence') { + return 'fence' + } + + if (selectedNode.type === 'slab') { + return 'slab' + } + + if (selectedNode.type === 'ceiling') { + return 'ceiling' + } + return null } diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index d323f12f..da6a952a 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -31,6 +31,7 @@ import { type PaintableMaterialTarget, resolveActivePaintMaterialFromSelection, resolvePaintTargetFromSelection, + type SingleSurfaceMaterialRole, } from '../lib/material-paint' const DEFAULT_ACTIVE_SIDEBAR_PANEL = 'site' @@ -94,6 +95,7 @@ export type MaterialTargetRole = | WallSurfaceSide | StairSurfaceMaterialRole | RoofSurfaceMaterialRole + | SingleSurfaceMaterialRole export type SelectedMaterialTarget = { nodeId: AnyNodeId From 4b5c3beadc22e3f264a6f74de9477bbc2f9c1728 Mon Sep 17 00:00:00 2001 From: Pascal Date: Tue, 21 Apr 2026 20:52:02 +0000 Subject: [PATCH 18/31] release: v0.6.0 Bump @pascal-app/core, @pascal-app/viewer, and @pascal-app/editor to 0.6.0. Update cross-package peer/dev dependency versions. --- bun.lock | 16 ++++++++-------- packages/core/package.json | 2 +- packages/editor/package.json | 10 +++++----- packages/viewer/package.json | 4 ++-- 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/bun.lock b/bun.lock index 484a3711..faa9b569 100644 --- a/bun.lock +++ b/bun.lock @@ -48,7 +48,7 @@ }, "packages/core": { "name": "@pascal-app/core", - "version": "0.5.1", + "version": "0.6.0", "dependencies": { "dedent": "^1.7.1", "idb-keyval": "^6.2.2", @@ -75,7 +75,7 @@ }, "packages/editor": { "name": "@pascal-app/editor", - "version": "0.5.1", + "version": "0.6.0", "dependencies": { "@iconify/react": "^6.0.2", "@number-flow/react": "^0.5.14", @@ -106,8 +106,8 @@ "zustand": "^5.0.11", }, "devDependencies": { - "@pascal-app/core": "^0.5.1", - "@pascal-app/viewer": "^0.5.1", + "@pascal-app/core": "^0.6.0", + "@pascal-app/viewer": "^0.6.0", "@pascal/typescript-config": "*", "@types/howler": "^2.2.12", "@types/node": "^22.19.12", @@ -117,8 +117,8 @@ "typescript": "5.9.3", }, "peerDependencies": { - "@pascal-app/core": "^0.5.1", - "@pascal-app/viewer": "^0.5.1", + "@pascal-app/core": "^0.6.0", + "@pascal-app/viewer": "^0.6.0", "@react-three/drei": "^10", "@react-three/fiber": "^9", "next": ">=15", @@ -167,7 +167,7 @@ }, "packages/viewer": { "name": "@pascal-app/viewer", - "version": "0.5.1", + "version": "0.6.0", "dependencies": { "polygon-clipping": "^0.15.7", "zustand": "^5", @@ -180,7 +180,7 @@ "typescript": "5.9.3", }, "peerDependencies": { - "@pascal-app/core": "^0.5.1", + "@pascal-app/core": "^0.6.0", "@react-three/drei": "^10", "@react-three/fiber": "^9", "react": "^18 || ^19", diff --git a/packages/core/package.json b/packages/core/package.json index 89ceeaa9..05551fe4 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@pascal-app/core", - "version": "0.5.1", + "version": "0.6.0", "description": "Core library for Pascal 3D building editor", "type": "module", "main": "./dist/index.js", diff --git a/packages/editor/package.json b/packages/editor/package.json index ef8403b1..0b0eb3c3 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -1,6 +1,6 @@ { "name": "@pascal-app/editor", - "version": "0.5.1", + "version": "0.6.0", "description": "Pascal building editor component", "type": "module", "exports": { @@ -11,8 +11,8 @@ "check-types": "tsc --noEmit" }, "peerDependencies": { - "@pascal-app/core": "^0.5.1", - "@pascal-app/viewer": "^0.5.1", + "@pascal-app/core": "^0.6.0", + "@pascal-app/viewer": "^0.6.0", "@react-three/drei": "^10", "@react-three/fiber": "^9", "next": ">=15", @@ -50,8 +50,8 @@ "zustand": "^5.0.11" }, "devDependencies": { - "@pascal-app/core": "^0.5.1", - "@pascal-app/viewer": "^0.5.1", + "@pascal-app/core": "^0.6.0", + "@pascal-app/viewer": "^0.6.0", "@pascal/typescript-config": "*", "@types/howler": "^2.2.12", "@types/node": "^22.19.12", diff --git a/packages/viewer/package.json b/packages/viewer/package.json index 68e98d9b..1c214282 100644 --- a/packages/viewer/package.json +++ b/packages/viewer/package.json @@ -1,6 +1,6 @@ { "name": "@pascal-app/viewer", - "version": "0.5.1", + "version": "0.6.0", "description": "3D viewer component for Pascal building editor", "type": "module", "main": "./dist/index.js", @@ -22,7 +22,7 @@ "prepublishOnly": "npm run build" }, "peerDependencies": { - "@pascal-app/core": "^0.5.1", + "@pascal-app/core": "^0.6.0", "@react-three/drei": "^10", "@react-three/fiber": "^9", "react": "^18 || ^19", From f9f8e6abdc30a9c60296d6413f11e091c3c89ab0 Mon Sep 17 00:00:00 2001 From: open-pascal Date: Tue, 21 Apr 2026 20:57:24 +0000 Subject: [PATCH 19/31] docs: add CHANGELOG for v0.6.0 with contributor credits --- CHANGELOG.md | 59 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..42b0a6fe --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,59 @@ +# Changelog + +## 0.6.0 (2026-04-21) + +### Features + +- **Multi-surface material system** — per-surface materials for walls, stairs, roofs with click-targeted 3D editing ([#266](https://github.com/pascalorg/editor/pull/266)) by [@sudhir9297](https://github.com/sudhir9297) +- **Automatic wall-room generation** — closed wall loops auto-split and generate slabs ([#255](https://github.com/pascalorg/editor/pull/255), [#257](https://github.com/pascalorg/editor/pull/257)) by [@sudhir9297](https://github.com/sudhir9297) +- **Stair-slab integration** — stair-driven cutouts in slabs and ceilings, auto ceilings from wall loops +- **Curved fence support** + endpoint move tools ([#267](https://github.com/pascalorg/editor/pull/267)) by [@sudhir9297](https://github.com/sudhir9297) +- **13 material presets** — granite, marble, parquet, wallpaper, wood and more ([#231](https://github.com/pascalorg/editor/pull/231)) by [@sudhir9297](https://github.com/sudhir9297) +- **Export scene system** — GLB, STL, OBJ formats ([#203](https://github.com/pascalorg/editor/pull/203)) by [@zephran-dev](https://github.com/zephran-dev), with STL/OBJ groundwork by [@mvanhorn](https://github.com/mvanhorn) ([#175](https://github.com/pascalorg/editor/pull/175)) +- **Street view / walkthrough mode** ([#173](https://github.com/pascalorg/editor/pull/173)) by [@Yashism](https://github.com/Yashism) +- **Duplicate project** ([#178](https://github.com/pascalorg/editor/pull/178)) by [@kleenkanteen](https://github.com/kleenkanteen) +- **Editable wall length slider** ([#195](https://github.com/pascalorg/editor/pull/195)) by [@zephran-dev](https://github.com/zephran-dev) +- **Infinity dragging slider** using PointerLock API ([#206](https://github.com/pascalorg/editor/pull/206)) by [@claygeo](https://github.com/claygeo) +- **Material system enhancements** ([#201](https://github.com/pascalorg/editor/pull/201)) by [@PMAT77](https://github.com/PMAT77) +- **Editor layout redesign v2** + 3D box select +- **Move/rotate building** + relative positioning for all tools +- **Grid snap toolbar controls** +- **Cut-out button** in floating action menu for slabs and ceilings + +### Fixes + +- **WebGPU renderer** — await `renderer.init()` in Canvas GL factory ([#233](https://github.com/pascalorg/editor/pull/233)) by [@b9llach](https://github.com/b9llach) +- **WebGPU fallback** — skip post-processing when unavailable ([#234](https://github.com/pascalorg/editor/pull/234)) by [@b9llach](https://github.com/b9llach) +- **Crash on mode switch** — fix crash when switching to Furniture mode ([#237](https://github.com/pascalorg/editor/pull/237)) by [@txhno](https://github.com/txhno) +- **Crash on duplicate** — prevent crash when duplicating elements ([#239](https://github.com/pascalorg/editor/pull/239)) by [@nnhhoang](https://github.com/nnhhoang) +- **Delete walls/slabs** via floating action menu ([#180](https://github.com/pascalorg/editor/pull/180)) by [@nnhhoang](https://github.com/nnhhoang) +- **Counter-clockwise rotation** — T key for CCW rotation on selected nodes ([#184](https://github.com/pascalorg/editor/pull/184)) by [@nnhhoang](https://github.com/nnhhoang) +- **Scene singleton cleanup** — release singletons on Editor unmount ([#214](https://github.com/pascalorg/editor/pull/214)) by [@geopenta](https://github.com/geopenta) +- **State management & memory leaks** ([#152](https://github.com/pascalorg/editor/pull/152)) by [@hobostay](https://github.com/hobostay) +- **Ghost wall prevention** — use WALL_MIN_LENGTH constant ([#168](https://github.com/pascalorg/editor/pull/168)) by [@zephran-dev](https://github.com/zephran-dev) +- **Catalog image optimization** — add sizes and loading props ([#189](https://github.com/pascalorg/editor/pull/189)) by [@korvixhq](https://github.com/korvixhq) +- **Code cleanup** — remove unused `@ts-expect-error` directive ([#150](https://github.com/pascalorg/editor/pull/150)) by [@cs68614-hash](https://github.com/cs68614-hash) +- Robust undo/redo with nested history pause/resume +- Post-processing recovery after duplicate scene mutations +- Improved snapping across all geometry types +- Thumbnails, placement, and responsiveness improvements +- Stair elevation sync with floor slabs + +### Contributors + +A huge thank you to everyone who contributed to this release! 🎉 + +- [@sudhir9297](https://github.com/sudhir9297) — material system, wall-room generation, curved walls, stairs, fences (7 PRs!) +- [@zephran-dev](https://github.com/zephran-dev) — export system, wall length slider, ghost wall fix +- [@nnhhoang](https://github.com/nnhhoang) — rotation controls, delete actions, crash fix +- [@b9llach](https://github.com/b9llach) — WebGPU renderer fixes +- [@txhno](https://github.com/txhno) — furniture mode crash fix +- [@Yashism](https://github.com/Yashism) — street view / walkthrough mode +- [@claygeo](https://github.com/claygeo) — infinity dragging slider +- [@geopenta](https://github.com/geopenta) — scene singleton cleanup +- [@kleenkanteen](https://github.com/kleenkanteen) — duplicate project feature +- [@mvanhorn](https://github.com/mvanhorn) — STL/OBJ export formats +- [@PMAT77](https://github.com/PMAT77) — material system enhancements +- [@korvixhq](https://github.com/korvixhq) — catalog image optimization +- [@hobostay](https://github.com/hobostay) — state management & memory leak fixes +- [@cs68614-hash](https://github.com/cs68614-hash) — code cleanup From 87dfdf1b381e37dc8f6c2b28c1a9457c20a813ae Mon Sep 17 00:00:00 2001 From: sudhir Date: Wed, 22 Apr 2026 10:52:35 +0530 Subject: [PATCH 20/31] Add paint hover previews to viewer --- packages/core/src/store/use-scene.ts | 6 +- .../editor/src/components/editor/index.tsx | 24 ++- .../components/editor/selection-manager.tsx | 139 +++++++++++++++++- .../renderers/ceiling/ceiling-renderer.tsx | 45 +++++- .../renderers/fence/fence-renderer.tsx | 26 +++- .../roof-segment/roof-segment-renderer.tsx | 66 ++++++--- .../renderers/roof/roof-renderer.tsx | 48 ++++-- .../renderers/slab/slab-renderer.tsx | 90 ++++++++---- .../stair-segment/stair-segment-renderer.tsx | 67 ++++++--- .../renderers/stair/stair-renderer.tsx | 86 ++++++----- .../renderers/wall/wall-renderer.tsx | 26 +++- packages/viewer/src/store/use-viewer.d.ts | 44 +++++- packages/viewer/src/store/use-viewer.ts | 70 ++++++++- .../viewer/src/systems/roof/roof-materials.ts | 25 +++- .../src/systems/stair/stair-materials.ts | 32 +++- 15 files changed, 638 insertions(+), 156 deletions(-) diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index ca310349..853f783a 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -157,14 +157,16 @@ function migrateStairSurfaceMaterials(node: Record) { if (node.treadMaterial !== undefined || typeof node.treadMaterialPreset === 'string') { return { material: node.treadMaterial, - materialPreset: typeof node.treadMaterialPreset === 'string' ? node.treadMaterialPreset : undefined, + materialPreset: + typeof node.treadMaterialPreset === 'string' ? node.treadMaterialPreset : undefined, } } if (node.sideMaterial !== undefined || typeof node.sideMaterialPreset === 'string') { return { material: node.sideMaterial, - materialPreset: typeof node.sideMaterialPreset === 'string' ? node.sideMaterialPreset : undefined, + materialPreset: + typeof node.sideMaterialPreset === 'string' ? node.sideMaterialPreset : undefined, } } diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index 08ee4eaf..648e9cb8 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -685,10 +685,12 @@ function PaintCursorLayer({ const activePaintMaterial = useEditor((s) => s.activePaintMaterial) const activePaintTarget = useEditor((s) => s.activePaintTarget) const paintDisabledFeedbackTick = useEditor((s) => s.paintDisabledFeedbackTick) + const hoverHighlightMode = useViewer((s) => s.hoverHighlightMode) const badgeRef = useRef(null) const lastPointerPositionRef = useRef<{ x: number; y: number } | null>(null) const [showDisabledFeedback, setShowDisabledFeedback] = useState(false) const active = mode === 'material-paint' && !isVersionPreviewMode + const showBlockedState = showDisabledFeedback || hoverHighlightMode === 'paint-disabled' useEffect(() => { if (!active) { @@ -748,10 +750,26 @@ function PaintCursorLayer({ ) const label = !hasMaterial ? 'Choose material' - : showDisabledFeedback + : showBlockedState ? 'Unsupported' : `Paint ${activePaintTarget}` - const icon = showDisabledFeedback ? 'mdi:block-helper' : 'mdi:format-color-fill' + const icon = showBlockedState ? 'mdi:block-helper' : 'mdi:format-color-fill' + + useEffect(() => { + if (!active) return + + const el = containerRef.current + if (!el) return + + if (hoverHighlightMode === 'paint-disabled') { + el.style.cursor = 'not-allowed' + return () => { + el.style.cursor = '' + } + } + + el.style.cursor = '' + }, [active, containerRef, hoverHighlightMode]) useEffect(() => { if (!paintDisabledFeedbackTick) return @@ -797,7 +815,7 @@ function PaintCursorLayer({ style={{ display: 'none', position: 'absolute', left: 0, top: 0 }} > ['materialPreview'] + +type PaintInteraction = { + apply: (() => void) | null + preview: SceneMaterialPreview + hoveredId: AnyNodeId +} + interface SelectionStrategy { types: SelectableNodeType[] handleSelect: (node: AnyNode, nativeEvent?: any, modifierKeys?: ModifierKeys) => void @@ -503,6 +511,32 @@ export const SelectionManager = () => { if (movingNode || curvingWall) return const triggerPaintDisabledFeedback = useEditor.getState().triggerPaintDisabledFeedback + let hoverFrame = 0 + let pendingHoveredId: AnyNodeId | null = null + let pendingHoverMode: HoverHighlightMode = 'default' + let pendingPreview: SceneMaterialPreview = null + + const flushHoverState = () => { + hoverFrame = 0 + const viewerState = useViewer.getState() + + if (viewerState.hoveredId !== pendingHoveredId) { + useViewer.setState({ hoveredId: pendingHoveredId }) + } + + setHoverHighlightMode(pendingHoverMode) + + if (pendingPreview) { + viewerState.setMaterialPreview(pendingPreview) + } else { + viewerState.clearMaterialPreview() + } + } + + const scheduleHoverState = () => { + if (hoverFrame !== 0) return + hoverFrame = window.requestAnimationFrame(flushHoverState) + } const resolveActivePaintMaterial = () => useEditor.getState().activePaintMaterial ?? @@ -515,11 +549,7 @@ export const SelectionManager = () => { selectedMaterialTarget: useEditor.getState().selectedMaterialTarget, }) - const getPaintInteraction = ( - event: NodeEvent, - ): { - apply: (() => void) | null - } | null => { + const getPaintInteraction = (event: NodeEvent): PaintInteraction | null => { const activePaintMaterial = resolveActivePaintMaterial() const node = event.node @@ -530,6 +560,7 @@ export const SelectionManager = () => { const compatible = role !== null && isActivePaintMaterialCompatible(activePaintMaterial, 'wall') return { + hoveredId: node.id as AnyNodeId, apply: compatible && hasActivePaintMaterial(activePaintMaterial) ? () => { @@ -546,6 +577,16 @@ export const SelectionManager = () => { ) } : null, + preview: + compatible && hasActivePaintMaterial(activePaintMaterial) && role + ? { + nodeId: node.id as AnyNodeId, + target: 'wall', + role, + material: activePaintMaterial.material, + materialPreset: activePaintMaterial.materialPreset, + } + : null, } } @@ -562,6 +603,7 @@ export const SelectionManager = () => { const compatible = role !== null && isActivePaintMaterialCompatible(activePaintMaterial, 'roof') return { + hoveredId: roofNode.id as AnyNodeId, apply: compatible && hasActivePaintMaterial(activePaintMaterial) ? () => { @@ -578,6 +620,16 @@ export const SelectionManager = () => { ) } : null, + preview: + compatible && hasActivePaintMaterial(activePaintMaterial) && role + ? { + nodeId: roofNode.id as AnyNodeId, + target: 'roof', + role, + material: activePaintMaterial.material, + materialPreset: activePaintMaterial.materialPreset, + } + : null, } } @@ -594,6 +646,7 @@ export const SelectionManager = () => { const compatible = role !== null && isActivePaintMaterialCompatible(activePaintMaterial, 'stair') return { + hoveredId: stairNode.id as AnyNodeId, apply: compatible && hasActivePaintMaterial(activePaintMaterial) ? () => { @@ -610,6 +663,16 @@ export const SelectionManager = () => { ) } : null, + preview: + compatible && hasActivePaintMaterial(activePaintMaterial) && role + ? { + nodeId: stairNode.id as AnyNodeId, + target: 'stair', + role, + material: activePaintMaterial.material, + materialPreset: activePaintMaterial.materialPreset, + } + : null, } } @@ -620,6 +683,7 @@ export const SelectionManager = () => { hasActivePaintMaterial(activePaintMaterial) return { + hoveredId: node.id as AnyNodeId, apply: compatible ? () => { useScene @@ -633,19 +697,62 @@ export const SelectionManager = () => { ) } : null, + preview: compatible + ? { + nodeId: node.id as AnyNodeId, + target, + role: 'surface', + material: activePaintMaterial.material, + materialPreset: activePaintMaterial.materialPreset, + } + : null, } } const disabledNodeTypes = ['item', 'window', 'door', 'zone'] if (disabledNodeTypes.includes(node.type)) { return { + hoveredId: node.id as AnyNodeId, apply: null, + preview: null, } } return null } + const onEnter = (event: NodeEvent) => { + if (boxSelectHandled) return + + const interaction = getPaintInteraction(event) + if (!interaction) return + + event.stopPropagation() + + if (!interaction.preview) { + pendingHoveredId = interaction.hoveredId + pendingHoverMode = 'paint-disabled' + pendingPreview = null + scheduleHoverState() + return + } + + pendingHoveredId = interaction.hoveredId + pendingHoverMode = 'paint-ready' + pendingPreview = interaction.preview + scheduleHoverState() + } + + const onLeave = (event: NodeEvent) => { + const interaction = getPaintInteraction(event) + if (!interaction) return + + pendingHoveredId = null + pendingHoverMode = 'default' + pendingPreview = null + scheduleHoverState() + } + const onClick = (event: NodeEvent) => { if (boxSelectHandled) return @@ -654,12 +761,19 @@ export const SelectionManager = () => { event.stopPropagation() + if (hoverFrame !== 0) { + window.cancelAnimationFrame(hoverFrame) + flushHoverState() + } + if (!interaction.apply) { + useViewer.getState().clearMaterialPreview() triggerPaintDisabledFeedback() return } interaction.apply() + useViewer.getState().clearMaterialPreview() } const allTypes = [ @@ -679,14 +793,24 @@ export const SelectionManager = () => { for (const type of allTypes) { emitter.on(`${type}:click` as any, onClick as any) + emitter.on(`${type}:enter` as any, onEnter as any) + emitter.on(`${type}:leave` as any, onLeave as any) } return () => { for (const type of allTypes) { emitter.off(`${type}:click` as any, onClick as any) + emitter.off(`${type}:enter` as any, onEnter as any) + emitter.off(`${type}:leave` as any, onLeave as any) } + if (hoverFrame !== 0) { + window.cancelAnimationFrame(hoverFrame) + } + useViewer.setState({ hoveredId: null }) + setHoverHighlightMode('default') + useViewer.getState().clearMaterialPreview() } - }, [curvingWall, mode, movingNode]) + }, [curvingWall, mode, movingNode, setHoverHighlightMode]) useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { @@ -1241,7 +1365,8 @@ const SelectionMaterialSync = () => { }, [hoverHighlightMode, hoveredId, previewSelectedIds, selectedIds, syncSelectionMaterials]) useEffect(() => { - return useScene.subscribe(() => { + return useScene.subscribe((state, prevState) => { + if (state.nodes === prevState.nodes) return syncSelectionMaterials() }) }, [syncSelectionMaterials]) diff --git a/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx b/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx index 51e17f4a..398abf54 100644 --- a/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx +++ b/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx @@ -1,8 +1,14 @@ -import { type CeilingNode, getMaterialPresetByRef, resolveMaterial, useRegistry } from '@pascal-app/core' +import { + type CeilingNode, + getMaterialPresetByRef, + resolveMaterial, + useRegistry, +} from '@pascal-app/core' import { useMemo, useRef } from 'react' import { float, mix, positionWorld, smoothstep } from 'three/tsl' import { BackSide, FrontSide, type Mesh, MeshBasicNodeMaterial } from 'three/webgpu' import { useNodeEvents } from '../../../hooks/use-node-events' +import useViewer from '../../../store/use-viewer' import { NodeRenderer } from '../node-renderer' const gridScale = 5 @@ -32,18 +38,47 @@ function createCeilingMaterials(color = '#999999') { return { topMaterial, bottomMaterial } } +const ceilingMaterialCache = new Map>() + +function getCeilingMaterials(color = '#999999') { + const cacheKey = color + const cached = ceilingMaterialCache.get(cacheKey) + if (cached) return cached + + const materials = createCeilingMaterials(color) + ceilingMaterialCache.set(cacheKey, materials) + return materials +} + export const CeilingRenderer = ({ node }: { node: CeilingNode }) => { const ref = useRef(null!) useRegistry(node.id, 'ceiling', ref) const handlers = useNodeEvents(node, 'ceiling') + const materialPreview = useViewer((state) => + state.materialPreview?.target === 'ceiling' && state.materialPreview.nodeId === node.id + ? state.materialPreview + : null, + ) const materials = useMemo(() => { - const preset = getMaterialPresetByRef(node.materialPreset) - const props = preset?.mapProperties ?? resolveMaterial(node.material) + const preset = getMaterialPresetByRef(materialPreview?.materialPreset ?? node.materialPreset) + const props = + preset?.mapProperties ?? resolveMaterial(materialPreview?.material ?? node.material) const color = props.color || '#999999' - return createCeilingMaterials(color) - }, [node.materialPreset, node.material, node.material?.preset, node.material?.properties, node.material?.texture]) + return getCeilingMaterials(color) + }, [ + node.materialPreset, + node.material, + node.material?.preset, + node.material?.properties, + node.material?.texture, + materialPreview?.materialPreset, + materialPreview?.material, + materialPreview?.material?.preset, + materialPreview?.material?.properties, + materialPreview?.material?.texture, + ]) return ( diff --git a/packages/viewer/src/components/renderers/fence/fence-renderer.tsx b/packages/viewer/src/components/renderers/fence/fence-renderer.tsx index 7f22d5e8..c5448ada 100644 --- a/packages/viewer/src/components/renderers/fence/fence-renderer.tsx +++ b/packages/viewer/src/components/renderers/fence/fence-renderer.tsx @@ -7,14 +7,22 @@ import { createMaterialFromPresetRef, DEFAULT_STAIR_MATERIAL, } from '../../../lib/materials' +import useViewer from '../../../store/use-viewer' export const FenceRenderer = ({ node }: { node: FenceNode }) => { const ref = useRef(null!) const handlers = useNodeEvents(node, 'fence') + const materialPreview = useViewer((state) => + state.materialPreview?.target === 'fence' && state.materialPreview.nodeId === node.id + ? state.materialPreview + : null, + ) const material = useMemo(() => { - const presetMaterial = createMaterialFromPresetRef(node.materialPreset) + const presetMaterial = createMaterialFromPresetRef( + materialPreview?.materialPreset ?? node.materialPreset, + ) if (presetMaterial) return presetMaterial - const mat = node.material + const mat = materialPreview?.material ?? node.material if (!mat) return DEFAULT_STAIR_MATERIAL return createMaterial(mat) }, [ @@ -23,6 +31,11 @@ export const FenceRenderer = ({ node }: { node: FenceNode }) => { node.material?.preset, node.material?.properties, node.material?.texture, + materialPreview?.materialPreset, + materialPreview?.material, + materialPreview?.material?.preset, + materialPreview?.material?.properties, + materialPreview?.material?.texture, ]) useRegistry(node.id, 'fence', ref) @@ -31,7 +44,14 @@ export const FenceRenderer = ({ node }: { node: FenceNode }) => { }, [node.id]) return ( - + ) diff --git a/packages/viewer/src/components/renderers/roof-segment/roof-segment-renderer.tsx b/packages/viewer/src/components/renderers/roof-segment/roof-segment-renderer.tsx index cd0fbd09..daebf9fb 100644 --- a/packages/viewer/src/components/renderers/roof-segment/roof-segment-renderer.tsx +++ b/packages/viewer/src/components/renderers/roof-segment/roof-segment-renderer.tsx @@ -1,4 +1,10 @@ -import { type AnyNodeId, type RoofNode, type RoofSegmentNode, useRegistry, useScene } from '@pascal-app/core' +import { + type AnyNodeId, + type RoofNode, + type RoofSegmentNode, + useRegistry, + useScene, +} from '@pascal-app/core' import { useEffect, useMemo, useRef } from 'react' import * as THREE from 'three' import { useNodeEvents } from '../../../hooks/use-node-events' @@ -14,8 +20,14 @@ export const RoofSegmentRenderer = ({ node }: { node: RoofSegmentNode }) => { const handlers = useNodeEvents(node, 'roof-segment') const debugColors = useViewer((s) => s.debugColors) - const parentNode = - node.parentId ? (nodes[node.parentId as AnyNodeId] as RoofNode | undefined) : undefined + const parentNode = node.parentId + ? (nodes[node.parentId as AnyNodeId] as RoofNode | undefined) + : undefined + const materialPreview = useViewer((state) => + state.materialPreview?.target === 'roof' && state.materialPreview.nodeId === parentNode?.id + ? state.materialPreview + : null, + ) const placeholderGeometry = useMemo(() => { const geometry = new THREE.BufferGeometry() geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3)) @@ -26,30 +38,40 @@ export const RoofSegmentRenderer = ({ node }: { node: RoofSegmentNode }) => { return geometry }, []) + const previewParentNode = !parentNode + ? undefined + : materialPreview?.role === 'top' + ? { + ...parentNode, + topMaterial: materialPreview.material, + topMaterialPreset: materialPreview.materialPreset, + material: undefined, + materialPreset: undefined, + } + : materialPreview?.role === 'edge' + ? { + ...parentNode, + edgeMaterial: materialPreview.material, + edgeMaterialPreset: materialPreview.materialPreset, + material: undefined, + materialPreset: undefined, + } + : materialPreview?.role === 'wall' + ? { + ...parentNode, + wallMaterial: materialPreview.material, + wallMaterialPreset: materialPreview.materialPreset, + material: undefined, + materialPreset: undefined, + } + : parentNode const customMaterial = useMemo(() => { if (node.material !== undefined || typeof node.materialPreset === 'string') { return null } - return parentNode ? getRoofMaterialArray(parentNode) : null - }, [ - node.materialPreset, - node.material, - node.material?.preset, - node.material?.properties, - node.material?.texture, - parentNode?.materialPreset, - parentNode?.material, - parentNode?.material?.preset, - parentNode?.material?.properties, - parentNode?.material?.texture, - parentNode?.topMaterial, - parentNode?.topMaterialPreset, - parentNode?.edgeMaterial, - parentNode?.edgeMaterialPreset, - parentNode?.wallMaterial, - parentNode?.wallMaterialPreset, - ]) + return previewParentNode ? getRoofMaterialArray(previewParentNode) : null + }, [node, previewParentNode]) const material = debugColors ? roofDebugMaterials : customMaterial || roofMaterials diff --git a/packages/viewer/src/components/renderers/roof/roof-renderer.tsx b/packages/viewer/src/components/renderers/roof/roof-renderer.tsx index a5fd4ee6..4a4eb866 100644 --- a/packages/viewer/src/components/renderers/roof/roof-renderer.tsx +++ b/packages/viewer/src/components/renderers/roof/roof-renderer.tsx @@ -14,6 +14,11 @@ export const RoofRenderer = ({ node }: { node: RoofNode }) => { const handlers = useNodeEvents(node, 'roof') const debugColors = useViewer((s) => s.debugColors) + const materialPreview = useViewer((state) => + state.materialPreview?.target === 'roof' && state.materialPreview.nodeId === node.id + ? state.materialPreview + : null, + ) const placeholderGeometry = useMemo(() => { const geometry = new THREE.BufferGeometry() geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3)) @@ -24,22 +29,33 @@ export const RoofRenderer = ({ node }: { node: RoofNode }) => { return geometry }, []) - const customMaterial = useMemo( - () => getRoofMaterialArray(node), - [ - node.materialPreset, - node.material, - node.material?.preset, - node.material?.properties, - node.material?.texture, - node.topMaterial, - node.topMaterialPreset, - node.edgeMaterial, - node.edgeMaterialPreset, - node.wallMaterial, - node.wallMaterialPreset, - ], - ) + const previewNode = + materialPreview?.role === 'top' + ? { + ...node, + topMaterial: materialPreview.material, + topMaterialPreset: materialPreview.materialPreset, + material: undefined, + materialPreset: undefined, + } + : materialPreview?.role === 'edge' + ? { + ...node, + edgeMaterial: materialPreview.material, + edgeMaterialPreset: materialPreview.materialPreset, + material: undefined, + materialPreset: undefined, + } + : materialPreview?.role === 'wall' + ? { + ...node, + wallMaterial: materialPreview.material, + wallMaterialPreset: materialPreview.materialPreset, + material: undefined, + materialPreset: undefined, + } + : node + const customMaterial = useMemo(() => getRoofMaterialArray(previewNode), [previewNode]) const material = debugColors ? roofDebugMaterials : customMaterial || roofMaterials diff --git a/packages/viewer/src/components/renderers/slab/slab-renderer.tsx b/packages/viewer/src/components/renderers/slab/slab-renderer.tsx index f491cef2..0e97f23d 100644 --- a/packages/viewer/src/components/renderers/slab/slab-renderer.tsx +++ b/packages/viewer/src/components/renderers/slab/slab-renderer.tsx @@ -1,13 +1,50 @@ import { getMaterialPresetByRef, type SlabNode, useRegistry } from '@pascal-app/core' -import { useEffect, useMemo, useRef } from 'react' -import * as THREE from 'three' +import { useMemo, useRef } from 'react' import type { Mesh } from 'three' +import * as THREE from 'three' import { useNodeEvents } from '../../../hooks/use-node-events' import { applyMaterialPresetToMaterials, createMaterial, DEFAULT_SLAB_MATERIAL, } from '../../../lib/materials' +import useViewer from '../../../store/use-viewer' + +const slabMaterialCache = new Map() + +function getSlabMaterial( + cacheKey: string, + params: { material?: SlabNode['material']; materialPreset?: string }, +) { + const cached = slabMaterialCache.get(cacheKey) + if (cached) return cached + + const preset = getMaterialPresetByRef(params.materialPreset) + const slabMaterial = preset + ? new THREE.MeshStandardMaterial() + : params.material + ? createMaterial(params.material).clone() + : DEFAULT_SLAB_MATERIAL.clone() + + if (preset) { + // Apply the preset to the slab-owned material so async texture loads update + // the instance we actually render after refresh as well. + applyMaterialPresetToMaterials(slabMaterial, preset) + } + + // Slabs participate in the WebGPU MRT scene pass. Keeping them opaque avoids + // pipeline variants that can fail when geometry is regenerated while a + // transparent/custom material is attached. + slabMaterial.transparent = false + slabMaterial.opacity = 1 + slabMaterial.alphaMap = null + slabMaterial.side = THREE.DoubleSide + slabMaterial.depthWrite = true + slabMaterial.needsUpdate = true + + slabMaterialCache.set(cacheKey, slabMaterial) + return slabMaterial +} export const SlabRenderer = ({ node }: { node: SlabNode }) => { const ref = useRef(null!) @@ -15,46 +52,37 @@ export const SlabRenderer = ({ node }: { node: SlabNode }) => { useRegistry(node.id, 'slab', ref) const handlers = useNodeEvents(node, 'slab') + const materialPreview = useViewer((state) => + state.materialPreview?.target === 'slab' && state.materialPreview.nodeId === node.id + ? state.materialPreview + : null, + ) const material = useMemo(() => { - const preset = getMaterialPresetByRef(node.materialPreset) - const slabMaterial = preset - ? new THREE.MeshStandardMaterial() - : node.material - ? createMaterial(node.material).clone() - : DEFAULT_SLAB_MATERIAL.clone() + const resolvedMaterial = materialPreview?.material ?? node.material + const resolvedMaterialPreset = materialPreview?.materialPreset ?? node.materialPreset + const cacheKey = JSON.stringify({ + material: resolvedMaterial ?? null, + materialPreset: resolvedMaterialPreset ?? null, + }) - if (preset) { - // Apply the preset to the slab-owned material so async texture loads update - // the instance we actually render after refresh as well. - applyMaterialPresetToMaterials(slabMaterial, preset) - } - - // Slabs participate in the WebGPU MRT scene pass. Keeping them opaque avoids - // pipeline variants that can fail when geometry is regenerated while a - // transparent/custom material is attached. - slabMaterial.transparent = false - slabMaterial.opacity = 1 - slabMaterial.alphaMap = null - slabMaterial.side = THREE.DoubleSide - slabMaterial.depthWrite = true - slabMaterial.needsUpdate = true - - return slabMaterial + return getSlabMaterial(cacheKey, { + material: resolvedMaterial, + materialPreset: resolvedMaterialPreset, + }) }, [ node.material, node.material?.preset, node.material?.properties, node.material?.texture, node.materialPreset, + materialPreview?.material, + materialPreview?.material?.preset, + materialPreview?.material?.properties, + materialPreview?.material?.texture, + materialPreview?.materialPreset, ]) - useEffect(() => { - return () => { - material.dispose() - } - }, [material]) - return ( { @@ -15,29 +22,45 @@ export const StairSegmentRenderer = ({ node }: { node: StairSegmentNode }) => { }, [node.id]) const handlers = useNodeEvents(node, 'stair-segment') - const parentNode = - node.parentId ? (nodes[node.parentId as AnyNodeId] as StairNode | undefined) : undefined + const parentNode = node.parentId + ? (nodes[node.parentId as AnyNodeId] as StairNode | undefined) + : undefined + const materialPreview = useViewer((state) => + state.materialPreview?.target === 'stair' && state.materialPreview.nodeId === parentNode?.id + ? state.materialPreview + : null, + ) + const previewParentNode = !parentNode + ? undefined + : materialPreview?.role === 'railing' + ? { + ...parentNode, + railingMaterial: materialPreview.material, + railingMaterialPreset: materialPreview.materialPreset, + material: undefined, + materialPreset: undefined, + } + : materialPreview?.role === 'tread' + ? { + ...parentNode, + treadMaterial: materialPreview.material, + treadMaterialPreset: materialPreview.materialPreset, + material: undefined, + materialPreset: undefined, + } + : materialPreview?.role === 'side' + ? { + ...parentNode, + sideMaterial: materialPreview.material, + sideMaterialPreset: materialPreview.materialPreset, + material: undefined, + materialPreset: undefined, + } + : parentNode const material = useMemo(() => { - return getStraightStairSegmentBodyMaterials(node, parentNode) - }, [ - node.materialPreset, - node.material, - node.material?.preset, - node.material?.properties, - node.material?.texture, - parentNode?.materialPreset, - parentNode?.material, - parentNode?.material?.preset, - parentNode?.material?.properties, - parentNode?.material?.texture, - parentNode?.railingMaterialPreset, - parentNode?.railingMaterial, - parentNode?.sideMaterialPreset, - parentNode?.sideMaterial, - parentNode?.treadMaterialPreset, - parentNode?.treadMaterial, - ]) + return getStraightStairSegmentBodyMaterials(node, previewParentNode) + }, [node, previewParentNode]) const placeholderGeometry = useMemo(() => { const geometry = new THREE.BufferGeometry() diff --git a/packages/viewer/src/components/renderers/stair/stair-renderer.tsx b/packages/viewer/src/components/renderers/stair/stair-renderer.tsx index b79ecec2..4bc8821a 100644 --- a/packages/viewer/src/components/renderers/stair/stair-renderer.tsx +++ b/packages/viewer/src/components/renderers/stair/stair-renderer.tsx @@ -8,10 +8,15 @@ import { import { useEffect, useLayoutEffect, useMemo, useRef } from 'react' import * as THREE from 'three' import { useNodeEvents } from '../../../hooks/use-node-events' -import { createMaterial, createMaterialFromPresetRef, DEFAULT_STAIR_MATERIAL } from '../../../lib/materials' import { - getStairRailingMaterial, + createMaterial, + createMaterialFromPresetRef, + DEFAULT_STAIR_MATERIAL, +} from '../../../lib/materials' +import useViewer from '../../../store/use-viewer' +import { getStairBodyMaterials, + getStairRailingMaterial, type StairBodyMaterials, } from '../../../systems/stair/stair-materials' import { NodeRenderer } from '../node-renderer' @@ -57,48 +62,55 @@ export const StairRenderer = ({ node }: { node: StairNode }) => { }, [node.id]) const handlers = useNodeEvents(node, 'stair') + const materialPreview = useViewer((state) => + state.materialPreview?.target === 'stair' && state.materialPreview.nodeId === node.id + ? state.materialPreview + : null, + ) + const previewNode = + materialPreview?.role === 'railing' + ? { + ...node, + railingMaterial: materialPreview.material, + railingMaterialPreset: materialPreview.materialPreset, + material: undefined, + materialPreset: undefined, + } + : materialPreview?.role === 'tread' + ? { + ...node, + treadMaterial: materialPreview.material, + treadMaterialPreset: materialPreview.materialPreset, + material: undefined, + materialPreset: undefined, + } + : materialPreview?.role === 'side' + ? { + ...node, + sideMaterial: materialPreview.material, + sideMaterialPreset: materialPreview.materialPreset, + material: undefined, + materialPreset: undefined, + } + : node const material = useMemo(() => { - const presetMaterial = createMaterialFromPresetRef(node.materialPreset) + const presetMaterial = createMaterialFromPresetRef(previewNode.materialPreset) if (presetMaterial) return presetMaterial - const mat = node.material + const mat = previewNode.material if (!mat) return DEFAULT_STAIR_MATERIAL return createMaterial(mat) }, [ - node.materialPreset, - node.material, - node.material?.preset, - node.material?.properties, - node.material?.texture, + previewNode.materialPreset, + previewNode.material, + previewNode.material?.preset, + previewNode.material?.properties, + previewNode.material?.texture, ]) - const straightBodyMaterials = useMemo( - () => getStairBodyMaterials(node), - [ - node.material, - node.materialPreset, - node.railingMaterial, - node.railingMaterialPreset, - node.sideMaterial, - node.sideMaterialPreset, - node.treadMaterial, - node.treadMaterialPreset, - ], - ) + const straightBodyMaterials = useMemo(() => getStairBodyMaterials(previewNode), [previewNode]) - const railingMaterial = useMemo( - () => getStairRailingMaterial(node), - [ - node.material, - node.materialPreset, - node.railingMaterial, - node.railingMaterialPreset, - node.sideMaterial, - node.sideMaterialPreset, - node.treadMaterial, - node.treadMaterialPreset, - ], - ) + const railingMaterial = useMemo(() => getStairRailingMaterial(previewNode), [previewNode]) const straightPlaceholderGeometry = useMemo(() => { const geometry = new THREE.BufferGeometry() @@ -132,7 +144,9 @@ export const StairRenderer = ({ node }: { node: StairNode }) => { receiveShadow /> ) : null} - {!isSegmentBasedStair ? : null} + {!isSegmentBasedStair ? ( + + ) : null} {isSegmentBasedStair ? ( diff --git a/packages/viewer/src/components/renderers/wall/wall-renderer.tsx b/packages/viewer/src/components/renderers/wall/wall-renderer.tsx index 9ff38006..a61fbb82 100644 --- a/packages/viewer/src/components/renderers/wall/wall-renderer.tsx +++ b/packages/viewer/src/components/renderers/wall/wall-renderer.tsx @@ -2,6 +2,7 @@ import { useRegistry, useScene, type WallNode } from '@pascal-app/core' import { useLayoutEffect, useRef } from 'react' import type { Mesh } from 'three' import { useNodeEvents } from '../../../hooks/use-node-events' +import useViewer from '../../../store/use-viewer' import { getVisibleWallMaterials } from '../../../systems/wall/wall-materials' import { NodeRenderer } from '../node-renderer' @@ -15,7 +16,30 @@ export const WallRenderer = ({ node }: { node: WallNode }) => { }, [node.id]) const handlers = useNodeEvents(node, 'wall') - const material = getVisibleWallMaterials(node) + const materialPreview = useViewer((state) => + state.materialPreview?.target === 'wall' && state.materialPreview.nodeId === node.id + ? state.materialPreview + : null, + ) + const previewNode = + materialPreview && materialPreview.role === 'interior' + ? { + ...node, + interiorMaterial: materialPreview.material, + interiorMaterialPreset: materialPreview.materialPreset, + material: undefined, + materialPreset: undefined, + } + : materialPreview && materialPreview.role === 'exterior' + ? { + ...node, + exteriorMaterial: materialPreview.material, + exteriorMaterialPreset: materialPreview.materialPreset, + material: undefined, + materialPreset: undefined, + } + : node + const material = getVisibleWallMaterials(previewNode) return ( diff --git a/packages/viewer/src/store/use-viewer.d.ts b/packages/viewer/src/store/use-viewer.d.ts index 4dde28a6..64586624 100644 --- a/packages/viewer/src/store/use-viewer.d.ts +++ b/packages/viewer/src/store/use-viewer.d.ts @@ -1,4 +1,14 @@ -import type { AnyNode, BaseNode, BuildingNode, LevelNode, ZoneNode } from '@pascal-app/core' +import type { + AnyNode, + BaseNode, + BuildingNode, + LevelNode, + MaterialSchema, + RoofSurfaceMaterialRole, + StairSurfaceMaterialRole, + WallSurfaceSide, + ZoneNode, +} from '@pascal-app/core' import type { Object3D } from 'three' type SelectionPath = { buildingId: BuildingNode['id'] | null @@ -10,12 +20,40 @@ type Outliner = { selectedObjects: Object3D[] hoveredObjects: Object3D[] } +type MaterialPreview = { + nodeId: string + target: 'wall' + role: WallSurfaceSide + material?: MaterialSchema + materialPreset?: string +} | { + nodeId: string + target: 'roof' + role: RoofSurfaceMaterialRole + material?: MaterialSchema + materialPreset?: string +} | { + nodeId: string + target: 'stair' + role: StairSurfaceMaterialRole + material?: MaterialSchema + materialPreset?: string +} | { + nodeId: string + target: 'fence' | 'slab' | 'ceiling' + role: 'surface' + material?: MaterialSchema + materialPreset?: string +} | null type ViewerState = { selection: SelectionPath previewSelectedIds: BaseNode['id'][] setPreviewSelectedIds: (ids: BaseNode['id'][]) => void - hoverHighlightMode: 'default' | 'delete' - setHoverHighlightMode: (mode: 'default' | 'delete') => void + materialPreview: MaterialPreview + setMaterialPreview: (preview: MaterialPreview) => void + clearMaterialPreview: () => void + hoverHighlightMode: 'default' | 'delete' | 'paint-ready' | 'paint-disabled' + setHoverHighlightMode: (mode: 'default' | 'delete' | 'paint-ready' | 'paint-disabled') => void hoveredId: AnyNode['id'] | ZoneNode['id'] | null setHoveredId: (id: AnyNode['id'] | ZoneNode['id'] | null) => void cameraMode: 'perspective' | 'orthographic' diff --git a/packages/viewer/src/store/use-viewer.ts b/packages/viewer/src/store/use-viewer.ts index f390edd6..c431ab71 100644 --- a/packages/viewer/src/store/use-viewer.ts +++ b/packages/viewer/src/store/use-viewer.ts @@ -1,6 +1,17 @@ 'use client' -import type { AnyNode, BaseNode, BuildingNode, LevelNode, ZoneNode } from '@pascal-app/core' +import type { + AnyNode, + AnyNodeId, + BaseNode, + BuildingNode, + LevelNode, + MaterialSchema, + RoofSurfaceMaterialRole, + StairSurfaceMaterialRole, + WallSurfaceSide, + ZoneNode, +} from '@pascal-app/core' import type { Object3D } from 'three' import { create } from 'zustand' @@ -18,10 +29,57 @@ type Outliner = { hoveredObjects: Object3D[] } +type MaterialPreview = + | { + nodeId: AnyNodeId + target: 'wall' + role: WallSurfaceSide + material?: MaterialSchema + materialPreset?: string + } + | { + nodeId: AnyNodeId + target: 'roof' + role: RoofSurfaceMaterialRole + material?: MaterialSchema + materialPreset?: string + } + | { + nodeId: AnyNodeId + target: 'stair' + role: StairSurfaceMaterialRole + material?: MaterialSchema + materialPreset?: string + } + | { + nodeId: AnyNodeId + target: 'fence' | 'slab' | 'ceiling' + role: 'surface' + material?: MaterialSchema + materialPreset?: string + } + | null + +function isSameMaterialPreview(left: MaterialPreview, right: MaterialPreview) { + if (left === right) return true + if (!left || !right) return false + + return ( + left.nodeId === right.nodeId && + left.target === right.target && + left.role === right.role && + left.material === right.material && + left.materialPreset === right.materialPreset + ) +} + type ViewerState = { selection: SelectionPath previewSelectedIds: BaseNode['id'][] setPreviewSelectedIds: (ids: BaseNode['id'][]) => void + materialPreview: MaterialPreview + setMaterialPreview: (preview: MaterialPreview) => void + clearMaterialPreview: () => void hoverHighlightMode: 'default' | 'delete' | 'paint-ready' | 'paint-disabled' setHoverHighlightMode: (mode: 'default' | 'delete' | 'paint-ready' | 'paint-disabled') => void hoveredId: AnyNode['id'] | ZoneNode['id'] | null @@ -84,6 +142,15 @@ const useViewer = create()( selection: { buildingId: null, levelId: null, zoneId: null, selectedIds: [] }, previewSelectedIds: [], setPreviewSelectedIds: (ids) => set({ previewSelectedIds: ids }), + materialPreview: null, + setMaterialPreview: (materialPreview) => + set((state) => + isSameMaterialPreview(state.materialPreview, materialPreview) + ? state + : { materialPreview }, + ), + clearMaterialPreview: () => + set((state) => (state.materialPreview === null ? state : { materialPreview: null })), hoverHighlightMode: 'default', setHoverHighlightMode: (mode) => set((state) => (state.hoverHighlightMode === mode ? state : { hoverHighlightMode: mode })), @@ -188,6 +255,7 @@ const useViewer = create()( selectedIds: [], }, previewSelectedIds: [], + materialPreview: null, }), outliner: { selectedObjects: [], hoveredObjects: [] }, diff --git a/packages/viewer/src/systems/roof/roof-materials.ts b/packages/viewer/src/systems/roof/roof-materials.ts index 3991050a..8510cfa6 100644 --- a/packages/viewer/src/systems/roof/roof-materials.ts +++ b/packages/viewer/src/systems/roof/roof-materials.ts @@ -8,6 +8,17 @@ import { createMaterial, createMaterialFromPresetRef } from '../../lib/materials export type RoofMaterialArray = [THREE.Material, THREE.Material, THREE.Material, THREE.Material] +const roofMaterialArrayCache = new Map() + +function getSurfaceMaterialSignature( + spec: ReturnType, +): string { + return JSON.stringify({ + material: spec.material ?? null, + materialPreset: spec.materialPreset ?? null, + }) +} + function createResolvedMaterial( material: RoofNode['material'] | RoofSegmentNode['material'] | undefined, materialPreset: string | undefined, @@ -27,6 +38,14 @@ export function getRoofMaterialArray(node: RoofNode): RoofMaterialArray | null { const top = getEffectiveRoofSurfaceMaterial(node, 'top') const edge = getEffectiveRoofSurfaceMaterial(node, 'edge') const wall = getEffectiveRoofSurfaceMaterial(node, 'wall') + const cacheKey = JSON.stringify({ + top: getSurfaceMaterialSignature(top), + edge: getSurfaceMaterialSignature(edge), + wall: getSurfaceMaterialSignature(wall), + }) + + const cached = roofMaterialArrayCache.get(cacheKey) + if (cached) return cached const topMaterial = createResolvedMaterial(top.material, top.materialPreset) const edgeMaterial = createResolvedMaterial(edge.material, edge.materialPreset) @@ -36,11 +55,13 @@ export function getRoofMaterialArray(node: RoofNode): RoofMaterialArray | null { return null } - return [ + const materialArray: RoofMaterialArray = [ edgeMaterial ?? wallMaterial ?? topMaterial ?? new THREE.MeshStandardMaterial(), wallMaterial ?? edgeMaterial ?? topMaterial ?? new THREE.MeshStandardMaterial(), wallMaterial ?? edgeMaterial ?? topMaterial ?? new THREE.MeshStandardMaterial(), topMaterial ?? wallMaterial ?? edgeMaterial ?? new THREE.MeshStandardMaterial(), ] -} + roofMaterialArrayCache.set(cacheKey, materialArray) + return materialArray +} diff --git a/packages/viewer/src/systems/stair/stair-materials.ts b/packages/viewer/src/systems/stair/stair-materials.ts index 81983479..fb63a628 100644 --- a/packages/viewer/src/systems/stair/stair-materials.ts +++ b/packages/viewer/src/systems/stair/stair-materials.ts @@ -12,6 +12,18 @@ import { export type StairBodyMaterials = [THREE.Material, THREE.Material] +const stairBodyMaterialCache = new Map() +const stairRailingMaterialCache = new Map() + +function getSurfaceMaterialSignature( + spec: ReturnType, +): string { + return JSON.stringify({ + material: spec.material ?? null, + materialPreset: spec.materialPreset ?? null, + }) +} + function createResolvedMaterial( material: StairNode['material'] | StairSegmentNode['material'] | undefined, materialPreset: string | undefined, @@ -30,16 +42,32 @@ function createResolvedMaterial( export function getStairBodyMaterials(stair: StairNode): StairBodyMaterials { const tread = getEffectiveStairSurfaceMaterial(stair, 'tread') const side = getEffectiveStairSurfaceMaterial(stair, 'side') + const cacheKey = JSON.stringify({ + tread: getSurfaceMaterialSignature(tread), + side: getSurfaceMaterialSignature(side), + }) - return [ + const cached = stairBodyMaterialCache.get(cacheKey) + if (cached) return cached + + const materials: StairBodyMaterials = [ createResolvedMaterial(tread.material, tread.materialPreset), createResolvedMaterial(side.material, side.materialPreset), ] + + stairBodyMaterialCache.set(cacheKey, materials) + return materials } export function getStairRailingMaterial(stair: StairNode): THREE.Material { const railing = getEffectiveStairSurfaceMaterial(stair, 'railing') - return createResolvedMaterial(railing.material, railing.materialPreset) + const cacheKey = getSurfaceMaterialSignature(railing) + const cached = stairRailingMaterialCache.get(cacheKey) + if (cached) return cached + + const material = createResolvedMaterial(railing.material, railing.materialPreset) + stairRailingMaterialCache.set(cacheKey, material) + return material } export function getStraightStairSegmentBodyMaterials( From fb3de8debc062408242f8df253ddfe413da3ccf1 Mon Sep 17 00:00:00 2001 From: sudhir Date: Wed, 22 Apr 2026 18:16:20 +0530 Subject: [PATCH 21/31] Refactor material paint preview handling --- .../editor/src/components/editor/index.tsx | 61 +-- .../components/editor/selection-manager.tsx | 346 +++++++++++++----- packages/editor/src/store/use-editor.tsx | 5 - 3 files changed, 253 insertions(+), 159 deletions(-) diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index 648e9cb8..b61644af 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -684,13 +684,8 @@ function PaintCursorLayer({ const mode = useEditor((s) => s.mode) const activePaintMaterial = useEditor((s) => s.activePaintMaterial) const activePaintTarget = useEditor((s) => s.activePaintTarget) - const paintDisabledFeedbackTick = useEditor((s) => s.paintDisabledFeedbackTick) - const hoverHighlightMode = useViewer((s) => s.hoverHighlightMode) const badgeRef = useRef(null) - const lastPointerPositionRef = useRef<{ x: number; y: number } | null>(null) - const [showDisabledFeedback, setShowDisabledFeedback] = useState(false) const active = mode === 'material-paint' && !isVersionPreviewMode - const showBlockedState = showDisabledFeedback || hoverHighlightMode === 'paint-disabled' useEffect(() => { if (!active) { @@ -717,7 +712,6 @@ function PaintCursorLayer({ const rect = el.getBoundingClientRect() nextX = e.clientX - rect.left nextY = e.clientY - rect.top - lastPointerPositionRef.current = { x: nextX, y: nextY } if (frame === 0) { frame = window.requestAnimationFrame(flushPosition) @@ -748,57 +742,8 @@ function PaintCursorLayer({ (activePaintMaterial.material !== undefined || activePaintMaterial.materialPreset !== undefined), ) - const label = !hasMaterial - ? 'Choose material' - : showBlockedState - ? 'Unsupported' - : `Paint ${activePaintTarget}` - const icon = showBlockedState ? 'mdi:block-helper' : 'mdi:format-color-fill' - - useEffect(() => { - if (!active) return - - const el = containerRef.current - if (!el) return - - if (hoverHighlightMode === 'paint-disabled') { - el.style.cursor = 'not-allowed' - return () => { - el.style.cursor = '' - } - } - - el.style.cursor = '' - }, [active, containerRef, hoverHighlightMode]) - - useEffect(() => { - if (!paintDisabledFeedbackTick) return - - const el = containerRef.current - const badge = badgeRef.current - const lastPointerPosition = lastPointerPositionRef.current - setShowDisabledFeedback(true) - if (el) { - el.style.cursor = 'not-allowed' - } - if (badge && lastPointerPosition) { - badge.style.display = 'block' - badge.style.transform = `translate(${lastPointerPosition.x + PAINT_CURSOR_BADGE_OFFSET_X}px, ${lastPointerPosition.y + PAINT_CURSOR_BADGE_OFFSET_Y}px)` - } - const timeoutId = window.setTimeout(() => { - setShowDisabledFeedback(false) - if (containerRef.current) { - containerRef.current.style.cursor = '' - } - }, 320) - - return () => { - window.clearTimeout(timeoutId) - if (el) { - el.style.cursor = '' - } - } - }, [containerRef, paintDisabledFeedbackTick]) + const label = !hasMaterial ? 'Choose material' : `Paint ${activePaintTarget}` + const icon = 'mdi:format-color-fill' useLayoutEffect(() => { if (!active && badgeRef.current) { @@ -815,7 +760,7 @@ function PaintCursorLayer({ style={{ display: 'none', position: 'absolute', left: 0, top: 0 }} > ['materialPreview'] +type PaintPreviewCleanup = () => void type PaintInteraction = { + key: string apply: (() => void) | null - preview: SceneMaterialPreview - hoveredId: AnyNodeId + preview: (() => PaintPreviewCleanup | null) | null } interface SelectionStrategy { @@ -201,6 +215,196 @@ function getIntersectionMaterialIndex( return group?.materialIndex } +function getRegisteredNodeObject(nodeId: string): Object3D | null { + return sceneRegistry.nodes.get(nodeId) ?? null +} + +function getRegisteredMesh(nodeId: string): Mesh | null { + const object = getRegisteredNodeObject(nodeId) + return object && (object as Mesh).isMesh ? (object as Mesh) : null +} + +function previewMeshMaterial(mesh: Mesh, material: Material | Material[]): PaintPreviewCleanup { + const previousMaterial = mesh.material + mesh.material = material + return () => { + mesh.material = previousMaterial + } +} + +function previewCursor(cursor: string): PaintPreviewCleanup { + const previousCursor = document.body.style.cursor + document.body.style.cursor = cursor + return () => { + document.body.style.cursor = previousCursor + } +} + +function getSingleSurfacePreviewMaterial(material: ActivePaintMaterial): Material | null { + if (material.materialPreset) { + return createMaterialFromPresetRef(material.materialPreset) + } + + if (material.material) { + return createMaterial(material.material) + } + + return null +} + +function applyWallPaintPreview( + node: WallNode, + role: WallSurfaceSide, + material: ActivePaintMaterial, +): PaintPreviewCleanup | null { + const mesh = getRegisteredMesh(node.id) + if (!mesh) return null + + const previewNode = { + ...node, + ...buildWallSurfaceMaterialPatch(node, role, material.material, material.materialPreset), + } + + return previewMeshMaterial(mesh, getVisibleWallMaterials(previewNode)) +} + +function applyRoofPaintPreview( + node: RoofNode, + role: 'top' | 'edge' | 'wall', + material: ActivePaintMaterial, +): PaintPreviewCleanup | null { + const root = getRegisteredNodeObject(node.id) + const mesh = root?.getObjectByName('merged-roof') as Mesh | undefined + if (!mesh) return null + + const previewNode = { + ...node, + ...buildRoofSurfaceMaterialPatch(node, role, material.material, material.materialPreset), + } + const previewMaterial = getRoofMaterialArray(previewNode) + if (!previewMaterial) return null + + return previewMeshMaterial(mesh, previewMaterial) +} + +function applyStairPaintPreview( + node: StairNode, + role: StairSurfaceMaterialRole, + material: ActivePaintMaterial, +): PaintPreviewCleanup | null { + const root = getRegisteredNodeObject(node.id) + if (!root) return null + + const previewNode = { + ...node, + ...buildStairSurfaceMaterialPatch(node, role, material.material, material.materialPreset), + } + const bodyMaterials = getStairBodyMaterials(previewNode) + const railingMaterial = getStairRailingMaterial(previewNode) + const restores: PaintPreviewCleanup[] = [] + + root.traverse((object) => { + if (!(object as Mesh).isMesh) return + const mesh = object as Mesh + if (mesh.name.startsWith('stair-railing')) { + restores.push(previewMeshMaterial(mesh, railingMaterial)) + return + } + if (Array.isArray(mesh.material) && mesh.material.length === 2) { + restores.push(previewMeshMaterial(mesh, bodyMaterials)) + return + } + if (mesh.name === 'merged-stair') { + restores.push(previewMeshMaterial(mesh, bodyMaterials)) + return + } + if (mesh.name.startsWith('stair-side')) { + restores.push(previewMeshMaterial(mesh, bodyMaterials[1])) + } + }) + + if (restores.length === 0) return null + + return () => { + for (let index = restores.length - 1; index >= 0; index -= 1) { + restores[index]?.() + } + } +} + +function applySingleSurfacePaintPreview( + node: FenceNode | SlabNode | CeilingNode, + material: ActivePaintMaterial, +): PaintPreviewCleanup | null { + if (node.type === 'ceiling') { + const root = getRegisteredMesh(node.id) + const overlay = root?.getObjectByName('ceiling-grid') as Mesh | undefined + if (!root || !overlay) return null + + const previewColor = + getMaterialPresetByRef(material.materialPreset)?.mapProperties.color ?? + resolveMaterial(material.material).color ?? + '#999999' + + const previousRootMaterial = root.material + const previousOverlayMaterial = overlay.material + const rootPreviewMaterial = Array.isArray(previousRootMaterial) + ? previousRootMaterial.map((entry) => entry.clone()) + : previousRootMaterial.clone() + const overlayPreviewMaterial = Array.isArray(previousOverlayMaterial) + ? previousOverlayMaterial.map((entry) => entry.clone()) + : previousOverlayMaterial.clone() + + const applyColor = (input: Material | Material[]) => { + const materials = Array.isArray(input) ? input : [input] + for (const entry of materials) { + const materialWithColor = entry as Material & { color?: Color; needsUpdate?: boolean } + if (materialWithColor.color instanceof Color) { + materialWithColor.color = new Color(previewColor) + } + materialWithColor.needsUpdate = true + } + } + + applyColor(rootPreviewMaterial) + applyColor(overlayPreviewMaterial) + root.material = rootPreviewMaterial + overlay.material = overlayPreviewMaterial + + return () => { + root.material = previousRootMaterial + overlay.material = previousOverlayMaterial + } + } + + const mesh = getRegisteredMesh(node.id) + if (!mesh) return null + + const previewMaterial = getSingleSurfacePreviewMaterial(material) + if (!previewMaterial) return null + + if (node.type === 'slab') { + const slabMaterial = previewMaterial.clone() + applyMaterialPresetToMaterials(slabMaterial, getMaterialPresetByRef(material.materialPreset)) + const previewMeshMaterialInput = slabMaterial as Material & { + alphaMap?: unknown + depthWrite?: boolean + needsUpdate?: boolean + opacity?: number + side?: number + transparent?: boolean + } + previewMeshMaterialInput.transparent = false + previewMeshMaterialInput.opacity = 1 + previewMeshMaterialInput.alphaMap = null + previewMeshMaterialInput.depthWrite = true + previewMeshMaterialInput.needsUpdate = true + return previewMeshMaterial(mesh, slabMaterial) + } + + return previewMeshMaterial(mesh, previewMaterial) +} + function setSelectedMaterialTargetForNode(node: AnyNode, role: MaterialTargetRole | null) { if (!role) { const currentTarget = useEditor.getState().selectedMaterialTarget @@ -510,32 +714,11 @@ export const SelectionManager = () => { if (mode !== 'material-paint') return if (movingNode || curvingWall) return - const triggerPaintDisabledFeedback = useEditor.getState().triggerPaintDisabledFeedback - let hoverFrame = 0 - let pendingHoveredId: AnyNodeId | null = null - let pendingHoverMode: HoverHighlightMode = 'default' - let pendingPreview: SceneMaterialPreview = null + let activePreview: { key: string; restore: PaintPreviewCleanup } | null = null - const flushHoverState = () => { - hoverFrame = 0 - const viewerState = useViewer.getState() - - if (viewerState.hoveredId !== pendingHoveredId) { - useViewer.setState({ hoveredId: pendingHoveredId }) - } - - setHoverHighlightMode(pendingHoverMode) - - if (pendingPreview) { - viewerState.setMaterialPreview(pendingPreview) - } else { - viewerState.clearMaterialPreview() - } - } - - const scheduleHoverState = () => { - if (hoverFrame !== 0) return - hoverFrame = window.requestAnimationFrame(flushHoverState) + const clearActivePreview = () => { + activePreview?.restore() + activePreview = null } const resolveActivePaintMaterial = () => @@ -560,7 +743,7 @@ export const SelectionManager = () => { const compatible = role !== null && isActivePaintMaterialCompatible(activePaintMaterial, 'wall') return { - hoveredId: node.id as AnyNodeId, + key: `wall:${node.id}:${role ?? 'unsupported'}`, apply: compatible && hasActivePaintMaterial(activePaintMaterial) ? () => { @@ -579,14 +762,8 @@ export const SelectionManager = () => { : null, preview: compatible && hasActivePaintMaterial(activePaintMaterial) && role - ? { - nodeId: node.id as AnyNodeId, - target: 'wall', - role, - material: activePaintMaterial.material, - materialPreset: activePaintMaterial.materialPreset, - } - : null, + ? () => applyWallPaintPreview(node as WallNode, role, activePaintMaterial) + : () => previewCursor('not-allowed'), } } @@ -603,7 +780,7 @@ export const SelectionManager = () => { const compatible = role !== null && isActivePaintMaterialCompatible(activePaintMaterial, 'roof') return { - hoveredId: roofNode.id as AnyNodeId, + key: `roof:${roofNode.id}:${role ?? 'unsupported'}`, apply: compatible && hasActivePaintMaterial(activePaintMaterial) ? () => { @@ -622,14 +799,8 @@ export const SelectionManager = () => { : null, preview: compatible && hasActivePaintMaterial(activePaintMaterial) && role - ? { - nodeId: roofNode.id as AnyNodeId, - target: 'roof', - role, - material: activePaintMaterial.material, - materialPreset: activePaintMaterial.materialPreset, - } - : null, + ? () => applyRoofPaintPreview(roofNode as RoofNode, role, activePaintMaterial) + : () => previewCursor('not-allowed'), } } @@ -646,7 +817,7 @@ export const SelectionManager = () => { const compatible = role !== null && isActivePaintMaterialCompatible(activePaintMaterial, 'stair') return { - hoveredId: stairNode.id as AnyNodeId, + key: `stair:${stairNode.id}:${role ?? 'unsupported'}`, apply: compatible && hasActivePaintMaterial(activePaintMaterial) ? () => { @@ -665,14 +836,8 @@ export const SelectionManager = () => { : null, preview: compatible && hasActivePaintMaterial(activePaintMaterial) && role - ? { - nodeId: stairNode.id as AnyNodeId, - target: 'stair', - role, - material: activePaintMaterial.material, - materialPreset: activePaintMaterial.materialPreset, - } - : null, + ? () => applyStairPaintPreview(stairNode as StairNode, role, activePaintMaterial) + : () => previewCursor('not-allowed'), } } @@ -683,7 +848,7 @@ export const SelectionManager = () => { hasActivePaintMaterial(activePaintMaterial) return { - hoveredId: node.id as AnyNodeId, + key: `${target}:${node.id}:surface`, apply: compatible ? () => { useScene @@ -698,23 +863,21 @@ export const SelectionManager = () => { } : null, preview: compatible - ? { - nodeId: node.id as AnyNodeId, - target, - role: 'surface', - material: activePaintMaterial.material, - materialPreset: activePaintMaterial.materialPreset, - } - : null, + ? () => + applySingleSurfacePaintPreview( + node as FenceNode | SlabNode | CeilingNode, + activePaintMaterial, + ) + : () => previewCursor('not-allowed'), } } const disabledNodeTypes = ['item', 'window', 'door', 'zone'] if (disabledNodeTypes.includes(node.type)) { return { - hoveredId: node.id as AnyNodeId, + key: `${node.type}:${node.id}:unsupported`, apply: null, - preview: null, + preview: () => previewCursor('not-allowed'), } } @@ -729,28 +892,27 @@ export const SelectionManager = () => { event.stopPropagation() - if (!interaction.preview) { - pendingHoveredId = interaction.hoveredId - pendingHoverMode = 'paint-disabled' - pendingPreview = null - scheduleHoverState() + if (activePreview?.key === interaction.key) { return } - pendingHoveredId = interaction.hoveredId - pendingHoverMode = 'paint-ready' - pendingPreview = interaction.preview - scheduleHoverState() + clearActivePreview() + + const restore = interaction.preview?.() + if (restore) { + activePreview = { key: interaction.key, restore } + } } const onLeave = (event: NodeEvent) => { const interaction = getPaintInteraction(event) if (!interaction) return - pendingHoveredId = null - pendingHoverMode = 'default' - pendingPreview = null - scheduleHoverState() + if (activePreview?.key !== interaction.key) { + return + } + + clearActivePreview() } const onClick = (event: NodeEvent) => { @@ -761,19 +923,16 @@ export const SelectionManager = () => { event.stopPropagation() - if (hoverFrame !== 0) { - window.cancelAnimationFrame(hoverFrame) - flushHoverState() - } - if (!interaction.apply) { - useViewer.getState().clearMaterialPreview() - triggerPaintDisabledFeedback() return } interaction.apply() - useViewer.getState().clearMaterialPreview() + if (activePreview?.key === interaction.key) { + activePreview = null + } else { + clearActivePreview() + } } const allTypes = [ @@ -792,25 +951,20 @@ export const SelectionManager = () => { ] as const for (const type of allTypes) { - emitter.on(`${type}:click` as any, onClick as any) emitter.on(`${type}:enter` as any, onEnter as any) emitter.on(`${type}:leave` as any, onLeave as any) + emitter.on(`${type}:click` as any, onClick as any) } return () => { for (const type of allTypes) { - emitter.off(`${type}:click` as any, onClick as any) emitter.off(`${type}:enter` as any, onEnter as any) emitter.off(`${type}:leave` as any, onLeave as any) + emitter.off(`${type}:click` as any, onClick as any) } - if (hoverFrame !== 0) { - window.cancelAnimationFrame(hoverFrame) - } - useViewer.setState({ hoveredId: null }) - setHoverHighlightMode('default') - useViewer.getState().clearMaterialPreview() + clearActivePreview() } - }, [curvingWall, mode, movingNode, setHoverHighlightMode]) + }, [curvingWall, mode, movingNode]) useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index da6a952a..eb030b0a 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -162,8 +162,6 @@ type EditorState = { activePaintTarget: PaintableMaterialTarget setActivePaintTarget: (target: PaintableMaterialTarget) => void primeMaterialPaintFromSelection: () => MaterialPaintSelectionSnapshot - paintDisabledFeedbackTick: number - triggerPaintDisabledFeedback: () => void hoveredPaintTarget: PaintableMaterialTarget | null setHoveredPaintTarget: (target: PaintableMaterialTarget | null) => void selectedReferenceId: string | null @@ -579,9 +577,6 @@ const useEditor = create()( activePaintMaterial: activePaintMaterial ?? get().activePaintMaterial, } }, - paintDisabledFeedbackTick: 0, - triggerPaintDisabledFeedback: () => - set((state) => ({ paintDisabledFeedbackTick: state.paintDisabledFeedbackTick + 1 })), hoveredPaintTarget: null, setHoveredPaintTarget: (target) => set((state) => From 4483957a0de02f0a29529e3b042159862f1934f5 Mon Sep 17 00:00:00 2001 From: sudhir Date: Thu, 23 Apr 2026 10:21:33 +0530 Subject: [PATCH 22/31] Remove material pickers from side panels --- .../components/ui/panels/ceiling-panel.tsx | 26 +------ .../src/components/ui/panels/door-panel.tsx | 12 --- .../src/components/ui/panels/fence-panel.tsx | 26 +------ .../src/components/ui/panels/roof-panel.tsx | 54 ------------- .../ui/panels/roof-segment-panel.tsx | 25 ------ .../src/components/ui/panels/slab-panel.tsx | 26 +------ .../src/components/ui/panels/stair-panel.tsx | 54 ------------- .../ui/panels/stair-segment-panel.tsx | 25 ------ .../src/components/ui/panels/wall-panel.tsx | 76 +------------------ .../src/components/ui/panels/window-panel.tsx | 12 --- 10 files changed, 4 insertions(+), 332 deletions(-) diff --git a/packages/editor/src/components/ui/panels/ceiling-panel.tsx b/packages/editor/src/components/ui/panels/ceiling-panel.tsx index 43365ec1..ee9991eb 100755 --- a/packages/editor/src/components/ui/panels/ceiling-panel.tsx +++ b/packages/editor/src/components/ui/panels/ceiling-panel.tsx @@ -1,13 +1,12 @@ 'use client' -import { type AnyNode, type CeilingNode, type MaterialSchema, useScene } from '@pascal-app/core' +import { type AnyNode, type CeilingNode, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { Edit, Move, Plus, Trash2 } from 'lucide-react' import { useCallback, useEffect } from 'react' import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' import { ActionButton, ActionGroup } from '../controls/action-button' -import { MaterialPicker } from '../controls/material-picker' import { PanelSection } from '../controls/panel-section' import { SliderControl } from '../controls/slider-control' import { PanelWrapper } from './panel-wrapper' @@ -32,20 +31,6 @@ export function CeilingPanel() { [selectedId, updateNode], ) - const handleMaterialChange = useCallback( - (material: MaterialSchema) => { - handleUpdate({ material, materialPreset: undefined }) - }, - [handleUpdate], - ) - - const handleMaterialPresetChange = useCallback( - (materialPreset: string) => { - handleUpdate({ materialPreset, material: undefined }) - }, - [handleUpdate], - ) - const handleClose = useCallback(() => { setSelection({ selectedIds: [] }) setEditingHole(null) @@ -257,15 +242,6 @@ export function CeilingPanel() { - - - } label="Move" onClick={handleMove} /> diff --git a/packages/editor/src/components/ui/panels/door-panel.tsx b/packages/editor/src/components/ui/panels/door-panel.tsx index 4c098d72..e02f13c7 100755 --- a/packages/editor/src/components/ui/panels/door-panel.tsx +++ b/packages/editor/src/components/ui/panels/door-panel.tsx @@ -5,7 +5,6 @@ import { type AnyNodeId, DoorNode, emitter, - type MaterialSchema, useScene, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' @@ -15,7 +14,6 @@ import { usePresetsAdapter } from '../../../contexts/presets-context' import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' import { ActionButton, ActionGroup } from '../controls/action-button' -import { MaterialPicker } from '../controls/material-picker' import { MetricControl } from '../controls/metric-control' import { PanelSection } from '../controls/panel-section' import { SegmentedControl } from '../controls/segmented-control' @@ -46,13 +44,6 @@ export function DoorPanel() { [selectedId, updateNode], ) - const handleMaterialChange = useCallback( - (material: MaterialSchema) => { - handleUpdate({ material }) - }, - [handleUpdate], - ) - const handleClose = useCallback(() => { setSelection({ selectedIds: [] }) }, [setSelection]) @@ -592,9 +583,6 @@ export function DoorPanel() { /> - - - ) } diff --git a/packages/editor/src/components/ui/panels/fence-panel.tsx b/packages/editor/src/components/ui/panels/fence-panel.tsx index 4e2785da..856e5592 100644 --- a/packages/editor/src/components/ui/panels/fence-panel.tsx +++ b/packages/editor/src/components/ui/panels/fence-panel.tsx @@ -1,9 +1,8 @@ 'use client' -import { type AnyNode, type AnyNodeId, type FenceNode, type MaterialSchema, useScene } from '@pascal-app/core' +import { type AnyNode, type AnyNodeId, type FenceNode, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { useCallback } from 'react' -import { MaterialPicker } from '../controls/material-picker' import { PanelSection } from '../controls/panel-section' import { SegmentedControl } from '../controls/segmented-control' import { SliderControl } from '../controls/slider-control' @@ -67,20 +66,6 @@ export function FencePanel() { setSelection({ selectedIds: [] }) }, [setSelection]) - const handleMaterialPresetChange = useCallback( - (materialPreset: string) => { - handleUpdate({ materialPreset, material: undefined }) - }, - [handleUpdate], - ) - - const handleCustomMaterialChange = useCallback( - (material: MaterialSchema) => { - handleUpdate({ material, materialPreset: undefined }) - }, - [handleUpdate], - ) - if (!(node && node.type === 'fence' && selectedId && selectedCount === 1)) return null const dx = node.end[0] - node.start[0] @@ -204,15 +189,6 @@ export function FencePanel() { /> - - - ) } diff --git a/packages/editor/src/components/ui/panels/roof-panel.tsx b/packages/editor/src/components/ui/panels/roof-panel.tsx index f546a8f3..adab45b3 100755 --- a/packages/editor/src/components/ui/panels/roof-panel.tsx +++ b/packages/editor/src/components/ui/panels/roof-panel.tsx @@ -3,8 +3,6 @@ import { type AnyNode, type AnyNodeId, - getEffectiveRoofSurfaceMaterial, - type MaterialSchema, type RoofNode, RoofNode as RoofNodeSchema, type RoofSegmentNode, @@ -15,11 +13,9 @@ import { useViewer } from '@pascal-app/viewer' import { Copy, Move, Plus, Trash2 } from 'lucide-react' import { useCallback } from 'react' import { useShallow } from 'zustand/react/shallow' -import { buildRoofSurfaceMaterialPatch } from '../../../lib/material-paint' import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' import { ActionButton, ActionGroup } from '../controls/action-button' -import { MaterialPicker } from '../controls/material-picker' import { PanelSection } from '../controls/panel-section' import { SliderControl } from '../controls/slider-control' import { PanelWrapper } from './panel-wrapper' @@ -30,9 +26,6 @@ export function RoofPanel() { const updateNode = useScene((s) => s.updateNode) const createNode = useScene((s) => s.createNode) const setMovingNode = useEditor((s) => s.setMovingNode) - const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget) - const setActivePaintMaterial = useEditor((s) => s.setActivePaintMaterial) - const setActivePaintTarget = useEditor((s) => s.setActivePaintTarget) const node = useScene((s) => selectedId ? (s.nodes[selectedId as AnyNode['id']] as RoofNode | undefined) : undefined, @@ -55,37 +48,6 @@ export function RoofPanel() { [selectedId, updateNode], ) - const materialTargetRole = - selectedMaterialTarget && - selectedMaterialTarget.nodeId === node?.id && - (selectedMaterialTarget.role === 'top' || - selectedMaterialTarget.role === 'edge' || - selectedMaterialTarget.role === 'wall') - ? selectedMaterialTarget.role - : null - const materialPickerValue = - node && materialTargetRole ? getEffectiveRoofSurfaceMaterial(node, materialTargetRole) : {} - - const handleTargetedMaterialChange = useCallback( - (material: MaterialSchema) => { - if (!node || !materialTargetRole) return - setActivePaintTarget('roof') - setActivePaintMaterial({ material, sourceTarget: 'roof' }) - handleUpdate(buildRoofSurfaceMaterialPatch(node, materialTargetRole, material, undefined)) - }, - [handleUpdate, materialTargetRole, node, setActivePaintMaterial, setActivePaintTarget], - ) - - const handleTargetedMaterialPresetChange = useCallback( - (materialPreset: string) => { - if (!node || !materialTargetRole) return - setActivePaintTarget('roof') - setActivePaintMaterial({ materialPreset, sourceTarget: 'roof' }) - handleUpdate(buildRoofSurfaceMaterialPatch(node, materialTargetRole, undefined, materialPreset)) - }, - [handleUpdate, materialTargetRole, node, setActivePaintMaterial, setActivePaintTarget], - ) - const handleClose = useCallback(() => { setSelection({ selectedIds: [] }) }, [setSelection]) @@ -290,22 +252,6 @@ export function RoofPanel() { /> - - {!materialTargetRole ? ( -
- Click the roof surface you want to edit. Materials apply to one target at a time. -
- ) : null} - -
) } diff --git a/packages/editor/src/components/ui/panels/roof-segment-panel.tsx b/packages/editor/src/components/ui/panels/roof-segment-panel.tsx index de2a1440..88c1887e 100755 --- a/packages/editor/src/components/ui/panels/roof-segment-panel.tsx +++ b/packages/editor/src/components/ui/panels/roof-segment-panel.tsx @@ -3,7 +3,6 @@ import { type AnyNode, type AnyNodeId, - type MaterialSchema, type RoofSegmentNode, RoofSegmentNode as RoofSegmentNodeSchema, type RoofType, @@ -15,7 +14,6 @@ import { useCallback } from 'react' import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' import { ActionButton, ActionGroup } from '../controls/action-button' -import { MaterialPicker } from '../controls/material-picker' import { PanelSection } from '../controls/panel-section' import { SegmentedControl } from '../controls/segmented-control' import { SliderControl } from '../controls/slider-control' @@ -52,20 +50,6 @@ export function RoofSegmentPanel() { [selectedId, updateNode], ) - const handleMaterialChange = useCallback( - (material: MaterialSchema) => { - handleUpdate({ material, materialPreset: undefined }) - }, - [handleUpdate], - ) - - const handleMaterialPresetChange = useCallback( - (materialPreset: string) => { - handleUpdate({ materialPreset, material: undefined }) - }, - [handleUpdate], - ) - const handleClose = useCallback(() => { setSelection({ selectedIds: [] }) }, [setSelection]) @@ -322,15 +306,6 @@ export function RoofSegmentPanel() { /> - - - ) } diff --git a/packages/editor/src/components/ui/panels/slab-panel.tsx b/packages/editor/src/components/ui/panels/slab-panel.tsx index bdd4071f..b44b1a64 100755 --- a/packages/editor/src/components/ui/panels/slab-panel.tsx +++ b/packages/editor/src/components/ui/panels/slab-panel.tsx @@ -1,13 +1,12 @@ 'use client' -import { type AnyNode, type MaterialSchema, type SlabNode, useScene } from '@pascal-app/core' +import { type AnyNode, type SlabNode, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { Edit, Move, Plus, Trash2 } from 'lucide-react' import { useCallback, useEffect } from 'react' import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' import { ActionButton, ActionGroup } from '../controls/action-button' -import { MaterialPicker } from '../controls/material-picker' import { PanelSection } from '../controls/panel-section' import { SliderControl } from '../controls/slider-control' import { PanelWrapper } from './panel-wrapper' @@ -32,20 +31,6 @@ export function SlabPanel() { [selectedId, updateNode], ) - const handleMaterialPresetChange = useCallback( - (materialPreset: string) => { - handleUpdate({ materialPreset, material: undefined }) - }, - [handleUpdate], - ) - - const handleCustomMaterialChange = useCallback( - (material: MaterialSchema) => { - handleUpdate({ material, materialPreset: undefined }) - }, - [handleUpdate], - ) - const handleClose = useCallback(() => { setSelection({ selectedIds: [] }) setEditingHole(null) @@ -257,15 +242,6 @@ export function SlabPanel() { /> - - - } label="Move" onClick={handleMove} /> diff --git a/packages/editor/src/components/ui/panels/stair-panel.tsx b/packages/editor/src/components/ui/panels/stair-panel.tsx index ea3f1d26..362c42aa 100644 --- a/packages/editor/src/components/ui/panels/stair-panel.tsx +++ b/packages/editor/src/components/ui/panels/stair-panel.tsx @@ -3,9 +3,7 @@ import { type AnyNode, type AnyNodeId, - getEffectiveStairSurfaceMaterial, type LevelNode, - type MaterialSchema, type StairNode, type StairRailingMode, type StairSlabOpeningMode, @@ -20,12 +18,10 @@ import { useViewer } from '@pascal-app/viewer' import { Copy, Move, Plus, Trash2 } from 'lucide-react' import { useCallback } from 'react' import { useShallow } from 'zustand/react/shallow' -import { buildStairSurfaceMaterialPatch } from '../../../lib/material-paint' import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' import { DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE } from '../../tools/stair/stair-defaults' import { ActionButton, ActionGroup } from '../controls/action-button' -import { MaterialPicker } from '../controls/material-picker' import { MetricControl } from '../controls/metric-control' import { PanelSection } from '../controls/panel-section' import { SegmentedControl } from '../controls/segmented-control' @@ -64,9 +60,6 @@ export function StairPanel() { const createNode = useScene((s) => s.createNode) const createNodes = useScene((s) => s.createNodes) const setMovingNode = useEditor((s) => s.setMovingNode) - const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget) - const setActivePaintMaterial = useEditor((s) => s.setActivePaintMaterial) - const setActivePaintTarget = useEditor((s) => s.setActivePaintTarget) const node = useScene((s) => selectedId ? (s.nodes[selectedId as AnyNode['id']] as StairNode | undefined) : undefined, @@ -97,37 +90,6 @@ export function StairPanel() { [selectedId, updateNode], ) - const materialTargetRole = - selectedMaterialTarget && - selectedMaterialTarget.nodeId === node?.id && - (selectedMaterialTarget.role === 'railing' || - selectedMaterialTarget.role === 'tread' || - selectedMaterialTarget.role === 'side') - ? selectedMaterialTarget.role - : null - const materialPickerValue = - node && materialTargetRole ? getEffectiveStairSurfaceMaterial(node, materialTargetRole) : {} - - const handleTargetedMaterialChange = useCallback( - (material: MaterialSchema) => { - if (!node || !materialTargetRole) return - setActivePaintTarget('stair') - setActivePaintMaterial({ material, sourceTarget: 'stair' }) - handleUpdate(buildStairSurfaceMaterialPatch(node, materialTargetRole, material, undefined)) - }, - [handleUpdate, materialTargetRole, node, setActivePaintMaterial, setActivePaintTarget], - ) - - const handleTargetedMaterialPresetChange = useCallback( - (materialPreset: string) => { - if (!node || !materialTargetRole) return - setActivePaintTarget('stair') - setActivePaintMaterial({ materialPreset, sourceTarget: 'stair' }) - handleUpdate(buildStairSurfaceMaterialPatch(node, materialTargetRole, undefined, materialPreset)) - }, - [handleUpdate, materialTargetRole, node, setActivePaintMaterial, setActivePaintTarget], - ) - const handleClose = useCallback(() => { setSelection({ selectedIds: [] }) }, [setSelection]) @@ -590,22 +552,6 @@ export function StairPanel() { /> - - {!materialTargetRole ? ( -
- Click the stair surface you want to edit. Materials apply to one target at a time. -
- ) : null} - -
) } diff --git a/packages/editor/src/components/ui/panels/stair-segment-panel.tsx b/packages/editor/src/components/ui/panels/stair-segment-panel.tsx index 17c84650..c783bd98 100644 --- a/packages/editor/src/components/ui/panels/stair-segment-panel.tsx +++ b/packages/editor/src/components/ui/panels/stair-segment-panel.tsx @@ -4,7 +4,6 @@ import { type AnyNode, type AnyNodeId, type AttachmentSide, - type MaterialSchema, type StairSegmentNode, StairSegmentNode as StairSegmentNodeSchema, type StairSegmentType, @@ -16,7 +15,6 @@ import { useCallback } from 'react' import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' import { ActionButton, ActionGroup } from '../controls/action-button' -import { MaterialPicker } from '../controls/material-picker' import { PanelSection } from '../controls/panel-section' import { SegmentedControl } from '../controls/segmented-control' import { SliderControl } from '../controls/slider-control' @@ -61,20 +59,6 @@ export function StairSegmentPanel() { [selectedId, updateNode], ) - const handleMaterialChange = useCallback( - (material: MaterialSchema) => { - handleUpdate({ material, materialPreset: undefined }) - }, - [handleUpdate], - ) - - const handleMaterialPresetChange = useCallback( - (materialPreset: string) => { - handleUpdate({ materialPreset, material: undefined }) - }, - [handleUpdate], - ) - const handleClose = useCallback(() => { setSelection({ selectedIds: [] }) }, [setSelection]) @@ -336,15 +320,6 @@ export function StairSegmentPanel() { /> - - - ) } diff --git a/packages/editor/src/components/ui/panels/wall-panel.tsx b/packages/editor/src/components/ui/panels/wall-panel.tsx index 899c92fc..c1fae2e7 100755 --- a/packages/editor/src/components/ui/panels/wall-panel.tsx +++ b/packages/editor/src/components/ui/panels/wall-panel.tsx @@ -3,24 +3,19 @@ import { type AnyNode, type AnyNodeId, - getEffectiveWallSurfaceMaterial, getClampedWallCurveOffset, getMaxWallCurveOffset, getWallCurveLength, - getWallSurfaceMaterialSignature, normalizeWallCurveOffset, - type MaterialSchema, useScene, type WallNode, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { Move, Spline } from 'lucide-react' -import { useCallback, useMemo } from 'react' +import { useCallback } from 'react' import { sfxEmitter } from '../../../lib/sfx-bus' -import { buildWallSurfaceMaterialPatch } from '../../../lib/material-paint' import useEditor from '../../../store/use-editor' import { ActionButton, ActionGroup } from '../controls/action-button' -import { MaterialPicker } from '../controls/material-picker' import { PanelSection } from '../controls/panel-section' import { SliderControl } from '../controls/slider-control' import { PanelWrapper } from './panel-wrapper' @@ -31,9 +26,6 @@ export function WallPanel() { const updateNode = useScene((s) => s.updateNode) const setMovingNode = useEditor((s) => s.setMovingNode) const setCurvingWall = useEditor((s) => s.setCurvingWall) - const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget) - const setActivePaintMaterial = useEditor((s) => s.setActivePaintMaterial) - const setActivePaintTarget = useEditor((s) => s.setActivePaintTarget) const node = useScene((s) => selectedId ? (s.nodes[selectedId as AnyNode['id']] as WallNode | undefined) : undefined, @@ -64,35 +56,6 @@ 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 = - selectedMaterialTarget && - selectedMaterialTarget.nodeId === node?.id && - (selectedMaterialTarget.role === 'interior' || selectedMaterialTarget.role === 'exterior') - ? selectedMaterialTarget.role - : null - const materialPickerValue = - materialTargetSide === 'interior' - ? effectiveInteriorMaterial - : materialTargetSide === 'exterior' - ? effectiveExteriorMaterial - : surfaceMaterialsMatch - ? effectiveInteriorMaterial - : {} - const handleUpdateLength = useCallback( (newLength: number) => { if (!node || newLength <= 0) return @@ -116,26 +79,6 @@ export function WallPanel() { [node, handleUpdate], ) - const handleMaterialPresetChange = useCallback( - (materialPreset: string) => { - if (!node || !materialTargetSide) return - setActivePaintTarget('wall') - setActivePaintMaterial({ materialPreset, sourceTarget: 'wall' }) - handleUpdate(buildWallSurfaceMaterialPatch(node, materialTargetSide, undefined, materialPreset)) - }, - [handleUpdate, materialTargetSide, node, setActivePaintMaterial, setActivePaintTarget], - ) - - const handleCustomMaterialChange = useCallback( - (material: MaterialSchema) => { - if (!node || !materialTargetSide) return - setActivePaintTarget('wall') - setActivePaintMaterial({ material, sourceTarget: 'wall' }) - handleUpdate(buildWallSurfaceMaterialPatch(node, materialTargetSide, material, undefined)) - }, - [handleUpdate, materialTargetSide, node, setActivePaintMaterial, setActivePaintTarget], - ) - const handleClose = useCallback(() => { setSelection({ selectedIds: [] }) }, [setSelection]) @@ -217,23 +160,6 @@ export function WallPanel() { )} - - {!materialTargetSide ? ( -
- Click the wall face you want to edit. Materials now apply to one side at a time. -
- ) : null} - -
- } label="Move" onClick={handleMove} /> diff --git a/packages/editor/src/components/ui/panels/window-panel.tsx b/packages/editor/src/components/ui/panels/window-panel.tsx index cc346cf0..a41891b0 100755 --- a/packages/editor/src/components/ui/panels/window-panel.tsx +++ b/packages/editor/src/components/ui/panels/window-panel.tsx @@ -4,7 +4,6 @@ import { type AnyNode, type AnyNodeId, emitter, - type MaterialSchema, useScene, WindowNode, } from '@pascal-app/core' @@ -15,7 +14,6 @@ import { usePresetsAdapter } from '../../../contexts/presets-context' import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' import { ActionButton, ActionGroup } from '../controls/action-button' -import { MaterialPicker } from '../controls/material-picker' import { MetricControl } from '../controls/metric-control' import { PanelSection } from '../controls/panel-section' import { SliderControl } from '../controls/slider-control' @@ -45,13 +43,6 @@ export function WindowPanel() { [selectedId, updateNode], ) - const handleMaterialChange = useCallback( - (material: MaterialSchema) => { - handleUpdate({ material }) - }, - [handleUpdate], - ) - const handleClose = useCallback(() => { setSelection({ selectedIds: [] }) }, [setSelection]) @@ -431,9 +422,6 @@ export function WindowPanel() { /> - - - ) } From 2876f39876abbdc77e04cb0cc9c83d2fb790ff9f Mon Sep 17 00:00:00 2001 From: sudhir Date: Thu, 23 Apr 2026 12:48:50 +0530 Subject: [PATCH 23/31] Refactor paint materials to use category-based compatibility --- packages/core/src/index.ts | 5 +- packages/core/src/material-library.ts | 120 +++++----- .../src/components/ui/action-menu/index.tsx | 47 +--- .../ui/controls/material-picker.tsx | 212 ++++++------------ .../src/components/ui/panels/paint-panel.tsx | 164 ++++++++++++++ .../components/ui/panels/panel-manager.tsx | 13 ++ packages/editor/src/lib/material-paint.ts | 32 ++- packages/editor/src/store/use-editor.tsx | 4 + 8 files changed, 358 insertions(+), 239 deletions(-) create mode 100644 packages/editor/src/components/ui/panels/paint-panel.tsx diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8c7778ba..d8d80978 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -40,11 +40,14 @@ export { export { baseMaterial, glassMaterial } from './materials' export { getCatalogMaterialById, + getMaterialCategoryTargets, getLibraryMaterialIdFromRef, getMaterialPresetByRef, - getMaterialsForTarget, + getMaterialsForCategory, LIBRARY_MATERIAL_REF_PREFIX, MATERIAL_CATALOG, + MATERIAL_CATEGORIES, + type MaterialCategory, type MaterialCatalogItem, toLibraryMaterialRef, } from './material-library' diff --git a/packages/core/src/material-library.ts b/packages/core/src/material-library.ts index f5455c96..a52ac07f 100644 --- a/packages/core/src/material-library.ts +++ b/packages/core/src/material-library.ts @@ -7,51 +7,63 @@ import { export type MaterialCatalogItem = { id: string label: string + category: MaterialCategory description?: string - targets: MaterialTarget[] previewThumbnailUrl?: string previewColor?: string preset: MaterialPresetPayload } -const WALL_TARGETS: MaterialTarget[] = [ - MaterialTargetSchema.enum.wall, -] +export const MATERIAL_CATEGORIES = [ + 'wood', + 'wallpaper', + 'parquet', + 'granite', + 'marble', + 'other', +] as const +export type MaterialCategory = (typeof MATERIAL_CATEGORIES)[number] -const SLAB_TARGETS: MaterialTarget[] = [ - MaterialTargetSchema.enum.slab, -] - -const WALL_AND_SLAB_TARGETS: MaterialTarget[] = [ - MaterialTargetSchema.enum.wall, - MaterialTargetSchema.enum.slab, -] - -const STAIR_TARGETS: MaterialTarget[] = [ - MaterialTargetSchema.enum.stair, - MaterialTargetSchema.enum['stair-segment'], -] - -const STAIR_AND_FENCE_TARGETS: MaterialTarget[] = [ - ...STAIR_TARGETS, - MaterialTargetSchema.enum.fence, -] - -const ROOF_TARGETS: MaterialTarget[] = [ - MaterialTargetSchema.enum.roof, - MaterialTargetSchema.enum['roof-segment'], -] - -const CEILING_TARGETS: MaterialTarget[] = [ - MaterialTargetSchema.enum.ceiling, -] +export const MATERIAL_CATEGORY_TARGETS: Record = { + wood: [ + MaterialTargetSchema.enum.wall, + MaterialTargetSchema.enum.slab, + MaterialTargetSchema.enum.stair, + MaterialTargetSchema.enum['stair-segment'], + MaterialTargetSchema.enum.fence, + MaterialTargetSchema.enum.roof, + MaterialTargetSchema.enum['roof-segment'], + ], + wallpaper: [MaterialTargetSchema.enum.wall], + parquet: [ + MaterialTargetSchema.enum.slab, + MaterialTargetSchema.enum.stair, + MaterialTargetSchema.enum['stair-segment'], + ], + granite: [MaterialTargetSchema.enum.slab], + marble: [ + MaterialTargetSchema.enum.slab, + MaterialTargetSchema.enum.stair, + MaterialTargetSchema.enum['stair-segment'], + ], + other: [ + MaterialTargetSchema.enum.wall, + MaterialTargetSchema.enum.roof, + MaterialTargetSchema.enum['roof-segment'], + MaterialTargetSchema.enum.stair, + MaterialTargetSchema.enum['stair-segment'], + MaterialTargetSchema.enum.fence, + MaterialTargetSchema.enum.slab, + MaterialTargetSchema.enum.ceiling, + ], +} export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'wall-wood1', label: 'Wood', + category: 'wood', description: 'Warm wood finish', - targets: [...WALL_TARGETS, ...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS, ...ROOF_TARGETS], previewThumbnailUrl: '/material/wood1/wood1_thumbnail.webp', preset: { maps: { @@ -85,8 +97,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'wall-wood2', label: 'Wood', + category: 'wood', description: 'Textured wood finish', - targets: [...WALL_TARGETS, ...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS, ...ROOF_TARGETS], previewThumbnailUrl: '/material/wood2/wood2_thumbnail.webp', preset: { maps: { @@ -121,8 +133,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'wall-wood3', label: 'Wood', + category: 'wood', description: 'Knotted timber finish', - targets: [...WALL_TARGETS, ...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS, ...ROOF_TARGETS], previewThumbnailUrl: '/material/wood3/wood3_thumbnail.webp', preset: { maps: { @@ -155,8 +167,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'wall-wood4', label: 'Wood', + category: 'wood', description: 'Oak stretcher finish', - targets: [...WALL_TARGETS, ...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS], previewThumbnailUrl: '/material/wood4/wood4_thumbnail.webp', preset: { maps: { @@ -189,8 +201,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'wall-wood5', label: 'Wood', + category: 'wood', description: 'Rich grain wood finish', - targets: [...WALL_TARGETS, ...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS], previewThumbnailUrl: '/material/wood5/wood5_thumnail.webp', preset: { maps: { @@ -225,8 +237,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'wall-granite1', label: 'Granite', + category: 'granite', description: 'Polished granite finish', - targets: SLAB_TARGETS, previewThumbnailUrl: '/material/granite1/granite_thumbnail.webp', preset: { maps: { @@ -259,8 +271,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'wall-marble1', label: 'Marble', + category: 'marble', description: 'Smooth marble finish', - targets: [...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS], previewThumbnailUrl: '/material/marble1/marble1_thumbnail.webp', preset: { maps: { @@ -293,8 +305,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'wall-marble2', label: 'Marble', + category: 'marble', description: 'Soft marble finish', - targets: [...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS], previewThumbnailUrl: '/material/marble2/marble2_thumbnail.webp', preset: { maps: { @@ -327,8 +339,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'wall-parquet1', label: 'Parquet', + category: 'parquet', description: 'Parquet wood finish', - targets: SLAB_TARGETS, previewThumbnailUrl: '/material/parquet1/parquet_thumnail.webp', preset: { maps: { @@ -361,8 +373,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'wall-parquet2', label: 'Parquet', + category: 'parquet', description: 'Soft parquet finish', - targets: SLAB_TARGETS, previewThumbnailUrl: '/material/parquet2/parquet2_thumbnail.webp', preset: { maps: { @@ -395,8 +407,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'wall-wallpaper1', label: 'Wallpaper', + category: 'wallpaper', description: 'Soft wallpaper finish', - targets: WALL_TARGETS, previewThumbnailUrl: '/material/wallpaper1/wallpaper1_thumbnail.webp', preset: { maps: { @@ -430,8 +442,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'wall-wallpaper2', label: 'Wallpaper', + category: 'wallpaper', description: 'Decorative wallpaper finish', - targets: WALL_TARGETS, previewThumbnailUrl: '/material/wallpaper2/wallpaper2_thumnail.webp', preset: { maps: { @@ -464,8 +476,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'wall-wallpaper3', label: 'Wallpaper', + category: 'wallpaper', description: 'Patterned wallpaper finish', - targets: WALL_TARGETS, previewThumbnailUrl: '/material/wallpaper3/wallpaper3_thumbnail.webp', preset: { maps: { @@ -498,14 +510,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'preset-white', label: 'White', + category: 'other', description: 'Clean painted finish', - targets: [ - ...WALL_TARGETS, - ...SLAB_TARGETS, - ...ROOF_TARGETS, - ...STAIR_AND_FENCE_TARGETS, - ...CEILING_TARGETS, - ], previewColor: '#ffffff', preset: { maps: {}, @@ -536,8 +542,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'preset-metal', label: 'Metal', + category: 'other', description: 'Brushed metal finish', - targets: [...WALL_TARGETS, ...SLAB_TARGETS], previewColor: '#c0c0c0', preset: { maps: {}, @@ -568,8 +574,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'preset-glass', label: 'Glass', + category: 'other', description: 'Light glass finish', - targets: [...WALL_TARGETS, ...SLAB_TARGETS], previewColor: '#87ceeb', preset: { maps: {}, @@ -599,8 +605,12 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, ] -export function getMaterialsForTarget(target: MaterialTarget): MaterialCatalogItem[] { - return MATERIAL_CATALOG.filter((item) => item.targets.includes(target)) +export function getMaterialsForCategory(category: MaterialCategory): MaterialCatalogItem[] { + return MATERIAL_CATALOG.filter((item) => item.category === category) +} + +export function getMaterialCategoryTargets(category: MaterialCategory): MaterialTarget[] { + return MATERIAL_CATEGORY_TARGETS[category] } export function getCatalogMaterialById(id?: string): MaterialCatalogItem | undefined { diff --git a/packages/editor/src/components/ui/action-menu/index.tsx b/packages/editor/src/components/ui/action-menu/index.tsx index 5eb7bf49..e1c59cb4 100644 --- a/packages/editor/src/components/ui/action-menu/index.tsx +++ b/packages/editor/src/components/ui/action-menu/index.tsx @@ -1,16 +1,13 @@ 'use client' -import { getMaterialsForTarget, toLibraryMaterialRef, useScene } from '@pascal-app/core' +import { useScene } from '@pascal-app/core' import { AnimatePresence, motion } from 'motion/react' import { useEffect, useMemo } from 'react' import { useViewer } from '@pascal-app/viewer' import { TooltipProvider } from './../../../components/ui/primitives/tooltip' import { MaterialPicker } from './../../../components/ui/controls/material-picker' import { useReducedMotion } from './../../../hooks/use-reduced-motion' -import { - isActivePaintMaterialCompatible, - resolvePaintTargetFromSelection, -} from './../../../lib/material-paint' +import { resolvePaintTargetFromSelection } from './../../../lib/material-paint' import { cn } from './../../../lib/utils' import useEditor from './../../../store/use-editor' import { ItemCatalog } from '../item-catalog/item-catalog' @@ -40,45 +37,17 @@ function PaintMaterialTray() { } }, [nodes, selectedId, setActivePaintTarget]) - const catalogItems = useMemo(() => getMaterialsForTarget(activePaintTarget), [activePaintTarget]) - - useEffect(() => { - const firstMaterial = catalogItems[0] - if (!firstMaterial) return - - const hasCompatibleMaterial = isActivePaintMaterialCompatible( - activePaintMaterial, - activePaintTarget, - ) - if (hasCompatibleMaterial) return - - setActivePaintMaterial({ - materialPreset: toLibraryMaterialRef(firstMaterial.id), - sourceTarget: activePaintTarget, - }) - }, [activePaintMaterial, activePaintTarget, catalogItems, setActivePaintMaterial]) - return (
{ - setActivePaintMaterial({ material, sourceTarget: activePaintTarget }) + onChange={(material, category) => { + setActivePaintMaterial({ material, category, sourceTarget: activePaintTarget }) }} - onSelectMaterialPreset={(materialPreset) => { - setActivePaintMaterial({ materialPreset, sourceTarget: activePaintTarget }) + onSelectMaterialPreset={(materialPreset, category) => { + setActivePaintMaterial({ materialPreset, category, sourceTarget: activePaintTarget }) }} - selectedMaterialPreset={ - activePaintMaterial?.sourceTarget === activePaintTarget - ? activePaintMaterial.materialPreset - : undefined - } - value={ - activePaintMaterial?.sourceTarget === activePaintTarget - ? activePaintMaterial.material - : undefined - } + selectedMaterialPreset={activePaintMaterial?.materialPreset} + value={activePaintMaterial?.material} />
) diff --git a/packages/editor/src/components/ui/controls/material-picker.tsx b/packages/editor/src/components/ui/controls/material-picker.tsx index e189276e..dbe0a6e3 100755 --- a/packages/editor/src/components/ui/controls/material-picker.tsx +++ b/packages/editor/src/components/ui/controls/material-picker.tsx @@ -1,55 +1,69 @@ 'use client' import { - getMaterialsForTarget, + getCatalogMaterialById, + getLibraryMaterialIdFromRef, + getMaterialsForCategory, + MATERIAL_CATEGORIES, toLibraryMaterialRef, + type MaterialCategory, type MaterialSchema, - type MaterialTarget, } from '@pascal-app/core' import { useEffect, useRef, useState } from 'react' +import useEditor from '../../../store/use-editor' type MaterialPickerProps = { - nodeType?: MaterialTarget value?: MaterialSchema selectedMaterialPreset?: string - onChange?: (material: MaterialSchema) => void - onSelectMaterialPreset?: (materialPreset: string) => void - hideSideControl?: boolean + onChange?: (material: MaterialSchema, category: MaterialCategory) => void + onSelectMaterialPreset?: (materialPreset: string, category: MaterialCategory) => void disabled?: boolean } export function MaterialPicker({ - nodeType, value, selectedMaterialPreset, onChange, onSelectMaterialPreset, - hideSideControl = false, disabled = false, }: MaterialPickerProps) { + const setPaintPanelOpen = useEditor((state) => state.setPaintPanelOpen) const [showCustom, setShowCustom] = useState(!!value?.properties) + const [selectedCategory, setSelectedCategory] = useState(MATERIAL_CATEGORIES[0]) const catalogScrollRef = useRef(null) - const catalogItems = nodeType ? getMaterialsForTarget(nodeType) : [] + const catalogItems = + selectedCategory === 'other' + ? getMaterialsForCategory('other') + : getMaterialsForCategory(selectedCategory) useEffect(() => { setShowCustom(!!value?.properties && !selectedMaterialPreset) }, [selectedMaterialPreset, value?.properties]) - const currentProps = value?.properties || { - color: '#ffffff', - roughness: 0.5, - metalness: 0, - opacity: 1, - transparent: false, - side: 'front' as const, - } + useEffect(() => { + if (!selectedMaterialPreset && value?.properties) { + setSelectedCategory('other') + return + } + + const catalogId = + getLibraryMaterialIdFromRef(selectedMaterialPreset) ?? value?.id ?? undefined + const selectedCatalogEntry = getCatalogMaterialById(catalogId) + if (selectedCatalogEntry?.category) { + setSelectedCategory(selectedCatalogEntry.category) + } + }, [selectedMaterialPreset, value?.id]) + const selectedCatalogId = selectedMaterialPreset ?? (value?.id ? toLibraryMaterialRef(value.id) : undefined) const handleCatalogSelect = (materialId: string) => { if (disabled) return setShowCustom(false) - onSelectMaterialPreset?.(toLibraryMaterialRef(materialId)) + setPaintPanelOpen(false) + const category = getCatalogMaterialById(materialId)?.category + if (!category) return + onSelectMaterialPreset?.(toLibraryMaterialRef(materialId), category) } useEffect(() => { @@ -76,40 +90,51 @@ export function MaterialPicker({ const handleCustomOpen = () => { if (disabled) return setShowCustom(true) - onChange?.({ - preset: 'custom', - properties: { - color: value?.properties?.color || '#ffffff', - roughness: value?.properties?.roughness ?? 0.5, - metalness: value?.properties?.metalness ?? 0, - opacity: value?.properties?.opacity ?? 1, - transparent: value?.properties?.transparent ?? false, - side: value?.properties?.side ?? 'front', + setPaintPanelOpen(true) + onChange?.( + { + preset: 'custom', + properties: { + color: value?.properties?.color || '#ffffff', + roughness: value?.properties?.roughness ?? 0.5, + metalness: value?.properties?.metalness ?? 0, + opacity: value?.properties?.opacity ?? 1, + transparent: value?.properties?.transparent ?? false, + side: value?.properties?.side ?? 'front', + }, }, - }) - } - - const handlePropertyChange = ( - prop: keyof typeof currentProps, - val: (typeof currentProps)[keyof typeof currentProps], - ) => { - if (disabled) return - onChange?.({ - preset: 'custom', - properties: { - ...currentProps, - [prop]: val, - }, - }) + 'other', + ) } return (
{(catalogItems.length > 0 || onChange) && (
- {catalogItems.length > 0 ? ( -
Library
- ) : null} +
+ {MATERIAL_CATEGORIES.map((category) => ( + + ))} +
))} - {onChange ? ( + {selectedCategory === 'other' && onChange ? (
)} - - {showCustom && onChange && ( -
-
- - handlePropertyChange('color', e.target.value)} - type="color" - value={currentProps.color} - /> - handlePropertyChange('color', e.target.value)} - type="text" - value={currentProps.color} - /> -
- -
- - handlePropertyChange('roughness', Number.parseFloat(e.target.value))} - step={0.01} - type="range" - value={currentProps.roughness} - /> - - {currentProps.roughness.toFixed(2)} - -
- -
- - handlePropertyChange('metalness', Number.parseFloat(e.target.value))} - step={0.01} - type="range" - value={currentProps.metalness} - /> - - {currentProps.metalness.toFixed(2)} - -
- -
- - { - const opacity = Number.parseFloat(e.target.value) - handlePropertyChange('opacity', opacity) - if (opacity < 1 && !currentProps.transparent) { - handlePropertyChange('transparent', true) - } - }} - step={0.01} - type="range" - value={currentProps.opacity} - /> - - {currentProps.opacity.toFixed(2)} - -
- - {!hideSideControl && ( -
- - -
- )} -
- )}
) } diff --git a/packages/editor/src/components/ui/panels/paint-panel.tsx b/packages/editor/src/components/ui/panels/paint-panel.tsx new file mode 100644 index 00000000..0df8c677 --- /dev/null +++ b/packages/editor/src/components/ui/panels/paint-panel.tsx @@ -0,0 +1,164 @@ +'use client' + +import useEditor from '../../../store/use-editor' +import { Input } from '../primitives/input' +import { PanelSection } from '../controls/panel-section' +import { PanelWrapper } from './panel-wrapper' + +function buildDefaultCustomMaterial() { + return { + preset: 'custom' as const, + properties: { + color: '#ffffff', + roughness: 0.5, + metalness: 0, + opacity: 1, + transparent: false, + side: 'front' as const, + }, + } +} + +export function PaintPanel() { + const activePaintMaterial = useEditor((state) => state.activePaintMaterial) + const activePaintTarget = useEditor((state) => state.activePaintTarget) + const setActivePaintMaterial = useEditor((state) => state.setActivePaintMaterial) + const setPaintPanelOpen = useEditor((state) => state.setPaintPanelOpen) + + const customMaterial = + activePaintMaterial?.material?.properties && !activePaintMaterial.materialPreset + ? activePaintMaterial.material + : null + + if (!customMaterial) return null + + const currentProps = customMaterial.properties ?? buildDefaultCustomMaterial().properties + + const updateCustomMaterial = ( + updates: Partial, + nextTransparent = currentProps.transparent, + ) => { + setActivePaintMaterial({ + material: { + preset: 'custom', + properties: { + ...currentProps, + ...updates, + transparent: nextTransparent, + }, + }, + category: 'other', + sourceTarget: activePaintMaterial?.sourceTarget ?? activePaintTarget, + }) + } + + return ( + setPaintPanelOpen(false)} + title="Material" + width={320} + > + +
+
+ +
+ updateCustomMaterial({ color: e.target.value })} + type="color" + value={currentProps.color} + /> + updateCustomMaterial({ color: e.target.value })} + value={currentProps.color} + /> +
+
+ +
+
+ + + {currentProps.roughness.toFixed(2)} + +
+ updateCustomMaterial({ roughness: Number.parseFloat(e.target.value) })} + step={0.01} + type="range" + value={currentProps.roughness} + /> +
+ +
+
+ + + {currentProps.metalness.toFixed(2)} + +
+ updateCustomMaterial({ metalness: Number.parseFloat(e.target.value) })} + step={0.01} + type="range" + value={currentProps.metalness} + /> +
+ +
+
+ + + {currentProps.opacity.toFixed(2)} + +
+ { + const opacity = Number.parseFloat(e.target.value) + updateCustomMaterial({ opacity }, opacity < 1 || currentProps.transparent) + }} + step={0.01} + type="range" + value={currentProps.opacity} + /> +
+ +
+ + +
+
+
+
+ ) +} diff --git a/packages/editor/src/components/ui/panels/panel-manager.tsx b/packages/editor/src/components/ui/panels/panel-manager.tsx index 1750c6fc..cc20f364 100755 --- a/packages/editor/src/components/ui/panels/panel-manager.tsx +++ b/packages/editor/src/components/ui/panels/panel-manager.tsx @@ -7,6 +7,7 @@ import { CeilingPanel } from './ceiling-panel' import { DoorPanel } from './door-panel' import { FencePanel } from './fence-panel' import { ItemPanel } from './item-panel' +import { PaintPanel } from './paint-panel' import { ReferencePanel } from './reference-panel' import { RoofPanel } from './roof-panel' import { RoofSegmentPanel } from './roof-segment-panel' @@ -19,6 +20,9 @@ import { WindowPanel } from './window-panel' export function PanelManager() { const selectedIds = useViewer((s) => s.selection.selectedIds) const selectedReferenceId = useEditor((s) => s.selectedReferenceId) + const isPaintPanelOpen = useEditor((s) => s.isPaintPanelOpen) + const mode = useEditor((s) => s.mode) + const activePaintMaterial = useEditor((s) => s.activePaintMaterial) // Only subscribe to the *type* of the single-selected node — string primitive // so we don't re-render on unrelated scene mutations. const selectedNodeType = useScene((s) => { @@ -32,6 +36,15 @@ export function PanelManager() { return } + if ( + isPaintPanelOpen && + mode === 'material-paint' && + activePaintMaterial?.material?.properties && + !activePaintMaterial.materialPreset + ) { + return + } + // Show appropriate panel based on selected node type if (selectedNodeType) { switch (selectedNodeType) { diff --git a/packages/editor/src/lib/material-paint.ts b/packages/editor/src/lib/material-paint.ts index 88bce034..e1f52156 100644 --- a/packages/editor/src/lib/material-paint.ts +++ b/packages/editor/src/lib/material-paint.ts @@ -8,6 +8,8 @@ import { getEffectiveStairSurfaceMaterial, getEffectiveWallSurfaceMaterial, getLibraryMaterialIdFromRef, + getMaterialCategoryTargets, + type MaterialCategory, type MaterialSchema, type MaterialTarget, type RoofNode, @@ -29,6 +31,7 @@ export type SingleSurfaceMaterialRole = 'surface' export type ActivePaintMaterial = { material?: MaterialSchema materialPreset?: string + category?: MaterialCategory sourceTarget: PaintableMaterialTarget } @@ -47,6 +50,17 @@ function getCatalogEntryForActivePaintMaterial(material: ActivePaintMaterial | n return getCatalogMaterialById(catalogId) } +function resolvePaintMaterialCategory( + material: MaterialSchema | undefined, + materialPreset: string | undefined, +): MaterialCategory | undefined { + const catalogId = getLibraryMaterialIdFromRef(materialPreset) ?? material?.id ?? undefined + const catalogEntry = getCatalogMaterialById(catalogId) + if (catalogEntry?.category) return catalogEntry.category + if (material || materialPreset === undefined) return 'other' + return undefined +} + export function getActivePaintMaterialLabel(material: ActivePaintMaterial | null | undefined) { return getCatalogEntryForActivePaintMaterial(material)?.label ?? 'Custom' } @@ -58,9 +72,10 @@ export function isActivePaintMaterialCompatible( if (!hasActivePaintMaterial(material)) return false const catalogEntry = getCatalogEntryForActivePaintMaterial(material) - if (!catalogEntry) return true + const category = material?.category ?? catalogEntry?.category + if (!category) return false - return catalogEntry.targets.includes(target) + return getMaterialCategoryTargets(category).includes(target) } export function buildWallSurfaceMaterialPatch( @@ -182,9 +197,10 @@ export function resolveActivePaintMaterialFromSelection(params: { materialPreset: surface.materialPreset, sourceTarget: 'wall', }) - ? { + ? { material: surface.material, materialPreset: surface.materialPreset, + category: resolvePaintMaterialCategory(surface.material, surface.materialPreset), sourceTarget: 'wall', } : null @@ -202,9 +218,10 @@ export function resolveActivePaintMaterialFromSelection(params: { materialPreset: surface.materialPreset, sourceTarget: 'roof', }) - ? { + ? { material: surface.material, materialPreset: surface.materialPreset, + category: resolvePaintMaterialCategory(surface.material, surface.materialPreset), sourceTarget: 'roof', } : null @@ -222,9 +239,10 @@ export function resolveActivePaintMaterialFromSelection(params: { materialPreset: surface.materialPreset, sourceTarget: 'stair', }) - ? { + ? { material: surface.material, materialPreset: surface.materialPreset, + category: resolvePaintMaterialCategory(surface.material, surface.materialPreset), sourceTarget: 'stair', } : null @@ -245,6 +263,10 @@ export function resolveActivePaintMaterialFromSelection(params: { ? { material: selectedNode.material, materialPreset: selectedNode.materialPreset, + category: resolvePaintMaterialCategory( + selectedNode.material, + selectedNode.materialPreset, + ), sourceTarget: target, } : null diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index 96043be3..f6d0488b 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -169,6 +169,8 @@ type EditorState = { primeMaterialPaintFromSelection: () => MaterialPaintSelectionSnapshot hoveredPaintTarget: PaintableMaterialTarget | null setHoveredPaintTarget: (target: PaintableMaterialTarget | null) => void + isPaintPanelOpen: boolean + setPaintPanelOpen: (open: boolean) => void selectedReferenceId: string | null setSelectedReferenceId: (id: string | null) => void // Space detection for cutaway mode @@ -591,6 +593,8 @@ const useEditor = create()( set((state) => state.hoveredPaintTarget === target ? state : { hoveredPaintTarget: target }, ), + isPaintPanelOpen: false, + setPaintPanelOpen: (open) => set({ isPaintPanelOpen: open }), selectedReferenceId: null, setSelectedReferenceId: (id) => set({ selectedReferenceId: id }), spaces: {}, From a5448a00530794475ea39a1b2974bb743a7a874b Mon Sep 17 00:00:00 2001 From: sudhir Date: Fri, 24 Apr 2026 10:43:38 +0530 Subject: [PATCH 24/31] Make material categories horizontally scrollable --- .../ui/controls/material-picker.tsx | 76 +++++++++++++------ 1 file changed, 52 insertions(+), 24 deletions(-) diff --git a/packages/editor/src/components/ui/controls/material-picker.tsx b/packages/editor/src/components/ui/controls/material-picker.tsx index dbe0a6e3..ce743482 100755 --- a/packages/editor/src/components/ui/controls/material-picker.tsx +++ b/packages/editor/src/components/ui/controls/material-picker.tsx @@ -31,6 +31,7 @@ export function MaterialPicker({ const [showCustom, setShowCustom] = useState(!!value?.properties) const [selectedCategory, setSelectedCategory] = useState(MATERIAL_CATEGORIES[0]) const catalogScrollRef = useRef(null) + const categoryScrollRef = useRef(null) const catalogItems = selectedCategory === 'other' ? getMaterialsForCategory('other') @@ -87,6 +88,27 @@ export function MaterialPicker({ } }, [catalogItems.length, onChange, showCustom]) + useEffect(() => { + const container = categoryScrollRef.current + if (!container) return + + const handleWheel = (event: WheelEvent) => { + const deltaX = event.deltaX + const deltaY = event.deltaY + const nextScrollLeft = container.scrollLeft + deltaX + deltaY + + if (nextScrollLeft === container.scrollLeft) return + + event.preventDefault() + container.scrollLeft = nextScrollLeft + } + + container.addEventListener('wheel', handleWheel, { passive: false }) + return () => { + container.removeEventListener('wheel', handleWheel) + } + }, []) + const handleCustomOpen = () => { if (disabled) return setShowCustom(true) @@ -110,30 +132,36 @@ export function MaterialPicker({ return (
{(catalogItems.length > 0 || onChange) && ( -
-
- {MATERIAL_CATEGORIES.map((category) => ( - - ))} +
+
+
+ {MATERIAL_CATEGORIES.map((category) => ( + + ))} +
Date: Fri, 24 Apr 2026 12:50:05 +0530 Subject: [PATCH 25/31] Use paint icon for material mode --- apps/editor/public/icons/paint.png | Bin 0 -> 99049 bytes .../components/ui/action-menu/control-modes.tsx | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 apps/editor/public/icons/paint.png diff --git a/apps/editor/public/icons/paint.png b/apps/editor/public/icons/paint.png new file mode 100644 index 0000000000000000000000000000000000000000..d66167bb0e797e64002123951f37d393d85ae334 GIT binary patch literal 99049 zcmce7hf`DE^EN0S1Vtc#(nMO2-a9BY^dd-;E;Y1>NUu>qYUt8IdT(kVNR5bqNC`zi zYNU6N8XzJ0#qYd-!+U2kbLY-v&z`+op0oShcq2otySG_xlaP?y)zJn$BOxI@{r96F zCq5aPMkkPvMt;-*shS3n?)AJSJ|lTkd|ks>bK~Fb|Mx4WF!WWc(8we%T={TL3t()n z338oBK;{vb>^M1*B;pO^{rHnym-3t7E|l{bM_;|Kbm1Th`wNLY7rXxjQbgIveD@`Uv{`So1))J zQ_z2_YL#t%U%z!P##o^)9zvz>^qtAMj@Leb(2c&BitSxQ4Yb{H86;|XCOGg}Xy}-- zhy!|n>cUw{DhW&uh$*$phZMQn=+ZDbz6qG4&($dB(bj-{8stmTtNO%d)}N$&ew@98 zN%{h56FXLn7(kv6f&Xn`#8W9K>_=?p8r#dIhwO{FDa%`BE}eRzVGNu%#kuG8!I-pi zn4zMEGn4LSK_pPZF>Yl|F8QgxvD&zKidg&W$DkKDD&O3YLRnc{D|kQaKmZ9hQx;B1SI`B9gU z9Fgetzcoc6DfTx1#1XEctcPcIq7gIBVG*w{I=y(=hqsTUs7wI>GvS~5Nz`M8%#Q%# zV1SMb2%H4cA_JIOlVA&OFD`92;iqIDvo`s23dIgYlw`SBX4wZ7omrw-ptuy|-Zf2Z z*fO^JaQV(WZ&#>qC*Y^He#N+$M6-#W+P5r;Tx;hh1PPsM71Mi)3SqTJ6*8rAzh zmhr7fxxlTVo@EE*tA8?M520&6Z_+GWwJTrpvg1a}%7zXHja_wOIEn*cAU&~7`Ixte zHzf^wJpWlq4Bx*wETt|n&jM(6e(|(Sg2ZLidA_y7hH1z_5smxyoG8%lDq$wvum3!> z=;*X#iD&hN3itctEx@d*xu`ZNO>Ht$DhNQ?@OF$*0uZQAN9S&o zmdC^6CQx(#MnV=-9t(GKu`k_*Qu05omLC336v`d!Ef<16vBwbX6+(At{)u{EO!ptH znT~0*2@7QcHAF1l?S}jOs439l8G|ux1%*Ze=-E9p;dvQtH|8kw33{TJXnPR7?Xs=y zuv^wHzWbj}h1W(8g~U4D*|hPYf^ zKuk_ULP>f_z&t|76BsR@_Q7iU5r+60cxyus!%sSv+T{s5l10HNMcek*2?CM8;)6K1 zjq0CDe$hAJCS;i)YYsm{h>nIz=6x~0EK_w}ZU$9#YwH*YYa~J-2j;W)?RktiZrG~9 zyl!g99@kD2ezAOgsJtsbgWf*QH1W~8XACq3>P(gZ#DpItqC>=q`1xScA-Xy?{)H~O-DqSU#GY>7iBy!#;tmcDw2eh&T5V_ zK~0TgVuC;ZTU==a@XLvx3hgYpfLbbTB}-KF`%%Xnc(b3)xP!kqi!%Nl(U>h)^{B9p z&OgC~y$)39XPCRah6ui(T*|lyd(`$bMdKSdOB)) zZdDvEK(aSn@x3z5JUwIm-4;V$MUSoj*YF9a%^D131|!W3@Tmzp zaq?%WAZun)r)YC)VO>3|LX*s_uIAbo2r?H$x|{p0QqF~0=#razRa@Cjs#X5pce&EN zV)g&CH3~oM8GPVN=nHHCqq;&cQ$Ar*z#gDj5}AgJHq4Ysg&~uKib@Qe1StH^mFH6u zC%?AZ4Xfyw(Fr%bx&}vQ3bHsRE^b3Z87C@!`0!xE{3PhV#^)lio_{f$TNG^Z0mF(P zY|RIwkO9~7t6O|=0TX-_6!EZt%G)<|Z_v@r-HroVrBje`r1KT4YR9FflTeatm*#LX zYZ^soz()oLHFaa8^hYssfmT*vZ)`XF&?<83xpH&|G1E->7Ga+vUD}UXo9vJXHg@mo zN+6jSmwIKi$J=IwM6o+@kzBwxk-6+pt%rR4{bMn7Y0)ZD6cAEzaEmlzd!l0bH0RvL zKwo-aI0Lb90AaIo55~1BG=CWTm+}8DKre-{ou}4~cF1lvHX5^Bajl9sAZ_qX4UPBF z#Y}+l`{OYTDj>5*bc=y>X3_US{u8Rv<~RQ1AQgM&R5z4O({JwEBwi})WOQzAa;E^r zbhBSZ8<_9B^hA$d*Eg3lK=kB@|v?cKM5rrogaxRA1DoEMh78SMEoiY zp^GLl`pxAns0vIY6Gv#P)zYS?-lhW@8pgokH*@`TBi*CRU!rSUyU&bWo6T-EhhN)Y z+`*iEdW^SbTRQ&}?@B~wJfhGk+3%U?WA4C>ftxBffu_->^qkFPIt*i3phP+;+A)b6 z6&kc=Rvc+>pOy37g=hfBvp~O$s=)cZ##9-pR8~68k6)LC|LtLQre&GSb3@q^Q4#fi z8*e%>Xv;^q(A1NOJpf-*=*$v~#_49uoIre!MMm)z+M0RNd9>zJDHF^?a_ zyiM129M#oPD@|ZwP7o+5L?r2fZ><`AGgW^B>Qkp#e7&j(qXKEdbj(!jT!^cDr|%ihO~sxh4k!*d`(3zIqnG{1q^4n5m2_#gJTnXZ(Jfw7Uk!dCGxSDUkIcj1aRcCn_msF5s z_A@n;kZ${hwad#deatXW?2F-Neu-Ye-(s)Ghm9LjJW?GjUQ7}eZLwjB^@$^zEb$;y zH2gYyB=`zTbmNGy5Csazf6vI$q=1x=7E&t{GD-*_TFlHWAr+K4abawgP9j{Ls>>aJ zpY(;Z!V`Hph2+^4l{(!@sfQs!n7_TPF7qJggE{R;P7lSgClhV#Y%jZiymokCNFyse z^x4K)G~(!{{o_y^w#lv|BrHJ2NLa@RWYnBKW+)~xEKfQWeIH=_pF59g%YTr2?i6&7 zNGm}4R6u=Fe$!sW6tTQHl?lWk6b6~z-+K5M^CKz#W{rB~3q1oM=)YOVgOfDzrG2}h zcRoz3i$tVuV=5Xlj>O|mPL5(5?K%Qp2OuZ~=-yDcX%n+}bS-SF9?12Gu9lA{9+Ag% z@FVU%m)e#PWvxKPPa#UN(n7l9o=yCFA(@#jXL%&8nA#m4 zV>bvhm!sQITn$1EIDTIhpbE;&0)aT4C(P;Y{-OYb^UKOob;#WU0b+`eFa8LpJborI zYzQ!8rY&?9Y+!k$`BO+Z0N#pkSXYxD4*s{ffp)iHRwbYFQ;BDO4aDy576-DAPZdv_6H ztXtDaqY)BnfraX0$>upNNhQ^_oNd8Q2T$=dB6u~PSs#20jH7m*(TDrLjw^CtB9_|~I9#Sfi9!3*JFgHDlxZ2xsTx8n>D?u&3W=*#` zW`)&p*2$YROQpA*z85%0AHX>Bv;+5Fe7hLO?t%g1gaEmPkg?Y#52$0UA0GZIBFE|3 z(9_QF^UWsPF6HYT+yq-81(C1avxy4yRKRll>#p%*K8_+OjsIbiM1=7>cyuB zF^-nZKKVe5LtHE2^6+8f?jNz6hT|T3=lkVS`|-nnoBcavfAm&%G==bUY8UhP zdSW(hl#+tfiq4(Bm_oym!$F22qZa5)QOPMx`{o9Zb}0|D6`7mhw}R18YGTqYG2)Zt zOJZ#e8nS-aawrBQX?D>x(`4@5V@Na5X*DEss57Lijcz8K=^;-KutXa#f3mM1$R}n4 zd#oqn2#f9vKejk*KMz2)`)xlrO>it8Hm560`mU|rEUjs(VZ=S2Wh}0(+RI0#!mGx6 zmtT!6jX~_*{M**>qxa+tr|lU_`{ZloaRJ3N%aoXqLXkc^zT zL5Il`QmF)a)S?pd3`{sJitrcye`;j>D~eq^OPDtQlQp7;go}F{K+N z2M-S(y1IHWx&FJzSwFbPsNKuf<;g6k11fhu07T%WpE^9nv{DQec+|cP`!pO7VnY+C zyczU($p3uFv+4R6FQ;74-r0^?UMiRmZvBuVujPql*jT_ohNimNCBT>mArJNo;F7cU zBf*_=4>nHLMzWo3I>U}u{_fy#ixz|dLK)j6q3F+27qOdUV^e^)M`KrL!j8s!CG&0R zb+irA!n4I;!nqK-W`StFPZGz}zW0YnDsADLM&HRGWbaKuy!>pYkoi{;hksuEwKRWt zub0EL`El^;^;!7hu;%S&<*u`l5+1L( zhQui2C-Do*)PY}mk$;O$R*{BdRrVL(^1VA2(EIW1o|}x7xUX4972%kJI81u){IJ$w z0CK~9R*HLnI=~O6UH(i=+a)GdzlF>O((u!()}^|O%A`H;JZDY*14wzgTX`g9fQfx{ zmS*mf&02|R5xTzS9Cke-i##>xJSOnqya}yHEbj+pjBFR?9XtN*K*}L=O^(D5ig(2^ z<$%=uC{Oir=>dtiI^yOugP*`}# zcJLR&sC>~E3Rb>|@`f&7o+Pr%AlSE;*jb#goQ28)m*Qs?FrN)cb329g{3pLhBY`Y+ zY@I)}b*Hq4OEbP}>$?G-sW-pRcQQ{c;qP)c`xz2tZ1tW8ZKQ@i(JcpMIX1~7L{>(@yP8(OvIKJJMu_H(JV>4c~YC=EwCY0s$^VCCU+XzSw_ux^aUok~6TM#Mee{JS=rS@kp!^v;lyP7JKk0yKK7 zTAp`Xht`^F>!ayag|0nL`F!;RDx&l%_Rw<;Nk9`a4qnA7;%1LTFBowfxN+sH1}y4> zE#B?(^}DLr4D`fBJObLC%{|sP6o|Ad~hJ>hfw6*j|}oU`7Rc$ybtpTdG#d zoOzZ@K%@DGFx^u-WpapgT5)0lgLzi1LorBfrY*ikGf*0w#5iYPw_22pTPPSPf?$-d zR{DC2dU>Hmm|Vhf5pLOnJ%un(<&0lVdAKwqKXTu;wB>onHz((P;cOLQyX0z?9OoQg zU3U+uXe;!TR}$6adn)&t#T*5Bwu3@;CSSHDV<5)W&99+{+PhLzvd8Igcrq z$%BP^U!Rk+N6A^x5$2*xKX8DB6Lz}b?ANj@6KvgwX@ovP>C2UWzOQ^;^b(h*eB6^Q z`T=>hzxwK8f4z13!d553s1*)$zoosv)w5L~{@$q6syJSP94O#h9c>AmNSHd5-lBTs zn8oR8QJa&c;VAo~M1bqQF(0L>H${WvsE!z%%-)h+IkiWqaJnUsEoJ%3)An5-q}P?+ z{yO_C=28;-L0Mjt@O>!zkRV})Lo$B7JX-C;A;ab*2}iLh>~4X+qAq#QHy0_{BE`!| zc-G7_%i`Z}d05mpF%Q3(W{PS=d2s(c%bSdv%y2+Rcpzem1>zV{$x0FJ`LoXE68Gcl zmTcAqGIoCM`Ro4YY?~-n>8#DoDI%g+M93!u6FS)mqGd{qU)1M1kY~g=&>~ddLKQ); zTOsqyqTEnR3H-A#c|-$Sd8#>2wbcFF?{zd&$K|UyrmM$0(_@s%b}f$@=L6nitpjRNqLFtleg;R-q~`e%P&w-!-;v z(h}RfTi-Tmh+W71H6WT`WlwWlocQkGEp^s2FF#Zx5O-ZOAOdR4z7@G|7P4-t4 zH26QgT2+-3IT;lIzC3=)`mAqBJ7j{^-onlGn;;5DK+s#MCP}|S>Z;yrEN{AP2Mdtg z>ig9CmxD}uJ533o_H^4l(w`pjunBD`6W;zf_X!SQI!VLZwUzmSI$i=74jtC`Di#-r zR}AFApcoJSisBgw8@SV_(AB~K+`W_ENG{%3WN8OF%?raUMZs_K!sGMr5;_tSh^%h= zP3iQRGE;hfEBNr`tP}6uq)~+FDk>7O9^`)(*d27a! z@H5xc$h2lDH+VBWr7w$PEvJV^5w80{6yJ8CQKY!QN!TZx7xEoYqX`$p7X4~N@A4k| zXMdd8ED@PZdQb0jz&_=A+!If-vYVyUplYbe1kAO>Dv0xja>HH6=Qe*8+A>Y%h;QbJ z-rul^<{mdx`>Yh|lA>d6pzBwZp5r2XR-Q4tBUIuGFfquL3N`>ag*>U0o{}kxkD1c| zdd2=mjNG4b4tV{UIaUtl5S@4axpkx9GyG5*qhL2}x1q$wyO{f=y}dsCb#Plpd!C3$ zWj0E5u>-oIXCFMj%rNHR)9WZb7v1mq-tw+E=-$h2HKVL|0?+Q(XjzLdDH>-j>dZOMf1Z{@9QYh(+<>ahBc7br6f>49jYmr($lw^Un>06_cPpRW|J!@_z{;ex$F$;|MR16 zXc2E|uGCbyx?r|%(YwB0k$A_hYtHszRVzApFKM6(VeG`pGB;71&bd`D@m%5UXxqfG zrTVb-ZA^Q`Gw+^MX-KNr^F}ZK0EGOtlw)bRs}tf`dekN=WybPyDXG*MbPonlz4NGRA=GUoFsq=C?5!^0p^vz$t=8A(a`1g!1^cWc=|LzErqOQVO%pN z8UT~?>SgkBg0tDs|J`2xv*yg?1c#{K2!+F*4_B;ntKU8Gklk!X)l|UecEFoTo9}I) zjX>)8__XccWvl+KqjiabRh!_Ykli#$J|YzalGUe6T+k6@2sQ|^l=c9r{CdCOW9!MHnKu^xdwWQ2`l9L$&K1&r=?*1p%)4w zO_kRkft@>5N6e(7CGmMy!#M#8rY(CJ&eghYj}3jBWH_QK6+90nr_$Q8r+v&5`U^~0 z;n2_%SoKDW@DCr@78H@xn8PAPMKr8ODNv!~Fm!HKzRuv>X`|c1 zdZ4bXPVhXu{fk_Cdx1pdeppZ#Vc|Lyd=P#mM=kv^iDCns(m(MZgU*Dm@JbeISbbmp z`{d`pIwW3Gx@ik?SNpMHPW|K*Q>gg3VrF;zz3i0GU4&&lY`4x2refcpOnm&`zyt1% z)p2g~rUF|wxt#!E?vmlGy+UjTVRdSqmmtx3Nd0H@`34eyiwJoMglP1YH9B;6Z*Q&1 z9XBaSL!ek)d$=meHHEU7nj4*w_j4YxlZW)q+vYJG3hs$HMS#Y}y{0##Ix&_CRpAFr8|7Iitm2z~Yz<^=5<5Lnl84>c z3rV^L3k$6Q`;=9`&bh93x%MwPjVR0&xf4{_DoVz6+i=KhUs%x>Jy_n6<-_!FNzp%o z@T#zDNm1E{fFC?_`c}|!?NPPwWeZgf?O?CUfcT#O8NR-?c~Ha-G~t+JQUyBDwlM6c zX%8CM5+veE<|fV*ddw@n46i|&AHjIBGe*g}<=y}H|8lW&d&BSS( zrba2Nc&!1tYl*dxe6A@OBlK2-Yb(rO8UrRQ+vC^0aaulpv5F+ehgi(DFcCx&&_#C$ z-?lS&QwYmW1^5#uT+85c2~~^fD5-v5PCl9sGfknh=gTF<_lQ}cL?R}Osq)d5e%_b% zwc{@xUoK#sq5%VSosX1=Z8wXJ`tg|^uNMai1J@r+CA;S* zlyBx1r} zp@pPdE~d|ZX+PLKSIFr4QXtZ_;rW_pu9{1h4EzW(a>LBMe9zaTO9|0aV8UyvVX3D^ zHQ>}R@luDD)w2V{%GBHphjkuo1~yVDyXct~ zdRd&T5KmTnDqgZug9&w3i7i=!y z99yq;_4-G|q`B*`kht638U~B(M)+7Z<7D4dMKGxBu1iAO1G|^0XVK;wJUt6YUnen%#+$tbO=ZrnR9L_?*=P`PAuqpzJvg*GQl z#L-*Y67QP$#Y|QAIjA+J#h!jk+bF?QqSppvzExL*H8{M~`@>1r= z{|cJkGB>l!kWtNs)HqCt@sriG$tp;I_Y|`kwM0c4ZEW-h^b+rz!dPdog@nnP>Bn!F zDh^vxMYiQ@bRFcIXhg-E59HAo!RO}0F+bLRyCWo=4uR_Zrw1K0ooCbXxiV&g%rhF1 z(suM2H@L+aA(Gi{o@eV0X?!Lu|Ggak5i6Zva=vqn4HBh+_>P=4~7t_H`JCzTGM8= zW{!+8ns@GH(Y#f;LF`LG9)xyYtr27}U?^KeA3=1LI#>a%V3 zZxkv@_s7Su8Sp*dB<)Xfb~m;{MhhgSJ7vj;GjRhQ)0Yb*IZUc9X|uJ%q6bz3y;%+c z-H*E8`M>m`_g{VVva8zm`qh->ppi**{pD#^R`sX5m;;Dr)~&Z~dLFRUk#&vm(;huW zEtOKX$N&~6zG;I=E0Rgg7!Oa-oCcmxp{%8ryyT>h7kjjg+;76};v)(7D+%oVYzGpj zM59dNFGLA01qADzzcZEd=9KORwl0neNmM31F+kXx_hejjIgsaoTU`)u&ttSO;)R-4eMk)eI*Wvp{F0DjNmxywbq@YYnu zLup?1wdtS6pF5|$v$xWo#pRbdx9-{J@!!pHV6fcGBx4v$Pp^N<+MAc6^$M~W^4s~n zj+tf-kMYskU_})w+3z{#j^&V-X%_RyXa9RvL`W1+ocSp_!`a{-13bOUSLsqwp@=Ya zR7Ff3P|;MvG4!(P8hV?Ew(SWsgwdyx$M%F4s#6!4m&6wjW3qg* zkX6D}GvQq1bLoLOp2{2&RDl25?+35NOKVnE7wV`!cZL_tD(vx$?bMqoq%3h|%2$oj zZBTP&IGU?V0#8~1Lkv$02bh1_orkxg$^y}$dn2+astl1E;#?nV@6a_r>M!<9>wKRj zt}p$loeHHD(`LHTTD#y9PV11_cya|6%AXZCuW$j#-)xn)NwZ#$5=i`VC;Zy`2AyAU zO;oh`5CvR%!qa*dLS?#mK9n*vw0%uuCvVrsq~5P^6#XKBYM9^geiT4JxO30q8Xn-*H@XZ`VFTu8nY* zzlh&S6OIN4a5Gf|Ec zJZ~b~L_eid`!>${2x4uyUsedm%=-Jjn6QMKO;K$#NYR>#gNL64D2DE54At%0Aw?rD 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⁣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

)8tv)wuzvUMopVoV%@N%Zv?qx+ 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 CcrP;m literal 0 HcmV?d00001 diff --git a/packages/editor/src/components/ui/action-menu/control-modes.tsx b/packages/editor/src/components/ui/action-menu/control-modes.tsx index f85c5e34..f258ef20 100755 --- a/packages/editor/src/components/ui/action-menu/control-modes.tsx +++ b/packages/editor/src/components/ui/action-menu/control-modes.tsx @@ -64,7 +64,7 @@ const controls: ControlConfig[] = [ }, { id: 'material-paint', - iconifyIcon: 'mdi:format-color-fill', + imageSrc: '/icons/paint.png', label: 'Material Paint', shortcut: 'P', color: 'hover:bg-amber-500/20 hover:text-amber-400', From 325f2e460351fc31d38bca0fb25864ceac317ad0 Mon Sep 17 00:00:00 2001 From: sudhir Date: Fri, 24 Apr 2026 22:49:04 +0530 Subject: [PATCH 26/31] Refactor viewer hover styles and remove preview state --- .../editor/src/components/editor/index.tsx | 20 +++- .../components/editor/selection-manager.tsx | 14 +-- .../renderers/ceiling/ceiling-renderer.tsx | 16 +-- .../renderers/fence/fence-renderer.tsx | 17 +-- .../roof-segment/roof-segment-renderer.tsx | 36 +----- .../renderers/roof/roof-renderer.tsx | 33 +----- .../renderers/slab/slab-renderer.tsx | 15 +-- .../stair-segment/stair-segment-renderer.tsx | 39 +------ .../renderers/stair/stair-renderer.tsx | 50 ++------ .../renderers/wall/wall-renderer.tsx | 26 +---- .../viewer/src/components/viewer/index.tsx | 6 +- .../src/components/viewer/post-processing.tsx | 109 +++++++++++++----- packages/viewer/src/index.ts | 11 +- packages/viewer/src/store/use-viewer.d.ts | 36 +----- packages/viewer/src/store/use-viewer.ts | 74 +----------- 15 files changed, 142 insertions(+), 360 deletions(-) diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index b61644af..a7591d90 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -7,7 +7,7 @@ import { spatialGridManager, useScene, } from '@pascal-app/core' -import { InteractiveSystem, useViewer, Viewer } from '@pascal-app/viewer' +import { type HoverStyles, InteractiveSystem, useViewer, Viewer } from '@pascal-app/viewer' import { memo, type ReactNode, @@ -75,6 +75,17 @@ const PAINT_CURSOR_BADGE_COLOR = '#f59e0b' const PAINT_CURSOR_BADGE_DISABLED_COLOR = '#94a3b8' const PAINT_CURSOR_BADGE_OFFSET_X = 14 const PAINT_CURSOR_BADGE_OFFSET_Y = 14 +const EDITOR_HOVER_STYLES: HoverStyles = { + default: { visibleColor: 0x00_aaff, hiddenColor: 0xf3_ff47, strength: 5, pulse: true }, + delete: { visibleColor: 0xef_4444, hiddenColor: 0x99_1b1b, strength: 6, pulse: false }, + 'paint-ready': { visibleColor: 0xf5_9e0b, hiddenColor: 0xfd_e068, strength: 5, pulse: true }, + 'paint-disabled': { + visibleColor: 0x94_a3b8, + hiddenColor: 0x47_5569, + strength: 4, + pulse: false, + }, +} /** * Wire up module-level singletons (spatial grid, space detection, SFX) for @@ -885,7 +896,10 @@ const ViewerCanvas = memo(function ViewerCanvas({ /> ) : null} - + + diff --git a/packages/editor/src/components/editor/selection-manager.tsx b/packages/editor/src/components/editor/selection-manager.tsx index a53c870d..bf1fd870 100755 --- a/packages/editor/src/components/editor/selection-manager.tsx +++ b/packages/editor/src/components/editor/selection-manager.tsx @@ -25,20 +25,18 @@ import { type WallSurfaceSide, } from '@pascal-app/core' -import { useViewer } from '@pascal-app/viewer' -import { useCallback, useEffect, useRef } from 'react' -import { type BufferGeometry, Color, type Material, type Mesh, type Object3D } from 'three' import { applyMaterialPresetToMaterials, createMaterial, createMaterialFromPresetRef, -} from '../../../../viewer/src/lib/materials' -import { getRoofMaterialArray } from '../../../../viewer/src/systems/roof/roof-materials' -import { + getRoofMaterialArray, getStairBodyMaterials, getStairRailingMaterial, -} from '../../../../viewer/src/systems/stair/stair-materials' -import { getVisibleWallMaterials } from '../../../../viewer/src/systems/wall/wall-materials' + getVisibleWallMaterials, + useViewer, +} from '@pascal-app/viewer' +import { useCallback, useEffect, useRef } from 'react' +import { type BufferGeometry, Color, type Material, type Mesh, type Object3D } from 'three' import { type ActivePaintMaterial, buildRoofSurfaceMaterialPatch, diff --git a/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx b/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx index 398abf54..91e7487c 100644 --- a/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx +++ b/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx @@ -8,7 +8,6 @@ import { useMemo, useRef } from 'react' import { float, mix, positionWorld, smoothstep } from 'three/tsl' import { BackSide, FrontSide, type Mesh, MeshBasicNodeMaterial } from 'three/webgpu' import { useNodeEvents } from '../../../hooks/use-node-events' -import useViewer from '../../../store/use-viewer' import { NodeRenderer } from '../node-renderer' const gridScale = 5 @@ -55,16 +54,10 @@ export const CeilingRenderer = ({ node }: { node: CeilingNode }) => { useRegistry(node.id, 'ceiling', ref) const handlers = useNodeEvents(node, 'ceiling') - const materialPreview = useViewer((state) => - state.materialPreview?.target === 'ceiling' && state.materialPreview.nodeId === node.id - ? state.materialPreview - : null, - ) const materials = useMemo(() => { - const preset = getMaterialPresetByRef(materialPreview?.materialPreset ?? node.materialPreset) - const props = - preset?.mapProperties ?? resolveMaterial(materialPreview?.material ?? node.material) + const preset = getMaterialPresetByRef(node.materialPreset) + const props = preset?.mapProperties ?? resolveMaterial(node.material) const color = props.color || '#999999' return getCeilingMaterials(color) }, [ @@ -73,11 +66,6 @@ export const CeilingRenderer = ({ node }: { node: CeilingNode }) => { node.material?.preset, node.material?.properties, node.material?.texture, - materialPreview?.materialPreset, - materialPreview?.material, - materialPreview?.material?.preset, - materialPreview?.material?.properties, - materialPreview?.material?.texture, ]) return ( diff --git a/packages/viewer/src/components/renderers/fence/fence-renderer.tsx b/packages/viewer/src/components/renderers/fence/fence-renderer.tsx index c5448ada..4e563572 100644 --- a/packages/viewer/src/components/renderers/fence/fence-renderer.tsx +++ b/packages/viewer/src/components/renderers/fence/fence-renderer.tsx @@ -7,22 +7,14 @@ import { createMaterialFromPresetRef, DEFAULT_STAIR_MATERIAL, } from '../../../lib/materials' -import useViewer from '../../../store/use-viewer' export const FenceRenderer = ({ node }: { node: FenceNode }) => { const ref = useRef(null!) const handlers = useNodeEvents(node, 'fence') - const materialPreview = useViewer((state) => - state.materialPreview?.target === 'fence' && state.materialPreview.nodeId === node.id - ? state.materialPreview - : null, - ) const material = useMemo(() => { - const presetMaterial = createMaterialFromPresetRef( - materialPreview?.materialPreset ?? node.materialPreset, - ) + const presetMaterial = createMaterialFromPresetRef(node.materialPreset) if (presetMaterial) return presetMaterial - const mat = materialPreview?.material ?? node.material + const mat = node.material if (!mat) return DEFAULT_STAIR_MATERIAL return createMaterial(mat) }, [ @@ -31,11 +23,6 @@ export const FenceRenderer = ({ node }: { node: FenceNode }) => { node.material?.preset, node.material?.properties, node.material?.texture, - materialPreview?.materialPreset, - materialPreview?.material, - materialPreview?.material?.preset, - materialPreview?.material?.properties, - materialPreview?.material?.texture, ]) useRegistry(node.id, 'fence', ref) diff --git a/packages/viewer/src/components/renderers/roof-segment/roof-segment-renderer.tsx b/packages/viewer/src/components/renderers/roof-segment/roof-segment-renderer.tsx index daebf9fb..8bd052fa 100644 --- a/packages/viewer/src/components/renderers/roof-segment/roof-segment-renderer.tsx +++ b/packages/viewer/src/components/renderers/roof-segment/roof-segment-renderer.tsx @@ -23,11 +23,6 @@ export const RoofSegmentRenderer = ({ node }: { node: RoofSegmentNode }) => { const parentNode = node.parentId ? (nodes[node.parentId as AnyNodeId] as RoofNode | undefined) : undefined - const materialPreview = useViewer((state) => - state.materialPreview?.target === 'roof' && state.materialPreview.nodeId === parentNode?.id - ? state.materialPreview - : null, - ) const placeholderGeometry = useMemo(() => { const geometry = new THREE.BufferGeometry() geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3)) @@ -38,40 +33,13 @@ export const RoofSegmentRenderer = ({ node }: { node: RoofSegmentNode }) => { return geometry }, []) - const previewParentNode = !parentNode - ? undefined - : materialPreview?.role === 'top' - ? { - ...parentNode, - topMaterial: materialPreview.material, - topMaterialPreset: materialPreview.materialPreset, - material: undefined, - materialPreset: undefined, - } - : materialPreview?.role === 'edge' - ? { - ...parentNode, - edgeMaterial: materialPreview.material, - edgeMaterialPreset: materialPreview.materialPreset, - material: undefined, - materialPreset: undefined, - } - : materialPreview?.role === 'wall' - ? { - ...parentNode, - wallMaterial: materialPreview.material, - wallMaterialPreset: materialPreview.materialPreset, - material: undefined, - materialPreset: undefined, - } - : parentNode const customMaterial = useMemo(() => { if (node.material !== undefined || typeof node.materialPreset === 'string') { return null } - return previewParentNode ? getRoofMaterialArray(previewParentNode) : null - }, [node, previewParentNode]) + return parentNode ? getRoofMaterialArray(parentNode) : null + }, [node, parentNode]) const material = debugColors ? roofDebugMaterials : customMaterial || roofMaterials diff --git a/packages/viewer/src/components/renderers/roof/roof-renderer.tsx b/packages/viewer/src/components/renderers/roof/roof-renderer.tsx index 4a4eb866..1dee2a98 100644 --- a/packages/viewer/src/components/renderers/roof/roof-renderer.tsx +++ b/packages/viewer/src/components/renderers/roof/roof-renderer.tsx @@ -14,11 +14,6 @@ export const RoofRenderer = ({ node }: { node: RoofNode }) => { const handlers = useNodeEvents(node, 'roof') const debugColors = useViewer((s) => s.debugColors) - const materialPreview = useViewer((state) => - state.materialPreview?.target === 'roof' && state.materialPreview.nodeId === node.id - ? state.materialPreview - : null, - ) const placeholderGeometry = useMemo(() => { const geometry = new THREE.BufferGeometry() geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3)) @@ -29,33 +24,7 @@ export const RoofRenderer = ({ node }: { node: RoofNode }) => { return geometry }, []) - const previewNode = - materialPreview?.role === 'top' - ? { - ...node, - topMaterial: materialPreview.material, - topMaterialPreset: materialPreview.materialPreset, - material: undefined, - materialPreset: undefined, - } - : materialPreview?.role === 'edge' - ? { - ...node, - edgeMaterial: materialPreview.material, - edgeMaterialPreset: materialPreview.materialPreset, - material: undefined, - materialPreset: undefined, - } - : materialPreview?.role === 'wall' - ? { - ...node, - wallMaterial: materialPreview.material, - wallMaterialPreset: materialPreview.materialPreset, - material: undefined, - materialPreset: undefined, - } - : node - const customMaterial = useMemo(() => getRoofMaterialArray(previewNode), [previewNode]) + const customMaterial = useMemo(() => getRoofMaterialArray(node), [node]) const material = debugColors ? roofDebugMaterials : customMaterial || roofMaterials diff --git a/packages/viewer/src/components/renderers/slab/slab-renderer.tsx b/packages/viewer/src/components/renderers/slab/slab-renderer.tsx index 0e97f23d..e2b935f6 100644 --- a/packages/viewer/src/components/renderers/slab/slab-renderer.tsx +++ b/packages/viewer/src/components/renderers/slab/slab-renderer.tsx @@ -8,7 +8,6 @@ import { createMaterial, DEFAULT_SLAB_MATERIAL, } from '../../../lib/materials' -import useViewer from '../../../store/use-viewer' const slabMaterialCache = new Map() @@ -52,15 +51,10 @@ export const SlabRenderer = ({ node }: { node: SlabNode }) => { useRegistry(node.id, 'slab', ref) const handlers = useNodeEvents(node, 'slab') - const materialPreview = useViewer((state) => - state.materialPreview?.target === 'slab' && state.materialPreview.nodeId === node.id - ? state.materialPreview - : null, - ) const material = useMemo(() => { - const resolvedMaterial = materialPreview?.material ?? node.material - const resolvedMaterialPreset = materialPreview?.materialPreset ?? node.materialPreset + const resolvedMaterial = node.material + const resolvedMaterialPreset = node.materialPreset const cacheKey = JSON.stringify({ material: resolvedMaterial ?? null, materialPreset: resolvedMaterialPreset ?? null, @@ -76,11 +70,6 @@ export const SlabRenderer = ({ node }: { node: SlabNode }) => { node.material?.properties, node.material?.texture, node.materialPreset, - materialPreview?.material, - materialPreview?.material?.preset, - materialPreview?.material?.properties, - materialPreview?.material?.texture, - materialPreview?.materialPreset, ]) return ( diff --git a/packages/viewer/src/components/renderers/stair-segment/stair-segment-renderer.tsx b/packages/viewer/src/components/renderers/stair-segment/stair-segment-renderer.tsx index 1c27a364..73c834ea 100644 --- a/packages/viewer/src/components/renderers/stair-segment/stair-segment-renderer.tsx +++ b/packages/viewer/src/components/renderers/stair-segment/stair-segment-renderer.tsx @@ -8,7 +8,6 @@ import { import { useEffect, useLayoutEffect, useMemo, useRef } from 'react' import * as THREE from 'three' import { useNodeEvents } from '../../../hooks/use-node-events' -import useViewer from '../../../store/use-viewer' import { getStraightStairSegmentBodyMaterials } from '../../../systems/stair/stair-materials' export const StairSegmentRenderer = ({ node }: { node: StairSegmentNode }) => { @@ -25,42 +24,10 @@ export const StairSegmentRenderer = ({ node }: { node: StairSegmentNode }) => { const parentNode = node.parentId ? (nodes[node.parentId as AnyNodeId] as StairNode | undefined) : undefined - const materialPreview = useViewer((state) => - state.materialPreview?.target === 'stair' && state.materialPreview.nodeId === parentNode?.id - ? state.materialPreview - : null, + const material = useMemo( + () => getStraightStairSegmentBodyMaterials(node, parentNode), + [node, parentNode], ) - const previewParentNode = !parentNode - ? undefined - : materialPreview?.role === 'railing' - ? { - ...parentNode, - railingMaterial: materialPreview.material, - railingMaterialPreset: materialPreview.materialPreset, - material: undefined, - materialPreset: undefined, - } - : materialPreview?.role === 'tread' - ? { - ...parentNode, - treadMaterial: materialPreview.material, - treadMaterialPreset: materialPreview.materialPreset, - material: undefined, - materialPreset: undefined, - } - : materialPreview?.role === 'side' - ? { - ...parentNode, - sideMaterial: materialPreview.material, - sideMaterialPreset: materialPreview.materialPreset, - material: undefined, - materialPreset: undefined, - } - : parentNode - - const material = useMemo(() => { - return getStraightStairSegmentBodyMaterials(node, previewParentNode) - }, [node, previewParentNode]) const placeholderGeometry = useMemo(() => { const geometry = new THREE.BufferGeometry() diff --git a/packages/viewer/src/components/renderers/stair/stair-renderer.tsx b/packages/viewer/src/components/renderers/stair/stair-renderer.tsx index 4bc8821a..d05ed49c 100644 --- a/packages/viewer/src/components/renderers/stair/stair-renderer.tsx +++ b/packages/viewer/src/components/renderers/stair/stair-renderer.tsx @@ -13,7 +13,6 @@ import { createMaterialFromPresetRef, DEFAULT_STAIR_MATERIAL, } from '../../../lib/materials' -import useViewer from '../../../store/use-viewer' import { getStairBodyMaterials, getStairRailingMaterial, @@ -62,55 +61,24 @@ export const StairRenderer = ({ node }: { node: StairNode }) => { }, [node.id]) const handlers = useNodeEvents(node, 'stair') - const materialPreview = useViewer((state) => - state.materialPreview?.target === 'stair' && state.materialPreview.nodeId === node.id - ? state.materialPreview - : null, - ) - const previewNode = - materialPreview?.role === 'railing' - ? { - ...node, - railingMaterial: materialPreview.material, - railingMaterialPreset: materialPreview.materialPreset, - material: undefined, - materialPreset: undefined, - } - : materialPreview?.role === 'tread' - ? { - ...node, - treadMaterial: materialPreview.material, - treadMaterialPreset: materialPreview.materialPreset, - material: undefined, - materialPreset: undefined, - } - : materialPreview?.role === 'side' - ? { - ...node, - sideMaterial: materialPreview.material, - sideMaterialPreset: materialPreview.materialPreset, - material: undefined, - materialPreset: undefined, - } - : node const material = useMemo(() => { - const presetMaterial = createMaterialFromPresetRef(previewNode.materialPreset) + const presetMaterial = createMaterialFromPresetRef(node.materialPreset) if (presetMaterial) return presetMaterial - const mat = previewNode.material + const mat = node.material if (!mat) return DEFAULT_STAIR_MATERIAL return createMaterial(mat) }, [ - previewNode.materialPreset, - previewNode.material, - previewNode.material?.preset, - previewNode.material?.properties, - previewNode.material?.texture, + node.materialPreset, + node.material, + node.material?.preset, + node.material?.properties, + node.material?.texture, ]) - const straightBodyMaterials = useMemo(() => getStairBodyMaterials(previewNode), [previewNode]) + const straightBodyMaterials = useMemo(() => getStairBodyMaterials(node), [node]) - const railingMaterial = useMemo(() => getStairRailingMaterial(previewNode), [previewNode]) + const railingMaterial = useMemo(() => getStairRailingMaterial(node), [node]) const straightPlaceholderGeometry = useMemo(() => { const geometry = new THREE.BufferGeometry() diff --git a/packages/viewer/src/components/renderers/wall/wall-renderer.tsx b/packages/viewer/src/components/renderers/wall/wall-renderer.tsx index a61fbb82..9ff38006 100644 --- a/packages/viewer/src/components/renderers/wall/wall-renderer.tsx +++ b/packages/viewer/src/components/renderers/wall/wall-renderer.tsx @@ -2,7 +2,6 @@ import { useRegistry, useScene, type WallNode } from '@pascal-app/core' import { useLayoutEffect, useRef } from 'react' import type { Mesh } from 'three' import { useNodeEvents } from '../../../hooks/use-node-events' -import useViewer from '../../../store/use-viewer' import { getVisibleWallMaterials } from '../../../systems/wall/wall-materials' import { NodeRenderer } from '../node-renderer' @@ -16,30 +15,7 @@ export const WallRenderer = ({ node }: { node: WallNode }) => { }, [node.id]) const handlers = useNodeEvents(node, 'wall') - const materialPreview = useViewer((state) => - state.materialPreview?.target === 'wall' && state.materialPreview.nodeId === node.id - ? state.materialPreview - : null, - ) - const previewNode = - materialPreview && materialPreview.role === 'interior' - ? { - ...node, - interiorMaterial: materialPreview.material, - interiorMaterialPreset: materialPreview.materialPreset, - material: undefined, - materialPreset: undefined, - } - : materialPreview && materialPreview.role === 'exterior' - ? { - ...node, - exteriorMaterial: materialPreview.material, - exteriorMaterialPreset: materialPreview.materialPreset, - material: undefined, - materialPreset: undefined, - } - : node - const material = getVisibleWallMaterials(previewNode) + const material = getVisibleWallMaterials(node) return ( diff --git a/packages/viewer/src/components/viewer/index.tsx b/packages/viewer/src/components/viewer/index.tsx index 42182d9a..6ee40f54 100644 --- a/packages/viewer/src/components/viewer/index.tsx +++ b/packages/viewer/src/components/viewer/index.tsx @@ -26,7 +26,7 @@ import { SceneRenderer } from '../renderers/scene-renderer' import FrameLimiter from './frame-limiter' import { Lights } from './lights' import { PerfMonitor } from './perf-monitor' -import PostProcessing from './post-processing' +import PostProcessing, { DEFAULT_HOVER_STYLES, type HoverStyles } from './post-processing' import { SelectionManager } from './selection-manager' import { ViewerCamera } from './viewer-camera' @@ -101,12 +101,14 @@ function GPUDeviceWatcher() { interface ViewerProps { children?: React.ReactNode + hoverStyles?: HoverStyles selectionManager?: 'default' | 'custom' perf?: boolean } const Viewer: React.FC = ({ children, + hoverStyles = DEFAULT_HOVER_STYLES, selectionManager = 'default', perf = false, }) => { @@ -165,7 +167,7 @@ const Viewer: React.FC = ({ - + {/* */} diff --git a/packages/viewer/src/components/viewer/post-processing.tsx b/packages/viewer/src/components/viewer/post-processing.tsx index 63217975..253fb927 100644 --- a/packages/viewer/src/components/viewer/post-processing.tsx +++ b/packages/viewer/src/components/viewer/post-processing.tsx @@ -47,6 +47,46 @@ const RETRY_DELAY_MS = 500 const DARK_BG = '#1f2433' const LIGHT_BG = '#ffffff' +export type HoverStyle = { + visibleColor: number + hiddenColor: number + strength: number + pulse: boolean +} + +export type HoverStyles = { + default: HoverStyle +} & Record + +const DEFAULT_HOVER_STYLE: HoverStyle = { + visibleColor: 0x00_aa_ff, + hiddenColor: 0xf3_ff_47, + strength: 5, + pulse: true, +} + +export const DEFAULT_HOVER_STYLES: HoverStyles = { + default: DEFAULT_HOVER_STYLE, + delete: { + visibleColor: 0xef_44_44, + hiddenColor: 0x99_1b_1b, + strength: 6, + pulse: false, + }, + 'paint-ready': { + visibleColor: 0xf5_9e_0b, + hiddenColor: 0xfd_e0_68, + strength: 5, + pulse: true, + }, + 'paint-disabled': { + visibleColor: 0x94_a3_b8, + hiddenColor: 0x47_55_69, + strength: 4, + pulse: false, + }, +} + function sanitizeOutlineObjects(objects: Object3D[]) { let nextIndex = 0 @@ -62,7 +102,11 @@ function sanitizeOutlineObjects(objects: Object3D[]) { objects.length = nextIndex } -const PostProcessingPasses = () => { +const PostProcessingPasses = ({ + hoverStyles = DEFAULT_HOVER_STYLES, +}: { + hoverStyles?: HoverStyles +}) => { const { gl: renderer, scene, camera } = useThree() const renderPipelineRef = useRef(null) const hasPipelineErrorRef = useRef(false) @@ -83,6 +127,10 @@ const PostProcessingPasses = () => { return l }, []) const hoverHighlightMode = useViewer((s) => s.hoverHighlightMode) + const hoverVisibleColor = useMemo(() => uniform(new Color(DEFAULT_HOVER_STYLE.visibleColor)), []) + const hoverHiddenColor = useMemo(() => uniform(new Color(DEFAULT_HOVER_STYLE.hiddenColor)), []) + const hoverStrength = useMemo(() => uniform(DEFAULT_HOVER_STYLE.strength), []) + const hoverPulseMix = useMemo(() => uniform(DEFAULT_HOVER_STYLE.pulse ? 0 : 1), []) // Subscribe to projectId so the pipeline rebuilds on project switch const projectId = useViewer((s) => s.projectId) @@ -119,6 +167,21 @@ const PostProcessingPasses = () => { } }, []) + useEffect(() => { + const style = hoverStyles[hoverHighlightMode] ?? hoverStyles.default + hoverVisibleColor.value.setHex(style.visibleColor) + hoverHiddenColor.value.setHex(style.hiddenColor) + hoverStrength.value = style.strength + hoverPulseMix.value = style.pulse ? 0 : 1 + }, [ + hoverHiddenColor, + hoverHighlightMode, + hoverPulseMix, + hoverStrength, + hoverStyles, + hoverVisibleColor, + ]) + // Build / rebuild the post-processing pipeline useEffect(() => { // Intentionally touch these so React/biome treat project switches and retry bumps @@ -248,36 +311,9 @@ const PostProcessingPasses = () => { .mul(selectedStrength) // Hovered: blue visible, yellow hidden, pulsing - const hoverVisibleColor = uniform( - new Color( - hoverHighlightMode === 'delete' - ? 0xef_44_44 - : hoverHighlightMode === 'paint-ready' - ? 0xf59e_0b - : hoverHighlightMode === 'paint-disabled' - ? 0x94_a3_b8 - : 0x00_aa_ff, - ), - ) - const hoverHiddenColor = uniform( - new Color( - hoverHighlightMode === 'delete' - ? 0x99_1b_1b - : hoverHighlightMode === 'paint-ready' - ? 0xfde0_68 - : hoverHighlightMode === 'paint-disabled' - ? 0x47_55_69 - : 0xf3_ff_47, - ), - ) - const hoverStrength = uniform( - hoverHighlightMode === 'delete' ? 6 : hoverHighlightMode === 'paint-disabled' ? 4 : 5, - ) const pulsePeriod = uniform(3) - const osc = - hoverHighlightMode === 'delete' || hoverHighlightMode === 'paint-disabled' - ? float(1) - : oscSine(time.div(pulsePeriod).mul(2)).mul(0.5).add(0.5) // [ 0.5, 1.0 ] + const oscillating = oscSine(time.div(pulsePeriod).mul(2)).mul(0.5).add(0.5) + const osc = mix(oscillating, float(1), hoverPulseMix) const hoverOutline = outlineNode.secondaryVisibleEdge .mul(hoverVisibleColor) .add(outlineNode.secondaryHiddenEdge.mul(hoverHiddenColor)) @@ -316,7 +352,18 @@ const PostProcessingPasses = () => { } renderPipelineRef.current = null } - }, [renderer, scene, camera, hoverHighlightMode, zoneLayers, projectId, pipelineVersion]) + }, [ + camera, + hoverHiddenColor, + hoverPulseMix, + hoverStrength, + hoverVisibleColor, + pipelineVersion, + projectId, + renderer, + scene, + zoneLayers, + ]) useFrame((_, delta) => { // Animate background colour toward the current theme target (same lerp as AnimatedBackground) diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index 799e1312..e867a444 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -1,12 +1,18 @@ export { default as Viewer } from './components/viewer' -export { SSGI_PARAMS } from './components/viewer/post-processing' +export type { HoverStyle, HoverStyles } from './components/viewer/post-processing' +export { + DEFAULT_HOVER_STYLES, + SSGI_PARAMS, +} from './components/viewer/post-processing' export { WalkthroughControls } from './components/viewer/walkthrough-controls' export { ASSETS_CDN_URL, resolveAssetUrl, resolveCdnUrl } from './lib/asset-url' export { SCENE_LAYER, ZONE_LAYER } from './lib/layers' export { + applyMaterialPresetToMaterials, clearMaterialCache, createDefaultMaterial, createMaterial, + createMaterialFromPresetRef, DEFAULT_CEILING_MATERIAL, DEFAULT_DOOR_MATERIAL, DEFAULT_ROOF_MATERIAL, @@ -19,3 +25,6 @@ export { mergedOutline } from './lib/merged-outline-node' export { default as useViewer } from './store/use-viewer' export { InteractiveSystem } from './systems/interactive/interactive-system' export { snapLevelsToTruePositions } from './systems/level/level-utils' +export { getRoofMaterialArray } from './systems/roof/roof-materials' +export { getStairBodyMaterials, getStairRailingMaterial } from './systems/stair/stair-materials' +export { getVisibleWallMaterials } from './systems/wall/wall-materials' diff --git a/packages/viewer/src/store/use-viewer.d.ts b/packages/viewer/src/store/use-viewer.d.ts index 64586624..1342032a 100644 --- a/packages/viewer/src/store/use-viewer.d.ts +++ b/packages/viewer/src/store/use-viewer.d.ts @@ -3,10 +3,6 @@ import type { BaseNode, BuildingNode, LevelNode, - MaterialSchema, - RoofSurfaceMaterialRole, - StairSurfaceMaterialRole, - WallSurfaceSide, ZoneNode, } from '@pascal-app/core' import type { Object3D } from 'three' @@ -20,40 +16,12 @@ type Outliner = { selectedObjects: Object3D[] hoveredObjects: Object3D[] } -type MaterialPreview = { - nodeId: string - target: 'wall' - role: WallSurfaceSide - material?: MaterialSchema - materialPreset?: string -} | { - nodeId: string - target: 'roof' - role: RoofSurfaceMaterialRole - material?: MaterialSchema - materialPreset?: string -} | { - nodeId: string - target: 'stair' - role: StairSurfaceMaterialRole - material?: MaterialSchema - materialPreset?: string -} | { - nodeId: string - target: 'fence' | 'slab' | 'ceiling' - role: 'surface' - material?: MaterialSchema - materialPreset?: string -} | null type ViewerState = { selection: SelectionPath previewSelectedIds: BaseNode['id'][] setPreviewSelectedIds: (ids: BaseNode['id'][]) => void - materialPreview: MaterialPreview - setMaterialPreview: (preview: MaterialPreview) => void - clearMaterialPreview: () => void - hoverHighlightMode: 'default' | 'delete' | 'paint-ready' | 'paint-disabled' - setHoverHighlightMode: (mode: 'default' | 'delete' | 'paint-ready' | 'paint-disabled') => void + hoverHighlightMode: string + setHoverHighlightMode: (mode: string) => void hoveredId: AnyNode['id'] | ZoneNode['id'] | null setHoveredId: (id: AnyNode['id'] | ZoneNode['id'] | null) => void cameraMode: 'perspective' | 'orthographic' diff --git a/packages/viewer/src/store/use-viewer.ts b/packages/viewer/src/store/use-viewer.ts index c431ab71..00649a63 100644 --- a/packages/viewer/src/store/use-viewer.ts +++ b/packages/viewer/src/store/use-viewer.ts @@ -1,17 +1,6 @@ 'use client' -import type { - AnyNode, - AnyNodeId, - BaseNode, - BuildingNode, - LevelNode, - MaterialSchema, - RoofSurfaceMaterialRole, - StairSurfaceMaterialRole, - WallSurfaceSide, - ZoneNode, -} from '@pascal-app/core' +import type { AnyNode, BaseNode, BuildingNode, LevelNode, ZoneNode } from '@pascal-app/core' import type { Object3D } from 'three' import { create } from 'zustand' @@ -29,59 +18,12 @@ type Outliner = { hoveredObjects: Object3D[] } -type MaterialPreview = - | { - nodeId: AnyNodeId - target: 'wall' - role: WallSurfaceSide - material?: MaterialSchema - materialPreset?: string - } - | { - nodeId: AnyNodeId - target: 'roof' - role: RoofSurfaceMaterialRole - material?: MaterialSchema - materialPreset?: string - } - | { - nodeId: AnyNodeId - target: 'stair' - role: StairSurfaceMaterialRole - material?: MaterialSchema - materialPreset?: string - } - | { - nodeId: AnyNodeId - target: 'fence' | 'slab' | 'ceiling' - role: 'surface' - material?: MaterialSchema - materialPreset?: string - } - | null - -function isSameMaterialPreview(left: MaterialPreview, right: MaterialPreview) { - if (left === right) return true - if (!left || !right) return false - - return ( - left.nodeId === right.nodeId && - left.target === right.target && - left.role === right.role && - left.material === right.material && - left.materialPreset === right.materialPreset - ) -} - type ViewerState = { selection: SelectionPath previewSelectedIds: BaseNode['id'][] setPreviewSelectedIds: (ids: BaseNode['id'][]) => void - materialPreview: MaterialPreview - setMaterialPreview: (preview: MaterialPreview) => void - clearMaterialPreview: () => void - hoverHighlightMode: 'default' | 'delete' | 'paint-ready' | 'paint-disabled' - setHoverHighlightMode: (mode: 'default' | 'delete' | 'paint-ready' | 'paint-disabled') => void + hoverHighlightMode: string + setHoverHighlightMode: (mode: string) => void hoveredId: AnyNode['id'] | ZoneNode['id'] | null setHoveredId: (id: AnyNode['id'] | ZoneNode['id'] | null) => void @@ -142,15 +84,6 @@ const useViewer = create()( selection: { buildingId: null, levelId: null, zoneId: null, selectedIds: [] }, previewSelectedIds: [], setPreviewSelectedIds: (ids) => set({ previewSelectedIds: ids }), - materialPreview: null, - setMaterialPreview: (materialPreview) => - set((state) => - isSameMaterialPreview(state.materialPreview, materialPreview) - ? state - : { materialPreview }, - ), - clearMaterialPreview: () => - set((state) => (state.materialPreview === null ? state : { materialPreview: null })), hoverHighlightMode: 'default', setHoverHighlightMode: (mode) => set((state) => (state.hoverHighlightMode === mode ? state : { hoverHighlightMode: mode })), @@ -255,7 +188,6 @@ const useViewer = create()( selectedIds: [], }, previewSelectedIds: [], - materialPreview: null, }), outliner: { selectedObjects: [], hoveredObjects: [] }, From 2b656bcf8ba980d5e63dbe47d377d425872e2884 Mon Sep 17 00:00:00 2001 From: sudhir Date: Fri, 24 Apr 2026 23:05:14 +0530 Subject: [PATCH 27/31] Remove editor hover styles from viewer defaults --- .../src/components/viewer/post-processing.tsx | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/packages/viewer/src/components/viewer/post-processing.tsx b/packages/viewer/src/components/viewer/post-processing.tsx index 253fb927..e06faa30 100644 --- a/packages/viewer/src/components/viewer/post-processing.tsx +++ b/packages/viewer/src/components/viewer/post-processing.tsx @@ -67,24 +67,6 @@ const DEFAULT_HOVER_STYLE: HoverStyle = { export const DEFAULT_HOVER_STYLES: HoverStyles = { default: DEFAULT_HOVER_STYLE, - delete: { - visibleColor: 0xef_44_44, - hiddenColor: 0x99_1b_1b, - strength: 6, - pulse: false, - }, - 'paint-ready': { - visibleColor: 0xf5_9e_0b, - hiddenColor: 0xfd_e0_68, - strength: 5, - pulse: true, - }, - 'paint-disabled': { - visibleColor: 0x94_a3_b8, - hiddenColor: 0x47_55_69, - strength: 4, - pulse: false, - }, } function sanitizeOutlineObjects(objects: Object3D[]) { From d36441fa47a13657114bda389fe6b216a45c6b4a Mon Sep 17 00:00:00 2001 From: sudhir Date: Fri, 24 Apr 2026 23:29:34 +0530 Subject: [PATCH 28/31] Add hover highlights to paint interactions --- .../components/editor/selection-manager.tsx | 32 ++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/packages/editor/src/components/editor/selection-manager.tsx b/packages/editor/src/components/editor/selection-manager.tsx index bf1fd870..1db09ba1 100755 --- a/packages/editor/src/components/editor/selection-manager.tsx +++ b/packages/editor/src/components/editor/selection-manager.tsx @@ -87,6 +87,8 @@ type PaintPreviewCleanup = () => void type PaintInteraction = { key: string apply: (() => void) | null + hoverMode: HoverHighlightMode + hoveredId: AnyNodeId preview: (() => PaintPreviewCleanup | null) | null } @@ -743,6 +745,11 @@ export const SelectionManager = () => { role !== null && isActivePaintMaterialCompatible(activePaintMaterial, 'wall') return { key: `wall:${node.id}:${role ?? 'unsupported'}`, + hoveredId: node.id as AnyNodeId, + hoverMode: + compatible && hasActivePaintMaterial(activePaintMaterial) && role + ? 'paint-ready' + : 'paint-disabled', apply: compatible && hasActivePaintMaterial(activePaintMaterial) ? () => { @@ -780,6 +787,11 @@ export const SelectionManager = () => { role !== null && isActivePaintMaterialCompatible(activePaintMaterial, 'roof') return { key: `roof:${roofNode.id}:${role ?? 'unsupported'}`, + hoveredId: roofNode.id as AnyNodeId, + hoverMode: + compatible && hasActivePaintMaterial(activePaintMaterial) && role + ? 'paint-ready' + : 'paint-disabled', apply: compatible && hasActivePaintMaterial(activePaintMaterial) ? () => { @@ -817,6 +829,11 @@ export const SelectionManager = () => { role !== null && isActivePaintMaterialCompatible(activePaintMaterial, 'stair') return { key: `stair:${stairNode.id}:${role ?? 'unsupported'}`, + hoveredId: stairNode.id as AnyNodeId, + hoverMode: + compatible && hasActivePaintMaterial(activePaintMaterial) && role + ? 'paint-ready' + : 'paint-disabled', apply: compatible && hasActivePaintMaterial(activePaintMaterial) ? () => { @@ -848,6 +865,8 @@ export const SelectionManager = () => { return { key: `${target}:${node.id}:surface`, + hoveredId: node.id as AnyNodeId, + hoverMode: compatible ? 'paint-ready' : 'paint-disabled', apply: compatible ? () => { useScene @@ -875,6 +894,8 @@ export const SelectionManager = () => { if (disabledNodeTypes.includes(node.type)) { return { key: `${node.type}:${node.id}:unsupported`, + hoveredId: node.id as AnyNodeId, + hoverMode: 'paint-disabled', apply: null, preview: () => previewCursor('not-allowed'), } @@ -896,6 +917,8 @@ export const SelectionManager = () => { } clearActivePreview() + useViewer.setState({ hoveredId: interaction.hoveredId }) + setHoverHighlightMode(interaction.hoverMode) const restore = interaction.preview?.() if (restore) { @@ -912,6 +935,10 @@ export const SelectionManager = () => { } clearActivePreview() + if (useViewer.getState().hoveredId === interaction.hoveredId) { + useViewer.setState({ hoveredId: null }) + } + setHoverHighlightMode('default') } const onClick = (event: NodeEvent) => { @@ -932,6 +959,7 @@ export const SelectionManager = () => { } else { clearActivePreview() } + setHoverHighlightMode(interaction.hoverMode) } const allTypes = [ @@ -962,8 +990,10 @@ export const SelectionManager = () => { emitter.off(`${type}:click` as any, onClick as any) } clearActivePreview() + useViewer.setState({ hoveredId: null }) + setHoverHighlightMode('default') } - }, [curvingWall, mode, movingNode]) + }, [curvingWall, mode, movingNode, setHoverHighlightMode]) useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { From cb268509ea99d57c92cd7af973f1dbb69994e667 Mon Sep 17 00:00:00 2001 From: sudhir Date: Sat, 25 Apr 2026 00:06:31 +0530 Subject: [PATCH 29/31] Invalidate viewer on hover style updates --- packages/viewer/src/components/viewer/post-processing.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/viewer/src/components/viewer/post-processing.tsx b/packages/viewer/src/components/viewer/post-processing.tsx index e06faa30..96c8c690 100644 --- a/packages/viewer/src/components/viewer/post-processing.tsx +++ b/packages/viewer/src/components/viewer/post-processing.tsx @@ -89,7 +89,7 @@ const PostProcessingPasses = ({ }: { hoverStyles?: HoverStyles }) => { - const { gl: renderer, scene, camera } = useThree() + const { gl: renderer, invalidate, scene, camera } = useThree() const renderPipelineRef = useRef(null) const hasPipelineErrorRef = useRef(false) const retryCountRef = useRef(0) @@ -155,6 +155,7 @@ const PostProcessingPasses = ({ hoverHiddenColor.value.setHex(style.hiddenColor) hoverStrength.value = style.strength hoverPulseMix.value = style.pulse ? 0 : 1 + invalidate() }, [ hoverHiddenColor, hoverHighlightMode, @@ -162,6 +163,7 @@ const PostProcessingPasses = ({ hoverStrength, hoverStyles, hoverVisibleColor, + invalidate, ]) // Build / rebuild the post-processing pipeline From 275a42cc7f55a5b41c7c95bd57b516c8cf0fdf48 Mon Sep 17 00:00:00 2001 From: sudhir Date: Sat, 25 Apr 2026 00:15:29 +0530 Subject: [PATCH 30/31] Simplify paint material compatibility logic --- packages/core/src/index.ts | 1 - packages/core/src/material-library.ts | 40 ------------------- .../components/editor/selection-manager.tsx | 17 +++----- .../src/components/ui/action-menu/index.tsx | 8 ++-- .../ui/controls/material-picker.tsx | 36 ++++++++--------- .../src/components/ui/panels/paint-panel.tsx | 1 - packages/editor/src/lib/material-paint.ts | 34 ---------------- 7 files changed, 25 insertions(+), 112 deletions(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d8d80978..7ef4e545 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -40,7 +40,6 @@ export { export { baseMaterial, glassMaterial } from './materials' export { getCatalogMaterialById, - getMaterialCategoryTargets, getLibraryMaterialIdFromRef, getMaterialPresetByRef, getMaterialsForCategory, diff --git a/packages/core/src/material-library.ts b/packages/core/src/material-library.ts index a52ac07f..5b08bec1 100644 --- a/packages/core/src/material-library.ts +++ b/packages/core/src/material-library.ts @@ -1,7 +1,5 @@ import { type MaterialPresetPayload, - type MaterialTarget, - MaterialTarget as MaterialTargetSchema, } from './schema/material' export type MaterialCatalogItem = { @@ -24,40 +22,6 @@ export const MATERIAL_CATEGORIES = [ ] as const export type MaterialCategory = (typeof MATERIAL_CATEGORIES)[number] -export const MATERIAL_CATEGORY_TARGETS: Record = { - wood: [ - MaterialTargetSchema.enum.wall, - MaterialTargetSchema.enum.slab, - MaterialTargetSchema.enum.stair, - MaterialTargetSchema.enum['stair-segment'], - MaterialTargetSchema.enum.fence, - MaterialTargetSchema.enum.roof, - MaterialTargetSchema.enum['roof-segment'], - ], - wallpaper: [MaterialTargetSchema.enum.wall], - parquet: [ - MaterialTargetSchema.enum.slab, - MaterialTargetSchema.enum.stair, - MaterialTargetSchema.enum['stair-segment'], - ], - granite: [MaterialTargetSchema.enum.slab], - marble: [ - MaterialTargetSchema.enum.slab, - MaterialTargetSchema.enum.stair, - MaterialTargetSchema.enum['stair-segment'], - ], - other: [ - MaterialTargetSchema.enum.wall, - MaterialTargetSchema.enum.roof, - MaterialTargetSchema.enum['roof-segment'], - MaterialTargetSchema.enum.stair, - MaterialTargetSchema.enum['stair-segment'], - MaterialTargetSchema.enum.fence, - MaterialTargetSchema.enum.slab, - MaterialTargetSchema.enum.ceiling, - ], -} - export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'wall-wood1', @@ -609,10 +573,6 @@ export function getMaterialsForCategory(category: MaterialCategory): MaterialCat return MATERIAL_CATALOG.filter((item) => item.category === category) } -export function getMaterialCategoryTargets(category: MaterialCategory): MaterialTarget[] { - return MATERIAL_CATEGORY_TARGETS[category] -} - export function getCatalogMaterialById(id?: string): MaterialCatalogItem | undefined { if (!id) return undefined return MATERIAL_CATALOG.find((item) => item.id === id) diff --git a/packages/editor/src/components/editor/selection-manager.tsx b/packages/editor/src/components/editor/selection-manager.tsx index 1db09ba1..884fc80d 100755 --- a/packages/editor/src/components/editor/selection-manager.tsx +++ b/packages/editor/src/components/editor/selection-manager.tsx @@ -44,7 +44,6 @@ import { buildStairSurfaceMaterialPatch, buildWallSurfaceMaterialPatch, hasActivePaintMaterial, - isActivePaintMaterialCompatible, resolveActivePaintMaterialFromSelection, } from '../../lib/material-paint' import { sfxEmitter } from '../../lib/sfx-bus' @@ -741,8 +740,7 @@ export const SelectionManager = () => { if (node.type === 'wall') { const role = resolveWallMaterialTarget(event as WallEvent) - const compatible = - role !== null && isActivePaintMaterialCompatible(activePaintMaterial, 'wall') + const compatible = role !== null && hasActivePaintMaterial(activePaintMaterial) return { key: `wall:${node.id}:${role ?? 'unsupported'}`, hoveredId: node.id as AnyNodeId, @@ -783,8 +781,7 @@ export const SelectionManager = () => { if (!roofNode || roofNode.type !== 'roof') return null const role = resolveRoofMaterialTarget(event as RoofEvent | RoofSegmentEvent) - const compatible = - role !== null && isActivePaintMaterialCompatible(activePaintMaterial, 'roof') + const compatible = role !== null && hasActivePaintMaterial(activePaintMaterial) return { key: `roof:${roofNode.id}:${role ?? 'unsupported'}`, hoveredId: roofNode.id as AnyNodeId, @@ -825,8 +822,7 @@ export const SelectionManager = () => { if (!stairNode || stairNode.type !== 'stair') return null const role = resolveStairMaterialTarget(event as StairEvent | StairSegmentEvent) - const compatible = - role !== null && isActivePaintMaterialCompatible(activePaintMaterial, 'stair') + const compatible = role !== null && hasActivePaintMaterial(activePaintMaterial) return { key: `stair:${stairNode.id}:${role ?? 'unsupported'}`, hoveredId: stairNode.id as AnyNodeId, @@ -858,13 +854,10 @@ export const SelectionManager = () => { } if (node.type === 'fence' || node.type === 'slab' || node.type === 'ceiling') { - const target = node.type - const compatible = - isActivePaintMaterialCompatible(activePaintMaterial, target) && - hasActivePaintMaterial(activePaintMaterial) + const compatible = hasActivePaintMaterial(activePaintMaterial) return { - key: `${target}:${node.id}:surface`, + key: `${node.type}:${node.id}:surface`, hoveredId: node.id as AnyNodeId, hoverMode: compatible ? 'paint-ready' : 'paint-disabled', apply: compatible diff --git a/packages/editor/src/components/ui/action-menu/index.tsx b/packages/editor/src/components/ui/action-menu/index.tsx index e1c59cb4..f74b6180 100644 --- a/packages/editor/src/components/ui/action-menu/index.tsx +++ b/packages/editor/src/components/ui/action-menu/index.tsx @@ -40,11 +40,11 @@ function PaintMaterialTray() { return (

{ - setActivePaintMaterial({ material, category, sourceTarget: activePaintTarget }) + onChange={(material) => { + setActivePaintMaterial({ material, sourceTarget: activePaintTarget }) }} - onSelectMaterialPreset={(materialPreset, category) => { - setActivePaintMaterial({ materialPreset, category, sourceTarget: activePaintTarget }) + onSelectMaterialPreset={(materialPreset) => { + setActivePaintMaterial({ materialPreset, sourceTarget: activePaintTarget }) }} selectedMaterialPreset={activePaintMaterial?.materialPreset} value={activePaintMaterial?.material} diff --git a/packages/editor/src/components/ui/controls/material-picker.tsx b/packages/editor/src/components/ui/controls/material-picker.tsx index ce743482..b328ee5e 100755 --- a/packages/editor/src/components/ui/controls/material-picker.tsx +++ b/packages/editor/src/components/ui/controls/material-picker.tsx @@ -6,7 +6,6 @@ import { getMaterialsForCategory, MATERIAL_CATEGORIES, toLibraryMaterialRef, - type MaterialCategory, type MaterialSchema, } from '@pascal-app/core' import { useEffect, useRef, useState } from 'react' @@ -15,8 +14,8 @@ import useEditor from '../../../store/use-editor' type MaterialPickerProps = { value?: MaterialSchema selectedMaterialPreset?: string - onChange?: (material: MaterialSchema, category: MaterialCategory) => void - onSelectMaterialPreset?: (materialPreset: string, category: MaterialCategory) => void + onChange?: (material: MaterialSchema) => void + onSelectMaterialPreset?: (materialPreset: string) => void disabled?: boolean } @@ -29,7 +28,9 @@ export function MaterialPicker({ }: MaterialPickerProps) { const setPaintPanelOpen = useEditor((state) => state.setPaintPanelOpen) const [showCustom, setShowCustom] = useState(!!value?.properties) - const [selectedCategory, setSelectedCategory] = useState(MATERIAL_CATEGORIES[0]) + const [selectedCategory, setSelectedCategory] = useState<(typeof MATERIAL_CATEGORIES)[number]>( + MATERIAL_CATEGORIES[0], + ) const catalogScrollRef = useRef(null) const categoryScrollRef = useRef(null) const catalogItems = @@ -62,9 +63,7 @@ export function MaterialPicker({ if (disabled) return setShowCustom(false) setPaintPanelOpen(false) - const category = getCatalogMaterialById(materialId)?.category - if (!category) return - onSelectMaterialPreset?.(toLibraryMaterialRef(materialId), category) + onSelectMaterialPreset?.(toLibraryMaterialRef(materialId)) } useEffect(() => { @@ -113,20 +112,17 @@ export function MaterialPicker({ if (disabled) return setShowCustom(true) setPaintPanelOpen(true) - onChange?.( - { - preset: 'custom', - properties: { - color: value?.properties?.color || '#ffffff', - roughness: value?.properties?.roughness ?? 0.5, - metalness: value?.properties?.metalness ?? 0, - opacity: value?.properties?.opacity ?? 1, - transparent: value?.properties?.transparent ?? false, - side: value?.properties?.side ?? 'front', - }, + onChange?.({ + preset: 'custom', + properties: { + color: value?.properties?.color || '#ffffff', + roughness: value?.properties?.roughness ?? 0.5, + metalness: value?.properties?.metalness ?? 0, + opacity: value?.properties?.opacity ?? 1, + transparent: value?.properties?.transparent ?? false, + side: value?.properties?.side ?? 'front', }, - 'other', - ) + }) } return ( diff --git a/packages/editor/src/components/ui/panels/paint-panel.tsx b/packages/editor/src/components/ui/panels/paint-panel.tsx index 0df8c677..ffa8ec9d 100644 --- a/packages/editor/src/components/ui/panels/paint-panel.tsx +++ b/packages/editor/src/components/ui/panels/paint-panel.tsx @@ -47,7 +47,6 @@ export function PaintPanel() { transparent: nextTransparent, }, }, - category: 'other', sourceTarget: activePaintMaterial?.sourceTarget ?? activePaintTarget, }) } diff --git a/packages/editor/src/lib/material-paint.ts b/packages/editor/src/lib/material-paint.ts index e1f52156..ffdde786 100644 --- a/packages/editor/src/lib/material-paint.ts +++ b/packages/editor/src/lib/material-paint.ts @@ -8,8 +8,6 @@ import { getEffectiveStairSurfaceMaterial, getEffectiveWallSurfaceMaterial, getLibraryMaterialIdFromRef, - getMaterialCategoryTargets, - type MaterialCategory, type MaterialSchema, type MaterialTarget, type RoofNode, @@ -31,7 +29,6 @@ export type SingleSurfaceMaterialRole = 'surface' export type ActivePaintMaterial = { material?: MaterialSchema materialPreset?: string - category?: MaterialCategory sourceTarget: PaintableMaterialTarget } @@ -50,34 +47,10 @@ function getCatalogEntryForActivePaintMaterial(material: ActivePaintMaterial | n return getCatalogMaterialById(catalogId) } -function resolvePaintMaterialCategory( - material: MaterialSchema | undefined, - materialPreset: string | undefined, -): MaterialCategory | undefined { - const catalogId = getLibraryMaterialIdFromRef(materialPreset) ?? material?.id ?? undefined - const catalogEntry = getCatalogMaterialById(catalogId) - if (catalogEntry?.category) return catalogEntry.category - if (material || materialPreset === undefined) return 'other' - return undefined -} - export function getActivePaintMaterialLabel(material: ActivePaintMaterial | null | undefined) { return getCatalogEntryForActivePaintMaterial(material)?.label ?? 'Custom' } -export function isActivePaintMaterialCompatible( - material: ActivePaintMaterial | null | undefined, - target: PaintableMaterialTarget, -) { - if (!hasActivePaintMaterial(material)) return false - - const catalogEntry = getCatalogEntryForActivePaintMaterial(material) - const category = material?.category ?? catalogEntry?.category - if (!category) return false - - return getMaterialCategoryTargets(category).includes(target) -} - export function buildWallSurfaceMaterialPatch( node: WallNode, targetSide: WallSurfaceSide, @@ -200,7 +173,6 @@ export function resolveActivePaintMaterialFromSelection(params: { ? { material: surface.material, materialPreset: surface.materialPreset, - category: resolvePaintMaterialCategory(surface.material, surface.materialPreset), sourceTarget: 'wall', } : null @@ -221,7 +193,6 @@ export function resolveActivePaintMaterialFromSelection(params: { ? { material: surface.material, materialPreset: surface.materialPreset, - category: resolvePaintMaterialCategory(surface.material, surface.materialPreset), sourceTarget: 'roof', } : null @@ -242,7 +213,6 @@ export function resolveActivePaintMaterialFromSelection(params: { ? { material: surface.material, materialPreset: surface.materialPreset, - category: resolvePaintMaterialCategory(surface.material, surface.materialPreset), sourceTarget: 'stair', } : null @@ -263,10 +233,6 @@ export function resolveActivePaintMaterialFromSelection(params: { ? { material: selectedNode.material, materialPreset: selectedNode.materialPreset, - category: resolvePaintMaterialCategory( - selectedNode.material, - selectedNode.materialPreset, - ), sourceTarget: target, } : null From f7d65acd0dd95e810ecdc45bc8f64e0758282dc5 Mon Sep 17 00:00:00 2001 From: wass08 Date: Mon, 27 Apr 2026 08:08:15 -0400 Subject: [PATCH 31/31] review architecture skill --- .claude/skills/review-architecture/SKILL.md | 119 ++++++++++++++++++++ .cursor/rules/tools.mdc | 10 +- .cursor/skills/review-architecture/SKILL.md | 1 + 3 files changed, 127 insertions(+), 3 deletions(-) create mode 100644 .claude/skills/review-architecture/SKILL.md create mode 120000 .cursor/skills/review-architecture/SKILL.md diff --git a/.claude/skills/review-architecture/SKILL.md b/.claude/skills/review-architecture/SKILL.md new file mode 100644 index 00000000..84cb6a80 --- /dev/null +++ b/.claude/skills/review-architecture/SKILL.md @@ -0,0 +1,119 @@ +--- +name: review-architecture +description: Review a PR against the Pascal architectural rules — layer boundaries (core/viewer/editor), systems/renderers/tools separation, hook hygiene (useEditor/useScene/useViewer), and selector performance. Use when the user asks to review a PR, audit a branch, or check that changes respect the codebase's architecture. +allowed-tools: Bash(git *) Bash(gh *) Read Grep Glob +--- + +Architectural review for Pascal PRs. The user will provide a PR URL, branch name, or ask to review the current branch. + +## 1. Load the rules (required — do not skip) + +Read these before reviewing any diff. They are the source of truth, not your training data: + +- `.claude/rules/systems.md` — core systems vs viewer systems, what each may do +- `.claude/rules/renderers.md` — renderer responsibilities and prohibitions +- `.claude/rules/tools.md` — editor tools live only in `apps/editor/components/tools/` +- `.claude/rules/viewer-isolation.md` — viewer must stay editor-agnostic +- `.claude/rules/layers.md` +- `.claude/rules/selection-managers.md` +- `.claude/rules/scene-registry.md` +- `.claude/rules/spatial-queries.md` +- `.claude/rules/node-schemas.md` +- `.claude/rules/events.md` + +Only the first four are required on every review; read the rest when the diff touches their subject area. + +## 2. Fetch the diff + +```bash +# If the user gave a PR URL or number: +gh pr diff + +# If reviewing the current branch: +git diff main...HEAD +``` + +Also list changed files so you can map each to the relevant rule: + +```bash +gh pr view --json files --jq '.files[].path' +# or +git diff --name-only main...HEAD +``` + +## 3. Layer classification — do this BEFORE the checklist + +For every new file, new type, new store field, or new exported helper introduced by the diff, answer one question: **which layer does this belong to — core, viewer, or editor?** If the answer is "editor" but the code lives in `packages/core` or `packages/viewer` (or vice versa), flag it as a **blocker**. This is the most common and most damaging class of violation, and the checklist below won't reliably catch it on its own — do this pass explicitly. + +### The three layers and what they own + +**`packages/core` — domain data + pure logic.** +Owns: node schemas, the scene store (`useScene`), live transforms store, core systems (wall mitering, slab polygons, space detection), event bus, plain 2D/3D math helpers, `sceneRegistry`. Consumed by every downstream package, including read-only embeds. Must not know about: Three.js/R3F, `packages/viewer`, `apps/editor`, any rendering or UI concept, any tool/mode/phase concept, or any *view*-specific concept (floorplan, paint preview, cursor indicators, selection outline styling, etc.). + +**`packages/viewer` — the 3D canvas, shippable standalone.** +Owns: ``, renderers, viewer systems (cutouts, zones, level positions, scans), the viewer store (`useViewer`) *for genuine presentation state only* (selection path, camera/level/wall/view modes, theme, display toggles, hover id). Consumed by both the editor and the read-only `/viewer/[id]` route. Must not know about: editor state (`useEditor`, tools, phases, modes), editor-only names baked into presentation modes (`'delete'`, `'paint-ready'`), editor-only state types (material preview, active paint target, floorplan anything). + +**`apps/editor` (and editor-scoped packages) — the editing experience.** +Owns: tools, `useEditor`, action menus, panels, the floorplan panel and its helpers, paint mode, selection-manager phase/mode logic, cursor badges, command palette, keyboard shortcuts — anything absent from the read-only viewer route. Injects itself into `` via children and props, never the reverse. + +### Five triggers that mean "this is probably editor" + +1. **Would the read-only `/viewer/[id]` route need this?** If no, it belongs in `apps/editor`. +2. **Does the name contain an editor-specific word?** (`Floorplan`, `Paint…`, `Draft…`, `Marquee`, `CursorBadge`, `HoverMode`, `…Tool`, `Moving…`, `Curving…`.) Default to editor and justify loudly if it's anywhere else. +3. **Does the type or field reference a tool/mode/phase vocabulary?** (`'delete'`, `'paint-ready'`, `'material-paint'`, `'site'`/`'structure'`/`'furnish'`, `'build'`/`'edit'`.) Belongs in `useEditor`, not `useViewer` or core. +4. **Does the helper compute something only a 2D editor view needs?** (Floorplan transforms, measurement offsets, SVG path builders, marquee bounds scoped to floorplan.) Editor. Generic 2D geometry that any view could use (polygon math, rotation, clamping, line thickening) can live in core *as long as its names are generic* — no `Floorplan` prefix. +5. **Does a new store field have a setter that no part of the target layer ever calls?** (e.g. `setMaterialPreview` in `useViewer` that only the editor would ever invoke.) That's a layering smell — the state belongs in the caller's layer. + +Write the classification down before writing findings. If core gains "Floorplan" types, or the viewer gains paint-mode vocabulary, or a renderer grows editor awareness — those are the blockers to lead with, not downstream symptoms. + +## 4. Review checklist + +### A. Layer boundaries +- `packages/viewer/**` does not import from `apps/editor` or reference `useEditor`, tool state, phase, or mode. +- `packages/core/**` does not import Three.js, react-three-fiber, or anything from `packages/viewer` / `apps/editor`. +- `packages/core/**` does not introduce types or helpers named after an editor view (`Floorplan*`, `Paint*`, `Draft*`). Generic plan-geometry helpers are fine; view-specific vocabulary is not. +- Renderers contain no geometry generation or domain logic — that belongs in a system. +- Tools mutate `useScene` (committed state) and `useLiveTransforms` (ephemeral drag state); direct `sceneRegistry` mesh transforms are allowed only under the live-drag exception in `.claude/rules/tools.md`. No business logic, no imports from `packages/viewer`. + +### B. Hook hygiene (`useEditor`, `useScene`, `useViewer`) +- Stores hold state + setters only. No business logic, side effects, async work, or derived computations inside the store definition. +- Derived values belong in selectors or systems, not in the store body. +- No cross-store coupling: a store's action should not call another store's actions inside itself. +- New state added to `useViewer` must be presentation-only (selection, camera, level mode, display toggles). Editor-only state (active tool, phase, edit mode, paint preview, floorplan state) goes in `useEditor`. + +### C. Selector performance +- Top-level components (pages, layouts, providers, `` siblings) must not subscribe to large or frequently-changing slices — e.g. `useScene(s => s.nodes)`, `useScene(s => s)`. Flag these: they re-render the whole subtree on every mutation. +- Selectors that return new object or array references each call (e.g. `s => ({ a: s.a, b: s.b })`, `s => s.items.filter(...)`) without a custom equality function (shallow or custom) are re-render hazards. +- Prefer subscribing by ID deep in the tree (one node per renderer) over subscribing to the full collection high up. + +### D. Separation of concerns +- Viewer and core stay unaware of editor-specific concepts (tools, phases, active modes, editor UI state, view-specific helpers). +- Editor-only overlays and systems are injected as children of ``, not added inside the viewer package. +- New node types added correctly: schema → core system (if derived geometry) → viewer renderer → register in `NodeRenderer`. + +## 5. Output format + +Group findings by severity: + +- **Blocker** — violates a rule in `.claude/rules` or breaks a layer boundary. Must be fixed before merge. +- **Suggestion** — likely problem, worth discussing. Not a hard block. +- **Nit** — minor, optional. + +For each finding, include: + +1. File and line: `path/to/file.ts:42` +2. The offending snippet (short — 1–5 lines) +3. The rule it violates, linked to the rule file (e.g. `.claude/rules/viewer-isolation.md`) +4. A concrete proposed fix + +Skip formatting, import ordering, and anything CI already covers. + +If the PR fully complies, say so explicitly — do not invent nits to appear thorough. + +## 6. Final summary + +End with: + +- Blocker count, suggestion count, nit count +- One-sentence verdict: ready to merge / needs changes / needs discussion +- If blockers exist, list the files the author should open first diff --git a/.cursor/rules/tools.mdc b/.cursor/rules/tools.mdc index 840e1d57..016cd002 100644 --- a/.cursor/rules/tools.mdc +++ b/.cursor/rules/tools.mdc @@ -60,11 +60,15 @@ export function MyTool() { ## Rules -- **Tools only mutate `useScene`** — they do not call Three.js APIs directly. +- **Tools mutate `useScene` for committed changes and `useLiveTransforms` for ephemeral drag state.** A tool's end-of-interaction write (click-to-commit, release-to-commit) goes to `useScene` and is captured in undo history. Per-mouse-move previews go to `useLiveTransforms` so history and subscribers aren't spammed. +- **Live-drag exception for direct mesh transforms.** During an active drag a tool may apply a transform offset directly to `sceneRegistry.nodes.get(id).position`/`rotation`/`scale` *when and only when* the same offset is mirrored into `useLiveTransforms` for that node. This exception exists because the 3D renderers don't reconcile `useLiveTransforms` onto `mesh.position` yet; once a `LiveTransformSystem` does that, this exception goes away. Conditions: + - The mesh offset must mirror the `useLiveTransforms` entry (same delta on both), so anything reading `useLiveTransforms` sees the same preview as the 3D view. + - The offset must be cleared on tool unmount, cancel, *and* commit — both `mesh.position.set(0, 0, 0)` and `useLiveTransforms.clear(id)`. + - The tool must not generate or mutate geometry in this path — only transform writes. Geometry generation still belongs in a core system. - **No business logic in tools** — delegate geometry/constraint rules to core systems. - **Preview geometry is local** — transient meshes shown while a tool is active live in the tool component, not in the scene store. -- **Clean up on unmount** — remove any pending/incomplete nodes when the tool unmounts. -- **Tools must not import from `@pascal-app/viewer`** — use the scene store and core hooks only. +- **Clean up on unmount** — remove any pending/incomplete nodes *and* any live transforms/mesh offsets when the tool unmounts. +- **Tools must not import from `@pascal-app/viewer`** — use the scene store and core hooks only. `sceneRegistry` is exported from `@pascal-app/core` and is the allowed door into the Three.js graph for the narrow purposes above. - Each tool should handle a single, well-scoped interaction. Split complex tools (e.g. "draw + move") into separate components selected by `useEditor`. ## Adding a New Tool diff --git a/.cursor/skills/review-architecture/SKILL.md b/.cursor/skills/review-architecture/SKILL.md new file mode 120000 index 00000000..0fb1d1aa --- /dev/null +++ b/.cursor/skills/review-architecture/SKILL.md @@ -0,0 +1 @@ +../../../.claude/skills/review-architecture/SKILL.md \ No newline at end of file