floorplan/zone: name label inside polygon + polygon editor on select
`buildZoneFloorplan` now emits a centered name label at the polygon's area-weighted centroid (Shoelace formula, with bbox-center fallback for degenerate rings). Label uses the legacy `FloorplanZoneLabel` styling: `fontSize: 0.2`, white fill, zone-color stroke, `paintOrder: 'stroke'` for the "outlined text" look that stays legible above any fill. When the zone is selected the builder also emits the polygon editor — edge-handle per edge, midpoint-handle per midpoint, endpoint-handle per vertex — driven by the shared `createPolygonVertexAffordance` / `createPolygonAddVertexAffordance` / `createPolygonMoveEdgeAffordance` factories slabs and ceilings already use. Zones have no `holes` field so the factory's optional `holeIndex` stays undefined and the operations target `node.polygon` directly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
3419cf8587
commit
0dee7747d2
@@ -0,0 +1,66 @@
|
|||||||
|
import { type NodeDefinition, ZoneNode as ZoneNodeSchema } from '@pascal-app/core'
|
||||||
|
import {
|
||||||
|
zoneAddVertexAffordance,
|
||||||
|
zoneMoveEdgeAffordance,
|
||||||
|
zoneMoveVertexAffordance,
|
||||||
|
} from './floorplan-affordances'
|
||||||
|
import { buildZoneFloorplan } from './floorplan'
|
||||||
|
import { zoneParametrics } from './parametrics'
|
||||||
|
import { ZoneNode } from './schema'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zone — Stage A. Custom-behavior escape hatch: zone uses TSL shader
|
||||||
|
* materials + `<Html>` portals + per-frame uniform poking, so it
|
||||||
|
* lives via `def.renderer` + `def.system` (no `def.geometry` possible
|
||||||
|
* because zone isn't really a mesh).
|
||||||
|
*/
|
||||||
|
export const zoneDefinition: NodeDefinition<typeof ZoneNode> = {
|
||||||
|
kind: 'zone',
|
||||||
|
schemaVersion: 1,
|
||||||
|
schema: ZoneNode,
|
||||||
|
category: 'site',
|
||||||
|
|
||||||
|
defaults: () => {
|
||||||
|
const stub = ZoneNodeSchema.parse({ id: 'zone_default' as never, type: 'zone' })
|
||||||
|
const { id: _id, type: _type, ...rest } = stub
|
||||||
|
return rest
|
||||||
|
},
|
||||||
|
|
||||||
|
capabilities: {
|
||||||
|
selectable: { hitVolume: 'bbox' },
|
||||||
|
duplicable: true,
|
||||||
|
deletable: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
parametrics: zoneParametrics,
|
||||||
|
|
||||||
|
renderer: {
|
||||||
|
kind: 'parametric',
|
||||||
|
module: () => import('./renderer'),
|
||||||
|
},
|
||||||
|
system: {
|
||||||
|
module: () => import('./system'),
|
||||||
|
priority: 4,
|
||||||
|
},
|
||||||
|
floorplan: buildZoneFloorplan,
|
||||||
|
// Polygon editor when selected — same three operations slabs / ceilings
|
||||||
|
// expose. The shared factories key off `node.polygon`, optional
|
||||||
|
// `node.holes` (absent on zones). See `floorplan-affordances.ts`.
|
||||||
|
floorplanAffordances: {
|
||||||
|
'move-vertex': zoneMoveVertexAffordance,
|
||||||
|
'add-vertex': zoneAddVertexAffordance,
|
||||||
|
'move-edge': zoneMoveEdgeAffordance,
|
||||||
|
},
|
||||||
|
|
||||||
|
presentation: {
|
||||||
|
label: 'Zone',
|
||||||
|
description: 'A polygonal site zone (lawn, water, paving) with a TSL gradient material.',
|
||||||
|
icon: { kind: 'url', src: '/icons/zone.png' },
|
||||||
|
paletteSection: 'site',
|
||||||
|
paletteOrder: 20,
|
||||||
|
},
|
||||||
|
|
||||||
|
mcp: {
|
||||||
|
description: 'A polygon-bounded site zone with a typed surface (grass / water / paving / ...).',
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { ZoneNode } from '@pascal-app/core'
|
||||||
|
import {
|
||||||
|
createPolygonAddVertexAffordance,
|
||||||
|
createPolygonMoveEdgeAffordance,
|
||||||
|
createPolygonVertexAffordance,
|
||||||
|
} from '../shared/polygon-vertex-affordance'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 2D drag affordances for zone — same three polygon-editing operations
|
||||||
|
* slabs and ceilings expose. Zones have no `holes` field, but the
|
||||||
|
* shared factory accepts that case (holeIndex stays undefined and the
|
||||||
|
* boundary polygon is the target).
|
||||||
|
*
|
||||||
|
* - `move-vertex` — drag an existing polygon vertex.
|
||||||
|
* - `add-vertex` — insert a new vertex at an edge midpoint, then drag.
|
||||||
|
* - `move-edge` — drag an entire edge perpendicular to itself.
|
||||||
|
*/
|
||||||
|
export const zoneMoveVertexAffordance = createPolygonVertexAffordance<ZoneNode>('zone')
|
||||||
|
export const zoneAddVertexAffordance = createPolygonAddVertexAffordance<ZoneNode>('zone')
|
||||||
|
export const zoneMoveEdgeAffordance = createPolygonMoveEdgeAffordance<ZoneNode>('zone')
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import type { FloorplanGeometry, FloorplanPoint, GeometryContext, ZoneNode } from '@pascal-app/core'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stage C floor-plan builder for zone. Zones are colored polygons —
|
||||||
|
* fill + outline both come from `zone.color`. Selection adds an
|
||||||
|
* accent-colored outline.
|
||||||
|
*
|
||||||
|
* The zone's `name` renders as a centered text label at the polygon's
|
||||||
|
* geometric centroid. The registry layer sorts zones before every
|
||||||
|
* other kind so the label + polygon sit *under* walls / slabs /
|
||||||
|
* furniture in the SVG document order (= z-order).
|
||||||
|
*/
|
||||||
|
export function buildZoneFloorplan(node: ZoneNode, ctx: GeometryContext): FloorplanGeometry | null {
|
||||||
|
const ring = node.polygon
|
||||||
|
if (!ring || ring.length < 3) return null
|
||||||
|
|
||||||
|
const view = ctx.viewState
|
||||||
|
const palette = view?.palette
|
||||||
|
const isSelected = view?.selected ?? false
|
||||||
|
const isHighlighted = view?.highlighted ?? false
|
||||||
|
const showSelectedChrome = isSelected || isHighlighted
|
||||||
|
|
||||||
|
const points: FloorplanPoint[] = ring.map(([x, z]) => [x, z] as FloorplanPoint)
|
||||||
|
const stroke = showSelectedChrome && palette ? palette.selectedStroke : node.color
|
||||||
|
const fillOpacity = isSelected ? 0.28 : 0.16
|
||||||
|
|
||||||
|
const children: FloorplanGeometry[] = [
|
||||||
|
{
|
||||||
|
kind: 'polygon',
|
||||||
|
points,
|
||||||
|
fill: node.color,
|
||||||
|
fillOpacity,
|
||||||
|
stroke,
|
||||||
|
strokeWidth: showSelectedChrome ? 0.08 : 0.05,
|
||||||
|
strokeOpacity: showSelectedChrome ? 0.96 : 0.72,
|
||||||
|
strokeLinejoin: 'round',
|
||||||
|
vectorEffect: 'non-scaling-stroke',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// Polygon editor — emitted only when the zone is the active
|
||||||
|
// selection. Same three handle types slabs / ceilings expose:
|
||||||
|
// edge-handle (drag whole edge), midpoint-handle (insert a vertex),
|
||||||
|
// endpoint-handle (drag an existing vertex). Order matters for
|
||||||
|
// hit-test layering: edges (large hit area) first, then midpoints,
|
||||||
|
// then vertices on top.
|
||||||
|
if (isSelected) {
|
||||||
|
for (let i = 0; i < ring.length; i++) {
|
||||||
|
const a = ring[i]!
|
||||||
|
const b = ring[(i + 1) % ring.length]!
|
||||||
|
children.push({
|
||||||
|
kind: 'edge-handle',
|
||||||
|
x1: a[0],
|
||||||
|
y1: a[1],
|
||||||
|
x2: b[0],
|
||||||
|
y2: b[1],
|
||||||
|
affordance: 'move-edge',
|
||||||
|
payload: { edgeIndex: i },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for (let i = 0; i < ring.length; i++) {
|
||||||
|
const a = ring[i]!
|
||||||
|
const b = ring[(i + 1) % ring.length]!
|
||||||
|
children.push({
|
||||||
|
kind: 'midpoint-handle',
|
||||||
|
point: [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2],
|
||||||
|
affordance: 'add-vertex',
|
||||||
|
payload: { edgeIndex: i },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for (let i = 0; i < ring.length; i++) {
|
||||||
|
const [x, z] = ring[i]!
|
||||||
|
children.push({
|
||||||
|
kind: 'endpoint-handle',
|
||||||
|
point: [x, z],
|
||||||
|
state: 'idle',
|
||||||
|
affordance: 'move-vertex',
|
||||||
|
payload: { vertexIndex: i },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name label — white fill inside a zone-colored stroke (`paintOrder:
|
||||||
|
// 'stroke'` paints the stroke first so the fill reads cleanly through
|
||||||
|
// it). Mirrors the legacy `FloorplanZoneLabel` so the look is
|
||||||
|
// consistent. Centered on the polygon's area-weighted centroid; the
|
||||||
|
// bbox-center fallback handles degenerate rings without throwing.
|
||||||
|
const name = node.name?.trim()
|
||||||
|
if (name) {
|
||||||
|
const [cx, cy] = polygonCentroid(ring)
|
||||||
|
children.push({
|
||||||
|
kind: 'text',
|
||||||
|
x: cx,
|
||||||
|
y: cy,
|
||||||
|
text: name,
|
||||||
|
// Same constants the legacy `FLOORPLAN_ZONE_LABEL_FONT_SIZE` uses
|
||||||
|
// (0.2 plan metres ≈ readable at typical building zooms).
|
||||||
|
fontSize: ZONE_LABEL_FONT_SIZE,
|
||||||
|
fill: '#ffffff',
|
||||||
|
stroke: node.color,
|
||||||
|
strokeWidth: ZONE_LABEL_FONT_SIZE * 0.35,
|
||||||
|
paintOrder: 'stroke',
|
||||||
|
fontFamily: 'system-ui, -apple-system, sans-serif',
|
||||||
|
fontWeight: 500,
|
||||||
|
textAnchor: 'middle',
|
||||||
|
dominantBaseline: 'central',
|
||||||
|
opacity: showSelectedChrome ? 1 : 0.92,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { kind: 'group', children }
|
||||||
|
}
|
||||||
|
|
||||||
|
const ZONE_LABEL_FONT_SIZE = 0.2
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Area-weighted centroid of a simple polygon (Shoelace formula). Falls
|
||||||
|
* back to the bounding-box center when the signed area is degenerate
|
||||||
|
* (collinear vertices, zero-area polygon) so the label still has a
|
||||||
|
* sensible anchor.
|
||||||
|
*/
|
||||||
|
function polygonCentroid(ring: ReadonlyArray<readonly [number, number]>): [number, number] {
|
||||||
|
let area = 0
|
||||||
|
let cx = 0
|
||||||
|
let cy = 0
|
||||||
|
for (let i = 0; i < ring.length; i++) {
|
||||||
|
const [x0, y0] = ring[i]!
|
||||||
|
const [x1, y1] = ring[(i + 1) % ring.length]!
|
||||||
|
const cross = x0 * y1 - x1 * y0
|
||||||
|
area += cross
|
||||||
|
cx += (x0 + x1) * cross
|
||||||
|
cy += (y0 + y1) * cross
|
||||||
|
}
|
||||||
|
area *= 0.5
|
||||||
|
if (Math.abs(area) < 1e-9) {
|
||||||
|
// Degenerate — fall back to bbox center.
|
||||||
|
let minX = Infinity
|
||||||
|
let maxX = -Infinity
|
||||||
|
let minY = Infinity
|
||||||
|
let maxY = -Infinity
|
||||||
|
for (const [x, y] of ring) {
|
||||||
|
if (x < minX) minX = x
|
||||||
|
if (x > maxX) maxX = x
|
||||||
|
if (y < minY) minY = y
|
||||||
|
if (y > maxY) maxY = y
|
||||||
|
}
|
||||||
|
return [(minX + maxX) / 2, (minY + maxY) / 2]
|
||||||
|
}
|
||||||
|
return [cx / (6 * area), cy / (6 * area)]
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { zoneDefinition } from './definition'
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import type { ParametricDescriptor, ZoneNode } from '@pascal-app/core'
|
||||||
|
|
||||||
|
export const zoneParametrics: ParametricDescriptor<ZoneNode> = {
|
||||||
|
groups: [],
|
||||||
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useRegistry, type ZoneNode } from '@pascal-app/core'
|
||||||
|
import { useNodeEvents, ZONE_LAYER } from '@pascal-app/viewer'
|
||||||
|
import { Html } from '@react-three/drei'
|
||||||
|
import { useMemo, useRef } from 'react'
|
||||||
|
import { BufferGeometry, Color, DoubleSide, Float32BufferAttribute, type Group, Shape } from 'three'
|
||||||
|
import { color, float, uniform, uv } from 'three/tsl'
|
||||||
|
import { MeshBasicNodeMaterial } from 'three/webgpu'
|
||||||
|
|
||||||
|
const Y_OFFSET = 0.01
|
||||||
|
const WALL_HEIGHT = 2.3
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a gradient material for zone walls using TSL
|
||||||
|
* Gradient goes from zone color at bottom to transparent at top
|
||||||
|
*/
|
||||||
|
const createWallGradientMaterial = (zoneColor: string) => {
|
||||||
|
const baseColor = color(new Color(zoneColor))
|
||||||
|
|
||||||
|
// Use UV y coordinate for vertical gradient (0 at bottom, 1 at top)
|
||||||
|
const gradientT = uv().y
|
||||||
|
|
||||||
|
const opacity = uniform(0)
|
||||||
|
// Fade opacity from 0.6 at bottom to 0 at top
|
||||||
|
const finalOpacity = float(0.6).mul(float(1).sub(gradientT)).mul(opacity)
|
||||||
|
|
||||||
|
return new MeshBasicNodeMaterial({
|
||||||
|
transparent: true,
|
||||||
|
colorNode: baseColor,
|
||||||
|
opacityNode: finalOpacity,
|
||||||
|
side: DoubleSide,
|
||||||
|
depthWrite: true,
|
||||||
|
depthTest: false,
|
||||||
|
userData: {
|
||||||
|
uOpacity: opacity,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a floor material for zones using TSL
|
||||||
|
*/
|
||||||
|
const createFloorMaterial = (zoneColor: string) => {
|
||||||
|
const baseColor = color(new Color(zoneColor))
|
||||||
|
const opacity = uniform(0)
|
||||||
|
return new MeshBasicNodeMaterial({
|
||||||
|
transparent: true,
|
||||||
|
colorNode: baseColor,
|
||||||
|
opacityNode: float(0.25).mul(opacity),
|
||||||
|
side: DoubleSide,
|
||||||
|
depthWrite: false,
|
||||||
|
depthTest: false,
|
||||||
|
userData: { uOpacity: opacity },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates wall geometry for zone borders
|
||||||
|
* Each wall segment is a vertical quad from one polygon point to the next
|
||||||
|
*/
|
||||||
|
const createWallGeometry = (polygon: Array<[number, number]>): BufferGeometry => {
|
||||||
|
const geometry = new BufferGeometry()
|
||||||
|
|
||||||
|
if (polygon.length < 2) return geometry
|
||||||
|
|
||||||
|
const positions: number[] = []
|
||||||
|
const uvs: number[] = []
|
||||||
|
const indices: number[] = []
|
||||||
|
|
||||||
|
// Create a wall segment for each edge of the polygon
|
||||||
|
for (let i = 0; i < polygon.length; i++) {
|
||||||
|
const current = polygon[i]!
|
||||||
|
const next = polygon[(i + 1) % polygon.length]!
|
||||||
|
|
||||||
|
const baseIndex = i * 4
|
||||||
|
|
||||||
|
// Four vertices per wall segment (two triangles forming a quad)
|
||||||
|
// Bottom-left
|
||||||
|
positions.push(current[0]!, Y_OFFSET, current[1]!)
|
||||||
|
uvs.push(0, 0)
|
||||||
|
|
||||||
|
// Bottom-right
|
||||||
|
positions.push(next[0]!, Y_OFFSET, next[1]!)
|
||||||
|
uvs.push(1, 0)
|
||||||
|
|
||||||
|
// Top-right
|
||||||
|
positions.push(next[0]!, Y_OFFSET + WALL_HEIGHT, next[1]!)
|
||||||
|
uvs.push(1, 1)
|
||||||
|
|
||||||
|
// Top-left
|
||||||
|
positions.push(current[0]!, Y_OFFSET + WALL_HEIGHT, current[1]!)
|
||||||
|
uvs.push(0, 1)
|
||||||
|
|
||||||
|
// Two triangles for the quad
|
||||||
|
indices.push(baseIndex, baseIndex + 1, baseIndex + 2, baseIndex, baseIndex + 2, baseIndex + 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
geometry.setAttribute('position', new Float32BufferAttribute(positions, 3))
|
||||||
|
geometry.setAttribute('uv', new Float32BufferAttribute(uvs, 2))
|
||||||
|
geometry.setIndex(indices)
|
||||||
|
geometry.computeVertexNormals()
|
||||||
|
|
||||||
|
return geometry
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ZoneRenderer = ({ node }: { node: ZoneNode }) => {
|
||||||
|
const ref = useRef<Group>(null!)
|
||||||
|
|
||||||
|
useRegistry(node.id, 'zone', ref)
|
||||||
|
|
||||||
|
// Create floor shape from polygon
|
||||||
|
const floorShape = useMemo(() => {
|
||||||
|
if (!node?.polygon || node.polygon.length < 3) return null
|
||||||
|
const shape = new Shape()
|
||||||
|
const firstPt = node.polygon[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.length; i++) {
|
||||||
|
const pt = node.polygon[i]!
|
||||||
|
shape.lineTo(pt[0]!, -pt[1]!)
|
||||||
|
}
|
||||||
|
shape.closePath()
|
||||||
|
|
||||||
|
return shape
|
||||||
|
}, [node?.polygon])
|
||||||
|
|
||||||
|
// Create wall geometry from polygon
|
||||||
|
const wallGeometry = useMemo(() => {
|
||||||
|
if (!node?.polygon || node.polygon.length < 2) return null
|
||||||
|
return createWallGeometry(node.polygon)
|
||||||
|
}, [node?.polygon])
|
||||||
|
|
||||||
|
// Calculate polygon centroid for label positioning using the geometric centroid formula
|
||||||
|
// This correctly handles polygons regardless of vertex distribution along edges
|
||||||
|
const centroid = useMemo(() => {
|
||||||
|
if (!node?.polygon || node.polygon.length < 3) return [0, 0] as [number, number]
|
||||||
|
|
||||||
|
const polygon = node.polygon
|
||||||
|
let signedArea = 0
|
||||||
|
let cx = 0
|
||||||
|
let cz = 0
|
||||||
|
|
||||||
|
for (let i = 0; i < polygon.length; i++) {
|
||||||
|
const [x0, z0] = polygon[i]!
|
||||||
|
const [x1, z1] = polygon[(i + 1) % polygon.length]!
|
||||||
|
|
||||||
|
// Cross product for signed area
|
||||||
|
const cross = x0 * z1 - x1 * z0
|
||||||
|
signedArea += cross
|
||||||
|
cx += (x0 + x1) * cross
|
||||||
|
cz += (z0 + z1) * cross
|
||||||
|
}
|
||||||
|
|
||||||
|
signedArea /= 2
|
||||||
|
const factor = 1 / (6 * signedArea)
|
||||||
|
|
||||||
|
return [cx * factor, cz * factor] as [number, number]
|
||||||
|
}, [node?.polygon])
|
||||||
|
|
||||||
|
// Create materials
|
||||||
|
const floorMaterial = useMemo(() => {
|
||||||
|
if (!node?.color) return null
|
||||||
|
return createFloorMaterial(node.color)
|
||||||
|
}, [node?.color])
|
||||||
|
|
||||||
|
const wallMaterial = useMemo(() => {
|
||||||
|
if (!node?.color) return null
|
||||||
|
return createWallGradientMaterial(node.color)
|
||||||
|
}, [node?.color])
|
||||||
|
|
||||||
|
const handlers = useNodeEvents(node, 'zone')
|
||||||
|
|
||||||
|
if (!(node && floorShape && wallGeometry && floorMaterial && wallMaterial)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group ref={ref} {...handlers} userData={{ labelPosition: [centroid[0], 1, centroid[1]] }}>
|
||||||
|
<Html
|
||||||
|
name="label"
|
||||||
|
position={[centroid[0], 1, centroid[1]]}
|
||||||
|
style={{ pointerEvents: 'none' }}
|
||||||
|
zIndexRange={[10, 0]}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
id={`${node.id}-label`}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
transform: 'translate3d(-50%, -50%, 0)',
|
||||||
|
opacity: 0,
|
||||||
|
transition: 'opacity 0.3s ease-in-out',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 'max-content',
|
||||||
|
color: 'white',
|
||||||
|
textShadow: `-1px -1px 0 ${node.color}, 1px -1px 0 ${node.color}, -1px 1px 0 ${node.color}, 1px 1px 0 ${node.color}`,
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{node.name}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="label-pin"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: '2px',
|
||||||
|
opacity: 0,
|
||||||
|
transition: 'opacity 0.5s ease-in-out',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '2px',
|
||||||
|
height: '40px',
|
||||||
|
backgroundColor: node.color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '10px',
|
||||||
|
height: '10px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: node.color,
|
||||||
|
border: '1px solid white',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Html>
|
||||||
|
|
||||||
|
{/* Floor fill */}
|
||||||
|
<mesh
|
||||||
|
layers={ZONE_LAYER}
|
||||||
|
material={floorMaterial}
|
||||||
|
name="floor"
|
||||||
|
position={[0, Y_OFFSET, 0]}
|
||||||
|
rotation={[-Math.PI / 2, 0, 0]}
|
||||||
|
>
|
||||||
|
<shapeGeometry args={[floorShape]} />
|
||||||
|
</mesh>
|
||||||
|
|
||||||
|
{/* Wall borders with gradient */}
|
||||||
|
<mesh geometry={wallGeometry} layers={ZONE_LAYER} material={wallMaterial} name="walls" />
|
||||||
|
</group>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ZoneRenderer
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { ZoneNode } from '@pascal-app/core'
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { ZoneSystem } from '@pascal-app/viewer'
|
||||||
|
|
||||||
|
export default ZoneSystem
|
||||||
Reference in New Issue
Block a user