Merge remote-tracking branch 'origin/main' into feat/mcp-server
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@pascal-app/core",
|
||||
"version": "0.5.1",
|
||||
"version": "0.6.0",
|
||||
"description": "Core library for Pascal 3D building editor",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ThreeEvent } from '@react-three/fiber'
|
||||
import type { Object3D } from 'three'
|
||||
import mitt from 'mitt'
|
||||
import type {
|
||||
BuildingNode,
|
||||
@@ -38,6 +39,8 @@ export interface NodeEvent<T extends AnyNode = AnyNode> {
|
||||
position: [number, number, number]
|
||||
localPosition: [number, number, number]
|
||||
normal?: [number, number, number]
|
||||
faceIndex?: number
|
||||
object: Object3D
|
||||
stopPropagation: () => void
|
||||
nativeEvent: ThreeEvent<PointerEvent>
|
||||
}
|
||||
|
||||
@@ -42,9 +42,11 @@ export {
|
||||
getCatalogMaterialById,
|
||||
getLibraryMaterialIdFromRef,
|
||||
getMaterialPresetByRef,
|
||||
getMaterialsForTarget,
|
||||
getMaterialsForCategory,
|
||||
LIBRARY_MATERIAL_REF_PREFIX,
|
||||
MATERIAL_CATALOG,
|
||||
MATERIAL_CATEGORIES,
|
||||
type MaterialCategory,
|
||||
type MaterialCatalogItem,
|
||||
toLibraryMaterialRef,
|
||||
} from './material-library'
|
||||
@@ -55,6 +57,12 @@ export {
|
||||
type ItemInteractiveState,
|
||||
useInteractive,
|
||||
} from './store/use-interactive'
|
||||
export {
|
||||
getSceneHistoryPauseDepth,
|
||||
pauseSceneHistory,
|
||||
resetSceneHistoryPauseDepth,
|
||||
resumeSceneHistory,
|
||||
} from './store/history-control'
|
||||
export { default as useLiveTransforms, type LiveTransform } from './store/use-live-transforms'
|
||||
export { clearSceneHistory, default as useScene } from './store/use-scene'
|
||||
export { CeilingSystem } from './systems/ceiling/ceiling-system'
|
||||
|
||||
@@ -4,6 +4,11 @@ import {
|
||||
isCurvedWall,
|
||||
} from '../systems/wall/wall-curve'
|
||||
import { CeilingNode, SlabNode, type CeilingNode as CeilingNodeType, type SlabNode as SlabNodeType, type WallNode } from '../schema'
|
||||
import {
|
||||
getSceneHistoryPauseDepth,
|
||||
pauseSceneHistory,
|
||||
resumeSceneHistory,
|
||||
} from '../store/history-control'
|
||||
import { simplifyClosedPolygon } from './polygon-geometry'
|
||||
|
||||
type Point2D = { x: number; y: number }
|
||||
@@ -855,6 +860,7 @@ export function initSpaceDetectionSync(sceneStore: any, editorStore: any): () =>
|
||||
|
||||
const unsubscribe = sceneStore.subscribe((state: any) => {
|
||||
if (isProcessing) return
|
||||
if (getSceneHistoryPauseDepth() > 0) return
|
||||
|
||||
const nodes = state.nodes
|
||||
const wallsByLevel = new Map<string, WallNode[]>()
|
||||
@@ -889,11 +895,11 @@ export function initSpaceDetectionSync(sceneStore: any, editorStore: any): () =>
|
||||
}
|
||||
|
||||
isProcessing = true
|
||||
sceneStore.temporal.getState().pause()
|
||||
pauseSceneHistory(sceneStore)
|
||||
try {
|
||||
runSpaceDetection([...levelsToUpdate], sceneStore, editorStore, nodes)
|
||||
} finally {
|
||||
sceneStore.temporal.getState().resume()
|
||||
resumeSceneHistory(sceneStore)
|
||||
previousSnapshots.clear()
|
||||
for (const [levelId, snapshot] of currentSnapshots.entries()) {
|
||||
previousSnapshots.set(levelId, snapshot)
|
||||
|
||||
@@ -1,57 +1,33 @@
|
||||
import {
|
||||
type MaterialPresetPayload,
|
||||
type MaterialTarget,
|
||||
MaterialTarget as MaterialTargetSchema,
|
||||
} from './schema/material'
|
||||
|
||||
export type MaterialCatalogItem = {
|
||||
id: string
|
||||
label: string
|
||||
category: MaterialCategory
|
||||
description?: string
|
||||
targets: MaterialTarget[]
|
||||
previewThumbnailUrl?: string
|
||||
previewColor?: string
|
||||
preset: MaterialPresetPayload
|
||||
}
|
||||
|
||||
const WALL_TARGETS: MaterialTarget[] = [
|
||||
MaterialTargetSchema.enum.wall,
|
||||
]
|
||||
|
||||
const SLAB_TARGETS: MaterialTarget[] = [
|
||||
MaterialTargetSchema.enum.slab,
|
||||
]
|
||||
|
||||
const WALL_AND_SLAB_TARGETS: MaterialTarget[] = [
|
||||
MaterialTargetSchema.enum.wall,
|
||||
MaterialTargetSchema.enum.slab,
|
||||
]
|
||||
|
||||
const STAIR_TARGETS: MaterialTarget[] = [
|
||||
MaterialTargetSchema.enum.stair,
|
||||
MaterialTargetSchema.enum['stair-segment'],
|
||||
]
|
||||
|
||||
const STAIR_AND_FENCE_TARGETS: MaterialTarget[] = [
|
||||
...STAIR_TARGETS,
|
||||
MaterialTargetSchema.enum.fence,
|
||||
]
|
||||
|
||||
const ROOF_TARGETS: MaterialTarget[] = [
|
||||
MaterialTargetSchema.enum.roof,
|
||||
MaterialTargetSchema.enum['roof-segment'],
|
||||
]
|
||||
|
||||
const CEILING_TARGETS: MaterialTarget[] = [
|
||||
MaterialTargetSchema.enum.ceiling,
|
||||
]
|
||||
export const MATERIAL_CATEGORIES = [
|
||||
'wood',
|
||||
'wallpaper',
|
||||
'parquet',
|
||||
'granite',
|
||||
'marble',
|
||||
'other',
|
||||
] as const
|
||||
export type MaterialCategory = (typeof MATERIAL_CATEGORIES)[number]
|
||||
|
||||
export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
|
||||
{
|
||||
id: 'wall-wood1',
|
||||
label: 'Wood',
|
||||
category: 'wood',
|
||||
description: 'Warm wood finish',
|
||||
targets: [...WALL_TARGETS, ...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS],
|
||||
previewThumbnailUrl: '/material/wood1/wood1_thumbnail.webp',
|
||||
preset: {
|
||||
maps: {
|
||||
@@ -85,8 +61,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
|
||||
{
|
||||
id: 'wall-wood2',
|
||||
label: 'Wood',
|
||||
category: 'wood',
|
||||
description: 'Textured wood finish',
|
||||
targets: [...WALL_TARGETS, ...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS],
|
||||
previewThumbnailUrl: '/material/wood2/wood2_thumbnail.webp',
|
||||
preset: {
|
||||
maps: {
|
||||
@@ -121,8 +97,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
|
||||
{
|
||||
id: 'wall-wood3',
|
||||
label: 'Wood',
|
||||
category: 'wood',
|
||||
description: 'Knotted timber finish',
|
||||
targets: [...WALL_TARGETS, ...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS],
|
||||
previewThumbnailUrl: '/material/wood3/wood3_thumbnail.webp',
|
||||
preset: {
|
||||
maps: {
|
||||
@@ -155,8 +131,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
|
||||
{
|
||||
id: 'wall-wood4',
|
||||
label: 'Wood',
|
||||
category: 'wood',
|
||||
description: 'Oak stretcher finish',
|
||||
targets: [...WALL_TARGETS, ...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS],
|
||||
previewThumbnailUrl: '/material/wood4/wood4_thumbnail.webp',
|
||||
preset: {
|
||||
maps: {
|
||||
@@ -189,8 +165,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
|
||||
{
|
||||
id: 'wall-wood5',
|
||||
label: 'Wood',
|
||||
category: 'wood',
|
||||
description: 'Rich grain wood finish',
|
||||
targets: [...WALL_TARGETS, ...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS],
|
||||
previewThumbnailUrl: '/material/wood5/wood5_thumnail.webp',
|
||||
preset: {
|
||||
maps: {
|
||||
@@ -225,8 +201,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
|
||||
{
|
||||
id: 'wall-granite1',
|
||||
label: 'Granite',
|
||||
category: 'granite',
|
||||
description: 'Polished granite finish',
|
||||
targets: SLAB_TARGETS,
|
||||
previewThumbnailUrl: '/material/granite1/granite_thumbnail.webp',
|
||||
preset: {
|
||||
maps: {
|
||||
@@ -259,8 +235,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
|
||||
{
|
||||
id: 'wall-marble1',
|
||||
label: 'Marble',
|
||||
category: 'marble',
|
||||
description: 'Smooth marble finish',
|
||||
targets: [...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS],
|
||||
previewThumbnailUrl: '/material/marble1/marble1_thumbnail.webp',
|
||||
preset: {
|
||||
maps: {
|
||||
@@ -293,8 +269,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
|
||||
{
|
||||
id: 'wall-marble2',
|
||||
label: 'Marble',
|
||||
category: 'marble',
|
||||
description: 'Soft marble finish',
|
||||
targets: [...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS],
|
||||
previewThumbnailUrl: '/material/marble2/marble2_thumbnail.webp',
|
||||
preset: {
|
||||
maps: {
|
||||
@@ -327,8 +303,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
|
||||
{
|
||||
id: 'wall-parquet1',
|
||||
label: 'Parquet',
|
||||
category: 'parquet',
|
||||
description: 'Parquet wood finish',
|
||||
targets: SLAB_TARGETS,
|
||||
previewThumbnailUrl: '/material/parquet1/parquet_thumnail.webp',
|
||||
preset: {
|
||||
maps: {
|
||||
@@ -361,8 +337,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
|
||||
{
|
||||
id: 'wall-parquet2',
|
||||
label: 'Parquet',
|
||||
category: 'parquet',
|
||||
description: 'Soft parquet finish',
|
||||
targets: SLAB_TARGETS,
|
||||
previewThumbnailUrl: '/material/parquet2/parquet2_thumbnail.webp',
|
||||
preset: {
|
||||
maps: {
|
||||
@@ -395,8 +371,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
|
||||
{
|
||||
id: 'wall-wallpaper1',
|
||||
label: 'Wallpaper',
|
||||
category: 'wallpaper',
|
||||
description: 'Soft wallpaper finish',
|
||||
targets: WALL_TARGETS,
|
||||
previewThumbnailUrl: '/material/wallpaper1/wallpaper1_thumbnail.webp',
|
||||
preset: {
|
||||
maps: {
|
||||
@@ -430,8 +406,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
|
||||
{
|
||||
id: 'wall-wallpaper2',
|
||||
label: 'Wallpaper',
|
||||
category: 'wallpaper',
|
||||
description: 'Decorative wallpaper finish',
|
||||
targets: WALL_TARGETS,
|
||||
previewThumbnailUrl: '/material/wallpaper2/wallpaper2_thumnail.webp',
|
||||
preset: {
|
||||
maps: {
|
||||
@@ -464,8 +440,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
|
||||
{
|
||||
id: 'wall-wallpaper3',
|
||||
label: 'Wallpaper',
|
||||
category: 'wallpaper',
|
||||
description: 'Patterned wallpaper finish',
|
||||
targets: WALL_TARGETS,
|
||||
previewThumbnailUrl: '/material/wallpaper3/wallpaper3_thumbnail.webp',
|
||||
preset: {
|
||||
maps: {
|
||||
@@ -498,14 +474,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
|
||||
{
|
||||
id: 'preset-white',
|
||||
label: 'White',
|
||||
category: 'other',
|
||||
description: 'Clean painted finish',
|
||||
targets: [
|
||||
...WALL_TARGETS,
|
||||
...SLAB_TARGETS,
|
||||
...ROOF_TARGETS,
|
||||
...STAIR_AND_FENCE_TARGETS,
|
||||
...CEILING_TARGETS,
|
||||
],
|
||||
previewColor: '#ffffff',
|
||||
preset: {
|
||||
maps: {},
|
||||
@@ -536,8 +506,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
|
||||
{
|
||||
id: 'preset-metal',
|
||||
label: 'Metal',
|
||||
category: 'other',
|
||||
description: 'Brushed metal finish',
|
||||
targets: [...WALL_TARGETS, ...SLAB_TARGETS],
|
||||
previewColor: '#c0c0c0',
|
||||
preset: {
|
||||
maps: {},
|
||||
@@ -568,8 +538,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
|
||||
{
|
||||
id: 'preset-glass',
|
||||
label: 'Glass',
|
||||
category: 'other',
|
||||
description: 'Light glass finish',
|
||||
targets: [...WALL_TARGETS, ...SLAB_TARGETS],
|
||||
previewColor: '#87ceeb',
|
||||
preset: {
|
||||
maps: {},
|
||||
@@ -599,8 +569,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
|
||||
},
|
||||
]
|
||||
|
||||
export function getMaterialsForTarget(target: MaterialTarget): MaterialCatalogItem[] {
|
||||
return MATERIAL_CATALOG.filter((item) => item.targets.includes(target))
|
||||
export function getMaterialsForCategory(category: MaterialCategory): MaterialCatalogItem[] {
|
||||
return MATERIAL_CATALOG.filter((item) => item.category === category)
|
||||
}
|
||||
|
||||
export function getCatalogMaterialById(id?: string): MaterialCatalogItem | undefined {
|
||||
|
||||
@@ -43,22 +43,30 @@ export type {
|
||||
} from './nodes/item'
|
||||
export { getScaledDimensions, ItemNode } from './nodes/item'
|
||||
export { LevelNode } from './nodes/level'
|
||||
export { RoofNode } from './nodes/roof'
|
||||
export { getEffectiveRoofSurfaceMaterial, RoofNode } from './nodes/roof'
|
||||
export type { RoofSurfaceMaterialRole, RoofSurfaceMaterialSpec } from './nodes/roof'
|
||||
export { RoofSegmentNode, RoofType } from './nodes/roof-segment'
|
||||
export { ScanNode } from './nodes/scan'
|
||||
// Nodes
|
||||
export { SiteNode } from './nodes/site'
|
||||
export { SlabNode } from './nodes/slab'
|
||||
export {
|
||||
getEffectiveStairSurfaceMaterial,
|
||||
StairNode,
|
||||
StairRailingMode,
|
||||
StairSlabOpeningMode,
|
||||
StairTopLandingMode,
|
||||
StairType,
|
||||
} from './nodes/stair'
|
||||
export type { StairSurfaceMaterialRole, StairSurfaceMaterialSpec } from './nodes/stair'
|
||||
export { AttachmentSide, StairSegmentNode, StairSegmentType } from './nodes/stair-segment'
|
||||
export { SurfaceHoleMetadata } from './nodes/surface-hole-metadata'
|
||||
export { WallNode } from './nodes/wall'
|
||||
export type { WallSurfaceMaterialSpec, WallSurfaceSide } from './nodes/wall'
|
||||
export {
|
||||
getEffectiveWallSurfaceMaterial,
|
||||
getWallSurfaceMaterialSignature,
|
||||
WallNode,
|
||||
} from './nodes/wall'
|
||||
export { WindowNode } from './nodes/window'
|
||||
export { ZoneNode } from './nodes/zone'
|
||||
export type { AnyNodeId, AnyNodeType } from './types'
|
||||
|
||||
@@ -13,6 +13,7 @@ export const FenceNode = BaseNode.extend({
|
||||
materialPreset: z.string().optional(),
|
||||
start: z.tuple([z.number(), z.number()]),
|
||||
end: z.tuple([z.number(), z.number()]),
|
||||
curveOffset: z.number().optional(),
|
||||
height: z.number().default(1.8),
|
||||
thickness: z.number().default(0.08),
|
||||
baseHeight: z.number().default(0.22),
|
||||
@@ -28,6 +29,7 @@ export const FenceNode = BaseNode.extend({
|
||||
dedent`
|
||||
Fence node - used to represent a fence segment in the building/site level coordinate system
|
||||
- start/end: fence endpoints in level coordinate system
|
||||
- curveOffset: midpoint sagitta offset used to bend the fence into an arc
|
||||
- height/thickness: overall fence dimensions in meters
|
||||
- baseHeight/postSpacing/postSize/topRailHeight: exact geometric controls from the plan3D fence model
|
||||
- groundClearance/edgeInset/baseStyle: fence support and inset configuration
|
||||
|
||||
@@ -1,14 +1,26 @@
|
||||
import dedent from 'dedent'
|
||||
import { z } from 'zod'
|
||||
import { BaseNode, nodeType, objectId } from '../base'
|
||||
import { MaterialSchema } from '../material'
|
||||
import { type MaterialSchema, MaterialSchema as MaterialSchemaSchema } from '../material'
|
||||
import { RoofSegmentNode } from './roof-segment'
|
||||
|
||||
export type RoofSurfaceMaterialRole = 'top' | 'edge' | 'wall'
|
||||
export type RoofSurfaceMaterialSpec = {
|
||||
material?: MaterialSchema
|
||||
materialPreset?: string
|
||||
}
|
||||
|
||||
export const RoofNode = BaseNode.extend({
|
||||
id: objectId('roof'),
|
||||
type: nodeType('roof'),
|
||||
material: MaterialSchema.optional(),
|
||||
material: MaterialSchemaSchema.optional(),
|
||||
materialPreset: z.string().optional(),
|
||||
topMaterial: MaterialSchemaSchema.optional(),
|
||||
topMaterialPreset: z.string().optional(),
|
||||
edgeMaterial: MaterialSchemaSchema.optional(),
|
||||
edgeMaterialPreset: z.string().optional(),
|
||||
wallMaterial: MaterialSchemaSchema.optional(),
|
||||
wallMaterialPreset: z.string().optional(),
|
||||
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||
// Rotation around Y axis in radians
|
||||
rotation: z.number().default(0),
|
||||
@@ -26,3 +38,66 @@ export const RoofNode = BaseNode.extend({
|
||||
)
|
||||
|
||||
export type RoofNode = z.infer<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 { z } from 'zod'
|
||||
import { BaseNode, nodeType, objectId } from '../base'
|
||||
import { MaterialSchema } from '../material'
|
||||
import { type MaterialSchema, MaterialSchema as MaterialSchemaSchema } from '../material'
|
||||
import { StairSegmentNode } from './stair-segment'
|
||||
|
||||
export const StairRailingMode = z.enum(['none', 'left', 'right', 'both'])
|
||||
@@ -13,12 +13,23 @@ export type StairRailingMode = z.infer<typeof StairRailingMode>
|
||||
export type StairType = z.infer<typeof StairType>
|
||||
export type StairTopLandingMode = z.infer<typeof StairTopLandingMode>
|
||||
export type StairSlabOpeningMode = z.infer<typeof StairSlabOpeningMode>
|
||||
export type StairSurfaceMaterialRole = 'railing' | 'tread' | 'side'
|
||||
export type StairSurfaceMaterialSpec = {
|
||||
material?: MaterialSchema
|
||||
materialPreset?: string
|
||||
}
|
||||
|
||||
export const StairNode = BaseNode.extend({
|
||||
id: objectId('stair'),
|
||||
type: nodeType('stair'),
|
||||
material: MaterialSchema.optional(),
|
||||
material: MaterialSchemaSchema.optional(),
|
||||
materialPreset: z.string().optional(),
|
||||
railingMaterial: MaterialSchemaSchema.optional(),
|
||||
railingMaterialPreset: z.string().optional(),
|
||||
treadMaterial: MaterialSchemaSchema.optional(),
|
||||
treadMaterialPreset: z.string().optional(),
|
||||
sideMaterial: MaterialSchemaSchema.optional(),
|
||||
sideMaterialPreset: z.string().optional(),
|
||||
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||
// Rotation around Y axis in radians
|
||||
rotation: z.number().default(0),
|
||||
@@ -71,3 +82,74 @@ export const StairNode = BaseNode.extend({
|
||||
)
|
||||
|
||||
export type StairNode = z.infer<typeof StairNode>
|
||||
|
||||
function getLegacyStairSurfaceMaterial(node: StairNode): StairSurfaceMaterialSpec {
|
||||
return {
|
||||
material: node.material,
|
||||
materialPreset: node.materialPreset,
|
||||
}
|
||||
}
|
||||
|
||||
export function getEffectiveStairSurfaceMaterial(
|
||||
node: StairNode,
|
||||
role: StairSurfaceMaterialRole,
|
||||
): StairSurfaceMaterialSpec {
|
||||
if (role === 'railing') {
|
||||
if (node.railingMaterial !== undefined || typeof node.railingMaterialPreset === 'string') {
|
||||
return {
|
||||
material: node.railingMaterial,
|
||||
materialPreset:
|
||||
typeof node.railingMaterialPreset === 'string' ? node.railingMaterialPreset : undefined,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (role === 'tread') {
|
||||
if (node.treadMaterial !== undefined || typeof node.treadMaterialPreset === 'string') {
|
||||
return {
|
||||
material: node.treadMaterial,
|
||||
materialPreset:
|
||||
typeof node.treadMaterialPreset === 'string' ? node.treadMaterialPreset : undefined,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (role === 'side') {
|
||||
if (node.sideMaterial !== undefined || typeof node.sideMaterialPreset === 'string') {
|
||||
return {
|
||||
material: node.sideMaterial,
|
||||
materialPreset:
|
||||
typeof node.sideMaterialPreset === 'string' ? node.sideMaterialPreset : undefined,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const treadFallback = {
|
||||
material: node.treadMaterial,
|
||||
materialPreset: typeof node.treadMaterialPreset === 'string' ? node.treadMaterialPreset : undefined,
|
||||
}
|
||||
const sideFallback = {
|
||||
material: node.sideMaterial,
|
||||
materialPreset: typeof node.sideMaterialPreset === 'string' ? node.sideMaterialPreset : undefined,
|
||||
}
|
||||
|
||||
if (role === 'tread' && (sideFallback.material !== undefined || sideFallback.materialPreset !== undefined)) {
|
||||
return sideFallback
|
||||
}
|
||||
|
||||
if (role === 'side' && (treadFallback.material !== undefined || treadFallback.materialPreset !== undefined)) {
|
||||
return treadFallback
|
||||
}
|
||||
|
||||
if (role === 'railing') {
|
||||
if (treadFallback.material !== undefined || treadFallback.materialPreset !== undefined) {
|
||||
return treadFallback
|
||||
}
|
||||
|
||||
if (sideFallback.material !== undefined || sideFallback.materialPreset !== undefined) {
|
||||
return sideFallback
|
||||
}
|
||||
}
|
||||
|
||||
return getLegacyStairSurfaceMaterial(node)
|
||||
}
|
||||
|
||||
@@ -12,8 +12,14 @@ export const WallNode = BaseNode.extend({
|
||||
children: z
|
||||
.array(z.union([ItemNode.shape.id, DoorNode.shape.id, WindowNode.shape.id]))
|
||||
.default([]),
|
||||
// Legacy single-material wall finish. Read for backward compatibility only.
|
||||
material: MaterialSchema.optional(),
|
||||
// Legacy single-material wall finish preset. Read for backward compatibility only.
|
||||
materialPreset: z.string().optional(),
|
||||
interiorMaterial: MaterialSchema.optional(),
|
||||
interiorMaterialPreset: z.string().optional(),
|
||||
exteriorMaterial: MaterialSchema.optional(),
|
||||
exteriorMaterialPreset: z.string().optional(),
|
||||
thickness: z.number().optional(),
|
||||
height: z.number().optional(),
|
||||
curveOffset: z.number().optional(),
|
||||
@@ -37,3 +43,62 @@ export const WallNode = BaseNode.extend({
|
||||
`,
|
||||
)
|
||||
export type WallNode = z.infer<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 { SceneState } from '../use-scene'
|
||||
|
||||
@@ -17,11 +23,7 @@ type WallMergePlan = {
|
||||
let pendingRafId: number | null = null
|
||||
let pendingUpdates: Set<AnyNodeId> = new Set()
|
||||
|
||||
function pointsEqual(
|
||||
a: [number, number],
|
||||
b: [number, number],
|
||||
tolerance = 1e-6,
|
||||
) {
|
||||
function pointsEqual(a: [number, number], b: [number, number], tolerance = 1e-6) {
|
||||
const dx = a[0] - b[0]
|
||||
const dz = a[1] - b[1]
|
||||
return dx * dx + dz * dz <= tolerance * tolerance
|
||||
@@ -40,32 +42,30 @@ function getWallEndpointAtPoint(
|
||||
return null
|
||||
}
|
||||
|
||||
function getWallFreeEndpoint(
|
||||
wall: Pick<WallNode, 'start' | 'end'>,
|
||||
sharedPoint: [number, number],
|
||||
) {
|
||||
function getWallFreeEndpoint(wall: Pick<WallNode, 'start' | 'end'>, sharedPoint: [number, number]) {
|
||||
return pointsEqual(wall.start, sharedPoint) ? wall.end : wall.start
|
||||
}
|
||||
|
||||
function areWallStylesCompatible(a: WallNode, b: WallNode) {
|
||||
const aInterior = getWallSurfaceMaterialSignature(getEffectiveWallSurfaceMaterial(a, 'interior'))
|
||||
const bInterior = getWallSurfaceMaterialSignature(getEffectiveWallSurfaceMaterial(b, 'interior'))
|
||||
const aExterior = getWallSurfaceMaterialSignature(getEffectiveWallSurfaceMaterial(a, 'exterior'))
|
||||
const bExterior = getWallSurfaceMaterialSignature(getEffectiveWallSurfaceMaterial(b, 'exterior'))
|
||||
|
||||
return (
|
||||
(a.parentId ?? null) === (b.parentId ?? null) &&
|
||||
Math.abs((a.curveOffset ?? 0) - (b.curveOffset ?? 0)) <= 1e-6 &&
|
||||
Math.abs((a.thickness ?? 0.2) - (b.thickness ?? 0.2)) <= 1e-6 &&
|
||||
Math.abs((a.height ?? 2.5) - (b.height ?? 2.5)) <= 1e-6 &&
|
||||
a.materialPreset === b.materialPreset &&
|
||||
JSON.stringify(a.material ?? null) === JSON.stringify(b.material ?? null) &&
|
||||
aInterior === bInterior &&
|
||||
aExterior === bExterior &&
|
||||
a.frontSide === b.frontSide &&
|
||||
a.backSide === b.backSide &&
|
||||
a.visible === b.visible
|
||||
)
|
||||
}
|
||||
|
||||
function areWallsCollinearAcrossPoint(
|
||||
a: WallNode,
|
||||
b: WallNode,
|
||||
sharedPoint: [number, number],
|
||||
) {
|
||||
function areWallsCollinearAcrossPoint(a: WallNode, b: WallNode, sharedPoint: [number, number]) {
|
||||
const freeA = getWallFreeEndpoint(a, sharedPoint)
|
||||
const freeB = getWallFreeEndpoint(b, sharedPoint)
|
||||
const ax = freeA[0] - sharedPoint[0]
|
||||
@@ -111,7 +111,10 @@ function buildMergedWallAttachmentUpdates(
|
||||
mergedEnd: [number, number],
|
||||
nodes: Record<AnyNodeId, AnyNode>,
|
||||
): WallAttachmentUpdate[] {
|
||||
const mergedLength = Math.max(Math.hypot(mergedEnd[0] - mergedStart[0], mergedEnd[1] - mergedStart[1]), 1e-6)
|
||||
const mergedLength = Math.max(
|
||||
Math.hypot(mergedEnd[0] - mergedStart[0], mergedEnd[1] - mergedStart[1]),
|
||||
1e-6,
|
||||
)
|
||||
const tangentX = (mergedEnd[0] - mergedStart[0]) / mergedLength
|
||||
const tangentZ = (mergedEnd[1] - mergedStart[1]) / mergedLength
|
||||
const updates: WallAttachmentUpdate[] = []
|
||||
@@ -126,11 +129,16 @@ function buildMergedWallAttachmentUpdates(
|
||||
const sourceWall = child.parentId === secondary.id ? secondary : primary
|
||||
const sourceLength = Math.max(wallLength(sourceWall), 1e-6)
|
||||
const localX = typeof child.position[0] === 'number' ? child.position[0] : 0
|
||||
const worldX = sourceWall.start[0] + ((sourceWall.end[0] - sourceWall.start[0]) * localX) / sourceLength
|
||||
const worldZ = sourceWall.start[1] + ((sourceWall.end[1] - sourceWall.start[1]) * localX) / sourceLength
|
||||
const worldX =
|
||||
sourceWall.start[0] + ((sourceWall.end[0] - sourceWall.start[0]) * localX) / sourceLength
|
||||
const worldZ =
|
||||
sourceWall.start[1] + ((sourceWall.end[1] - sourceWall.start[1]) * localX) / sourceLength
|
||||
const nextLocalX = Math.max(
|
||||
0,
|
||||
Math.min(mergedLength, (worldX - mergedStart[0]) * tangentX + (worldZ - mergedStart[1]) * tangentZ),
|
||||
Math.min(
|
||||
mergedLength,
|
||||
(worldX - mergedStart[0]) * tangentX + (worldZ - mergedStart[1]) * tangentZ,
|
||||
),
|
||||
)
|
||||
|
||||
updates.push({
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
let sceneHistoryPauseDepth = 0
|
||||
|
||||
type TemporalStoreLike = {
|
||||
temporal: {
|
||||
getState(): {
|
||||
pause(): void
|
||||
resume(): void
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function pauseSceneHistory(sceneStore: TemporalStoreLike): void {
|
||||
if (sceneHistoryPauseDepth === 0) {
|
||||
sceneStore.temporal.getState().pause()
|
||||
}
|
||||
sceneHistoryPauseDepth += 1
|
||||
}
|
||||
|
||||
export function resumeSceneHistory(sceneStore: TemporalStoreLike): void {
|
||||
if (sceneHistoryPauseDepth === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
sceneHistoryPauseDepth -= 1
|
||||
if (sceneHistoryPauseDepth === 0) {
|
||||
sceneStore.temporal.getState().resume()
|
||||
}
|
||||
}
|
||||
|
||||
export function getSceneHistoryPauseDepth(): number {
|
||||
return sceneHistoryPauseDepth
|
||||
}
|
||||
|
||||
export function resetSceneHistoryPauseDepth(): void {
|
||||
sceneHistoryPauseDepth = 0
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { SiteNode } from '../schema/nodes/site'
|
||||
import { StairNode as StairNodeSchema } from '../schema/nodes/stair'
|
||||
import { StairSegmentNode as StairSegmentNodeSchema } from '../schema/nodes/stair-segment'
|
||||
import type { AnyNode, AnyNodeId } from '../schema/types'
|
||||
import { resetSceneHistoryPauseDepth } from './history-control'
|
||||
import * as nodeActions from './actions/node-actions'
|
||||
|
||||
function getFiniteNumber(value: unknown, fallback: number) {
|
||||
@@ -100,6 +101,189 @@ function normalizeStairSegmentNode(node: Record<string, unknown>) {
|
||||
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> {
|
||||
const patchedNodes = { ...nodes }
|
||||
for (const [id, node] of Object.entries(patchedNodes)) {
|
||||
@@ -141,7 +325,7 @@ function migrateNodes(nodes: Record<string, any>): Record<string, AnyNode> {
|
||||
}
|
||||
|
||||
if (node.type === 'stair') {
|
||||
const normalized = normalizeStairNode(node)
|
||||
const normalized = normalizeStairNode(migrateStairSurfaceMaterials(node))
|
||||
if (normalized) {
|
||||
patchedNodes[id] = normalized
|
||||
}
|
||||
@@ -153,6 +337,14 @@ function migrateNodes(nodes: Record<string, any>): Record<string, AnyNode> {
|
||||
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>
|
||||
}
|
||||
@@ -439,6 +631,7 @@ let prevNodesSnapshot: Record<AnyNodeId, AnyNode> | null = null
|
||||
|
||||
export function clearSceneHistory() {
|
||||
useScene.temporal.getState().clear()
|
||||
resetSceneHistoryPauseDepth()
|
||||
prevPastLength = 0
|
||||
prevFutureLength = 0
|
||||
prevNodesSnapshot = null
|
||||
@@ -458,8 +651,9 @@ useScene.temporal.subscribe((state) => {
|
||||
// Capture the previous snapshot before RAF fires
|
||||
const snapshotBefore = prevNodesSnapshot
|
||||
|
||||
// Use RAF to ensure all middleware and store updates are complete
|
||||
requestAnimationFrame(() => {
|
||||
// Defer to a microtask so the scene store has settled before we diff,
|
||||
// but still mark walls/items dirty before the next paint.
|
||||
queueMicrotask(() => {
|
||||
const currentNodes = useScene.getState().nodes
|
||||
const { markDirty } = useScene.getState()
|
||||
|
||||
|
||||
@@ -4,12 +4,90 @@ import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js
|
||||
import { sceneRegistry } from '../../hooks/scene-registry/scene-registry'
|
||||
import type { AnyNodeId, FenceNode } from '../../schema'
|
||||
import useScene from '../../store/use-scene'
|
||||
import { getWallCurveFrameAt, getWallCurveLength } from '../wall/wall-curve'
|
||||
|
||||
type FencePart = {
|
||||
position: [number, number, number]
|
||||
rotationY?: number
|
||||
scale: [number, number, number]
|
||||
}
|
||||
|
||||
const MIN_CURVE_SEGMENT_LENGTH = 0.18
|
||||
|
||||
function createFencePartGeometry(part: FencePart) {
|
||||
const geometry = new THREE.BoxGeometry(1, 1, 1)
|
||||
geometry.scale(part.scale[0], part.scale[1], part.scale[2])
|
||||
if (part.rotationY) {
|
||||
geometry.rotateY(part.rotationY)
|
||||
}
|
||||
geometry.translate(part.position[0], part.position[1], part.position[2])
|
||||
applyFenceUVs(geometry)
|
||||
return geometry
|
||||
}
|
||||
|
||||
function getFencePointAt(fence: FenceNode, t: number) {
|
||||
const frame = getWallCurveFrameAt(fence, t)
|
||||
return {
|
||||
point: frame.point,
|
||||
tangentAngle: Math.atan2(frame.tangent.y, frame.tangent.x),
|
||||
}
|
||||
}
|
||||
|
||||
function createStraightFenceSpanPart(
|
||||
start: [number, number],
|
||||
end: [number, number],
|
||||
centerY: number,
|
||||
height: number,
|
||||
depth: number,
|
||||
): FencePart | null {
|
||||
const dx = end[0] - start[0]
|
||||
const dz = end[1] - start[1]
|
||||
const length = Math.hypot(dx, dz)
|
||||
if (length <= 1e-4) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
position: [(start[0] + end[0]) / 2, centerY, (start[1] + end[1]) / 2],
|
||||
rotationY: -Math.atan2(dz, dx),
|
||||
scale: [length, height, depth],
|
||||
}
|
||||
}
|
||||
|
||||
function createFenceCurveSpanParts(
|
||||
fence: FenceNode,
|
||||
startT: number,
|
||||
endT: number,
|
||||
centerY: number,
|
||||
height: number,
|
||||
depth: number,
|
||||
): FencePart[] {
|
||||
const parts: FencePart[] = []
|
||||
const frameCount = Math.max(
|
||||
1,
|
||||
Math.ceil((getWallCurveLength(fence) * Math.max(1e-4, endT - startT)) / MIN_CURVE_SEGMENT_LENGTH),
|
||||
)
|
||||
|
||||
let previous = getFencePointAt(fence, startT)
|
||||
for (let index = 1; index <= frameCount; index += 1) {
|
||||
const t = startT + (endT - startT) * (index / frameCount)
|
||||
const current = getFencePointAt(fence, t)
|
||||
const segment = createStraightFenceSpanPart(
|
||||
[previous.point.x, previous.point.y],
|
||||
[current.point.x, current.point.y],
|
||||
centerY,
|
||||
height,
|
||||
depth,
|
||||
)
|
||||
if (segment) {
|
||||
parts.push(segment)
|
||||
}
|
||||
previous = current
|
||||
}
|
||||
|
||||
return parts
|
||||
}
|
||||
|
||||
function applyFenceUVs(geometry: THREE.BufferGeometry) {
|
||||
const position = geometry.getAttribute('position')
|
||||
const normal = geometry.getAttribute('normal')
|
||||
@@ -20,26 +98,13 @@ function applyFenceUVs(geometry: THREE.BufferGeometry) {
|
||||
let minX = Number.POSITIVE_INFINITY
|
||||
let minY = Number.POSITIVE_INFINITY
|
||||
let minZ = Number.POSITIVE_INFINITY
|
||||
let maxX = Number.NEGATIVE_INFINITY
|
||||
let maxY = Number.NEGATIVE_INFINITY
|
||||
let maxZ = Number.NEGATIVE_INFINITY
|
||||
|
||||
for (let index = 0; index < position.count; index += 1) {
|
||||
const px = position.getX(index)
|
||||
const py = position.getY(index)
|
||||
const pz = position.getZ(index)
|
||||
minX = Math.min(minX, px)
|
||||
minY = Math.min(minY, py)
|
||||
minZ = Math.min(minZ, pz)
|
||||
maxX = Math.max(maxX, px)
|
||||
maxY = Math.max(maxY, py)
|
||||
maxZ = Math.max(maxZ, pz)
|
||||
minX = Math.min(minX, position.getX(index))
|
||||
minY = Math.min(minY, position.getY(index))
|
||||
minZ = Math.min(minZ, position.getZ(index))
|
||||
}
|
||||
|
||||
const width = Math.max(maxX - minX, 0.001)
|
||||
const height = Math.max(maxY - minY, 0.001)
|
||||
const depth = Math.max(maxZ - minZ, 0.001)
|
||||
|
||||
for (let index = 0; index < position.count; index += 1) {
|
||||
const px = position.getX(index)
|
||||
const py = position.getY(index)
|
||||
@@ -52,14 +117,14 @@ function applyFenceUVs(geometry: THREE.BufferGeometry) {
|
||||
let v = 0
|
||||
|
||||
if (ny >= nx && ny >= nz) {
|
||||
u = (px - minX) / width
|
||||
v = (pz - minZ) / depth
|
||||
u = px - minX
|
||||
v = pz - minZ
|
||||
} else if (nx >= nz) {
|
||||
u = (pz - minZ) / depth
|
||||
v = (py - minY) / height
|
||||
u = pz - minZ
|
||||
v = py - minY
|
||||
} else {
|
||||
u = (px - minX) / width
|
||||
v = (py - minY) / height
|
||||
u = px - minX
|
||||
v = py - minY
|
||||
}
|
||||
|
||||
uvs[index * 2] = u
|
||||
@@ -84,10 +149,7 @@ function getStyleDefaults(style: FenceNode['style']) {
|
||||
|
||||
function createFenceParts(fence: FenceNode): FencePart[] {
|
||||
const parts: FencePart[] = []
|
||||
const length = Math.max(
|
||||
Math.hypot(fence.end[0] - fence.start[0], fence.end[1] - fence.start[1]),
|
||||
0.01,
|
||||
)
|
||||
const length = Math.max(getWallCurveLength(fence), 0.01)
|
||||
const panelDepth = Math.max(fence.thickness, 0.03)
|
||||
const clearance = Math.max(fence.groundClearance, 0)
|
||||
const styleDefaults = getStyleDefaults(fence.style)
|
||||
@@ -100,31 +162,39 @@ function createFenceParts(fence: FenceNode): FencePart[] {
|
||||
const isFloating = fence.baseStyle === 'floating'
|
||||
const baseY = isFloating ? clearance : 0
|
||||
const effectiveBaseHeight = baseHeight
|
||||
const startInsetT = Math.min(0.499, edgeInset / length)
|
||||
const endInsetT = Math.max(0.501, 1 - edgeInset / length)
|
||||
|
||||
if (!isFloating) {
|
||||
parts.push({
|
||||
position: [0, baseY + effectiveBaseHeight / 2, 0],
|
||||
scale: [length, effectiveBaseHeight, panelDepth * 1.05],
|
||||
})
|
||||
parts.push({
|
||||
position: [0, baseY + effectiveBaseHeight + verticalHeight * 0.15, 0],
|
||||
scale: [length, topRailHeight * 0.8, panelDepth * 0.35],
|
||||
})
|
||||
parts.push(
|
||||
...createFenceCurveSpanParts(
|
||||
fence,
|
||||
0,
|
||||
1,
|
||||
baseY + effectiveBaseHeight / 2,
|
||||
effectiveBaseHeight,
|
||||
panelDepth * 1.05,
|
||||
),
|
||||
)
|
||||
parts.push(
|
||||
...createFenceCurveSpanParts(
|
||||
fence,
|
||||
0,
|
||||
1,
|
||||
baseY + effectiveBaseHeight + verticalHeight * 0.15,
|
||||
topRailHeight * 0.8,
|
||||
panelDepth * 0.35,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const count = Math.max(2, Math.floor((length - edgeInset * 2) / spacing) + 1)
|
||||
const step = count > 1 ? (length - edgeInset * 2) / (count - 1) : 0
|
||||
const startX = -length / 2 + edgeInset
|
||||
const verticalY = baseY + effectiveBaseHeight + verticalHeight / 2
|
||||
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const x = count === 1 ? 0 : startX + step * index
|
||||
let posX = x
|
||||
const t = count === 1 ? 0.5 : startInsetT + (endInsetT - startInsetT) * (index / (count - 1))
|
||||
const frame = getFencePointAt(fence, t)
|
||||
const isEdgePost = index === 0 || index === count - 1
|
||||
if (count > 1) {
|
||||
if (index === 0) posX = -length / 2 + edgeInset + postWidth / 2
|
||||
else if (index === count - 1) posX = length / 2 - edgeInset - postWidth / 2
|
||||
}
|
||||
const postHeight =
|
||||
isFloating && isEdgePost
|
||||
? effectiveBaseHeight + verticalHeight + topRailHeight + clearance
|
||||
@@ -132,21 +202,34 @@ function createFenceParts(fence: FenceNode): FencePart[] {
|
||||
const postY = isFloating && isEdgePost ? postHeight / 2 : verticalY
|
||||
|
||||
parts.push({
|
||||
position: [posX, postY, 0],
|
||||
position: [frame.point.x, postY, frame.point.y],
|
||||
rotationY: -frame.tangentAngle,
|
||||
scale: [postWidth, postHeight, Math.max(panelDepth * 0.35, 0.012)],
|
||||
})
|
||||
}
|
||||
|
||||
parts.push({
|
||||
position: [0, baseY + effectiveBaseHeight + verticalHeight + topRailHeight / 2, 0],
|
||||
scale: [length, topRailHeight, Math.max(panelDepth * 0.55, 0.018)],
|
||||
})
|
||||
parts.push(
|
||||
...createFenceCurveSpanParts(
|
||||
fence,
|
||||
0,
|
||||
1,
|
||||
baseY + effectiveBaseHeight + verticalHeight + topRailHeight / 2,
|
||||
topRailHeight,
|
||||
Math.max(panelDepth * 0.55, 0.018),
|
||||
),
|
||||
)
|
||||
|
||||
if (isFloating) {
|
||||
parts.push({
|
||||
position: [0, baseY + effectiveBaseHeight + topRailHeight / 2, 0],
|
||||
scale: [length, topRailHeight, Math.max(panelDepth * 0.55, 0.018)],
|
||||
})
|
||||
parts.push(
|
||||
...createFenceCurveSpanParts(
|
||||
fence,
|
||||
0,
|
||||
1,
|
||||
baseY + effectiveBaseHeight + topRailHeight / 2,
|
||||
topRailHeight,
|
||||
Math.max(panelDepth * 0.55, 0.018),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return parts
|
||||
@@ -154,16 +237,14 @@ function createFenceParts(fence: FenceNode): FencePart[] {
|
||||
|
||||
function generateFenceGeometry(fence: FenceNode) {
|
||||
const parts = createFenceParts(fence)
|
||||
const geometries = parts.map((part) => {
|
||||
const geometry = new THREE.BoxGeometry(1, 1, 1)
|
||||
geometry.scale(part.scale[0], part.scale[1], part.scale[2])
|
||||
geometry.translate(part.position[0], part.position[1], part.position[2])
|
||||
return geometry
|
||||
})
|
||||
const geometries = parts.map(createFencePartGeometry)
|
||||
|
||||
const merged = mergeGeometries(geometries, false) ?? new THREE.BufferGeometry()
|
||||
geometries.forEach((geometry) => geometry.dispose())
|
||||
applyFenceUVs(merged)
|
||||
const mergedUv = merged.getAttribute('uv')
|
||||
if (mergedUv) {
|
||||
merged.setAttribute('uv2', new THREE.Float32BufferAttribute(Array.from(mergedUv.array), 2))
|
||||
}
|
||||
merged.computeVertexNormals()
|
||||
return merged
|
||||
}
|
||||
@@ -178,12 +259,8 @@ function updateFenceGeometry(fenceId: FenceNode['id']) {
|
||||
const newGeometry = generateFenceGeometry(node)
|
||||
mesh.geometry.dispose()
|
||||
mesh.geometry = newGeometry
|
||||
|
||||
const centerX = (node.start[0] + node.end[0]) / 2
|
||||
const centerZ = (node.start[1] + node.end[1]) / 2
|
||||
const angle = Math.atan2(node.end[1] - node.start[1], node.end[0] - node.start[0])
|
||||
mesh.position.set(centerX, 0, centerZ)
|
||||
mesh.rotation.set(0, -angle, 0)
|
||||
mesh.position.set(0, 0, 0)
|
||||
mesh.rotation.set(0, 0, 0)
|
||||
}
|
||||
|
||||
export const FenceSystem = () => {
|
||||
|
||||
@@ -11,7 +11,7 @@ import useScene from '../../store/use-scene'
|
||||
const csgEvaluator = new Evaluator()
|
||||
csgEvaluator.useGroups = true
|
||||
;(csgEvaluator as any).consolidateGroups = false // shared dummyMats across brushes causes consolidation to misalign groupIndices vs groupOrder indices → crash
|
||||
csgEvaluator.attributes = ['position', 'normal']
|
||||
csgEvaluator.attributes = ['position', 'normal', 'uv']
|
||||
|
||||
function prepareBrushForCSG(brush: Brush) {
|
||||
brush.geometry.computeBoundsTree = computeBoundsTree
|
||||
@@ -25,6 +25,7 @@ const _position = new THREE.Vector3()
|
||||
const _quaternion = new THREE.Quaternion()
|
||||
const _scale = new THREE.Vector3(1, 1, 1)
|
||||
const _yAxis = new THREE.Vector3(0, 1, 0)
|
||||
const _uvFaceNormal = new THREE.Vector3()
|
||||
|
||||
// Pending merged-roof updates carried across frames (for throttling)
|
||||
const pendingRoofUpdates = new Set<AnyNodeId>()
|
||||
@@ -251,6 +252,7 @@ function updateMergedRoofGeometry(
|
||||
g.materialIndex = mapRoofGroupMaterialIndex(g.materialIndex, resultMaterials, matToIndex)
|
||||
}
|
||||
|
||||
ensureUv2Attribute(resultGeo)
|
||||
resultGeo.computeVertexNormals()
|
||||
mergedMesh.geometry.dispose()
|
||||
mergedMesh.geometry = resultGeo
|
||||
@@ -641,6 +643,7 @@ export function generateRoofSegmentGeometry(node: RoofSegmentNode): THREE.Buffer
|
||||
wallBrush.geometry.dispose()
|
||||
innerBrush.geometry.dispose()
|
||||
|
||||
ensureUv2Attribute(resultGeo)
|
||||
resultGeo.computeVertexNormals()
|
||||
return resultGeo
|
||||
}
|
||||
@@ -936,6 +939,7 @@ function createGeometryFromFaces(
|
||||
): THREE.BufferGeometry {
|
||||
const positions: number[] = []
|
||||
const normals: number[] = []
|
||||
const uvs: number[] = []
|
||||
const indices: number[] = []
|
||||
const groups: { start: number; count: number; materialIndex: number }[] = []
|
||||
let vertexCount = 0
|
||||
@@ -974,6 +978,10 @@ function createGeometryFromFaces(
|
||||
normals.push(normal.x, normal.y, normal.z)
|
||||
normals.push(normal.x, normal.y, normal.z)
|
||||
|
||||
pushRoofUv(uvs, p0, normal)
|
||||
pushRoofUv(uvs, fi, normal)
|
||||
pushRoofUv(uvs, fi1, normal)
|
||||
|
||||
indices.push(vertexCount, vertexCount + 1, vertexCount + 2)
|
||||
|
||||
faceVertexCount += 3
|
||||
@@ -990,6 +998,7 @@ function createGeometryFromFaces(
|
||||
const geometry = new THREE.BufferGeometry()
|
||||
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3))
|
||||
geometry.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3))
|
||||
geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2))
|
||||
geometry.setIndex(indices)
|
||||
|
||||
for (const g of groups) {
|
||||
@@ -999,6 +1008,34 @@ function createGeometryFromFaces(
|
||||
// Merge identical vertices to optimize geometry for CSG and create clean topology
|
||||
const mergedGeo = mergeVertices(geometry, 1e-4)
|
||||
geometry.dispose()
|
||||
ensureUv2Attribute(mergedGeo)
|
||||
|
||||
return mergedGeo
|
||||
}
|
||||
|
||||
function pushRoofUv(uvs: number[], point: THREE.Vector3, normal: THREE.Vector3) {
|
||||
_uvFaceNormal.copy(normal).normalize()
|
||||
|
||||
const absX = Math.abs(_uvFaceNormal.x)
|
||||
const absY = Math.abs(_uvFaceNormal.y)
|
||||
const absZ = Math.abs(_uvFaceNormal.z)
|
||||
|
||||
if (absY >= absX && absY >= absZ) {
|
||||
uvs.push(point.x, point.z)
|
||||
return
|
||||
}
|
||||
|
||||
if (absX >= absZ) {
|
||||
uvs.push(point.z, point.y)
|
||||
return
|
||||
}
|
||||
|
||||
uvs.push(point.x, point.y)
|
||||
}
|
||||
|
||||
function ensureUv2Attribute(geometry: THREE.BufferGeometry) {
|
||||
const uv = geometry.getAttribute('uv')
|
||||
if (!uv) return
|
||||
|
||||
geometry.setAttribute('uv2', new THREE.Float32BufferAttribute(Array.from(uv.array), 2))
|
||||
}
|
||||
|
||||
@@ -12,6 +12,10 @@ import { syncAutoStairOpenings } from './stair-opening-sync'
|
||||
const pendingStairUpdates = new Set<AnyNodeId>()
|
||||
const MAX_STAIRS_PER_FRAME = 2
|
||||
const MAX_SEGMENTS_PER_FRAME = 4
|
||||
const STAIR_TREAD_MATERIAL_INDEX = 0
|
||||
const STAIR_SIDE_MATERIAL_INDEX = 1
|
||||
const _uvPosition = new THREE.Vector3()
|
||||
const _uvNormal = new THREE.Vector3()
|
||||
|
||||
// ============================================================================
|
||||
// STAIR SYSTEM
|
||||
@@ -198,7 +202,7 @@ function generateStairSegmentGeometry(
|
||||
|
||||
shape.lineTo(0, 0)
|
||||
|
||||
const geometry = new THREE.ExtrudeGeometry(shape, {
|
||||
const extrudedGeometry = new THREE.ExtrudeGeometry(shape, {
|
||||
steps: 1,
|
||||
depth: width,
|
||||
bevelEnabled: false,
|
||||
@@ -209,7 +213,16 @@ function generateStairSegmentGeometry(
|
||||
const matrix = new THREE.Matrix4()
|
||||
matrix.makeRotationY(-Math.PI / 2)
|
||||
matrix.setPosition(width / 2, 0, 0)
|
||||
geometry.applyMatrix4(matrix)
|
||||
extrudedGeometry.applyMatrix4(matrix)
|
||||
extrudedGeometry.computeVertexNormals()
|
||||
|
||||
const geometry = extrudedGeometry.toNonIndexed() ?? extrudedGeometry
|
||||
if (geometry !== extrudedGeometry) {
|
||||
extrudedGeometry.dispose()
|
||||
}
|
||||
|
||||
applyStairSegmentUvs(geometry)
|
||||
ensureUv2Attribute(geometry)
|
||||
|
||||
return geometry
|
||||
}
|
||||
@@ -219,6 +232,7 @@ function updateStairSegmentGeometry(node: StairSegmentNode, mesh: THREE.Mesh) {
|
||||
const absoluteHeight = computeAbsoluteHeight(node)
|
||||
|
||||
const newGeometry = generateStairSegmentGeometry(node, absoluteHeight)
|
||||
applyStraightStairMaterialGroups(newGeometry)
|
||||
|
||||
mesh.geometry.dispose()
|
||||
mesh.geometry = newGeometry
|
||||
@@ -363,6 +377,7 @@ function updateMergedStairGeometry(
|
||||
}
|
||||
|
||||
const merged = mergeGeometries(geometries, false) ?? createEmptyGeometry()
|
||||
applyStraightStairMaterialGroups(merged)
|
||||
replaceMeshGeometry(mergedMesh, merged)
|
||||
|
||||
// Dispose individual geometries
|
||||
@@ -371,6 +386,108 @@ function updateMergedStairGeometry(
|
||||
}
|
||||
}
|
||||
|
||||
function applyStraightStairMaterialGroups(geometry: THREE.BufferGeometry) {
|
||||
const position = geometry.getAttribute('position')
|
||||
if (!position || position.count < 3) {
|
||||
geometry.clearGroups()
|
||||
return
|
||||
}
|
||||
|
||||
const index = geometry.getIndex()
|
||||
const triangleCount = index ? index.count / 3 : position.count / 3
|
||||
|
||||
if (!Number.isFinite(triangleCount) || triangleCount <= 0) {
|
||||
geometry.clearGroups()
|
||||
return
|
||||
}
|
||||
|
||||
const triangleMaterials: number[] = new Array(triangleCount)
|
||||
const v0 = new THREE.Vector3()
|
||||
const v1 = new THREE.Vector3()
|
||||
const v2 = new THREE.Vector3()
|
||||
const edge1 = new THREE.Vector3()
|
||||
const edge2 = new THREE.Vector3()
|
||||
const normal = new THREE.Vector3()
|
||||
|
||||
for (let triangleIndex = 0; triangleIndex < triangleCount; triangleIndex++) {
|
||||
const vertexOffset = triangleIndex * 3
|
||||
const a = index ? index.getX(vertexOffset) : vertexOffset
|
||||
const b = index ? index.getX(vertexOffset + 1) : vertexOffset + 1
|
||||
const c = index ? index.getX(vertexOffset + 2) : vertexOffset + 2
|
||||
|
||||
v0.fromBufferAttribute(position, a)
|
||||
v1.fromBufferAttribute(position, b)
|
||||
v2.fromBufferAttribute(position, c)
|
||||
|
||||
edge1.subVectors(v1, v0)
|
||||
edge2.subVectors(v2, v0)
|
||||
normal.crossVectors(edge1, edge2)
|
||||
|
||||
triangleMaterials[triangleIndex] =
|
||||
normal.lengthSq() > 0 && normal.normalize().y > 0.75
|
||||
? STAIR_TREAD_MATERIAL_INDEX
|
||||
: STAIR_SIDE_MATERIAL_INDEX
|
||||
}
|
||||
|
||||
geometry.clearGroups()
|
||||
|
||||
let currentMaterial = triangleMaterials[0]
|
||||
let groupStart = 0
|
||||
|
||||
for (let triangleIndex = 1; triangleIndex < triangleMaterials.length; triangleIndex++) {
|
||||
const materialIndex = triangleMaterials[triangleIndex]
|
||||
if (materialIndex === currentMaterial) continue
|
||||
|
||||
geometry.addGroup(groupStart * 3, (triangleIndex - groupStart) * 3, currentMaterial)
|
||||
groupStart = triangleIndex
|
||||
currentMaterial = materialIndex
|
||||
}
|
||||
|
||||
geometry.addGroup(
|
||||
groupStart * 3,
|
||||
(triangleMaterials.length - groupStart) * 3,
|
||||
currentMaterial ?? STAIR_SIDE_MATERIAL_INDEX,
|
||||
)
|
||||
}
|
||||
|
||||
function applyStairSegmentUvs(geometry: THREE.BufferGeometry) {
|
||||
const position = geometry.getAttribute('position')
|
||||
const normal = geometry.getAttribute('normal')
|
||||
|
||||
if (!position || !normal || position.count === 0) {
|
||||
geometry.deleteAttribute('uv')
|
||||
return
|
||||
}
|
||||
|
||||
const uv: number[] = []
|
||||
|
||||
for (let index = 0; index < position.count; index++) {
|
||||
_uvPosition.fromBufferAttribute(position, index)
|
||||
_uvNormal.fromBufferAttribute(normal, index).normalize()
|
||||
|
||||
const absX = Math.abs(_uvNormal.x)
|
||||
const absY = Math.abs(_uvNormal.y)
|
||||
const absZ = Math.abs(_uvNormal.z)
|
||||
|
||||
if (absY >= absX && absY >= absZ) {
|
||||
uv.push(_uvPosition.x, _uvPosition.z)
|
||||
} else if (absX >= absZ) {
|
||||
uv.push(_uvPosition.z, _uvPosition.y)
|
||||
} else {
|
||||
uv.push(_uvPosition.x, _uvPosition.y)
|
||||
}
|
||||
}
|
||||
|
||||
geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uv, 2))
|
||||
}
|
||||
|
||||
function ensureUv2Attribute(geometry: THREE.BufferGeometry) {
|
||||
const uv = geometry.getAttribute('uv')
|
||||
if (!uv) return
|
||||
|
||||
geometry.setAttribute('uv2', new THREE.Float32BufferAttribute(Array.from(uv.array), 2))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SEGMENT CHAINING
|
||||
// ============================================================================
|
||||
@@ -441,6 +558,8 @@ function rotateXZ(x: number, z: number, angle: number): [number, number] {
|
||||
function createEmptyGeometry(): THREE.BufferGeometry {
|
||||
const geometry = new THREE.BufferGeometry()
|
||||
geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3))
|
||||
geometry.addGroup(0, 0, STAIR_TREAD_MATERIAL_INDEX)
|
||||
geometry.addGroup(0, 0, STAIR_SIDE_MATERIAL_INDEX)
|
||||
return geometry
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { Point2D } from './wall-mitering'
|
||||
import type { WallNode } from '../../schema'
|
||||
import type { FenceNode, WallNode } from '../../schema'
|
||||
|
||||
const CURVE_EPSILON = 1e-6
|
||||
const DEFAULT_SAMPLE_SEGMENTS = 24
|
||||
|
||||
type WallCurveLike = Pick<WallNode, 'start' | 'end' | 'curveOffset'>
|
||||
type WallCurveLike = Pick<WallNode | FenceNode, 'start' | 'end' | 'curveOffset'>
|
||||
|
||||
type CurveFrame = {
|
||||
point: Point2D
|
||||
@@ -198,7 +198,7 @@ export function getWallCurveLength(wall: WallCurveLike, segments = DEFAULT_SAMPL
|
||||
}
|
||||
|
||||
export function getWallSurfacePolygon(
|
||||
wall: Pick<WallNode, 'start' | 'end' | 'curveOffset' | 'thickness'>,
|
||||
wall: Pick<WallNode | FenceNode, 'start' | 'end' | 'curveOffset' | 'thickness'>,
|
||||
segments = DEFAULT_SAMPLE_SEGMENTS,
|
||||
miterOverrides?: WallSurfaceMiterOverrides,
|
||||
) {
|
||||
|
||||
@@ -7,20 +7,30 @@ import { spatialGridManager } from '../../hooks/spatial-grid/spatial-grid-manage
|
||||
import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync'
|
||||
import type { AnyNode, AnyNodeId, WallNode } from '../../schema'
|
||||
import useScene from '../../store/use-scene'
|
||||
import { DEFAULT_WALL_HEIGHT, getWallPlanFootprint, getWallThickness } from './wall-footprint'
|
||||
import { getWallCurveFrameAt, getWallSurfacePolygon, isCurvedWall } from './wall-curve'
|
||||
import { DEFAULT_WALL_HEIGHT, getWallPlanFootprint, getWallThickness } from './wall-footprint'
|
||||
import {
|
||||
calculateLevelMiters,
|
||||
getAdjacentWallIds,
|
||||
getWallMiterBoundaryPoints,
|
||||
type Point2D,
|
||||
type WallMiterData,
|
||||
pointToKey,
|
||||
type WallMiterData,
|
||||
} from './wall-mitering'
|
||||
|
||||
// Reusable CSG evaluator for better performance
|
||||
const csgEvaluator = new Evaluator()
|
||||
const CURVED_WALL_3D_ENDPOINT_INSET = 0.0015
|
||||
const WALL_FACE_NORMAL_Y_EPSILON = 0.6
|
||||
const WALL_FACE_EDGE_DISTANCE_EPSILON = 0.003
|
||||
|
||||
type WallBoundaryEdgeTag = 'front' | 'back' | 'base'
|
||||
|
||||
type TaggedWallBoundaryEdge = {
|
||||
start: THREE.Vector2
|
||||
end: THREE.Vector2
|
||||
tag: WallBoundaryEdgeTag
|
||||
}
|
||||
|
||||
function ensureUv2Attribute(geometry: THREE.BufferGeometry) {
|
||||
const uv = geometry.getAttribute('uv')
|
||||
@@ -78,6 +88,207 @@ function insetCurvedWallBoundaryPointsFor3D(
|
||||
return next
|
||||
}
|
||||
|
||||
function addTaggedWallBoundaryEdge(
|
||||
edges: TaggedWallBoundaryEdge[],
|
||||
points: { x: number; z: number }[],
|
||||
startIndex: number,
|
||||
endIndex: number,
|
||||
tag: WallBoundaryEdgeTag,
|
||||
) {
|
||||
const start = points[startIndex]
|
||||
const end = points[endIndex]
|
||||
if (!(start && end)) return
|
||||
if (Math.hypot(end.x - start.x, end.z - start.z) < 1e-6) return
|
||||
|
||||
edges.push({
|
||||
start: new THREE.Vector2(start.x, start.z),
|
||||
end: new THREE.Vector2(end.x, end.z),
|
||||
tag,
|
||||
})
|
||||
}
|
||||
|
||||
function buildTaggedWallBoundaryEdges(
|
||||
wall: WallNode,
|
||||
localPoints: { x: number; z: number }[],
|
||||
miterData: WallMiterData,
|
||||
): TaggedWallBoundaryEdge[] {
|
||||
if (localPoints.length < 2) return []
|
||||
|
||||
const edges: TaggedWallBoundaryEdge[] = []
|
||||
|
||||
if (isCurvedWall(wall)) {
|
||||
const sidePointCount = Math.floor(localPoints.length / 2)
|
||||
if (sidePointCount < 2) return edges
|
||||
|
||||
for (let index = 0; index < sidePointCount - 1; index += 1) {
|
||||
addTaggedWallBoundaryEdge(edges, localPoints, index, index + 1, 'back')
|
||||
}
|
||||
|
||||
addTaggedWallBoundaryEdge(edges, localPoints, sidePointCount - 1, sidePointCount, 'base')
|
||||
|
||||
for (let index = sidePointCount; index < localPoints.length - 1; index += 1) {
|
||||
addTaggedWallBoundaryEdge(edges, localPoints, index, index + 1, 'front')
|
||||
}
|
||||
|
||||
addTaggedWallBoundaryEdge(edges, localPoints, localPoints.length - 1, 0, 'base')
|
||||
return edges
|
||||
}
|
||||
|
||||
const startKey = pointToKey({ x: wall.start[0], y: wall.start[1] })
|
||||
const startJunction = miterData.junctionData.get(startKey)?.get(wall.id)
|
||||
const startLeftIndex = startJunction ? localPoints.length - 2 : localPoints.length - 1
|
||||
const endLeftIndex = startJunction ? localPoints.length - 3 : localPoints.length - 2
|
||||
|
||||
addTaggedWallBoundaryEdge(edges, localPoints, 0, 1, 'back')
|
||||
|
||||
for (let index = 1; index < endLeftIndex; index += 1) {
|
||||
addTaggedWallBoundaryEdge(edges, localPoints, index, index + 1, 'base')
|
||||
}
|
||||
|
||||
addTaggedWallBoundaryEdge(edges, localPoints, endLeftIndex, startLeftIndex, 'front')
|
||||
|
||||
for (let index = startLeftIndex; index < localPoints.length - 1; index += 1) {
|
||||
addTaggedWallBoundaryEdge(edges, localPoints, index, index + 1, 'base')
|
||||
}
|
||||
|
||||
addTaggedWallBoundaryEdge(edges, localPoints, localPoints.length - 1, 0, 'base')
|
||||
|
||||
return edges
|
||||
}
|
||||
|
||||
function distanceToWallBoundaryEdge(point: THREE.Vector2, edge: TaggedWallBoundaryEdge): number {
|
||||
const edgeDx = edge.end.x - edge.start.x
|
||||
const edgeDz = edge.end.y - edge.start.y
|
||||
const pointDx = point.x - edge.start.x
|
||||
const pointDz = point.y - edge.start.y
|
||||
const edgeLengthSq = edgeDx * edgeDx + edgeDz * edgeDz
|
||||
|
||||
if (edgeLengthSq < 1e-12) {
|
||||
return point.distanceTo(edge.start)
|
||||
}
|
||||
|
||||
const t = THREE.MathUtils.clamp((pointDx * edgeDx + pointDz * edgeDz) / edgeLengthSq, 0, 1)
|
||||
const closestX = edge.start.x + edgeDx * t
|
||||
const closestZ = edge.start.y + edgeDz * t
|
||||
|
||||
return Math.hypot(point.x - closestX, point.y - closestZ)
|
||||
}
|
||||
|
||||
function getWallFaceMaterialIndex(
|
||||
wall: Pick<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
|
||||
// ============================================================================
|
||||
@@ -252,6 +463,7 @@ export function generateExtrudedWall(
|
||||
|
||||
// Convert polygon to local coordinates
|
||||
const localPoints = polyPoints.map(worldToLocal)
|
||||
const boundaryEdges = buildTaggedWallBoundaryEdges(wallNode, localPoints, miterData)
|
||||
|
||||
// Build THREE.js shape
|
||||
// Shape uses (x, y) where we map: shape.x = local.x, shape.y = -local.z
|
||||
@@ -272,6 +484,7 @@ export function generateExtrudedWall(
|
||||
// Rotate so extrusion direction (Z) becomes height direction (Y)
|
||||
geometry.rotateX(-Math.PI / 2)
|
||||
geometry.computeVertexNormals()
|
||||
assignWallMaterialGroups(geometry, wallNode, boundaryEdges)
|
||||
ensureUv2Attribute(geometry)
|
||||
|
||||
// Apply CSG subtraction for cutouts (doors/windows)
|
||||
@@ -307,6 +520,7 @@ export function generateExtrudedWall(
|
||||
|
||||
const resultGeometry = resultBrush.geometry
|
||||
resultGeometry.computeVertexNormals()
|
||||
assignWallMaterialGroups(resultGeometry, wallNode, boundaryEdges)
|
||||
ensureUv2Attribute(resultGeometry)
|
||||
|
||||
return resultGeometry
|
||||
|
||||
Reference in New Issue
Block a user