Merge pull request #266 from sudhir9297/feat/nodes-material-system
feat: nodes material system and bug fix
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import type { ThreeEvent } from '@react-three/fiber'
|
import type { ThreeEvent } from '@react-three/fiber'
|
||||||
|
import type { Object3D } from 'three'
|
||||||
import mitt from 'mitt'
|
import mitt from 'mitt'
|
||||||
import type {
|
import type {
|
||||||
BuildingNode,
|
BuildingNode,
|
||||||
@@ -38,6 +39,8 @@ export interface NodeEvent<T extends AnyNode = AnyNode> {
|
|||||||
position: [number, number, number]
|
position: [number, number, number]
|
||||||
localPosition: [number, number, number]
|
localPosition: [number, number, number]
|
||||||
normal?: [number, number, number]
|
normal?: [number, number, number]
|
||||||
|
faceIndex?: number
|
||||||
|
object: Object3D
|
||||||
stopPropagation: () => void
|
stopPropagation: () => void
|
||||||
nativeEvent: ThreeEvent<PointerEvent>
|
nativeEvent: ThreeEvent<PointerEvent>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
|
|||||||
id: 'wall-wood1',
|
id: 'wall-wood1',
|
||||||
label: 'Wood',
|
label: 'Wood',
|
||||||
description: 'Warm wood finish',
|
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',
|
previewThumbnailUrl: '/material/wood1/wood1_thumbnail.webp',
|
||||||
preset: {
|
preset: {
|
||||||
maps: {
|
maps: {
|
||||||
@@ -86,7 +86,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
|
|||||||
id: 'wall-wood2',
|
id: 'wall-wood2',
|
||||||
label: 'Wood',
|
label: 'Wood',
|
||||||
description: 'Textured wood finish',
|
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',
|
previewThumbnailUrl: '/material/wood2/wood2_thumbnail.webp',
|
||||||
preset: {
|
preset: {
|
||||||
maps: {
|
maps: {
|
||||||
@@ -122,7 +122,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
|
|||||||
id: 'wall-wood3',
|
id: 'wall-wood3',
|
||||||
label: 'Wood',
|
label: 'Wood',
|
||||||
description: 'Knotted timber finish',
|
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',
|
previewThumbnailUrl: '/material/wood3/wood3_thumbnail.webp',
|
||||||
preset: {
|
preset: {
|
||||||
maps: {
|
maps: {
|
||||||
|
|||||||
@@ -4,6 +4,13 @@ export { BaseNode, generateId, Material, nodeType, objectId } from './base'
|
|||||||
export { CameraSchema } from './camera'
|
export { CameraSchema } from './camera'
|
||||||
// Collections
|
// Collections
|
||||||
export { type Collection, type CollectionId, generateCollectionId } from './collections'
|
export { type Collection, type CollectionId, generateCollectionId } from './collections'
|
||||||
|
export type {
|
||||||
|
MaterialMapProperties,
|
||||||
|
MaterialMaps,
|
||||||
|
MaterialPresetPayload,
|
||||||
|
MaterialTarget as MaterialTargetValue,
|
||||||
|
TextureWrapMode as TextureWrapModeValue,
|
||||||
|
} from './material'
|
||||||
// Material
|
// Material
|
||||||
export {
|
export {
|
||||||
DEFAULT_MATERIALS,
|
DEFAULT_MATERIALS,
|
||||||
@@ -14,15 +21,8 @@ export {
|
|||||||
MaterialProperties,
|
MaterialProperties,
|
||||||
MaterialSchema,
|
MaterialSchema,
|
||||||
MaterialTarget,
|
MaterialTarget,
|
||||||
TextureWrapMode,
|
|
||||||
resolveMaterial,
|
resolveMaterial,
|
||||||
} from './material'
|
TextureWrapMode,
|
||||||
export type {
|
|
||||||
MaterialMapProperties,
|
|
||||||
MaterialMaps,
|
|
||||||
MaterialPresetPayload,
|
|
||||||
MaterialTarget as MaterialTargetValue,
|
|
||||||
TextureWrapMode as TextureWrapModeValue,
|
|
||||||
} from './material'
|
} from './material'
|
||||||
export { BuildingNode } from './nodes/building'
|
export { BuildingNode } from './nodes/building'
|
||||||
export { CeilingNode } from './nodes/ceiling'
|
export { CeilingNode } from './nodes/ceiling'
|
||||||
@@ -43,22 +43,30 @@ export type {
|
|||||||
} from './nodes/item'
|
} from './nodes/item'
|
||||||
export { getScaledDimensions, ItemNode } from './nodes/item'
|
export { getScaledDimensions, ItemNode } from './nodes/item'
|
||||||
export { LevelNode } from './nodes/level'
|
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 { RoofSegmentNode, RoofType } from './nodes/roof-segment'
|
||||||
export { ScanNode } from './nodes/scan'
|
export { ScanNode } from './nodes/scan'
|
||||||
// Nodes
|
// Nodes
|
||||||
export { SiteNode } from './nodes/site'
|
export { SiteNode } from './nodes/site'
|
||||||
export { SlabNode } from './nodes/slab'
|
export { SlabNode } from './nodes/slab'
|
||||||
export {
|
export {
|
||||||
|
getEffectiveStairSurfaceMaterial,
|
||||||
StairNode,
|
StairNode,
|
||||||
StairRailingMode,
|
StairRailingMode,
|
||||||
StairSlabOpeningMode,
|
StairSlabOpeningMode,
|
||||||
StairTopLandingMode,
|
StairTopLandingMode,
|
||||||
StairType,
|
StairType,
|
||||||
} from './nodes/stair'
|
} from './nodes/stair'
|
||||||
|
export type { StairSurfaceMaterialRole, StairSurfaceMaterialSpec } from './nodes/stair'
|
||||||
export { AttachmentSide, StairSegmentNode, StairSegmentType } from './nodes/stair-segment'
|
export { AttachmentSide, StairSegmentNode, StairSegmentType } from './nodes/stair-segment'
|
||||||
export { SurfaceHoleMetadata } from './nodes/surface-hole-metadata'
|
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 { WindowNode } from './nodes/window'
|
||||||
export { ZoneNode } from './nodes/zone'
|
export { ZoneNode } from './nodes/zone'
|
||||||
export type { AnyNodeId, AnyNodeType } from './types'
|
export type { AnyNodeId, AnyNodeType } from './types'
|
||||||
|
|||||||
@@ -1,14 +1,26 @@
|
|||||||
import dedent from 'dedent'
|
import dedent from 'dedent'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { BaseNode, nodeType, objectId } from '../base'
|
import { BaseNode, nodeType, objectId } from '../base'
|
||||||
import { MaterialSchema } from '../material'
|
import { type MaterialSchema, MaterialSchema as MaterialSchemaSchema } from '../material'
|
||||||
import { RoofSegmentNode } from './roof-segment'
|
import { RoofSegmentNode } from './roof-segment'
|
||||||
|
|
||||||
|
export type RoofSurfaceMaterialRole = 'top' | 'edge' | 'wall'
|
||||||
|
export type RoofSurfaceMaterialSpec = {
|
||||||
|
material?: MaterialSchema
|
||||||
|
materialPreset?: string
|
||||||
|
}
|
||||||
|
|
||||||
export const RoofNode = BaseNode.extend({
|
export const RoofNode = BaseNode.extend({
|
||||||
id: objectId('roof'),
|
id: objectId('roof'),
|
||||||
type: nodeType('roof'),
|
type: nodeType('roof'),
|
||||||
material: MaterialSchema.optional(),
|
material: MaterialSchemaSchema.optional(),
|
||||||
materialPreset: z.string().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]),
|
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||||
// Rotation around Y axis in radians
|
// Rotation around Y axis in radians
|
||||||
rotation: z.number().default(0),
|
rotation: z.number().default(0),
|
||||||
@@ -26,3 +38,66 @@ export const RoofNode = BaseNode.extend({
|
|||||||
)
|
)
|
||||||
|
|
||||||
export type RoofNode = z.infer<typeof RoofNode>
|
export type RoofNode = z.infer<typeof RoofNode>
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import dedent from 'dedent'
|
import dedent from 'dedent'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { BaseNode, nodeType, objectId } from '../base'
|
import { BaseNode, nodeType, objectId } from '../base'
|
||||||
import { MaterialSchema } from '../material'
|
import { type MaterialSchema, MaterialSchema as MaterialSchemaSchema } from '../material'
|
||||||
import { StairSegmentNode } from './stair-segment'
|
import { StairSegmentNode } from './stair-segment'
|
||||||
|
|
||||||
export const StairRailingMode = z.enum(['none', 'left', 'right', 'both'])
|
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 StairType = z.infer<typeof StairType>
|
||||||
export type StairTopLandingMode = z.infer<typeof StairTopLandingMode>
|
export type StairTopLandingMode = z.infer<typeof StairTopLandingMode>
|
||||||
export type StairSlabOpeningMode = z.infer<typeof StairSlabOpeningMode>
|
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({
|
export const StairNode = BaseNode.extend({
|
||||||
id: objectId('stair'),
|
id: objectId('stair'),
|
||||||
type: nodeType('stair'),
|
type: nodeType('stair'),
|
||||||
material: MaterialSchema.optional(),
|
material: MaterialSchemaSchema.optional(),
|
||||||
materialPreset: z.string().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]),
|
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||||
// Rotation around Y axis in radians
|
// Rotation around Y axis in radians
|
||||||
rotation: z.number().default(0),
|
rotation: z.number().default(0),
|
||||||
@@ -71,3 +82,74 @@ export const StairNode = BaseNode.extend({
|
|||||||
)
|
)
|
||||||
|
|
||||||
export type StairNode = z.infer<typeof StairNode>
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,8 +12,14 @@ export const WallNode = BaseNode.extend({
|
|||||||
children: z
|
children: z
|
||||||
.array(z.union([ItemNode.shape.id, DoorNode.shape.id, WindowNode.shape.id]))
|
.array(z.union([ItemNode.shape.id, DoorNode.shape.id, WindowNode.shape.id]))
|
||||||
.default([]),
|
.default([]),
|
||||||
|
// Legacy single-material wall finish. Read for backward compatibility only.
|
||||||
material: MaterialSchema.optional(),
|
material: MaterialSchema.optional(),
|
||||||
|
// Legacy single-material wall finish preset. Read for backward compatibility only.
|
||||||
materialPreset: z.string().optional(),
|
materialPreset: z.string().optional(),
|
||||||
|
interiorMaterial: MaterialSchema.optional(),
|
||||||
|
interiorMaterialPreset: z.string().optional(),
|
||||||
|
exteriorMaterial: MaterialSchema.optional(),
|
||||||
|
exteriorMaterialPreset: z.string().optional(),
|
||||||
thickness: z.number().optional(),
|
thickness: z.number().optional(),
|
||||||
height: z.number().optional(),
|
height: z.number().optional(),
|
||||||
curveOffset: z.number().optional(),
|
curveOffset: z.number().optional(),
|
||||||
@@ -37,3 +43,62 @@ export const WallNode = BaseNode.extend({
|
|||||||
`,
|
`,
|
||||||
)
|
)
|
||||||
export type WallNode = z.infer<typeof WallNode>
|
export type WallNode = z.infer<typeof WallNode>
|
||||||
|
|
||||||
|
export type WallSurfaceSide = 'interior' | 'exterior'
|
||||||
|
|
||||||
|
export type WallSurfaceMaterialSpec = {
|
||||||
|
material?: z.infer<typeof MaterialSchema>
|
||||||
|
materialPreset?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type WallSurfaceMaterialSource = {
|
||||||
|
material?: z.infer<typeof MaterialSchema>
|
||||||
|
materialPreset?: string
|
||||||
|
interiorMaterial?: z.infer<typeof MaterialSchema>
|
||||||
|
interiorMaterialPreset?: string
|
||||||
|
exteriorMaterial?: z.infer<typeof MaterialSchema>
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 { CollectionId } from '../../schema/collections'
|
||||||
import type { SceneState } from '../use-scene'
|
import type { SceneState } from '../use-scene'
|
||||||
|
|
||||||
@@ -17,11 +23,7 @@ type WallMergePlan = {
|
|||||||
let pendingRafId: number | null = null
|
let pendingRafId: number | null = null
|
||||||
let pendingUpdates: Set<AnyNodeId> = new Set()
|
let pendingUpdates: Set<AnyNodeId> = new Set()
|
||||||
|
|
||||||
function pointsEqual(
|
function pointsEqual(a: [number, number], b: [number, number], tolerance = 1e-6) {
|
||||||
a: [number, number],
|
|
||||||
b: [number, number],
|
|
||||||
tolerance = 1e-6,
|
|
||||||
) {
|
|
||||||
const dx = a[0] - b[0]
|
const dx = a[0] - b[0]
|
||||||
const dz = a[1] - b[1]
|
const dz = a[1] - b[1]
|
||||||
return dx * dx + dz * dz <= tolerance * tolerance
|
return dx * dx + dz * dz <= tolerance * tolerance
|
||||||
@@ -40,32 +42,30 @@ function getWallEndpointAtPoint(
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
function getWallFreeEndpoint(
|
function getWallFreeEndpoint(wall: Pick<WallNode, 'start' | 'end'>, sharedPoint: [number, number]) {
|
||||||
wall: Pick<WallNode, 'start' | 'end'>,
|
|
||||||
sharedPoint: [number, number],
|
|
||||||
) {
|
|
||||||
return pointsEqual(wall.start, sharedPoint) ? wall.end : wall.start
|
return pointsEqual(wall.start, sharedPoint) ? wall.end : wall.start
|
||||||
}
|
}
|
||||||
|
|
||||||
function areWallStylesCompatible(a: WallNode, b: WallNode) {
|
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 (
|
return (
|
||||||
(a.parentId ?? null) === (b.parentId ?? null) &&
|
(a.parentId ?? null) === (b.parentId ?? null) &&
|
||||||
Math.abs((a.curveOffset ?? 0) - (b.curveOffset ?? 0)) <= 1e-6 &&
|
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.thickness ?? 0.2) - (b.thickness ?? 0.2)) <= 1e-6 &&
|
||||||
Math.abs((a.height ?? 2.5) - (b.height ?? 2.5)) <= 1e-6 &&
|
Math.abs((a.height ?? 2.5) - (b.height ?? 2.5)) <= 1e-6 &&
|
||||||
a.materialPreset === b.materialPreset &&
|
aInterior === bInterior &&
|
||||||
JSON.stringify(a.material ?? null) === JSON.stringify(b.material ?? null) &&
|
aExterior === bExterior &&
|
||||||
a.frontSide === b.frontSide &&
|
a.frontSide === b.frontSide &&
|
||||||
a.backSide === b.backSide &&
|
a.backSide === b.backSide &&
|
||||||
a.visible === b.visible
|
a.visible === b.visible
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function areWallsCollinearAcrossPoint(
|
function areWallsCollinearAcrossPoint(a: WallNode, b: WallNode, sharedPoint: [number, number]) {
|
||||||
a: WallNode,
|
|
||||||
b: WallNode,
|
|
||||||
sharedPoint: [number, number],
|
|
||||||
) {
|
|
||||||
const freeA = getWallFreeEndpoint(a, sharedPoint)
|
const freeA = getWallFreeEndpoint(a, sharedPoint)
|
||||||
const freeB = getWallFreeEndpoint(b, sharedPoint)
|
const freeB = getWallFreeEndpoint(b, sharedPoint)
|
||||||
const ax = freeA[0] - sharedPoint[0]
|
const ax = freeA[0] - sharedPoint[0]
|
||||||
@@ -111,7 +111,10 @@ function buildMergedWallAttachmentUpdates(
|
|||||||
mergedEnd: [number, number],
|
mergedEnd: [number, number],
|
||||||
nodes: Record<AnyNodeId, AnyNode>,
|
nodes: Record<AnyNodeId, AnyNode>,
|
||||||
): WallAttachmentUpdate[] {
|
): 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 tangentX = (mergedEnd[0] - mergedStart[0]) / mergedLength
|
||||||
const tangentZ = (mergedEnd[1] - mergedStart[1]) / mergedLength
|
const tangentZ = (mergedEnd[1] - mergedStart[1]) / mergedLength
|
||||||
const updates: WallAttachmentUpdate[] = []
|
const updates: WallAttachmentUpdate[] = []
|
||||||
@@ -126,11 +129,16 @@ function buildMergedWallAttachmentUpdates(
|
|||||||
const sourceWall = child.parentId === secondary.id ? secondary : primary
|
const sourceWall = child.parentId === secondary.id ? secondary : primary
|
||||||
const sourceLength = Math.max(wallLength(sourceWall), 1e-6)
|
const sourceLength = Math.max(wallLength(sourceWall), 1e-6)
|
||||||
const localX = typeof child.position[0] === 'number' ? child.position[0] : 0
|
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 worldX =
|
||||||
const worldZ = sourceWall.start[1] + ((sourceWall.end[1] - sourceWall.start[1]) * localX) / sourceLength
|
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(
|
const nextLocalX = Math.max(
|
||||||
0,
|
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({
|
updates.push({
|
||||||
|
|||||||
@@ -100,6 +100,187 @@ function normalizeStairSegmentNode(node: Record<string, unknown>) {
|
|||||||
return parsed.success ? parsed.data : null
|
return parsed.success ? parsed.data : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function migrateWallSurfaceMaterials(node: Record<string, any>) {
|
||||||
|
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 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 migrateRoofSurfaceMaterials(node: Record<string, any>) {
|
||||||
|
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<string, any>): Record<string, AnyNode> {
|
function migrateNodes(nodes: Record<string, any>): Record<string, AnyNode> {
|
||||||
const patchedNodes = { ...nodes }
|
const patchedNodes = { ...nodes }
|
||||||
for (const [id, node] of Object.entries(patchedNodes)) {
|
for (const [id, node] of Object.entries(patchedNodes)) {
|
||||||
@@ -141,7 +322,7 @@ function migrateNodes(nodes: Record<string, any>): Record<string, AnyNode> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (node.type === 'stair') {
|
if (node.type === 'stair') {
|
||||||
const normalized = normalizeStairNode(node)
|
const normalized = normalizeStairNode(migrateStairSurfaceMaterials(node))
|
||||||
if (normalized) {
|
if (normalized) {
|
||||||
patchedNodes[id] = normalized
|
patchedNodes[id] = normalized
|
||||||
}
|
}
|
||||||
@@ -153,6 +334,14 @@ function migrateNodes(nodes: Record<string, any>): Record<string, AnyNode> {
|
|||||||
patchedNodes[id] = normalized
|
patchedNodes[id] = normalized
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (node.type === 'wall') {
|
||||||
|
patchedNodes[id] = migrateWallSurfaceMaterials(patchedNodes[id])
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === 'roof') {
|
||||||
|
patchedNodes[id] = migrateRoofSurfaceMaterials(patchedNodes[id])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return patchedNodes as Record<string, AnyNode>
|
return patchedNodes as Record<string, AnyNode>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,26 +20,13 @@ function applyFenceUVs(geometry: THREE.BufferGeometry) {
|
|||||||
let minX = Number.POSITIVE_INFINITY
|
let minX = Number.POSITIVE_INFINITY
|
||||||
let minY = Number.POSITIVE_INFINITY
|
let minY = Number.POSITIVE_INFINITY
|
||||||
let minZ = 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) {
|
for (let index = 0; index < position.count; index += 1) {
|
||||||
const px = position.getX(index)
|
minX = Math.min(minX, position.getX(index))
|
||||||
const py = position.getY(index)
|
minY = Math.min(minY, position.getY(index))
|
||||||
const pz = position.getZ(index)
|
minZ = Math.min(minZ, 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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
for (let index = 0; index < position.count; index += 1) {
|
||||||
const px = position.getX(index)
|
const px = position.getX(index)
|
||||||
const py = position.getY(index)
|
const py = position.getY(index)
|
||||||
@@ -52,14 +39,14 @@ function applyFenceUVs(geometry: THREE.BufferGeometry) {
|
|||||||
let v = 0
|
let v = 0
|
||||||
|
|
||||||
if (ny >= nx && ny >= nz) {
|
if (ny >= nx && ny >= nz) {
|
||||||
u = (px - minX) / width
|
u = px - minX
|
||||||
v = (pz - minZ) / depth
|
v = pz - minZ
|
||||||
} else if (nx >= nz) {
|
} else if (nx >= nz) {
|
||||||
u = (pz - minZ) / depth
|
u = pz - minZ
|
||||||
v = (py - minY) / height
|
v = py - minY
|
||||||
} else {
|
} else {
|
||||||
u = (px - minX) / width
|
u = px - minX
|
||||||
v = (py - minY) / height
|
v = py - minY
|
||||||
}
|
}
|
||||||
|
|
||||||
uvs[index * 2] = u
|
uvs[index * 2] = u
|
||||||
@@ -157,13 +144,17 @@ function generateFenceGeometry(fence: FenceNode) {
|
|||||||
const geometries = parts.map((part) => {
|
const geometries = parts.map((part) => {
|
||||||
const geometry = new THREE.BoxGeometry(1, 1, 1)
|
const geometry = new THREE.BoxGeometry(1, 1, 1)
|
||||||
geometry.scale(part.scale[0], part.scale[1], part.scale[2])
|
geometry.scale(part.scale[0], part.scale[1], part.scale[2])
|
||||||
|
applyFenceUVs(geometry)
|
||||||
geometry.translate(part.position[0], part.position[1], part.position[2])
|
geometry.translate(part.position[0], part.position[1], part.position[2])
|
||||||
return geometry
|
return geometry
|
||||||
})
|
})
|
||||||
|
|
||||||
const merged = mergeGeometries(geometries, false) ?? new THREE.BufferGeometry()
|
const merged = mergeGeometries(geometries, false) ?? new THREE.BufferGeometry()
|
||||||
geometries.forEach((geometry) => geometry.dispose())
|
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()
|
merged.computeVertexNormals()
|
||||||
return merged
|
return merged
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import useScene from '../../store/use-scene'
|
|||||||
const csgEvaluator = new Evaluator()
|
const csgEvaluator = new Evaluator()
|
||||||
csgEvaluator.useGroups = true
|
csgEvaluator.useGroups = true
|
||||||
;(csgEvaluator as any).consolidateGroups = false // shared dummyMats across brushes causes consolidation to misalign groupIndices vs groupOrder indices → crash
|
;(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) {
|
function prepareBrushForCSG(brush: Brush) {
|
||||||
brush.geometry.computeBoundsTree = computeBoundsTree
|
brush.geometry.computeBoundsTree = computeBoundsTree
|
||||||
@@ -25,6 +25,7 @@ const _position = new THREE.Vector3()
|
|||||||
const _quaternion = new THREE.Quaternion()
|
const _quaternion = new THREE.Quaternion()
|
||||||
const _scale = new THREE.Vector3(1, 1, 1)
|
const _scale = new THREE.Vector3(1, 1, 1)
|
||||||
const _yAxis = new THREE.Vector3(0, 1, 0)
|
const _yAxis = new THREE.Vector3(0, 1, 0)
|
||||||
|
const _uvFaceNormal = new THREE.Vector3()
|
||||||
|
|
||||||
// Pending merged-roof updates carried across frames (for throttling)
|
// Pending merged-roof updates carried across frames (for throttling)
|
||||||
const pendingRoofUpdates = new Set<AnyNodeId>()
|
const pendingRoofUpdates = new Set<AnyNodeId>()
|
||||||
@@ -251,6 +252,7 @@ function updateMergedRoofGeometry(
|
|||||||
g.materialIndex = mapRoofGroupMaterialIndex(g.materialIndex, resultMaterials, matToIndex)
|
g.materialIndex = mapRoofGroupMaterialIndex(g.materialIndex, resultMaterials, matToIndex)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ensureUv2Attribute(resultGeo)
|
||||||
resultGeo.computeVertexNormals()
|
resultGeo.computeVertexNormals()
|
||||||
mergedMesh.geometry.dispose()
|
mergedMesh.geometry.dispose()
|
||||||
mergedMesh.geometry = resultGeo
|
mergedMesh.geometry = resultGeo
|
||||||
@@ -641,6 +643,7 @@ export function generateRoofSegmentGeometry(node: RoofSegmentNode): THREE.Buffer
|
|||||||
wallBrush.geometry.dispose()
|
wallBrush.geometry.dispose()
|
||||||
innerBrush.geometry.dispose()
|
innerBrush.geometry.dispose()
|
||||||
|
|
||||||
|
ensureUv2Attribute(resultGeo)
|
||||||
resultGeo.computeVertexNormals()
|
resultGeo.computeVertexNormals()
|
||||||
return resultGeo
|
return resultGeo
|
||||||
}
|
}
|
||||||
@@ -936,6 +939,7 @@ function createGeometryFromFaces(
|
|||||||
): THREE.BufferGeometry {
|
): THREE.BufferGeometry {
|
||||||
const positions: number[] = []
|
const positions: number[] = []
|
||||||
const normals: number[] = []
|
const normals: number[] = []
|
||||||
|
const uvs: number[] = []
|
||||||
const indices: number[] = []
|
const indices: number[] = []
|
||||||
const groups: { start: number; count: number; materialIndex: number }[] = []
|
const groups: { start: number; count: number; materialIndex: number }[] = []
|
||||||
let vertexCount = 0
|
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)
|
||||||
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)
|
indices.push(vertexCount, vertexCount + 1, vertexCount + 2)
|
||||||
|
|
||||||
faceVertexCount += 3
|
faceVertexCount += 3
|
||||||
@@ -990,6 +998,7 @@ function createGeometryFromFaces(
|
|||||||
const geometry = new THREE.BufferGeometry()
|
const geometry = new THREE.BufferGeometry()
|
||||||
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3))
|
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3))
|
||||||
geometry.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3))
|
geometry.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3))
|
||||||
|
geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2))
|
||||||
geometry.setIndex(indices)
|
geometry.setIndex(indices)
|
||||||
|
|
||||||
for (const g of groups) {
|
for (const g of groups) {
|
||||||
@@ -999,6 +1008,34 @@ function createGeometryFromFaces(
|
|||||||
// Merge identical vertices to optimize geometry for CSG and create clean topology
|
// Merge identical vertices to optimize geometry for CSG and create clean topology
|
||||||
const mergedGeo = mergeVertices(geometry, 1e-4)
|
const mergedGeo = mergeVertices(geometry, 1e-4)
|
||||||
geometry.dispose()
|
geometry.dispose()
|
||||||
|
ensureUv2Attribute(mergedGeo)
|
||||||
|
|
||||||
return 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))
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ import { syncAutoStairOpenings } from './stair-opening-sync'
|
|||||||
const pendingStairUpdates = new Set<AnyNodeId>()
|
const pendingStairUpdates = new Set<AnyNodeId>()
|
||||||
const MAX_STAIRS_PER_FRAME = 2
|
const MAX_STAIRS_PER_FRAME = 2
|
||||||
const MAX_SEGMENTS_PER_FRAME = 4
|
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
|
// STAIR SYSTEM
|
||||||
@@ -198,7 +202,7 @@ function generateStairSegmentGeometry(
|
|||||||
|
|
||||||
shape.lineTo(0, 0)
|
shape.lineTo(0, 0)
|
||||||
|
|
||||||
const geometry = new THREE.ExtrudeGeometry(shape, {
|
const extrudedGeometry = new THREE.ExtrudeGeometry(shape, {
|
||||||
steps: 1,
|
steps: 1,
|
||||||
depth: width,
|
depth: width,
|
||||||
bevelEnabled: false,
|
bevelEnabled: false,
|
||||||
@@ -209,7 +213,16 @@ function generateStairSegmentGeometry(
|
|||||||
const matrix = new THREE.Matrix4()
|
const matrix = new THREE.Matrix4()
|
||||||
matrix.makeRotationY(-Math.PI / 2)
|
matrix.makeRotationY(-Math.PI / 2)
|
||||||
matrix.setPosition(width / 2, 0, 0)
|
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
|
return geometry
|
||||||
}
|
}
|
||||||
@@ -219,6 +232,7 @@ function updateStairSegmentGeometry(node: StairSegmentNode, mesh: THREE.Mesh) {
|
|||||||
const absoluteHeight = computeAbsoluteHeight(node)
|
const absoluteHeight = computeAbsoluteHeight(node)
|
||||||
|
|
||||||
const newGeometry = generateStairSegmentGeometry(node, absoluteHeight)
|
const newGeometry = generateStairSegmentGeometry(node, absoluteHeight)
|
||||||
|
applyStraightStairMaterialGroups(newGeometry)
|
||||||
|
|
||||||
mesh.geometry.dispose()
|
mesh.geometry.dispose()
|
||||||
mesh.geometry = newGeometry
|
mesh.geometry = newGeometry
|
||||||
@@ -363,6 +377,7 @@ function updateMergedStairGeometry(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const merged = mergeGeometries(geometries, false) ?? createEmptyGeometry()
|
const merged = mergeGeometries(geometries, false) ?? createEmptyGeometry()
|
||||||
|
applyStraightStairMaterialGroups(merged)
|
||||||
replaceMeshGeometry(mergedMesh, merged)
|
replaceMeshGeometry(mergedMesh, merged)
|
||||||
|
|
||||||
// Dispose individual geometries
|
// 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
|
// SEGMENT CHAINING
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -441,6 +558,8 @@ function rotateXZ(x: number, z: number, angle: number): [number, number] {
|
|||||||
function createEmptyGeometry(): THREE.BufferGeometry {
|
function createEmptyGeometry(): THREE.BufferGeometry {
|
||||||
const geometry = new THREE.BufferGeometry()
|
const geometry = new THREE.BufferGeometry()
|
||||||
geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3))
|
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
|
return geometry
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,20 +7,30 @@ import { spatialGridManager } from '../../hooks/spatial-grid/spatial-grid-manage
|
|||||||
import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync'
|
import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync'
|
||||||
import type { AnyNode, AnyNodeId, WallNode } from '../../schema'
|
import type { AnyNode, AnyNodeId, WallNode } from '../../schema'
|
||||||
import useScene from '../../store/use-scene'
|
import useScene from '../../store/use-scene'
|
||||||
import { DEFAULT_WALL_HEIGHT, getWallPlanFootprint, getWallThickness } from './wall-footprint'
|
|
||||||
import { getWallCurveFrameAt, getWallSurfacePolygon, isCurvedWall } from './wall-curve'
|
import { getWallCurveFrameAt, getWallSurfacePolygon, isCurvedWall } from './wall-curve'
|
||||||
|
import { DEFAULT_WALL_HEIGHT, getWallPlanFootprint, getWallThickness } from './wall-footprint'
|
||||||
import {
|
import {
|
||||||
calculateLevelMiters,
|
calculateLevelMiters,
|
||||||
getAdjacentWallIds,
|
getAdjacentWallIds,
|
||||||
getWallMiterBoundaryPoints,
|
getWallMiterBoundaryPoints,
|
||||||
type Point2D,
|
type Point2D,
|
||||||
type WallMiterData,
|
|
||||||
pointToKey,
|
pointToKey,
|
||||||
|
type WallMiterData,
|
||||||
} from './wall-mitering'
|
} from './wall-mitering'
|
||||||
|
|
||||||
// Reusable CSG evaluator for better performance
|
// Reusable CSG evaluator for better performance
|
||||||
const csgEvaluator = new Evaluator()
|
const csgEvaluator = new Evaluator()
|
||||||
const CURVED_WALL_3D_ENDPOINT_INSET = 0.0015
|
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) {
|
function ensureUv2Attribute(geometry: THREE.BufferGeometry) {
|
||||||
const uv = geometry.getAttribute('uv')
|
const uv = geometry.getAttribute('uv')
|
||||||
@@ -78,6 +88,207 @@ function insetCurvedWallBoundaryPointsFor3D(
|
|||||||
return next
|
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<WallNode, 'frontSide' | 'backSide'>,
|
||||||
|
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<number>(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
|
// WALL SYSTEM
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -252,6 +463,7 @@ export function generateExtrudedWall(
|
|||||||
|
|
||||||
// Convert polygon to local coordinates
|
// Convert polygon to local coordinates
|
||||||
const localPoints = polyPoints.map(worldToLocal)
|
const localPoints = polyPoints.map(worldToLocal)
|
||||||
|
const boundaryEdges = buildTaggedWallBoundaryEdges(wallNode, localPoints, miterData)
|
||||||
|
|
||||||
// Build THREE.js shape
|
// Build THREE.js shape
|
||||||
// Shape uses (x, y) where we map: shape.x = local.x, shape.y = -local.z
|
// 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)
|
// Rotate so extrusion direction (Z) becomes height direction (Y)
|
||||||
geometry.rotateX(-Math.PI / 2)
|
geometry.rotateX(-Math.PI / 2)
|
||||||
geometry.computeVertexNormals()
|
geometry.computeVertexNormals()
|
||||||
|
assignWallMaterialGroups(geometry, wallNode, boundaryEdges)
|
||||||
ensureUv2Attribute(geometry)
|
ensureUv2Attribute(geometry)
|
||||||
|
|
||||||
// Apply CSG subtraction for cutouts (doors/windows)
|
// Apply CSG subtraction for cutouts (doors/windows)
|
||||||
@@ -307,6 +520,7 @@ export function generateExtrudedWall(
|
|||||||
|
|
||||||
const resultGeometry = resultBrush.geometry
|
const resultGeometry = resultBrush.geometry
|
||||||
resultGeometry.computeVertexNormals()
|
resultGeometry.computeVertexNormals()
|
||||||
|
assignWallMaterialGroups(resultGeometry, wallNode, boundaryEdges)
|
||||||
ensureUv2Attribute(resultGeometry)
|
ensureUv2Attribute(resultGeometry)
|
||||||
|
|
||||||
return resultGeometry
|
return resultGeometry
|
||||||
|
|||||||
@@ -5,16 +5,24 @@ import {
|
|||||||
emitter,
|
emitter,
|
||||||
type ItemNode,
|
type ItemNode,
|
||||||
type NodeEvent,
|
type NodeEvent,
|
||||||
|
type RoofEvent,
|
||||||
|
type RoofSegmentEvent,
|
||||||
resolveLevelId,
|
resolveLevelId,
|
||||||
sceneRegistry,
|
sceneRegistry,
|
||||||
|
type StairEvent,
|
||||||
|
type StairNode,
|
||||||
|
type StairSurfaceMaterialRole,
|
||||||
|
type StairSegmentEvent,
|
||||||
useScene,
|
useScene,
|
||||||
|
type WallEvent,
|
||||||
|
type WallSurfaceSide,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
|
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { useCallback, useEffect, useRef } from 'react'
|
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 { sfxEmitter } from '../../lib/sfx-bus'
|
||||||
import useEditor, { type Phase, 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'
|
import { boxSelectHandled } from '../tools/select/box-select-tool'
|
||||||
|
|
||||||
const isNodeInCurrentLevel = (node: AnyNode): boolean => {
|
const isNodeInCurrentLevel = (node: AnyNode): boolean => {
|
||||||
@@ -68,6 +76,123 @@ export const resolveBuildingId = (
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveWallMaterialTarget(event: WallEvent): WallSurfaceSide | null {
|
||||||
|
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]
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveStairMaterialTarget(
|
||||||
|
event: StairEvent | StairSegmentEvent,
|
||||||
|
): StairSurfaceMaterialRole | null {
|
||||||
|
const hitObjectName = event.nativeEvent.object?.name ?? ''
|
||||||
|
const materialIndex = getIntersectionMaterialIndex(getEventObject(event), event.faceIndex)
|
||||||
|
|
||||||
|
if (hitObjectName.startsWith('stair-railing')) {
|
||||||
|
return 'railing'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hitObjectName.startsWith('stair-side')) {
|
||||||
|
return 'side'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (materialIndex === 0) {
|
||||||
|
return 'tread'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (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
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRoofMaterialTarget(
|
||||||
|
event: RoofEvent | RoofSegmentEvent,
|
||||||
|
): 'top' | 'edge' | 'wall' | null {
|
||||||
|
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'
|
||||||
|
if (normalY !== undefined && Math.abs(normalY) <= 0.35) return 'edge'
|
||||||
|
if (normalY !== undefined && normalY < -0.35) return 'wall'
|
||||||
|
|
||||||
|
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 = {
|
const HIGHLIGHT_PROFILES = {
|
||||||
delete: {
|
delete: {
|
||||||
color: new Color('#dc2626'),
|
color: new Color('#dc2626'),
|
||||||
@@ -439,6 +564,42 @@ export const SelectionManager = () => {
|
|||||||
|
|
||||||
activeStrategy.handleSelect(nodeToSelect, event.nativeEvent, modifierKeysRef.current)
|
activeStrategy.handleSelect(nodeToSelect, event.nativeEvent, modifierKeysRef.current)
|
||||||
|
|
||||||
|
let nextMaterialTargetHandled = false
|
||||||
|
|
||||||
|
if (node.type === 'wall' && nodeToSelect.type === 'wall') {
|
||||||
|
setSelectedMaterialTargetForNode(
|
||||||
|
nodeToSelect,
|
||||||
|
resolveWallMaterialTarget(event as WallEvent),
|
||||||
|
)
|
||||||
|
nextMaterialTargetHandled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
(node.type === 'stair' || node.type === 'stair-segment') &&
|
||||||
|
nodeToSelect.type === 'stair'
|
||||||
|
) {
|
||||||
|
setSelectedMaterialTargetForNode(
|
||||||
|
nodeToSelect,
|
||||||
|
resolveStairMaterialTarget(event as StairEvent | StairSegmentEvent),
|
||||||
|
)
|
||||||
|
nextMaterialTargetHandled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
(node.type === 'roof' || node.type === 'roof-segment') &&
|
||||||
|
nodeToSelect.type === 'roof'
|
||||||
|
) {
|
||||||
|
setSelectedMaterialTargetForNode(
|
||||||
|
nodeToSelect,
|
||||||
|
resolveRoofMaterialTarget(event as RoofEvent | RoofSegmentEvent),
|
||||||
|
)
|
||||||
|
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
|
// Reset the handled flag after a short delay to allow grid:click to be ignored
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
clickHandledRef.current = false
|
clickHandledRef.current = false
|
||||||
@@ -471,6 +632,7 @@ export const SelectionManager = () => {
|
|||||||
const { phase, structureLayer } = useEditor.getState()
|
const { phase, structureLayer } = useEditor.getState()
|
||||||
const activeStrategy = SELECTION_STRATEGIES[phase]
|
const activeStrategy = SELECTION_STRATEGIES[phase]
|
||||||
if (activeStrategy) activeStrategy.handleDeselect()
|
if (activeStrategy) activeStrategy.handleDeselect()
|
||||||
|
useEditor.getState().setSelectedMaterialTarget(null)
|
||||||
|
|
||||||
// When deselecting from zone mode, return to structure select
|
// When deselecting from zone mode, return to structure select
|
||||||
if (phase === 'structure' && structureLayer === 'zones') {
|
if (phase === 'structure' && structureLayer === 'zones') {
|
||||||
@@ -704,6 +866,12 @@ export const SelectionManager = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SelectionStateSync = () => {
|
const SelectionStateSync = () => {
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return useScene.subscribe((state) => {
|
return useScene.subscribe((state) => {
|
||||||
const { buildingId, levelId, zoneId, selectedIds } = useViewer.getState().selection
|
const { buildingId, levelId, zoneId, selectedIds } = useViewer.getState().selection
|
||||||
@@ -732,6 +900,28 @@ const SelectionStateSync = () => {
|
|||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedMaterialTarget) return
|
||||||
|
|
||||||
|
if (!singleSelectedId) {
|
||||||
|
setSelectedMaterialTarget(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedNode = useScene.getState().nodes[singleSelectedId as AnyNodeId]
|
||||||
|
if (
|
||||||
|
!selectedNode ||
|
||||||
|
(selectedNode.type !== 'wall' && selectedNode.type !== 'stair' && selectedNode.type !== 'roof')
|
||||||
|
) {
|
||||||
|
setSelectedMaterialTarget(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedMaterialTarget.nodeId !== selectedNode.id) {
|
||||||
|
setSelectedMaterialTarget(null)
|
||||||
|
}
|
||||||
|
}, [selectedMaterialTarget, setSelectedMaterialTarget, singleSelectedId])
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useEffect, useRef } from 'react'
|
|||||||
* Imperatively toggles the Three.js visibility of roof objects based on the
|
* Imperatively toggles the Three.js visibility of roof objects based on the
|
||||||
* editor selection — without causing React re-renders in RoofRenderer.
|
* 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
|
* - merged-roof mesh is hidden
|
||||||
* - segments-wrapper group is shown (individual segments visible for editing)
|
* - segments-wrapper group is shown (individual segments visible for editing)
|
||||||
* - all children are marked dirty so RoofSystem rebuilds their geometry
|
* - all children are marked dirty so RoofSystem rebuilds their geometry
|
||||||
@@ -22,14 +22,14 @@ export const RoofEditSystem = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const nodes = useScene.getState().nodes
|
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<string>()
|
const activeRoofIds = new Set<string>()
|
||||||
for (const id of selectedIds) {
|
for (const id of selectedIds) {
|
||||||
const node = nodes[id as AnyNodeId]
|
const node = nodes[id as AnyNodeId]
|
||||||
if (!node) continue
|
if (!node) continue
|
||||||
if (node.type === 'roof') {
|
if (node.type === 'roof-segment' && node.parentId) {
|
||||||
activeRoofIds.add(id)
|
|
||||||
} else if (node.type === 'roof-segment' && node.parentId) {
|
|
||||||
activeRoofIds.add(node.parentId)
|
activeRoofIds.add(node.parentId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
type MaterialSchema,
|
type MaterialSchema,
|
||||||
type MaterialTarget,
|
type MaterialTarget,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
type MaterialPickerProps = {
|
type MaterialPickerProps = {
|
||||||
nodeType?: MaterialTarget
|
nodeType?: MaterialTarget
|
||||||
@@ -14,6 +14,8 @@ type MaterialPickerProps = {
|
|||||||
selectedMaterialPreset?: string
|
selectedMaterialPreset?: string
|
||||||
onChange?: (material: MaterialSchema) => void
|
onChange?: (material: MaterialSchema) => void
|
||||||
onSelectMaterialPreset?: (materialPreset: string) => void
|
onSelectMaterialPreset?: (materialPreset: string) => void
|
||||||
|
hideSideControl?: boolean
|
||||||
|
disabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MaterialPicker({
|
export function MaterialPicker({
|
||||||
@@ -22,10 +24,16 @@ export function MaterialPicker({
|
|||||||
selectedMaterialPreset,
|
selectedMaterialPreset,
|
||||||
onChange,
|
onChange,
|
||||||
onSelectMaterialPreset,
|
onSelectMaterialPreset,
|
||||||
|
hideSideControl = false,
|
||||||
|
disabled = false,
|
||||||
}: MaterialPickerProps) {
|
}: MaterialPickerProps) {
|
||||||
const [showCustom, setShowCustom] = useState<boolean>(!!value?.properties)
|
const [showCustom, setShowCustom] = useState<boolean>(!!value?.properties)
|
||||||
const catalogItems = nodeType ? getMaterialsForTarget(nodeType) : []
|
const catalogItems = nodeType ? getMaterialsForTarget(nodeType) : []
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setShowCustom(!!value?.properties && !selectedMaterialPreset)
|
||||||
|
}, [selectedMaterialPreset, value?.properties])
|
||||||
|
|
||||||
const currentProps = value?.properties || {
|
const currentProps = value?.properties || {
|
||||||
color: '#ffffff',
|
color: '#ffffff',
|
||||||
roughness: 0.5,
|
roughness: 0.5,
|
||||||
@@ -38,11 +46,13 @@ export function MaterialPicker({
|
|||||||
selectedMaterialPreset ?? (value?.id ? toLibraryMaterialRef(value.id) : undefined)
|
selectedMaterialPreset ?? (value?.id ? toLibraryMaterialRef(value.id) : undefined)
|
||||||
|
|
||||||
const handleCatalogSelect = (materialId: string) => {
|
const handleCatalogSelect = (materialId: string) => {
|
||||||
|
if (disabled) return
|
||||||
setShowCustom(false)
|
setShowCustom(false)
|
||||||
onSelectMaterialPreset?.(toLibraryMaterialRef(materialId))
|
onSelectMaterialPreset?.(toLibraryMaterialRef(materialId))
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCustomOpen = () => {
|
const handleCustomOpen = () => {
|
||||||
|
if (disabled) return
|
||||||
setShowCustom(true)
|
setShowCustom(true)
|
||||||
onChange?.({
|
onChange?.({
|
||||||
preset: 'custom',
|
preset: 'custom',
|
||||||
@@ -61,6 +71,7 @@ export function MaterialPicker({
|
|||||||
prop: keyof typeof currentProps,
|
prop: keyof typeof currentProps,
|
||||||
val: (typeof currentProps)[keyof typeof currentProps],
|
val: (typeof currentProps)[keyof typeof currentProps],
|
||||||
) => {
|
) => {
|
||||||
|
if (disabled) return
|
||||||
onChange?.({
|
onChange?.({
|
||||||
preset: 'custom',
|
preset: 'custom',
|
||||||
properties: {
|
properties: {
|
||||||
@@ -71,7 +82,7 @@ export function MaterialPicker({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className={`space-y-3 ${disabled ? 'pointer-events-none opacity-50' : ''}`}>
|
||||||
{(catalogItems.length > 0 || onChange) && (
|
{(catalogItems.length > 0 || onChange) && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{catalogItems.length > 0 ? (
|
{catalogItems.length > 0 ? (
|
||||||
@@ -193,6 +204,7 @@ export function MaterialPicker({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!hideSideControl && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<label className="w-16 text-gray-500 text-xs">Side</label>
|
<label className="w-16 text-gray-500 text-xs">Side</label>
|
||||||
<select
|
<select
|
||||||
@@ -207,6 +219,7 @@ export function MaterialPicker({
|
|||||||
<option value="double">Double</option>
|
<option value="double">Double</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,6 +21,20 @@ function stepPrecision(s: number): number {
|
|||||||
return Math.max(0, Math.ceil(-Math.log10(s)))
|
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({
|
export function SliderControl({
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
@@ -58,16 +72,14 @@ export function SliderControl({
|
|||||||
if (isEditing) return
|
if (isEditing) return
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const direction = e.deltaY < 0 ? 1 : -1
|
const direction = e.deltaY < 0 ? 1 : -1
|
||||||
let s = step
|
const s = getAdjustedStep(step, e)
|
||||||
if (e.shiftKey) s = step * 10
|
|
||||||
else if (e.altKey) s = step * 0.1
|
|
||||||
const newValue = clamp(valueRef.current + direction * s)
|
const newValue = clamp(valueRef.current + direction * s)
|
||||||
const final = Number.parseFloat(newValue.toFixed(stepPrecision(s)))
|
const final = Number.parseFloat(newValue.toFixed(stepPrecision(s)))
|
||||||
if (final !== valueRef.current) onChange(final)
|
if (final !== valueRef.current) onChange(final)
|
||||||
}
|
}
|
||||||
el.addEventListener('wheel', handleWheel, { passive: false })
|
el.addEventListener('wheel', handleWheel, { passive: false })
|
||||||
return () => el.removeEventListener('wheel', handleWheel)
|
return () => el.removeEventListener('wheel', handleWheel)
|
||||||
}, [isEditing, step, clamp, onChange, precision])
|
}, [isEditing, step, clamp, onChange])
|
||||||
|
|
||||||
// Arrow key support while hovered
|
// Arrow key support while hovered
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -78,9 +90,7 @@ export function SliderControl({
|
|||||||
else if (e.key === 'ArrowDown' || e.key === 'ArrowLeft') direction = -1
|
else if (e.key === 'ArrowDown' || e.key === 'ArrowLeft') direction = -1
|
||||||
if (direction !== 0) {
|
if (direction !== 0) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
let s = step
|
const s = getAdjustedStep(step, e)
|
||||||
if (e.shiftKey) s = step * 10
|
|
||||||
else if (e.metaKey || e.ctrlKey) s = step * 0.1
|
|
||||||
const newValue = clamp(valueRef.current + direction * s)
|
const newValue = clamp(valueRef.current + direction * s)
|
||||||
const final = Number.parseFloat(newValue.toFixed(stepPrecision(s)))
|
const final = Number.parseFloat(newValue.toFixed(stepPrecision(s)))
|
||||||
if (final !== valueRef.current) onChange(final)
|
if (final !== valueRef.current) onChange(final)
|
||||||
@@ -88,7 +98,7 @@ export function SliderControl({
|
|||||||
}
|
}
|
||||||
window.addEventListener('keydown', handleKeyDown)
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||||
}, [isHovered, isEditing, step, clamp, onChange, precision])
|
}, [isHovered, isEditing, step, clamp, onChange])
|
||||||
|
|
||||||
const handleLabelPointerDown = useCallback(
|
const handleLabelPointerDown = useCallback(
|
||||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||||
@@ -107,16 +117,14 @@ export function SliderControl({
|
|||||||
if (!dragRef.current) return
|
if (!dragRef.current) return
|
||||||
const { startX, startValue } = dragRef.current
|
const { startX, startValue } = dragRef.current
|
||||||
const dx = e.clientX - startX
|
const dx = e.clientX - startX
|
||||||
let s = step
|
const s = getAdjustedStep(step, e)
|
||||||
if (e.shiftKey) s = step * 10
|
|
||||||
else if (e.metaKey || e.ctrlKey) s = step * 0.1
|
|
||||||
// 4 px per step at default sensitivity
|
// 4 px per step at default sensitivity
|
||||||
const newValue = clamp(
|
const newValue = clamp(
|
||||||
Number.parseFloat((startValue + (dx / 4) * s).toFixed(stepPrecision(s))),
|
Number.parseFloat((startValue + (dx / 4) * s).toFixed(stepPrecision(s))),
|
||||||
)
|
)
|
||||||
onChange(newValue)
|
onChange(newValue)
|
||||||
},
|
},
|
||||||
[step, precision, clamp, onChange],
|
[step, clamp, onChange],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleLabelPointerUp = useCallback(
|
const handleLabelPointerUp = useCallback(
|
||||||
@@ -163,12 +171,18 @@ export function SliderControl({
|
|||||||
setIsEditing(false)
|
setIsEditing(false)
|
||||||
} else if (e.key === 'ArrowUp') {
|
} else if (e.key === 'ArrowUp') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const newV = clamp(value + step)
|
const adjustedStep = getAdjustedStep(step, e)
|
||||||
|
const newV = clamp(
|
||||||
|
Number.parseFloat((value + adjustedStep).toFixed(stepPrecision(adjustedStep))),
|
||||||
|
)
|
||||||
onChange(newV)
|
onChange(newV)
|
||||||
setInputValue(newV.toFixed(precision))
|
setInputValue(newV.toFixed(precision))
|
||||||
} else if (e.key === 'ArrowDown') {
|
} else if (e.key === 'ArrowDown') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const newV = clamp(value - step)
|
const adjustedStep = getAdjustedStep(step, e)
|
||||||
|
const newV = clamp(
|
||||||
|
Number.parseFloat((value - adjustedStep).toFixed(stepPrecision(adjustedStep))),
|
||||||
|
)
|
||||||
onChange(newV)
|
onChange(newV)
|
||||||
setInputValue(newV.toFixed(precision))
|
setInputValue(newV.toFixed(precision))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,10 @@
|
|||||||
import {
|
import {
|
||||||
type AnyNode,
|
type AnyNode,
|
||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
|
getEffectiveRoofSurfaceMaterial,
|
||||||
type MaterialSchema,
|
type MaterialSchema,
|
||||||
type RoofNode,
|
type RoofNode,
|
||||||
|
type RoofSurfaceMaterialRole,
|
||||||
RoofNode as RoofNodeSchema,
|
RoofNode as RoofNodeSchema,
|
||||||
type RoofSegmentNode,
|
type RoofSegmentNode,
|
||||||
RoofSegmentNode as RoofSegmentNodeSchema,
|
RoofSegmentNode as RoofSegmentNodeSchema,
|
||||||
@@ -22,12 +24,39 @@ import { PanelSection } from '../controls/panel-section'
|
|||||||
import { SliderControl } from '../controls/slider-control'
|
import { SliderControl } from '../controls/slider-control'
|
||||||
import { PanelWrapper } from './panel-wrapper'
|
import { PanelWrapper } from './panel-wrapper'
|
||||||
|
|
||||||
|
function buildRoofSurfaceMaterialPatch(
|
||||||
|
node: RoofNode,
|
||||||
|
targetRole: RoofSurfaceMaterialRole,
|
||||||
|
material: MaterialSchema | undefined,
|
||||||
|
materialPreset: string | undefined,
|
||||||
|
): Partial<RoofNode> {
|
||||||
|
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() {
|
export function RoofPanel() {
|
||||||
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
||||||
const setSelection = useViewer((s) => s.setSelection)
|
const setSelection = useViewer((s) => s.setSelection)
|
||||||
const updateNode = useScene((s) => s.updateNode)
|
const updateNode = useScene((s) => s.updateNode)
|
||||||
const createNode = useScene((s) => s.createNode)
|
const createNode = useScene((s) => s.createNode)
|
||||||
const setMovingNode = useEditor((s) => s.setMovingNode)
|
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||||
|
const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget)
|
||||||
|
|
||||||
const node = useScene((s) =>
|
const node = useScene((s) =>
|
||||||
selectedId ? (s.nodes[selectedId as AnyNode['id']] as RoofNode | undefined) : undefined,
|
selectedId ? (s.nodes[selectedId as AnyNode['id']] as RoofNode | undefined) : undefined,
|
||||||
@@ -50,18 +79,31 @@ export function RoofPanel() {
|
|||||||
[selectedId, updateNode],
|
[selectedId, updateNode],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleMaterialChange = useCallback(
|
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) => {
|
(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) => {
|
(materialPreset: string) => {
|
||||||
handleUpdate({ materialPreset, material: undefined })
|
if (!node || !materialTargetRole) return
|
||||||
|
handleUpdate(buildRoofSurfaceMaterialPatch(node, materialTargetRole, undefined, materialPreset))
|
||||||
},
|
},
|
||||||
[handleUpdate],
|
[handleUpdate, materialTargetRole, node],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
@@ -170,11 +212,13 @@ export function RoofPanel() {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<ActionGroup>
|
||||||
<ActionButton
|
<ActionButton
|
||||||
icon={<Plus className="h-3.5 w-3.5" />}
|
icon={<Plus className="h-3.5 w-3.5" />}
|
||||||
label="Add Segment"
|
label="Add Segment"
|
||||||
onClick={handleAddSegment}
|
onClick={handleAddSegment}
|
||||||
/>
|
/>
|
||||||
|
</ActionGroup>
|
||||||
</PanelSection>
|
</PanelSection>
|
||||||
|
|
||||||
<PanelSection title="Position">
|
<PanelSection title="Position">
|
||||||
@@ -267,12 +311,19 @@ export function RoofPanel() {
|
|||||||
</ActionGroup>
|
</ActionGroup>
|
||||||
</PanelSection>
|
</PanelSection>
|
||||||
<PanelSection title="Material">
|
<PanelSection title="Material">
|
||||||
|
{!materialTargetRole ? (
|
||||||
|
<div className="mb-3 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 text-[11px] text-muted-foreground">
|
||||||
|
Click the roof surface you want to edit. Materials apply to one target at a time.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<MaterialPicker
|
<MaterialPicker
|
||||||
|
disabled={!materialTargetRole}
|
||||||
|
hideSideControl
|
||||||
nodeType="roof"
|
nodeType="roof"
|
||||||
onChange={handleMaterialChange}
|
onChange={handleTargetedMaterialChange}
|
||||||
onSelectMaterialPreset={handleMaterialPresetChange}
|
onSelectMaterialPreset={handleTargetedMaterialPresetChange}
|
||||||
selectedMaterialPreset={node.materialPreset}
|
selectedMaterialPreset={materialPickerValue.materialPreset}
|
||||||
value={node.material}
|
value={materialPickerValue.material}
|
||||||
/>
|
/>
|
||||||
</PanelSection>
|
</PanelSection>
|
||||||
</PanelWrapper>
|
</PanelWrapper>
|
||||||
|
|||||||
@@ -3,10 +3,12 @@
|
|||||||
import {
|
import {
|
||||||
type AnyNode,
|
type AnyNode,
|
||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
|
getEffectiveStairSurfaceMaterial,
|
||||||
type LevelNode,
|
type LevelNode,
|
||||||
type MaterialSchema,
|
type MaterialSchema,
|
||||||
type StairNode,
|
type StairNode,
|
||||||
type StairRailingMode,
|
type StairRailingMode,
|
||||||
|
type StairSurfaceMaterialRole,
|
||||||
type StairSlabOpeningMode,
|
type StairSlabOpeningMode,
|
||||||
type StairTopLandingMode,
|
type StairTopLandingMode,
|
||||||
type StairType,
|
type StairType,
|
||||||
@@ -31,6 +33,32 @@ import { SliderControl } from '../controls/slider-control'
|
|||||||
import { ToggleControl } from '../controls/toggle-control'
|
import { ToggleControl } from '../controls/toggle-control'
|
||||||
import { PanelWrapper } from './panel-wrapper'
|
import { PanelWrapper } from './panel-wrapper'
|
||||||
|
|
||||||
|
function buildStairSurfaceMaterialPatch(
|
||||||
|
node: StairNode,
|
||||||
|
targetRole: StairSurfaceMaterialRole,
|
||||||
|
material: MaterialSchema | undefined,
|
||||||
|
materialPreset: string | undefined,
|
||||||
|
): Partial<StairNode> {
|
||||||
|
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 }[] = [
|
const RAILING_MODE_OPTIONS: { label: string; value: StairRailingMode }[] = [
|
||||||
{ label: 'None', value: 'none' },
|
{ label: 'None', value: 'none' },
|
||||||
{ label: 'Left', value: 'left' },
|
{ label: 'Left', value: 'left' },
|
||||||
@@ -62,6 +90,7 @@ export function StairPanel() {
|
|||||||
const createNode = useScene((s) => s.createNode)
|
const createNode = useScene((s) => s.createNode)
|
||||||
const createNodes = useScene((s) => s.createNodes)
|
const createNodes = useScene((s) => s.createNodes)
|
||||||
const setMovingNode = useEditor((s) => s.setMovingNode)
|
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||||
|
const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget)
|
||||||
|
|
||||||
const node = useScene((s) =>
|
const node = useScene((s) =>
|
||||||
selectedId ? (s.nodes[selectedId as AnyNode['id']] as StairNode | undefined) : undefined,
|
selectedId ? (s.nodes[selectedId as AnyNode['id']] as StairNode | undefined) : undefined,
|
||||||
@@ -92,18 +121,31 @@ export function StairPanel() {
|
|||||||
[selectedId, updateNode],
|
[selectedId, updateNode],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleMaterialChange = useCallback(
|
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) => {
|
(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) => {
|
(materialPreset: string) => {
|
||||||
handleUpdate({ materialPreset, material: undefined })
|
if (!node || !materialTargetRole) return
|
||||||
|
handleUpdate(buildStairSurfaceMaterialPatch(node, materialTargetRole, undefined, materialPreset))
|
||||||
},
|
},
|
||||||
[handleUpdate],
|
[handleUpdate, materialTargetRole, node],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
@@ -569,12 +611,19 @@ export function StairPanel() {
|
|||||||
</ActionGroup>
|
</ActionGroup>
|
||||||
</PanelSection>
|
</PanelSection>
|
||||||
<PanelSection title="Material">
|
<PanelSection title="Material">
|
||||||
|
{!materialTargetRole ? (
|
||||||
|
<div className="mb-3 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 text-[11px] text-muted-foreground">
|
||||||
|
Click the stair surface you want to edit. Materials apply to one target at a time.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<MaterialPicker
|
<MaterialPicker
|
||||||
|
disabled={!materialTargetRole}
|
||||||
|
hideSideControl
|
||||||
nodeType="stair"
|
nodeType="stair"
|
||||||
onChange={handleMaterialChange}
|
onChange={handleTargetedMaterialChange}
|
||||||
onSelectMaterialPreset={handleMaterialPresetChange}
|
onSelectMaterialPreset={handleTargetedMaterialPresetChange}
|
||||||
selectedMaterialPreset={node.materialPreset}
|
selectedMaterialPreset={materialPickerValue.materialPreset}
|
||||||
value={node.material}
|
value={materialPickerValue.material}
|
||||||
/>
|
/>
|
||||||
</PanelSection>
|
</PanelSection>
|
||||||
</PanelWrapper>
|
</PanelWrapper>
|
||||||
|
|||||||
@@ -3,17 +3,20 @@
|
|||||||
import {
|
import {
|
||||||
type AnyNode,
|
type AnyNode,
|
||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
|
getEffectiveWallSurfaceMaterial,
|
||||||
getClampedWallCurveOffset,
|
getClampedWallCurveOffset,
|
||||||
getMaxWallCurveOffset,
|
getMaxWallCurveOffset,
|
||||||
getWallCurveLength,
|
getWallCurveLength,
|
||||||
|
getWallSurfaceMaterialSignature,
|
||||||
normalizeWallCurveOffset,
|
normalizeWallCurveOffset,
|
||||||
type MaterialSchema,
|
type MaterialSchema,
|
||||||
useScene,
|
useScene,
|
||||||
|
type WallSurfaceSide,
|
||||||
type WallNode,
|
type WallNode,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { Move, Spline } from 'lucide-react'
|
import { Move, Spline } from 'lucide-react'
|
||||||
import { useCallback } from 'react'
|
import { useCallback, useMemo } from 'react'
|
||||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||||
import useEditor from '../../../store/use-editor'
|
import useEditor from '../../../store/use-editor'
|
||||||
import { ActionButton, ActionGroup } from '../controls/action-button'
|
import { ActionButton, ActionGroup } from '../controls/action-button'
|
||||||
@@ -22,12 +25,39 @@ import { PanelSection } from '../controls/panel-section'
|
|||||||
import { SliderControl } from '../controls/slider-control'
|
import { SliderControl } from '../controls/slider-control'
|
||||||
import { PanelWrapper } from './panel-wrapper'
|
import { PanelWrapper } from './panel-wrapper'
|
||||||
|
|
||||||
|
function buildWallSurfaceMaterialPatch(
|
||||||
|
node: WallNode,
|
||||||
|
targetSide: WallSurfaceSide | null,
|
||||||
|
material: MaterialSchema | undefined,
|
||||||
|
materialPreset: string | undefined,
|
||||||
|
): Partial<WallNode> {
|
||||||
|
const nextSurfaceMaterial = { material, materialPreset }
|
||||||
|
const nextInterior =
|
||||||
|
targetSide === null || targetSide === 'interior'
|
||||||
|
? nextSurfaceMaterial
|
||||||
|
: getEffectiveWallSurfaceMaterial(node, 'interior')
|
||||||
|
const nextExterior =
|
||||||
|
targetSide === null || targetSide === 'exterior'
|
||||||
|
? nextSurfaceMaterial
|
||||||
|
: getEffectiveWallSurfaceMaterial(node, 'exterior')
|
||||||
|
|
||||||
|
return {
|
||||||
|
interiorMaterial: nextInterior.material,
|
||||||
|
interiorMaterialPreset: nextInterior.materialPreset,
|
||||||
|
exteriorMaterial: nextExterior.material,
|
||||||
|
exteriorMaterialPreset: nextExterior.materialPreset,
|
||||||
|
material: undefined,
|
||||||
|
materialPreset: undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function WallPanel() {
|
export function WallPanel() {
|
||||||
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
||||||
const setSelection = useViewer((s) => s.setSelection)
|
const setSelection = useViewer((s) => s.setSelection)
|
||||||
const updateNode = useScene((s) => s.updateNode)
|
const updateNode = useScene((s) => s.updateNode)
|
||||||
const setMovingNode = useEditor((s) => s.setMovingNode)
|
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||||
const setCurvingWall = useEditor((s) => s.setCurvingWall)
|
const setCurvingWall = useEditor((s) => s.setCurvingWall)
|
||||||
|
const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget)
|
||||||
|
|
||||||
const node = useScene((s) =>
|
const node = useScene((s) =>
|
||||||
selectedId ? (s.nodes[selectedId as AnyNode['id']] as WallNode | undefined) : undefined,
|
selectedId ? (s.nodes[selectedId as AnyNode['id']] as WallNode | undefined) : undefined,
|
||||||
@@ -58,6 +88,35 @@ export function WallPanel() {
|
|||||||
[selectedId, updateNode],
|
[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(
|
const handleUpdateLength = useCallback(
|
||||||
(newLength: number) => {
|
(newLength: number) => {
|
||||||
if (!node || newLength <= 0) return
|
if (!node || newLength <= 0) return
|
||||||
@@ -83,16 +142,18 @@ export function WallPanel() {
|
|||||||
|
|
||||||
const handleMaterialPresetChange = useCallback(
|
const handleMaterialPresetChange = useCallback(
|
||||||
(materialPreset: string) => {
|
(materialPreset: string) => {
|
||||||
handleUpdate({ materialPreset, material: undefined })
|
if (!node || !materialTargetSide) return
|
||||||
|
handleUpdate(buildWallSurfaceMaterialPatch(node, materialTargetSide, undefined, materialPreset))
|
||||||
},
|
},
|
||||||
[handleUpdate],
|
[handleUpdate, materialTargetSide, node],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleCustomMaterialChange = useCallback(
|
const handleCustomMaterialChange = useCallback(
|
||||||
(material: MaterialSchema) => {
|
(material: MaterialSchema) => {
|
||||||
handleUpdate({ material, materialPreset: undefined })
|
if (!node || !materialTargetSide) return
|
||||||
|
handleUpdate(buildWallSurfaceMaterialPatch(node, materialTargetSide, material, undefined))
|
||||||
},
|
},
|
||||||
[handleUpdate],
|
[handleUpdate, materialTargetSide, node],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
@@ -169,7 +230,7 @@ export function WallPanel() {
|
|||||||
min={-Math.max(0.01, maxCurveOffset)}
|
min={-Math.max(0.01, maxCurveOffset)}
|
||||||
onChange={(v) => handleUpdate({ curveOffset: normalizeWallCurveOffset(node, v) })}
|
onChange={(v) => handleUpdate({ curveOffset: normalizeWallCurveOffset(node, v) })}
|
||||||
precision={2}
|
precision={2}
|
||||||
step={0.01}
|
step={0.1}
|
||||||
unit="m"
|
unit="m"
|
||||||
value={Math.round(curveOffset * 100) / 100}
|
value={Math.round(curveOffset * 100) / 100}
|
||||||
/>
|
/>
|
||||||
@@ -177,15 +238,23 @@ export function WallPanel() {
|
|||||||
</PanelSection>
|
</PanelSection>
|
||||||
|
|
||||||
<PanelSection title="Material">
|
<PanelSection title="Material">
|
||||||
|
{!materialTargetSide ? (
|
||||||
|
<div className="mb-3 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 text-[11px] text-muted-foreground">
|
||||||
|
Click the wall face you want to edit. Materials now apply to one side at a time.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<MaterialPicker
|
<MaterialPicker
|
||||||
|
disabled={!materialTargetSide}
|
||||||
|
hideSideControl
|
||||||
nodeType="wall"
|
nodeType="wall"
|
||||||
onChange={handleCustomMaterialChange}
|
onChange={handleCustomMaterialChange}
|
||||||
onSelectMaterialPreset={handleMaterialPresetChange}
|
onSelectMaterialPreset={handleMaterialPresetChange}
|
||||||
selectedMaterialPreset={node.materialPreset}
|
selectedMaterialPreset={materialPickerValue.materialPreset}
|
||||||
value={node.material}
|
value={materialPickerValue.material}
|
||||||
/>
|
/>
|
||||||
</PanelSection>
|
</PanelSection>
|
||||||
|
|
||||||
|
<PanelSection title="Actions">
|
||||||
<ActionGroup>
|
<ActionGroup>
|
||||||
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
|
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
|
||||||
{!hasWallChildrenBlockingCurve && (
|
{!hasWallChildrenBlockingCurve && (
|
||||||
@@ -196,6 +265,7 @@ export function WallPanel() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</ActionGroup>
|
</ActionGroup>
|
||||||
|
</PanelSection>
|
||||||
</PanelWrapper>
|
</PanelWrapper>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,25 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import type { AssetInput } from '@pascal-app/core'
|
|
||||||
import {
|
import {
|
||||||
|
type AnyNodeId,
|
||||||
|
type AssetInput,
|
||||||
type BuildingNode,
|
type BuildingNode,
|
||||||
type CeilingNode,
|
type CeilingNode,
|
||||||
type DoorNode,
|
type DoorNode,
|
||||||
type FenceNode,
|
type FenceNode,
|
||||||
type ItemNode,
|
type ItemNode,
|
||||||
type LevelNode,
|
type LevelNode,
|
||||||
|
type RoofSurfaceMaterialRole,
|
||||||
type RoofNode,
|
type RoofNode,
|
||||||
type RoofSegmentNode,
|
type RoofSegmentNode,
|
||||||
type SlabNode,
|
type SlabNode,
|
||||||
type Space,
|
type Space,
|
||||||
|
type StairSurfaceMaterialRole,
|
||||||
type StairNode,
|
type StairNode,
|
||||||
type StairSegmentNode,
|
type StairSegmentNode,
|
||||||
useScene,
|
useScene,
|
||||||
type WallNode,
|
type WallNode,
|
||||||
|
type WallSurfaceSide,
|
||||||
type WindowNode,
|
type WindowNode,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
@@ -80,6 +84,13 @@ export type MovingWallEndpoint = {
|
|||||||
endpoint: 'start' | 'end'
|
endpoint: 'start' | 'end'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type MaterialTargetRole = WallSurfaceSide | StairSurfaceMaterialRole | RoofSurfaceMaterialRole
|
||||||
|
|
||||||
|
export type SelectedMaterialTarget = {
|
||||||
|
nodeId: AnyNodeId
|
||||||
|
role: MaterialTargetRole
|
||||||
|
}
|
||||||
|
|
||||||
type EditorState = {
|
type EditorState = {
|
||||||
phase: Phase
|
phase: Phase
|
||||||
setPhase: (phase: Phase) => void
|
setPhase: (phase: Phase) => void
|
||||||
@@ -127,6 +138,8 @@ type EditorState = {
|
|||||||
setMovingWallEndpoint: (value: MovingWallEndpoint | null) => void
|
setMovingWallEndpoint: (value: MovingWallEndpoint | null) => void
|
||||||
curvingWall: WallNode | null
|
curvingWall: WallNode | null
|
||||||
setCurvingWall: (wall: WallNode | null) => void
|
setCurvingWall: (wall: WallNode | null) => void
|
||||||
|
selectedMaterialTarget: SelectedMaterialTarget | null
|
||||||
|
setSelectedMaterialTarget: (target: SelectedMaterialTarget | null) => void
|
||||||
selectedReferenceId: string | null
|
selectedReferenceId: string | null
|
||||||
setSelectedReferenceId: (id: string | null) => void
|
setSelectedReferenceId: (id: string | null) => void
|
||||||
// Space detection for cutaway mode
|
// Space detection for cutaway mode
|
||||||
@@ -502,6 +515,8 @@ const useEditor = create<EditorState>()(
|
|||||||
setMovingWallEndpoint: (value) => set({ movingWallEndpoint: value }),
|
setMovingWallEndpoint: (value) => set({ movingWallEndpoint: value }),
|
||||||
curvingWall: null,
|
curvingWall: null,
|
||||||
setCurvingWall: (wall) => set({ curvingWall: wall }),
|
setCurvingWall: (wall) => set({ curvingWall: wall }),
|
||||||
|
selectedMaterialTarget: null,
|
||||||
|
setSelectedMaterialTarget: (target) => set({ selectedMaterialTarget: target }),
|
||||||
selectedReferenceId: null,
|
selectedReferenceId: null,
|
||||||
setSelectedReferenceId: (id) => set({ selectedReferenceId: id }),
|
setSelectedReferenceId: (id) => set({ selectedReferenceId: id }),
|
||||||
spaces: {},
|
spaces: {},
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
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 { useMemo, useRef } from 'react'
|
import { useEffect, useMemo, useRef } from 'react'
|
||||||
import type * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
import { useNodeEvents } from '../../../hooks/use-node-events'
|
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||||
import { createMaterial, createMaterialFromPresetRef } from '../../../lib/materials'
|
|
||||||
import useViewer from '../../../store/use-viewer'
|
import useViewer from '../../../store/use-viewer'
|
||||||
|
import { getRoofMaterialArray } from '../../../systems/roof/roof-materials'
|
||||||
import { roofDebugMaterials, roofMaterials } from '../roof/roof-materials'
|
import { roofDebugMaterials, roofMaterials } from '../roof/roof-materials'
|
||||||
|
|
||||||
export const RoofSegmentRenderer = ({ node }: { node: RoofSegmentNode }) => {
|
export const RoofSegmentRenderer = ({ node }: { node: RoofSegmentNode }) => {
|
||||||
@@ -16,16 +16,22 @@ export const RoofSegmentRenderer = ({ node }: { node: RoofSegmentNode }) => {
|
|||||||
const debugColors = useViewer((s) => s.debugColors)
|
const debugColors = useViewer((s) => s.debugColors)
|
||||||
const parentNode =
|
const parentNode =
|
||||||
node.parentId ? (nodes[node.parentId as AnyNodeId] as RoofNode | undefined) : undefined
|
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 customMaterial = useMemo(() => {
|
||||||
const effectiveMaterialPreset = node.materialPreset ?? parentNode?.materialPreset
|
if (node.material !== undefined || typeof node.materialPreset === 'string') {
|
||||||
const effectiveMaterial = node.material ?? parentNode?.material
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
const presetMaterial = createMaterialFromPresetRef(effectiveMaterialPreset)
|
return parentNode ? getRoofMaterialArray(parentNode) : null
|
||||||
if (presetMaterial) return presetMaterial
|
|
||||||
const mat = effectiveMaterial
|
|
||||||
if (!mat) return null
|
|
||||||
return createMaterial(mat)
|
|
||||||
}, [
|
}, [
|
||||||
node.materialPreset,
|
node.materialPreset,
|
||||||
node.material,
|
node.material,
|
||||||
@@ -37,21 +43,31 @@ export const RoofSegmentRenderer = ({ node }: { node: RoofSegmentNode }) => {
|
|||||||
parentNode?.material?.preset,
|
parentNode?.material?.preset,
|
||||||
parentNode?.material?.properties,
|
parentNode?.material?.properties,
|
||||||
parentNode?.material?.texture,
|
parentNode?.material?.texture,
|
||||||
|
parentNode?.topMaterial,
|
||||||
|
parentNode?.topMaterialPreset,
|
||||||
|
parentNode?.edgeMaterial,
|
||||||
|
parentNode?.edgeMaterialPreset,
|
||||||
|
parentNode?.wallMaterial,
|
||||||
|
parentNode?.wallMaterialPreset,
|
||||||
])
|
])
|
||||||
|
|
||||||
const material = debugColors ? roofDebugMaterials : customMaterial || roofMaterials
|
const material = debugColors ? roofDebugMaterials : customMaterial || roofMaterials
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
placeholderGeometry.dispose()
|
||||||
|
}
|
||||||
|
}, [placeholderGeometry])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<mesh
|
<mesh
|
||||||
|
geometry={placeholderGeometry}
|
||||||
material={material}
|
material={material}
|
||||||
position={node.position}
|
position={node.position}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
rotation-y={node.rotation}
|
rotation-y={node.rotation}
|
||||||
visible={node.visible}
|
visible={node.visible}
|
||||||
{...handlers}
|
{...handlers}
|
||||||
>
|
/>
|
||||||
{/* RoofSystem will replace this geometry in the next frame */}
|
|
||||||
<boxGeometry args={[0, 0, 0]} />
|
|
||||||
</mesh>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { type RoofNode, useRegistry } from '@pascal-app/core'
|
import { type RoofNode, useRegistry } from '@pascal-app/core'
|
||||||
import { useMemo, useRef } from 'react'
|
import { useEffect, useMemo, useRef } from 'react'
|
||||||
import type * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
import { useNodeEvents } from '../../../hooks/use-node-events'
|
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||||
import { createMaterial, createMaterialFromPresetRef } from '../../../lib/materials'
|
|
||||||
import useViewer from '../../../store/use-viewer'
|
import useViewer from '../../../store/use-viewer'
|
||||||
|
import { getRoofMaterialArray } from '../../../systems/roof/roof-materials'
|
||||||
import { NodeRenderer } from '../node-renderer'
|
import { NodeRenderer } from '../node-renderer'
|
||||||
import { roofDebugMaterials, roofMaterials } from './roof-materials'
|
import { roofDebugMaterials, roofMaterials } from './roof-materials'
|
||||||
|
|
||||||
@@ -14,17 +14,41 @@ export const RoofRenderer = ({ node }: { node: RoofNode }) => {
|
|||||||
|
|
||||||
const handlers = useNodeEvents(node, 'roof')
|
const handlers = useNodeEvents(node, 'roof')
|
||||||
const debugColors = useViewer((s) => s.debugColors)
|
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 customMaterial = useMemo(
|
||||||
const presetMaterial = createMaterialFromPresetRef(node.materialPreset)
|
() => getRoofMaterialArray(node),
|
||||||
if (presetMaterial) return presetMaterial
|
[
|
||||||
const mat = node.material
|
node.materialPreset,
|
||||||
if (!mat) return null
|
node.material,
|
||||||
return createMaterial(mat)
|
node.material?.preset,
|
||||||
}, [node.materialPreset, node.material, node.material?.preset, node.material?.properties, node.material?.texture])
|
node.material?.properties,
|
||||||
|
node.material?.texture,
|
||||||
|
node.topMaterial,
|
||||||
|
node.topMaterialPreset,
|
||||||
|
node.edgeMaterial,
|
||||||
|
node.edgeMaterialPreset,
|
||||||
|
node.wallMaterial,
|
||||||
|
node.wallMaterialPreset,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
const material = debugColors ? roofDebugMaterials : customMaterial || roofMaterials
|
const material = debugColors ? roofDebugMaterials : customMaterial || roofMaterials
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
placeholderGeometry.dispose()
|
||||||
|
}
|
||||||
|
}, [placeholderGeometry])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group
|
<group
|
||||||
position={node.position}
|
position={node.position}
|
||||||
@@ -33,9 +57,13 @@ export const RoofRenderer = ({ node }: { node: RoofNode }) => {
|
|||||||
visible={node.visible}
|
visible={node.visible}
|
||||||
{...handlers}
|
{...handlers}
|
||||||
>
|
>
|
||||||
<mesh castShadow material={material} name="merged-roof" receiveShadow>
|
<mesh
|
||||||
<boxGeometry args={[0, 0, 0]} />
|
castShadow
|
||||||
</mesh>
|
geometry={placeholderGeometry}
|
||||||
|
material={material}
|
||||||
|
name="merged-roof"
|
||||||
|
receiveShadow
|
||||||
|
/>
|
||||||
<group name="segments-wrapper" visible={false}>
|
<group name="segments-wrapper" visible={false}>
|
||||||
{(node.children ?? []).map((childId) => (
|
{(node.children ?? []).map((childId) => (
|
||||||
<NodeRenderer key={childId} nodeId={childId} />
|
<NodeRenderer key={childId} nodeId={childId} />
|
||||||
|
|||||||
@@ -55,7 +55,16 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const next = nodeList
|
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) => {
|
.filter((n) => {
|
||||||
if (!Number.isFinite(lowestLevelIndex)) return true
|
if (!Number.isFinite(lowestLevelIndex)) return true
|
||||||
const parentLevel = n.parentId ? levelIndexById.get(n.parentId as string) : undefined
|
const parentLevel = n.parentId ? levelIndexById.get(n.parentId as string) : undefined
|
||||||
|
|||||||
@@ -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 { useEffect, useMemo, useRef } from 'react'
|
||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
import type { Mesh } from 'three'
|
import type { Mesh } from 'three'
|
||||||
import { useNodeEvents } from '../../../hooks/use-node-events'
|
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||||
import {
|
import {
|
||||||
|
applyMaterialPresetToMaterials,
|
||||||
createMaterial,
|
createMaterial,
|
||||||
createMaterialFromPresetRef,
|
|
||||||
DEFAULT_SLAB_MATERIAL,
|
DEFAULT_SLAB_MATERIAL,
|
||||||
} from '../../../lib/materials'
|
} from '../../../lib/materials'
|
||||||
|
|
||||||
@@ -17,9 +17,18 @@ export const SlabRenderer = ({ node }: { node: SlabNode }) => {
|
|||||||
const handlers = useNodeEvents(node, 'slab')
|
const handlers = useNodeEvents(node, 'slab')
|
||||||
|
|
||||||
const material = useMemo(() => {
|
const material = useMemo(() => {
|
||||||
const presetMaterial = createMaterialFromPresetRef(node.materialPreset)
|
const preset = getMaterialPresetByRef(node.materialPreset)
|
||||||
const sourceMaterial = presetMaterial ?? (node.material ? createMaterial(node.material) : DEFAULT_SLAB_MATERIAL)
|
const slabMaterial = preset
|
||||||
const slabMaterial = sourceMaterial.clone()
|
? 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
|
// Slabs participate in the WebGPU MRT scene pass. Keeping them opaque avoids
|
||||||
// pipeline variants that can fail when geometry is regenerated while a
|
// pipeline variants that can fail when geometry is regenerated while a
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { type AnyNodeId, type StairNode, type StairSegmentNode, useRegistry, useScene } from '@pascal-app/core'
|
import { type AnyNodeId, type StairNode, type StairSegmentNode, useRegistry, useScene } from '@pascal-app/core'
|
||||||
import { useLayoutEffect, useMemo, useRef } from 'react'
|
import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'
|
||||||
import type * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
import { useNodeEvents } from '../../../hooks/use-node-events'
|
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 }) => {
|
export const StairSegmentRenderer = ({ node }: { node: StairSegmentNode }) => {
|
||||||
const ref = useRef<THREE.Mesh>(null!)
|
const ref = useRef<THREE.Mesh>(null!)
|
||||||
@@ -19,14 +19,7 @@ export const StairSegmentRenderer = ({ node }: { node: StairSegmentNode }) => {
|
|||||||
node.parentId ? (nodes[node.parentId as AnyNodeId] as StairNode | undefined) : undefined
|
node.parentId ? (nodes[node.parentId as AnyNodeId] as StairNode | undefined) : undefined
|
||||||
|
|
||||||
const material = useMemo(() => {
|
const material = useMemo(() => {
|
||||||
const effectiveMaterialPreset = node.materialPreset ?? parentNode?.materialPreset
|
return getStraightStairSegmentBodyMaterials(node, parentNode)
|
||||||
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)
|
|
||||||
}, [
|
}, [
|
||||||
node.materialPreset,
|
node.materialPreset,
|
||||||
node.material,
|
node.material,
|
||||||
@@ -38,19 +31,37 @@ export const StairSegmentRenderer = ({ node }: { node: StairSegmentNode }) => {
|
|||||||
parentNode?.material?.preset,
|
parentNode?.material?.preset,
|
||||||
parentNode?.material?.properties,
|
parentNode?.material?.properties,
|
||||||
parentNode?.material?.texture,
|
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 (
|
return (
|
||||||
<mesh
|
<mesh
|
||||||
|
geometry={placeholderGeometry}
|
||||||
material={material}
|
material={material}
|
||||||
position={node.position}
|
position={node.position}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
rotation-y={node.rotation}
|
rotation-y={node.rotation}
|
||||||
visible={node.visible}
|
visible={node.visible}
|
||||||
{...handlers}
|
{...handlers}
|
||||||
>
|
/>
|
||||||
{/* StairSystem will replace this geometry in the next frame */}
|
|
||||||
<boxGeometry args={[0, 0, 0]} />
|
|
||||||
</mesh>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,14 +5,15 @@ import {
|
|||||||
useRegistry,
|
useRegistry,
|
||||||
useScene,
|
useScene,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { useLayoutEffect, useMemo, useRef } from 'react'
|
import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'
|
||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
import { useNodeEvents } from '../../../hooks/use-node-events'
|
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||||
|
import { createMaterial, createMaterialFromPresetRef, DEFAULT_STAIR_MATERIAL } from '../../../lib/materials'
|
||||||
import {
|
import {
|
||||||
createMaterial,
|
getStairRailingMaterial,
|
||||||
createMaterialFromPresetRef,
|
getStairBodyMaterials,
|
||||||
DEFAULT_STAIR_MATERIAL,
|
type StairBodyMaterials,
|
||||||
} from '../../../lib/materials'
|
} from '../../../systems/stair/stair-materials'
|
||||||
import { NodeRenderer } from '../node-renderer'
|
import { NodeRenderer } from '../node-renderer'
|
||||||
|
|
||||||
type SegmentTransform = {
|
type SegmentTransform = {
|
||||||
@@ -71,6 +72,48 @@ export const StairRenderer = ({ node }: { node: StairNode }) => {
|
|||||||
node.material?.texture,
|
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 (
|
return (
|
||||||
<group
|
<group
|
||||||
position-x={node.position[0]}
|
position-x={node.position[0]}
|
||||||
@@ -81,12 +124,16 @@ export const StairRenderer = ({ node }: { node: StairNode }) => {
|
|||||||
{...handlers}
|
{...handlers}
|
||||||
>
|
>
|
||||||
{isSegmentBasedStair ? (
|
{isSegmentBasedStair ? (
|
||||||
<mesh castShadow material={material} name="merged-stair" receiveShadow>
|
<mesh
|
||||||
<boxGeometry args={[0, 0, 0]} />
|
castShadow
|
||||||
</mesh>
|
geometry={straightPlaceholderGeometry}
|
||||||
|
material={straightBodyMaterials}
|
||||||
|
name="merged-stair"
|
||||||
|
receiveShadow
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{!isSegmentBasedStair ? <CurvedStairBody material={material} stair={node} /> : null}
|
{!isSegmentBasedStair ? <CurvedStairBody bodyMaterials={straightBodyMaterials} stair={node} /> : null}
|
||||||
<StairRailings material={material} stair={node} />
|
<StairRailings material={railingMaterial} stair={node} />
|
||||||
{isSegmentBasedStair ? (
|
{isSegmentBasedStair ? (
|
||||||
<group name="segments-wrapper" visible={false}>
|
<group name="segments-wrapper" visible={false}>
|
||||||
{(node.children ?? []).map((childId) => (
|
{(node.children ?? []).map((childId) => (
|
||||||
@@ -170,6 +217,7 @@ function StairRailings({ stair, material }: { stair: StairNode; material: THREE.
|
|||||||
geometry={BALUSTER_GEOMETRY}
|
geometry={BALUSTER_GEOMETRY}
|
||||||
key={`${stair.id}-curved-baluster-${sideIndex}-${pointIndex}`}
|
key={`${stair.id}-curved-baluster-${sideIndex}-${pointIndex}`}
|
||||||
material={material}
|
material={material}
|
||||||
|
name="stair-railing-baluster"
|
||||||
position={[point[0], point[1] + railHeight / 2, point[2]]}
|
position={[point[0], point[1] + railHeight / 2, point[2]]}
|
||||||
receiveShadow
|
receiveShadow
|
||||||
scale={[balusterRadius, railHeight, balusterRadius]}
|
scale={[balusterRadius, railHeight, balusterRadius]}
|
||||||
@@ -227,6 +275,7 @@ function StairRailings({ stair, material }: { stair: StairNode; material: THREE.
|
|||||||
geometry={BALUSTER_GEOMETRY}
|
geometry={BALUSTER_GEOMETRY}
|
||||||
key={`${segmentPath.layout.segment.id}-${sidePath.side}-baluster-${pointIndex}`}
|
key={`${segmentPath.layout.segment.id}-${sidePath.side}-baluster-${pointIndex}`}
|
||||||
material={material}
|
material={material}
|
||||||
|
name="stair-railing-baluster"
|
||||||
position={[point[2], point[1] + railHeight / 2, point[0]]}
|
position={[point[2], point[1] + railHeight / 2, point[0]]}
|
||||||
receiveShadow
|
receiveShadow
|
||||||
scale={[balusterRadius, railHeight, balusterRadius]}
|
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 BALUSTER_GEOMETRY = new THREE.CylinderGeometry(1, 1, 1, 8)
|
||||||
const RAIL_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({
|
function RailSegment({
|
||||||
start,
|
start,
|
||||||
@@ -367,6 +418,7 @@ function RailSegment({
|
|||||||
castShadow
|
castShadow
|
||||||
geometry={RAIL_GEOMETRY}
|
geometry={RAIL_GEOMETRY}
|
||||||
material={material}
|
material={material}
|
||||||
|
name="stair-railing-rail"
|
||||||
position={[midpoint.x, midpoint.y, midpoint.z]}
|
position={[midpoint.x, midpoint.y, midpoint.z]}
|
||||||
quaternion={quaternion}
|
quaternion={quaternion}
|
||||||
receiveShadow
|
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 stepCount = Math.max(2, Math.round(stair.stepCount ?? 10))
|
||||||
const totalRise = Math.max(stair.totalRise ?? 2.5, 0.1)
|
const totalRise = Math.max(stair.totalRise ?? 2.5, 0.1)
|
||||||
const stepHeight = totalRise / stepCount
|
const stepHeight = totalRise / stepCount
|
||||||
@@ -411,7 +470,8 @@ function CurvedStairBody({ stair, material }: { stair: StairNode; material: THRE
|
|||||||
<mesh
|
<mesh
|
||||||
castShadow
|
castShadow
|
||||||
receiveShadow
|
receiveShadow
|
||||||
material={material}
|
material={sideMaterial}
|
||||||
|
name="stair-side"
|
||||||
position={[0, spiralColumnHeight / 2, 0]}
|
position={[0, spiralColumnHeight / 2, 0]}
|
||||||
>
|
>
|
||||||
<cylinderGeometry
|
<cylinderGeometry
|
||||||
@@ -443,7 +503,8 @@ function CurvedStairBody({ stair, material }: { stair: StairNode; material: THRE
|
|||||||
{isSpiral && (stair.showStepSupports ?? true) ? (
|
{isSpiral && (stair.showStepSupports ?? true) ? (
|
||||||
<mesh
|
<mesh
|
||||||
castShadow
|
castShadow
|
||||||
material={material}
|
material={sideMaterial}
|
||||||
|
name="stair-side"
|
||||||
position={[
|
position={[
|
||||||
Math.cos(midAngle) *
|
Math.cos(midAngle) *
|
||||||
(spiralColumnRadius +
|
(spiralColumnRadius +
|
||||||
@@ -470,7 +531,7 @@ function CurvedStairBody({ stair, material }: { stair: StairNode; material: THRE
|
|||||||
<CurvedStepMesh
|
<CurvedStepMesh
|
||||||
endAngle={endAngle}
|
endAngle={endAngle}
|
||||||
innerRadius={innerRadius}
|
innerRadius={innerRadius}
|
||||||
material={material}
|
material={bodyMaterials}
|
||||||
outerRadius={outerRadius}
|
outerRadius={outerRadius}
|
||||||
positionY={0}
|
positionY={0}
|
||||||
startAngle={startAngle}
|
startAngle={startAngle}
|
||||||
@@ -484,7 +545,7 @@ function CurvedStairBody({ stair, material }: { stair: StairNode; material: THRE
|
|||||||
<CurvedStepMesh
|
<CurvedStepMesh
|
||||||
endAngle={sweepAngle / 2 + spiralLandingSweep}
|
endAngle={sweepAngle / 2 + spiralLandingSweep}
|
||||||
innerRadius={innerRadius}
|
innerRadius={innerRadius}
|
||||||
material={material}
|
material={bodyMaterials}
|
||||||
outerRadius={outerRadius}
|
outerRadius={outerRadius}
|
||||||
positionY={spiralLastStepTop}
|
positionY={spiralLastStepTop}
|
||||||
startAngle={sweepAngle / 2}
|
startAngle={sweepAngle / 2}
|
||||||
@@ -513,7 +574,7 @@ function CurvedStepMesh({
|
|||||||
stepHeight: number
|
stepHeight: number
|
||||||
thickness: number
|
thickness: number
|
||||||
positionY: number
|
positionY: number
|
||||||
material: THREE.Material
|
material: THREE.Material | THREE.Material[]
|
||||||
}) {
|
}) {
|
||||||
const geometry = useMemo(
|
const geometry = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -556,15 +617,39 @@ function buildCurvedStepGeometry(
|
|||||||
|
|
||||||
const positions: number[] = []
|
const positions: number[] = []
|
||||||
const normals: number[] = []
|
const normals: number[] = []
|
||||||
|
const uvs: number[] = []
|
||||||
|
const triangleMaterialIndices: number[] = []
|
||||||
|
|
||||||
const pointOnArc = (radius: number, angle: number, y: number) =>
|
const pointOnArc = (radius: number, angle: number, y: number) =>
|
||||||
new THREE.Vector3(Math.cos(angle) * radius, y, Math.sin(angle) * radius)
|
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 = (
|
const pushTriangle = (
|
||||||
a: THREE.Vector3,
|
a: THREE.Vector3,
|
||||||
b: THREE.Vector3,
|
b: THREE.Vector3,
|
||||||
c: THREE.Vector3,
|
c: THREE.Vector3,
|
||||||
normal: THREE.Vector3,
|
normal: THREE.Vector3,
|
||||||
|
materialIndex: number,
|
||||||
) => {
|
) => {
|
||||||
const edgeAB = b.clone().sub(a)
|
const edgeAB = b.clone().sub(a)
|
||||||
const edgeAC = c.clone().sub(a)
|
const edgeAC = c.clone().sub(a)
|
||||||
@@ -573,7 +658,9 @@ function buildCurvedStepGeometry(
|
|||||||
for (const point of ordered) {
|
for (const point of ordered) {
|
||||||
positions.push(point.x, point.y, point.z)
|
positions.push(point.x, point.y, point.z)
|
||||||
normals.push(normal.x, normal.y, normal.z)
|
normals.push(normal.x, normal.y, normal.z)
|
||||||
|
pushUv(point, normal, materialIndex)
|
||||||
}
|
}
|
||||||
|
triangleMaterialIndices.push(materialIndex)
|
||||||
}
|
}
|
||||||
|
|
||||||
const pushQuad = (
|
const pushQuad = (
|
||||||
@@ -582,9 +669,10 @@ function buildCurvedStepGeometry(
|
|||||||
c: THREE.Vector3,
|
c: THREE.Vector3,
|
||||||
d: THREE.Vector3,
|
d: THREE.Vector3,
|
||||||
normal: THREE.Vector3,
|
normal: THREE.Vector3,
|
||||||
|
materialIndex: number,
|
||||||
) => {
|
) => {
|
||||||
pushTriangle(a, b, c, normal)
|
pushTriangle(a, b, c, normal, materialIndex)
|
||||||
pushTriangle(a, c, d, normal)
|
pushTriangle(a, c, d, normal, materialIndex)
|
||||||
}
|
}
|
||||||
|
|
||||||
const upNormal = new THREE.Vector3(0, 1, 0)
|
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 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()
|
const innerNormal = new THREE.Vector3(-Math.cos(midAngle), 0, -Math.sin(midAngle)).normalize()
|
||||||
|
|
||||||
pushQuad(innerStartTop, outerStartTop, outerEndTop, innerEndTop, upNormal)
|
pushQuad(
|
||||||
pushQuad(innerStartBottom, innerEndBottom, outerEndBottom, outerStartBottom, downNormal)
|
innerStartTop,
|
||||||
pushQuad(innerStartBottom, innerStartTop, innerEndTop, innerEndBottom, innerNormal)
|
outerStartTop,
|
||||||
pushQuad(outerStartBottom, outerEndBottom, outerEndTop, outerStartTop, outerNormal)
|
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)
|
const startInnerBottom = pointOnArc(innerRadius, startAngle, y0)
|
||||||
@@ -634,12 +750,49 @@ function buildCurvedStepGeometry(
|
|||||||
sweepDirection * Math.cos(endAngle),
|
sweepDirection * Math.cos(endAngle),
|
||||||
).normalize()
|
).normalize()
|
||||||
|
|
||||||
pushQuad(startInnerBottom, startOuterBottom, startOuterTop, startInnerTop, startNormal)
|
pushQuad(
|
||||||
pushQuad(endInnerBottom, endInnerTop, endOuterTop, endOuterBottom, endNormal)
|
startInnerBottom,
|
||||||
|
startOuterBottom,
|
||||||
|
startOuterTop,
|
||||||
|
startInnerTop,
|
||||||
|
startNormal,
|
||||||
|
STAIR_SIDE_MATERIAL_INDEX,
|
||||||
|
)
|
||||||
|
pushQuad(
|
||||||
|
endInnerBottom,
|
||||||
|
endInnerTop,
|
||||||
|
endOuterTop,
|
||||||
|
endOuterBottom,
|
||||||
|
endNormal,
|
||||||
|
STAIR_SIDE_MATERIAL_INDEX,
|
||||||
|
)
|
||||||
|
|
||||||
const geometry = new THREE.BufferGeometry()
|
const geometry = new THREE.BufferGeometry()
|
||||||
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3))
|
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3))
|
||||||
geometry.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 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()
|
geometry.computeVertexNormals()
|
||||||
return geometry
|
return geometry
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
import { useRegistry, useScene, type WallNode } from '@pascal-app/core'
|
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 type { Mesh } from 'three'
|
||||||
import { useNodeEvents } from '../../../hooks/use-node-events'
|
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||||
import {
|
import { getVisibleWallMaterials } from '../../../systems/wall/wall-materials'
|
||||||
createMaterial,
|
|
||||||
createMaterialFromPresetRef,
|
|
||||||
DEFAULT_WALL_MATERIAL,
|
|
||||||
} from '../../../lib/materials'
|
|
||||||
import { NodeRenderer } from '../node-renderer'
|
import { NodeRenderer } from '../node-renderer'
|
||||||
|
|
||||||
export const WallRenderer = ({ node }: { node: WallNode }) => {
|
export const WallRenderer = ({ node }: { node: WallNode }) => {
|
||||||
@@ -19,20 +15,7 @@ export const WallRenderer = ({ node }: { node: WallNode }) => {
|
|||||||
}, [node.id])
|
}, [node.id])
|
||||||
|
|
||||||
const handlers = useNodeEvents(node, 'wall')
|
const handlers = useNodeEvents(node, 'wall')
|
||||||
|
const material = getVisibleWallMaterials(node)
|
||||||
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,
|
|
||||||
])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<mesh castShadow material={material} receiveShadow ref={ref} visible={node.visible}>
|
<mesh castShadow material={material} receiveShadow ref={ref} visible={node.visible}>
|
||||||
|
|||||||
@@ -38,7 +38,15 @@ export const GroundOccluder = () => {
|
|||||||
const polygons: [number, number][][] = []
|
const polygons: [number, number][][] = []
|
||||||
|
|
||||||
Object.values(nodes).forEach((node) => {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ export function useNodeEvents<T extends NodeType>(node: NodeConfig[T]['node'], t
|
|||||||
position: [e.point.x, e.point.y, e.point.z],
|
position: [e.point.x, e.point.y, e.point.z],
|
||||||
localPosition: [localPoint.x, localPoint.y, localPoint.z],
|
localPosition: [localPoint.x, localPoint.y, localPoint.z],
|
||||||
normal: e.face ? [e.face.normal.x, e.face.normal.y, e.face.normal.z] : undefined,
|
normal: e.face ? [e.face.normal.x, e.face.normal.y, e.face.normal.z] : undefined,
|
||||||
|
faceIndex: e.faceIndex ?? undefined,
|
||||||
|
object: e.object,
|
||||||
stopPropagation: () => e.stopPropagation(),
|
stopPropagation: () => e.stopPropagation(),
|
||||||
nativeEvent: e,
|
nativeEvent: e,
|
||||||
} as NodeConfig[T]['event']
|
} as NodeConfig[T]['event']
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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]
|
||||||
|
}
|
||||||
@@ -1,210 +1,14 @@
|
|||||||
import {
|
import { type AnyNodeId, emitter, sceneRegistry, useScene, type WallNode } from '@pascal-app/core'
|
||||||
type AnyNodeId,
|
|
||||||
baseMaterial,
|
|
||||||
emitter,
|
|
||||||
getMaterialPresetByRef,
|
|
||||||
sceneRegistry,
|
|
||||||
useScene,
|
|
||||||
type WallNode,
|
|
||||||
} from '@pascal-app/core'
|
|
||||||
import { useFrame } from '@react-three/fiber'
|
import { useFrame } from '@react-three/fiber'
|
||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import type { Material } from 'three'
|
import type { Material } from 'three'
|
||||||
import { Color } from 'three'
|
import { type Mesh, Vector3 } from 'three/webgpu'
|
||||||
import { Fn, float, fract, length, mix, positionLocal, smoothstep, step, vec2 } from 'three/tsl'
|
|
||||||
import { type Mesh, MeshStandardNodeMaterial, Vector3 } from 'three/webgpu'
|
|
||||||
import useViewer from '../../store/use-viewer'
|
import useViewer from '../../store/use-viewer'
|
||||||
import { createMaterial, createMaterialFromPresetRef } from '../../lib/materials'
|
import { getMaterialsForWall } from './wall-materials'
|
||||||
|
|
||||||
const tmpVec = new Vector3()
|
const tmpVec = new Vector3()
|
||||||
const u = new Vector3()
|
const u = new Vector3()
|
||||||
const v = 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<string, WallMaterials>()
|
|
||||||
|
|
||||||
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(
|
function getWallHideState(
|
||||||
wallNode: WallNode,
|
wallNode: WallNode,
|
||||||
@@ -301,7 +105,7 @@ export const WallCutout = () => {
|
|||||||
? materials.deleteVisible
|
? materials.deleteVisible
|
||||||
: isSelectionHighlighted
|
: isSelectionHighlighted
|
||||||
? materials.highlightedVisible
|
? materials.highlightedVisible
|
||||||
: getVisibleWallMaterial(wallNode)
|
: materials.visible
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
lastWallMode.current = wallMode
|
lastWallMode.current = wallMode
|
||||||
@@ -311,7 +115,7 @@ export const WallCutout = () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const snapshot = new Map<Mesh, Material>()
|
const snapshot = new Map<Mesh, Material | Material[]>()
|
||||||
|
|
||||||
const restoreForCapture = () => {
|
const restoreForCapture = () => {
|
||||||
sceneRegistry.byType.wall.forEach((wallId) => {
|
sceneRegistry.byType.wall.forEach((wallId) => {
|
||||||
@@ -320,10 +124,10 @@ export const WallCutout = () => {
|
|||||||
const wallNode = useScene.getState().nodes[wallId as AnyNodeId] as WallNode | undefined
|
const wallNode = useScene.getState().nodes[wallId as AnyNodeId] as WallNode | undefined
|
||||||
if (!wallNode || wallNode.type !== 'wall') return
|
if (!wallNode || wallNode.type !== 'wall') return
|
||||||
const mats = getMaterialsForWall(wallNode)
|
const mats = getMaterialsForWall(wallNode)
|
||||||
const current = wallMesh.material as Material
|
const current = wallMesh.material as Material | Material[]
|
||||||
snapshot.set(wallMesh, current)
|
snapshot.set(wallMesh, current)
|
||||||
if (current === mats.highlightedVisible || current === mats.deleteVisible) {
|
if (current === mats.highlightedVisible || current === mats.deleteVisible) {
|
||||||
wallMesh.material = getVisibleWallMaterial(wallNode)
|
wallMesh.material = mats.visible
|
||||||
} else if (current === mats.highlightedInvisible || current === mats.deleteInvisible) {
|
} else if (current === mats.highlightedInvisible || current === mats.deleteInvisible) {
|
||||||
wallMesh.material = mats.invisible
|
wallMesh.material = mats.invisible
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<string, WallMaterials>()
|
||||||
|
|
||||||
|
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<Material>()
|
||||||
|
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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user