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:
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user