Merge remote-tracking branch 'origin/main' into feat/mcp-server

This commit is contained in:
Aymeric Rabot
2026-04-27 17:22:14 -04:00
82 changed files with 4729 additions and 1063 deletions
+1 -1
View File
@@ -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",
+3
View File
@@ -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>
}
+9 -1
View File
@@ -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'
+8 -2
View File
@@ -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)
+28 -58
View File
@@ -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 {
+10 -2
View File
@@ -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'
+2
View File
@@ -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
+77 -2
View File
@@ -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)
}
+84 -2
View File
@@ -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)
}
+65
View File
@@ -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,
})
}
+29 -21
View File
@@ -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
}
+197 -3
View File
@@ -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()
+141 -64
View File
@@ -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 = () => {
+38 -1
View File
@@ -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
}
+3 -3
View File
@@ -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,
) {
+216 -2
View File
@@ -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