feat: 2D editing — floorplan panel, measurements, command palette (v0.3.0)

* sync: port 2D editing features from monorepo (v0.3.0)

## New Features

### Floorplan Panel (7.5K LOC)
- Full 2D editing interface with wall drawing, measurement, and unit display
- Interactive floorplan view with pan/zoom, grid snapping
- Wall thickness visualization, door/window placement
- Zone and slab polygon editing
- Guide image overlay support
- Metric/imperial unit toggle

### Command Palette Overhaul
- Complete rewrite with new command registry system
- Keyboard shortcuts UI with ShortcutToken component
- Editor-specific commands (floorplan, measurements, camera focus)
- Improved search and action organization

### Wall Measurements
- Real-time wall length labels in 3D view
- Metric/imperial conversion
- Wall measurement UI component

### Enhanced Tools
- Wall drafting utilities (grid snapping, validation)
- Node action menu for quick operations
- Improved polygon editing for zones/slabs/sites
- Roof segment panel for granular roof control

## Package Changes

### @pascal-app/core@0.3.0
- New wall-footprint.ts: 2D wall footprint calculation
- Wall mitering exports for floorplan view
- camera-controls:focus event
- Space detection undo pause/resume
- Mark sibling nodes dirty on deletion (miter recalc)

### @pascal-app/viewer@0.3.0
- ErrorBoundary component for robust item rendering
- Broken item fallback UI
- Wall renderer: mark dirty on mount
- Ground occluder: only lowest level punches through
- Unit state (metric/imperial) in viewer store

### @pascal-app/editor@0.1.0
- 48 file changes (16 new, 32 modified)
- New stores: useCommandRegistry, usePaletteViewRegistry
- Tree node drag-and-drop system
- Level selection utilities
- Enhanced scene graph operations

## Apps/Editor
- GeistPixelSquare font for pixel-perfect UI
- Blueprint icon asset
- Updated layout and globals for font support

## Security
- Zero AI imports or internal package refs
- All monorepo-specific code excluded
- Clean audit: no API keys or secrets

## Testing
- Security audit passed 
- All AI/internal code excluded 
- Version bumps applied 

79 files modified, 16 files added
~2600 insertions, ~1400 deletions

* fix: lint cleanup — suppress intentional dep warnings, fix missing dep, remove stale ignores

- Add biome-ignore for 3 intentional useEffect reset patterns (levelId, selectedGuide, selectedId)
- Fix actual missing dependency: currentBuildingId in handleSiteEditShortcutSelect callback
- Remove 3 stale biome-ignore comments in r3f.d.ts (rule not active)

Build and lint pass clean.

* fix: type errors — polygon area guards, selection cast, readonly keywords, door guard

- ceiling-panel, slab-panel, ceiling-tree-node, slab-tree-node, zone-tree-node:
  guard polygon[i]/polygon[j] array access before arithmetic (TS2532)
- scene.ts: introduce toViewerSelection() helper to cast persisted string IDs
  to branded template literal types expected by useViewer.setSelection (TS2345)
- door-panel: guard early return when node is undefined in setSegmentHeightRatio (TS18048)
- editor-commands: remove 'as const' from inline command object, keywords is mutable string[] (TS2322)

All type checks pass. Build and lint clean.

---------

