Add click-targeted stair materials and UV mapping
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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<typeof StairRailingMode>
|
||||
export type StairType = z.infer<typeof StairType>
|
||||
export type StairTopLandingMode = z.infer<typeof StairTopLandingMode>
|
||||
export type StairSlabOpeningMode = z.infer<typeof StairSlabOpeningMode>
|
||||
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<typeof StairNode>
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -143,6 +143,87 @@ function migrateWallSurfaceMaterials(node: Record<string, any>) {
|
||||
return node
|
||||
}
|
||||
|
||||
function migrateStairSurfaceMaterials(node: Record<string, any>) {
|
||||
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<string, any>): Record<string, AnyNode> {
|
||||
const patchedNodes = { ...nodes }
|
||||
for (const [id, node] of Object.entries(patchedNodes)) {
|
||||
@@ -184,7 +265,7 @@ function migrateNodes(nodes: Record<string, any>): Record<string, AnyNode> {
|
||||
}
|
||||
|
||||
if (node.type === 'stair') {
|
||||
const normalized = normalizeStairNode(node)
|
||||
const normalized = normalizeStairNode(migrateStairSurfaceMaterials(node))
|
||||
if (normalized) {
|
||||
patchedNodes[id] = normalized
|
||||
}
|
||||
|
||||
@@ -12,6 +12,10 @@ import { syncAutoStairOpenings } from './stair-opening-sync'
|
||||
const pendingStairUpdates = new Set<AnyNodeId>()
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user