From 4f9b822e5412d14039fc885c5b9c3f1f25abecdb Mon Sep 17 00:00:00 2001 From: sudhir Date: Mon, 20 Apr 2026 12:01:23 +0530 Subject: [PATCH] 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] +}