From 0dee7747d2cf2d68269c83f7b9bb3cd7d9fe711a Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Tue, 19 May 2026 15:12:02 -0400 Subject: [PATCH] floorplan/zone: name label inside polygon + polygon editor on select MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) --- packages/nodes/src/zone/definition.ts | 66 +++++ .../nodes/src/zone/floorplan-affordances.ts | 20 ++ packages/nodes/src/zone/floorplan.ts | 150 ++++++++++ packages/nodes/src/zone/index.ts | 1 + packages/nodes/src/zone/parametrics.ts | 5 + packages/nodes/src/zone/renderer.tsx | 258 ++++++++++++++++++ packages/nodes/src/zone/schema.ts | 1 + packages/nodes/src/zone/system.tsx | 5 + 8 files changed, 506 insertions(+) create mode 100644 packages/nodes/src/zone/definition.ts create mode 100644 packages/nodes/src/zone/floorplan-affordances.ts create mode 100644 packages/nodes/src/zone/floorplan.ts create mode 100644 packages/nodes/src/zone/index.ts create mode 100644 packages/nodes/src/zone/parametrics.ts create mode 100644 packages/nodes/src/zone/renderer.tsx create mode 100644 packages/nodes/src/zone/schema.ts create mode 100644 packages/nodes/src/zone/system.tsx diff --git a/packages/nodes/src/zone/definition.ts b/packages/nodes/src/zone/definition.ts new file mode 100644 index 00000000..1d43a014 --- /dev/null +++ b/packages/nodes/src/zone/definition.ts @@ -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 + `` 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 = { + 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 / ...).', + }, +} diff --git a/packages/nodes/src/zone/floorplan-affordances.ts b/packages/nodes/src/zone/floorplan-affordances.ts new file mode 100644 index 00000000..44eb9431 --- /dev/null +++ b/packages/nodes/src/zone/floorplan-affordances.ts @@ -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('zone') +export const zoneAddVertexAffordance = createPolygonAddVertexAffordance('zone') +export const zoneMoveEdgeAffordance = createPolygonMoveEdgeAffordance('zone') diff --git a/packages/nodes/src/zone/floorplan.ts b/packages/nodes/src/zone/floorplan.ts new file mode 100644 index 00000000..a488f924 --- /dev/null +++ b/packages/nodes/src/zone/floorplan.ts @@ -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): [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)] +} diff --git a/packages/nodes/src/zone/index.ts b/packages/nodes/src/zone/index.ts new file mode 100644 index 00000000..156fac99 --- /dev/null +++ b/packages/nodes/src/zone/index.ts @@ -0,0 +1 @@ +export { zoneDefinition } from './definition' diff --git a/packages/nodes/src/zone/parametrics.ts b/packages/nodes/src/zone/parametrics.ts new file mode 100644 index 00000000..3cfce936 --- /dev/null +++ b/packages/nodes/src/zone/parametrics.ts @@ -0,0 +1,5 @@ +import type { ParametricDescriptor, ZoneNode } from '@pascal-app/core' + +export const zoneParametrics: ParametricDescriptor = { + groups: [], +} diff --git a/packages/nodes/src/zone/renderer.tsx b/packages/nodes/src/zone/renderer.tsx new file mode 100644 index 00000000..d72a11eb --- /dev/null +++ b/packages/nodes/src/zone/renderer.tsx @@ -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(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 ( + + +
+
+ {node.name} +
+
+
+
+
+
+ + + {/* Floor fill */} + + + + + {/* Wall borders with gradient */} + + + ) +} + +export default ZoneRenderer diff --git a/packages/nodes/src/zone/schema.ts b/packages/nodes/src/zone/schema.ts new file mode 100644 index 00000000..601df6e5 --- /dev/null +++ b/packages/nodes/src/zone/schema.ts @@ -0,0 +1 @@ +export { ZoneNode } from '@pascal-app/core' diff --git a/packages/nodes/src/zone/system.tsx b/packages/nodes/src/zone/system.tsx new file mode 100644 index 00000000..fb9a2f7a --- /dev/null +++ b/packages/nodes/src/zone/system.tsx @@ -0,0 +1,5 @@ +'use client' + +import { ZoneSystem } from '@pascal-app/viewer' + +export default ZoneSystem