Co-authored-by: Anton Pascal <anton-pascal@users.noreply.github.com>
This commit is contained in:
Anton
2026-03-24 19:48:11 +00:00
committed by GitHub
co-authored by Anton Pascal
parent 3791614a9d
commit f06f2fa1b1
95 changed files with 12378 additions and 1393 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@pascal-app/core",
"version": "0.2.0",
"version": "0.3.0",
"description": "Core library for Pascal 3D building editor",
"type": "module",
"main": "./dist/index.js",
+1
View File
@@ -76,6 +76,7 @@ export interface ThumbnailGenerateEvent {
type CameraControlEvents = {
'camera-controls:view': CameraControlEvent
'camera-controls:focus': CameraControlEvent
'camera-controls:capture': CameraControlEvent
'camera-controls:top-view': undefined
'camera-controls:orbit-cw': undefined
+12
View File
@@ -54,6 +54,18 @@ export { DoorSystem } from './systems/door/door-system'
export { ItemSystem } from './systems/item/item-system'
export { RoofSystem } from './systems/roof/roof-system'
export { SlabSystem } from './systems/slab/slab-system'
export {
DEFAULT_WALL_HEIGHT,
DEFAULT_WALL_THICKNESS,
getWallPlanFootprint,
getWallThickness,
} from './systems/wall/wall-footprint'
export {
calculateLevelMiters,
type Point2D,
pointToKey,
type WallMiterData,
} from './systems/wall/wall-mitering'
export { WallSystem } from './systems/wall/wall-system'
export { WindowSystem } from './systems/window/window-system'
export { isObject } from './utils/types'
+2
View File
@@ -104,9 +104,11 @@ export function initSpaceDetectionSync(
// Run detection for affected levels
if (levelsToUpdate.size > 0) {
isProcessing = true
sceneStore.temporal.getState().pause()
try {
runSpaceDetection(Array.from(levelsToUpdate), sceneStore, editorStore, nodes)
} finally {
sceneStore.temporal.getState().resume()
isProcessing = false
}
}
+12 -1
View File
@@ -343,7 +343,18 @@ useScene.temporal.subscribe((state) => {
// Nodes that were deleted (exist in prev but not current)
for (const [id, node] of Object.entries(snapshotBefore) as [AnyNodeId, AnyNode][]) {
if (!currentNodes[id]) {
if (node.parentId) markDirty(node.parentId as AnyNodeId)
const parentId = node.parentId as AnyNodeId | undefined
if (parentId) {
markDirty(parentId)
// Mark sibling nodes dirty so they can update their geometry
// (e.g. adjacent walls need to recalculate miter/junction geometry)
const parent = currentNodes[parentId]
if (parent && 'children' in parent) {
for (const childId of (parent as AnyNode & { children: string[] }).children) {
markDirty(childId as AnyNodeId)
}
}
}
}
}
} else {
@@ -0,0 +1,61 @@
import type { WallNode } from '../../schema'
import { type Point2D, pointToKey, type WallMiterData } from './wall-mitering'
export const DEFAULT_WALL_THICKNESS = 0.1
export const DEFAULT_WALL_HEIGHT = 2.5
export function getWallThickness(wallNode: WallNode): number {
return wallNode.thickness ?? DEFAULT_WALL_THICKNESS
}
export function getWallPlanFootprint(wallNode: WallNode, miterData: WallMiterData): Point2D[] {
const { junctionData } = miterData
const wallStart: Point2D = { x: wallNode.start[0], y: wallNode.start[1] }
const wallEnd: Point2D = { x: wallNode.end[0], y: wallNode.end[1] }
const thickness = getWallThickness(wallNode)
const halfT = thickness / 2
const v = { x: wallEnd.x - wallStart.x, y: wallEnd.y - wallStart.y }
const L = Math.sqrt(v.x * v.x + v.y * v.y)
if (L < 1e-9) {
return []
}
const nUnit = { x: -v.y / L, y: v.x / L }
const keyStart = pointToKey(wallStart)
const keyEnd = pointToKey(wallEnd)
const startJunction = junctionData.get(keyStart)?.get(wallNode.id)
const endJunction = junctionData.get(keyEnd)?.get(wallNode.id)
const pStartLeft: Point2D = startJunction?.left || {
x: wallStart.x + nUnit.x * halfT,
y: wallStart.y + nUnit.y * halfT,
}
const pStartRight: Point2D = startJunction?.right || {
x: wallStart.x - nUnit.x * halfT,
y: wallStart.y - nUnit.y * halfT,
}
// Junction offsets are stored relative to the outgoing direction.
const pEndLeft: Point2D = endJunction?.right || {
x: wallEnd.x + nUnit.x * halfT,
y: wallEnd.y + nUnit.y * halfT,
}
const pEndRight: Point2D = endJunction?.left || {
x: wallEnd.x - nUnit.x * halfT,
y: wallEnd.y - nUnit.y * halfT,
}
const polygon: Point2D[] = [pStartRight, pEndRight]
if (endJunction) {
polygon.push(wallEnd)
}
polygon.push(pEndLeft, pStartLeft)
if (startJunction) {
polygon.push(wallStart)
}
return polygon
}
+6 -51
View File
@@ -7,11 +7,11 @@ 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 {
calculateLevelMiters,
getAdjacentWallIds,
type Point2D,
pointToKey,
type WallMiterData,
} from './wall-mitering'
@@ -148,17 +148,14 @@ export function generateExtrudedWall(
miterData: WallMiterData,
slabElevation = 0,
) {
const { junctionData } = miterData
const wallStart: Point2D = { x: wallNode.start[0], y: wallNode.start[1] }
const wallEnd: Point2D = { x: wallNode.end[0], y: wallNode.end[1] }
// Positive slab: shift the whole wall up (full height preserved)
// Negative slab: extend wall downward so top stays fixed at wallNode.height
const wallHeight = wallNode.height ?? 2.5
const wallHeight = wallNode.height ?? DEFAULT_WALL_HEIGHT
const height = slabElevation > 0 ? wallHeight : wallHeight - slabElevation
const thickness = wallNode.thickness ?? 0.1
const halfT = thickness / 2
const thickness = getWallThickness(wallNode)
// Wall direction and normal (exactly like demo)
const v = { x: wallEnd.x - wallStart.x, y: wallEnd.y - wallStart.y }
@@ -166,51 +163,9 @@ export function generateExtrudedWall(
if (L < 1e-9) {
return new THREE.BufferGeometry()
}
const nUnit = { x: -v.y / L, y: v.x / L }
// Get junction data for start and end (exactly like demo)
const keyStart = pointToKey(wallStart)
const keyEnd = pointToKey(wallEnd)
const startJunction = junctionData.get(keyStart)?.get(wallNode.id)
const endJunction = junctionData.get(keyEnd)?.get(wallNode.id)
// Calculate polygon corners in world coordinates (exactly like demo)
// p_start_L = left side at start
// p_start_R = right side at start
// p_end_L = left side at end
// p_end_R = right side at end
const p_start_L: Point2D = startJunction?.left || {
x: wallStart.x + nUnit.x * halfT,
y: wallStart.y + nUnit.y * halfT,
}
const p_start_R: Point2D = startJunction?.right || {
x: wallStart.x - nUnit.x * halfT,
y: wallStart.y - nUnit.y * halfT,
}
// At end, SWAP left/right from junction data (exactly like demo)
// This is because junction stores left/right relative to OUTGOING direction,
// which is reversed at the end of the wall
const p_end_L: Point2D = endJunction?.right || {
x: wallEnd.x + nUnit.x * halfT,
y: wallEnd.y + nUnit.y * halfT,
}
const p_end_R: Point2D = endJunction?.left || {
x: wallEnd.x - nUnit.x * halfT,
y: wallEnd.y - nUnit.y * halfT,
}
// Build polygon points (exactly like demo)
// Order: start-right -> end-right -> [end center] -> end-left -> start-left -> [start center]
const polyPoints: Point2D[] = [p_start_R, p_end_R]
if (endJunction) {
polyPoints.push(wallEnd) // Add center vertex at junction
}
polyPoints.push(p_end_L, p_start_L)
if (startJunction) {
polyPoints.push(wallStart) // Add center vertex at junction
const polyPoints = getWallPlanFootprint(wallNode, miterData)
if (polyPoints.length < 3) {
return new THREE.BufferGeometry()
}
// Transform world coordinates to wall-local coordinates