sync: comprehensive monorepo → editor parity (2D/3D decoupling, UX polish, crash fixes)
Squash merge of 3 commits: 1. useLiveTransforms store for 2D/3D decoupling + floorplan overhaul + Sentry crash fixes 2. Comprehensive 59-file sync bringing editor to full monorepo parity (selection highlights, delete tool, furnish/zone modes, keyboard shortcuts, all panels) 3. Missing files fix (materials.ts, merged-outline-node.ts, type fix) 75 files changed, ~6K additions.
This commit is contained in:
@@ -14,7 +14,7 @@ const lineY = smoothstep(lineWidth, 0, gridY).add(smoothstep(1.0 - lineWidth, 1.
|
||||
const gridPattern = lineX.max(lineY)
|
||||
const gridOpacity = mix(float(0.2), float(0.6), gridPattern)
|
||||
|
||||
function createCeilingMaterials(color: string = '#999999') {
|
||||
function createCeilingMaterials(color = '#999999') {
|
||||
const topMaterial = new MeshBasicNodeMaterial({
|
||||
color,
|
||||
transparent: true,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import {
|
||||
type AnimationEffect,
|
||||
type AnyNodeId,
|
||||
baseMaterial,
|
||||
glassMaterial,
|
||||
type Interactive,
|
||||
type ItemNode,
|
||||
type LightEffect,
|
||||
@@ -16,36 +18,18 @@ import { Suspense, useEffect, useMemo, useRef } from 'react'
|
||||
import type { AnimationAction, Group, Material, Mesh } from 'three'
|
||||
import { MathUtils } from 'three'
|
||||
import { positionLocal, smoothstep, time } from 'three/tsl'
|
||||
import { DoubleSide, MeshStandardNodeMaterial } from 'three/webgpu'
|
||||
import { MeshStandardNodeMaterial } from 'three/webgpu'
|
||||
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||
import { resolveCdnUrl } from '../../../lib/asset-url'
|
||||
import { useItemLightPool } from '../../../store/use-item-light-pool'
|
||||
import { ErrorBoundary } from '../../error-boundary'
|
||||
import { NodeRenderer } from '../node-renderer'
|
||||
|
||||
// Shared materials to avoid creating new instances for every mesh
|
||||
const defaultMaterial = new MeshStandardNodeMaterial({
|
||||
color: 0xff_ff_ff,
|
||||
roughness: 1,
|
||||
metalness: 0,
|
||||
})
|
||||
|
||||
const glassMaterial = new MeshStandardNodeMaterial({
|
||||
name: 'glass',
|
||||
color: 'lightgray',
|
||||
roughness: 0.8,
|
||||
metalness: 0,
|
||||
transparent: true,
|
||||
opacity: 0.35,
|
||||
side: DoubleSide,
|
||||
depthWrite: false,
|
||||
})
|
||||
|
||||
const getMaterialForOriginal = (original: Material): MeshStandardNodeMaterial => {
|
||||
if (original.name.toLowerCase() === 'glass') {
|
||||
return glassMaterial
|
||||
}
|
||||
return defaultMaterial
|
||||
return baseMaterial
|
||||
}
|
||||
|
||||
const BrokenItemFallback = ({ node }: { node: ItemNode }) => {
|
||||
@@ -145,6 +129,18 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => {
|
||||
if (Array.isArray(mesh.material)) {
|
||||
mesh.material = mesh.material.map((mat) => getMaterialForOriginal(mat))
|
||||
hasGlass = mesh.material.some((mat) => mat.name === 'glass')
|
||||
|
||||
// Fix geometry groups that reference materialIndex beyond the material
|
||||
// array length — this causes three-mesh-bvh to crash with
|
||||
// "Cannot read properties of undefined (reading 'side')"
|
||||
const matCount = mesh.material.length
|
||||
if (mesh.geometry.groups.length > 0) {
|
||||
for (const group of mesh.geometry.groups) {
|
||||
if (group.materialIndex !== undefined && group.materialIndex >= matCount) {
|
||||
group.materialIndex = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
mesh.material = getMaterialForOriginal(mesh.material)
|
||||
hasGlass = mesh.material.name === 'glass'
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { type SiteNode, useRegistry } from '@pascal-app/core'
|
||||
import { type SiteNode, type SlabNode, useRegistry, useScene } from '@pascal-app/core'
|
||||
import polygonClipping from 'polygon-clipping'
|
||||
import { useMemo, useRef } from 'react'
|
||||
import { BufferGeometry, Float32BufferAttribute, type Group, Shape } from 'three'
|
||||
import { BufferGeometry, Float32BufferAttribute, type Group, Path, Shape } from 'three'
|
||||
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||
import useViewer from '../../../store/use-viewer'
|
||||
import { NodeRenderer } from '../node-renderer'
|
||||
|
||||
const Y_OFFSET = 0.01
|
||||
@@ -29,29 +31,76 @@ const createBoundaryLineGeometry = (points: Array<[number, number]>): BufferGeom
|
||||
return geometry
|
||||
}
|
||||
|
||||
type S = ReturnType<typeof useScene.getState>
|
||||
|
||||
export const SiteRenderer = ({ node }: { node: SiteNode }) => {
|
||||
const ref = useRef<Group>(null!)
|
||||
|
||||
useRegistry(node.id, 'site', ref)
|
||||
|
||||
// Create floor shape from polygon points
|
||||
const floorShape = useMemo(() => {
|
||||
const theme = useViewer((state) => state.theme)
|
||||
const bgColor = theme === 'dark' ? '#1f2433' : '#fafafa'
|
||||
|
||||
// Cache slab polygon references to keep the selector stable across unrelated store updates
|
||||
const slabPolygonsCache = useRef<[number, number][][]>([])
|
||||
const slabPolygons = useScene((state: S) => {
|
||||
const nodeList = Object.values(state.nodes)
|
||||
|
||||
const levelIndexById = new Map<string, number>()
|
||||
let lowestLevelIndex = Number.POSITIVE_INFINITY
|
||||
nodeList.forEach((n) => {
|
||||
if (n.type !== 'level') return
|
||||
levelIndexById.set(n.id, n.level)
|
||||
lowestLevelIndex = Math.min(lowestLevelIndex, n.level)
|
||||
})
|
||||
|
||||
const next = nodeList
|
||||
.filter((n): n is SlabNode => n.type === 'slab' && n.visible && n.polygon.length >= 3)
|
||||
.filter((n) => {
|
||||
if (!Number.isFinite(lowestLevelIndex)) return true
|
||||
const parentLevel = n.parentId ? levelIndexById.get(n.parentId as string) : undefined
|
||||
return parentLevel === lowestLevelIndex
|
||||
})
|
||||
.map((n) => n.polygon as [number, number][])
|
||||
|
||||
const prev = slabPolygonsCache.current
|
||||
if (next.length === prev.length && next.every((p, i) => p === prev[i])) return prev
|
||||
slabPolygonsCache.current = next
|
||||
return next
|
||||
})
|
||||
|
||||
// Ground shape: site polygon with slab footprints punched as holes
|
||||
const groundShape = useMemo(() => {
|
||||
if (!node?.polygon?.points || node.polygon.points.length < 3) return null
|
||||
|
||||
const pts = node.polygon.points
|
||||
const shape = new Shape()
|
||||
const firstPt = node.polygon.points[0]!
|
||||
|
||||
// Shape is in X-Y plane, we rotate it to X-Z plane
|
||||
// Negate Y (which becomes Z) to get correct orientation
|
||||
shape.moveTo(firstPt[0]!, -firstPt[1]!)
|
||||
|
||||
for (let i = 1; i < node.polygon.points.length; i++) {
|
||||
const pt = node.polygon.points[i]!
|
||||
shape.lineTo(pt[0]!, -pt[1]!)
|
||||
}
|
||||
shape.moveTo(pts[0]![0], -pts[0]![1])
|
||||
for (let i = 1; i < pts.length; i++) shape.lineTo(pts[i]![0], -pts[i]![1])
|
||||
shape.closePath()
|
||||
|
||||
if (slabPolygons.length > 0) {
|
||||
const multiPolygons = slabPolygons.map((p) => [
|
||||
p.map((pt) => [pt[0], -pt[1]] as [number, number]),
|
||||
])
|
||||
const unioned = polygonClipping.union(
|
||||
multiPolygons[0] as polygonClipping.Polygon,
|
||||
...(multiPolygons.slice(1) as polygonClipping.Polygon[]),
|
||||
)
|
||||
for (const geom of unioned) {
|
||||
const ring = geom[0]
|
||||
if (ring && ring.length > 0) {
|
||||
const hole = new Path()
|
||||
hole.moveTo(ring[0]![0], ring[0]![1])
|
||||
for (let i = 1; i < ring.length; i++) hole.lineTo(ring[i]![0], ring[i]![1])
|
||||
hole.closePath()
|
||||
shape.holes.push(hole)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return shape
|
||||
}, [node?.polygon?.points])
|
||||
}, [node?.polygon?.points, slabPolygons])
|
||||
|
||||
// Create boundary line geometry
|
||||
const lineGeometry = useMemo(() => {
|
||||
@@ -61,7 +110,7 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => {
|
||||
|
||||
const handlers = useNodeEvents(node, 'site')
|
||||
|
||||
if (!(node && floorShape && lineGeometry)) {
|
||||
if (!(node && lineGeometry)) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -75,11 +124,19 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => {
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Transparent floor fill */}
|
||||
<mesh position={[0, Y_OFFSET - 0.005, 0]} receiveShadow rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<shapeGeometry args={[floorShape]} />
|
||||
<shadowMaterial opacity={0.75} transparent />
|
||||
</mesh>
|
||||
{/* Ground fill: site polygon with slab holes, occludes below-grade geometry */}
|
||||
{groundShape && (
|
||||
<mesh position={[0, -0.05, 0]} receiveShadow rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<shapeGeometry args={[groundShape]} />
|
||||
<meshStandardMaterial
|
||||
color={bgColor}
|
||||
depthWrite={true}
|
||||
polygonOffset={true}
|
||||
polygonOffsetFactor={1}
|
||||
polygonOffsetUnits={1}
|
||||
/>
|
||||
</mesh>
|
||||
)}
|
||||
|
||||
{/* Simple boundary line */}
|
||||
{/* @ts-ignore */}
|
||||
|
||||
@@ -23,8 +23,8 @@ export const SlabRenderer = ({ node }: { node: SlabNode }) => {
|
||||
receiveShadow
|
||||
ref={ref}
|
||||
{...handlers}
|
||||
visible={node.visible}
|
||||
material={material}
|
||||
visible={node.visible}
|
||||
>
|
||||
<boxGeometry args={[0, 0, 0]} />
|
||||
</mesh>
|
||||
|
||||
@@ -23,7 +23,7 @@ export const WallRenderer = ({ node }: { node: WallNode }) => {
|
||||
}, [node.material, node.material?.preset, node.material?.properties, node.material?.texture])
|
||||
|
||||
return (
|
||||
<mesh castShadow receiveShadow ref={ref} visible={node.visible} material={material}>
|
||||
<mesh castShadow material={material} receiveShadow ref={ref} visible={node.visible}>
|
||||
<boxGeometry args={[0, 0, 0]} />
|
||||
<mesh name="collision-mesh" visible={false} {...handlers}>
|
||||
<boxGeometry args={[0, 0, 0]} />
|
||||
|
||||
Reference in New Issue
Block a user