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:
@@ -0,0 +1,16 @@
|
||||
import type { ErrorInfo, ReactNode } from 'react'
|
||||
import { Component } from 'react'
|
||||
|
||||
export class ErrorBoundary extends Component<
|
||||
{ children: ReactNode; fallback: ReactNode },
|
||||
{ hasError: boolean }
|
||||
> {
|
||||
state = { hasError: false }
|
||||
static getDerivedStateFromError() {
|
||||
return { hasError: true }
|
||||
}
|
||||
componentDidCatch(_e: Error, _i: ErrorInfo) {}
|
||||
render() {
|
||||
return this.state.hasError ? this.props.fallback : this.props.children
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import { DoubleSide, 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
|
||||
@@ -47,6 +48,17 @@ const getMaterialForOriginal = (original: Material): MeshStandardNodeMaterial =>
|
||||
return defaultMaterial
|
||||
}
|
||||
|
||||
const BrokenItemFallback = ({ node }: { node: ItemNode }) => {
|
||||
const handlers = useNodeEvents(node, 'item')
|
||||
const [w, h, d] = node.asset.dimensions
|
||||
return (
|
||||
<mesh position-y={h / 2} {...handlers}>
|
||||
<boxGeometry args={[w, h, d]} />
|
||||
<meshStandardMaterial color="#ef4444" opacity={0.6} transparent wireframe />
|
||||
</mesh>
|
||||
)
|
||||
}
|
||||
|
||||
export const ItemRenderer = ({ node }: { node: ItemNode }) => {
|
||||
const ref = useRef<Group>(null!)
|
||||
|
||||
@@ -54,9 +66,11 @@ export const ItemRenderer = ({ node }: { node: ItemNode }) => {
|
||||
|
||||
return (
|
||||
<group position={node.position} ref={ref} rotation={node.rotation} visible={node.visible}>
|
||||
<Suspense fallback={<PreviewModel node={node} />}>
|
||||
<ModelRenderer node={node} />
|
||||
</Suspense>
|
||||
<ErrorBoundary fallback={<BrokenItemFallback node={node} />}>
|
||||
<Suspense fallback={<PreviewModel node={node} />}>
|
||||
<ModelRenderer node={node} />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
{node.children?.map((childId) => (
|
||||
<NodeRenderer key={childId} nodeId={childId} />
|
||||
))}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useRegistry, type WallNode } from '@pascal-app/core'
|
||||
import { useRef } from 'react'
|
||||
import { useRegistry, useScene, type WallNode } from '@pascal-app/core'
|
||||
import { useLayoutEffect, useRef } from 'react'
|
||||
import type { Mesh } from 'three'
|
||||
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||
import { NodeRenderer } from '../node-renderer'
|
||||
@@ -9,6 +9,11 @@ export const WallRenderer = ({ node }: { node: WallNode }) => {
|
||||
|
||||
useRegistry(node.id, 'wall', ref)
|
||||
|
||||
// Mark dirty on mount so WallSystem rebuilds geometry when wall (re)appears
|
||||
useLayoutEffect(() => {
|
||||
useScene.getState().markDirty(node.id)
|
||||
}, [node.id])
|
||||
|
||||
const handlers = useNodeEvents(node, 'wall')
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useScene } from '@pascal-app/core'
|
||||
import { type LevelNode, useScene } from '@pascal-app/core'
|
||||
import polygonClipping from 'polygon-clipping'
|
||||
import { useMemo } from 'react'
|
||||
import * as THREE from 'three'
|
||||
@@ -20,13 +20,39 @@ export const GroundOccluder = () => {
|
||||
s.lineTo(-size, size)
|
||||
s.closePath()
|
||||
|
||||
// Collect all polygons for slabs and zones
|
||||
const levelIndexById = new Map<LevelNode['id'], number>()
|
||||
let lowestLevelIndex = Number.POSITIVE_INFINITY
|
||||
|
||||
Object.values(nodes).forEach((node) => {
|
||||
if (node.type !== 'level') {
|
||||
return
|
||||
}
|
||||
|
||||
levelIndexById.set(node.id, node.level)
|
||||
lowestLevelIndex = Math.min(lowestLevelIndex, node.level)
|
||||
})
|
||||
|
||||
// Only the lowest level should punch through the ground plane.
|
||||
// Upper-level slabs should still cast shadows, but they should not
|
||||
// reveal their footprint on the level-zero ground material.
|
||||
const polygons: [number, number][][] = []
|
||||
|
||||
Object.values(nodes).forEach((node) => {
|
||||
if (node.type === 'slab' && node.polygon && node.polygon.length >= 3) {
|
||||
polygons.push(node.polygon as [number, number][])
|
||||
if (!(node.type === 'slab' && node.visible && node.polygon.length >= 3)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (Number.isFinite(lowestLevelIndex)) {
|
||||
const parentLevelIndex = node.parentId
|
||||
? levelIndexById.get(node.parentId as LevelNode['id'])
|
||||
: undefined
|
||||
|
||||
if (parentLevelIndex !== lowestLevelIndex) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
polygons.push(node.polygon as [number, number][])
|
||||
})
|
||||
|
||||
if (polygons.length > 0) {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { loadAssetUrl } from '@pascal-app/core'
|
||||
|
||||
// @ts-expect-error
|
||||
export const ASSETS_CDN_URL = process.env.NEXT_PUBLIC_ASSETS_CDN_URL || 'https://editor.pascal.app'
|
||||
|
||||
/**
|
||||
|
||||
Vendored
+94
-6
@@ -1,8 +1,96 @@
|
||||
// Augment @react-three/fiber's ThreeElements to include all Three.js JSX intrinsic elements.
|
||||
// This must be a project-wide declaration so all files can use <directionalLight />, etc.
|
||||
import type { ThreeToJSXElements } from '@react-three/fiber'
|
||||
import * as THREE from 'three/webgpu'
|
||||
/**
|
||||
* R3F JSX intrinsic element declarations for three.js primitives.
|
||||
*
|
||||
* @react-three/fiber augments JSX.IntrinsicElements globally via module
|
||||
* augmentation, but the augmentation doesn't reliably propagate during
|
||||
* composite tsc --build in CI because bun resolves @react-three/fiber's
|
||||
* peer deps into variant directories where @types/three is unreachable.
|
||||
*
|
||||
* This file replicates the module augmentation pattern R3F uses, declaring
|
||||
* the subset of three.js elements we actually use.
|
||||
*
|
||||
* The empty export makes this file a module, which is required for
|
||||
* `declare module` to augment existing modules rather than replace them.
|
||||
*/
|
||||
|
||||
declare module '@react-three/fiber' {
|
||||
interface ThreeElements extends ThreeToJSXElements<typeof THREE> {}
|
||||
export {}
|
||||
|
||||
interface ThreeJSXElements {
|
||||
// Containers
|
||||
group: any
|
||||
scene: any
|
||||
// Geometries
|
||||
boxGeometry: any
|
||||
planeGeometry: any
|
||||
circleGeometry: any
|
||||
cylinderGeometry: any
|
||||
sphereGeometry: any
|
||||
extrudeGeometry: any
|
||||
shapeGeometry: any
|
||||
bufferGeometry: any
|
||||
edgesGeometry: any
|
||||
ringGeometry: any
|
||||
// Meshes & lines
|
||||
mesh: any
|
||||
instancedMesh: any
|
||||
line: any
|
||||
lineSegments: any
|
||||
lineLoop: any
|
||||
points: any
|
||||
// Materials
|
||||
meshStandardMaterial: any
|
||||
meshBasicMaterial: any
|
||||
meshPhongMaterial: any
|
||||
meshLambertMaterial: any
|
||||
meshPhysicalMaterial: any
|
||||
meshNormalMaterial: any
|
||||
shadowMaterial: any
|
||||
lineBasicMaterial: any
|
||||
lineDashedMaterial: any
|
||||
pointsMaterial: any
|
||||
shaderMaterial: any
|
||||
rawShaderMaterial: any
|
||||
spriteMaterial: any
|
||||
// Lights
|
||||
ambientLight: any
|
||||
directionalLight: any
|
||||
pointLight: any
|
||||
spotLight: any
|
||||
hemisphereLight: any
|
||||
rectAreaLight: any
|
||||
// Cameras
|
||||
perspectiveCamera: any
|
||||
orthographicCamera: any
|
||||
// Helpers
|
||||
gridHelper: any
|
||||
axesHelper: any
|
||||
arrowHelper: any
|
||||
// Misc
|
||||
sprite: any
|
||||
lOD: any
|
||||
fog: any
|
||||
color: any
|
||||
// Buffer attribute
|
||||
bufferAttribute: any
|
||||
instancedBufferAttribute: any
|
||||
// Primitive (R3F-specific)
|
||||
primitive: any
|
||||
}
|
||||
|
||||
declare module 'react' {
|
||||
namespace JSX {
|
||||
interface IntrinsicElements extends ThreeJSXElements {}
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'react/jsx-runtime' {
|
||||
namespace JSX {
|
||||
interface IntrinsicElements extends ThreeJSXElements {}
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'react/jsx-dev-runtime' {
|
||||
namespace JSX {
|
||||
interface IntrinsicElements extends ThreeJSXElements {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,9 @@ type ViewerState = {
|
||||
theme: 'light' | 'dark'
|
||||
setTheme: (theme: 'light' | 'dark') => void
|
||||
|
||||
unit: 'metric' | 'imperial'
|
||||
setUnit: (unit: 'metric' | 'imperial') => void
|
||||
|
||||
levelMode: 'stacked' | 'exploded' | 'solo' | 'manual'
|
||||
setLevelMode: (mode: 'stacked' | 'exploded' | 'solo' | 'manual') => void
|
||||
|
||||
@@ -81,6 +84,9 @@ const useViewer = create<ViewerState>()(
|
||||
theme: 'light',
|
||||
setTheme: (theme) => set({ theme }),
|
||||
|
||||
unit: 'metric',
|
||||
setUnit: (unit) => set({ unit }),
|
||||
|
||||
levelMode: 'stacked',
|
||||
setLevelMode: (mode) => set({ levelMode: mode }),
|
||||
|
||||
@@ -187,6 +193,7 @@ const useViewer = create<ViewerState>()(
|
||||
partialize: (state) => ({
|
||||
cameraMode: state.cameraMode,
|
||||
theme: state.theme,
|
||||
unit: state.unit,
|
||||
levelMode: state.levelMode,
|
||||
wallMode: state.wallMode,
|
||||
projectPreferences: state.projectPreferences,
|
||||
|
||||
@@ -286,7 +286,7 @@ export function ItemLightSystem() {
|
||||
castShadow={false}
|
||||
intensity={0}
|
||||
key={i}
|
||||
ref={(el) => {
|
||||
ref={(el: any) => {
|
||||
lightRefs.current[i] = el
|
||||
}}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user