Merge remote-tracking branch 'origin/main' into feat/editor-ux-rendering-placement

This commit is contained in:
Aymeric Rabot
2026-06-10 14:21:35 -04:00
51 changed files with 1338 additions and 515 deletions
+4 -4
View File
@@ -12,7 +12,7 @@
}, },
"dependencies": { "dependencies": {
"@iconify/react": "^6.0.2", "@iconify/react": "^6.0.2",
"@number-flow/react": "^0.5.14", "@number-flow/react": "^0.6.0",
"@pascal-app/core": "*", "@pascal-app/core": "*",
"@pascal-app/editor": "*", "@pascal-app/editor": "*",
"@pascal-app/mcp": "*", "@pascal-app/mcp": "*",
@@ -25,7 +25,7 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"geist": "^1.7.0", "geist": "^1.7.0",
"lucide-react": "^1.7.0", "lucide-react": "^1.7.0",
"next": "16.2.1", "next": "16.2.6",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
@@ -40,10 +40,10 @@
"@types/node": "^22.19.12", "@types/node": "^22.19.12",
"@types/react": "19.2.2", "@types/react": "19.2.2",
"@types/react-dom": "19.2.2", "@types/react-dom": "19.2.2",
"agentation": "^2.3.2", "agentation": "^3.0.2",
"react-grab": "^0.1.29", "react-grab": "^0.1.29",
"react-scan": "^0.5.3", "react-scan": "^0.5.3",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "6.0.2" "typescript": "6.0.3"
} }
} }
+2 -2
View File
@@ -23,7 +23,7 @@
"@react-three/fiber": "^9.5.0", "@react-three/fiber": "^9.5.0",
"@tailwindcss/postcss": "^4.2.1", "@tailwindcss/postcss": "^4.2.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"next": "16.2.1", "next": "16.2.6",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
@@ -39,6 +39,6 @@
"@types/react": "^19.2.2", "@types/react": "^19.2.2",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/three": "^0.184.0", "@types/three": "^0.184.0",
"typescript": "6.0.2" "typescript": "6.0.3"
} }
} }
+497 -273
View File
File diff suppressed because it is too large Load Diff
+7 -6
View File
@@ -23,20 +23,21 @@
"release:major": "gh workflow run release.yml -f package=all -f bump=major" "release:major": "gh workflow run release.yml -f package=all -f bump=major"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^2.4.6", "@biomejs/biome": "^2.4.16",
"dotenv-cli": "^11.0.0", "dotenv-cli": "^11.0.0",
"turbo": "^2.8.15", "turbo": "^2.9.17",
"typescript": "6.0.2", "typescript": "6.0.3",
"ultracite": "^7.2.5" "ultracite": "^7.8.2"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
}, },
"packageManager": "bun@1.3.0", "packageManager": "bun@1.3.0",
"overrides": { "overrides": {
"@types/react": "19.2.14", "@types/react": "19.2.17",
"@types/react-dom": "19.2.3", "@types/react-dom": "19.2.3",
"@types/three": "0.184.0" "@types/three": "0.184.1",
"three": "0.184.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@tailwindcss/oxide-darwin-arm64": "4.3.0", "@tailwindcss/oxide-darwin-arm64": "4.3.0",
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@pascal-app/core", "name": "@pascal-app/core",
"version": "0.8.0", "version": "0.9.1",
"description": "Core library for Pascal 3D building editor", "description": "Core library for Pascal 3D building editor",
"type": "module", "type": "module",
"main": "./dist/index.js", "main": "./dist/index.js",
@@ -83,7 +83,7 @@
"@types/bun": "^1.3.0", "@types/bun": "^1.3.0",
"@types/react": "^19.2.2", "@types/react": "^19.2.2",
"@types/three": "^0.184.0", "@types/three": "^0.184.0",
"typescript": "6.0.2" "typescript": "6.0.3"
}, },
"keywords": [ "keywords": [
"3d", "3d",
+10 -1
View File
@@ -220,6 +220,14 @@ type AIChatEvents = {
} }
} }
export interface RoomPresetCreateEvent {
zoneId: ZoneNode['id']
}
type RoomPresetEvents = {
'room-preset:create': RoomPresetCreateEvent
}
type EditorEvents = GridEvents & type EditorEvents = GridEvents &
NodeEvents<'wall', WallEvent> & NodeEvents<'wall', WallEvent> &
NodeEvents<'fence', FenceEvent> & NodeEvents<'fence', FenceEvent> &
@@ -260,6 +268,7 @@ type EditorEvents = GridEvents &
WindowAnimationEvents & WindowAnimationEvents &
ThumbnailEvents & ThumbnailEvents &
SnapshotEvents & SnapshotEvents &
AIChatEvents AIChatEvents &
RoomPresetEvents
export const emitter = mitt<EditorEvents>() export const emitter = mitt<EditorEvents>()
+2
View File
@@ -20,6 +20,7 @@ export type {
RidgeVentEvent, RidgeVentEvent,
RoofEvent, RoofEvent,
RoofSegmentEvent, RoofSegmentEvent,
RoomPresetCreateEvent,
ScanEvent, ScanEvent,
ShelfEvent, ShelfEvent,
SiteEvent, SiteEvent,
@@ -60,6 +61,7 @@ export {
isOperationDoorType, isOperationDoorType,
SECTIONAL_GARAGE_RENDER_OPEN_SCALE, SECTIONAL_GARAGE_RENDER_OPEN_SCALE,
} from './lib/door-operation' } from './lib/door-operation'
export { getDefaultLevelName, getLevelDisplayName } from './lib/level-name'
export { export {
type Point2D as PolygonPoint2D, type Point2D as PolygonPoint2D,
pointInPolygon as pointInPolygon2D, pointInPolygon as pointInPolygon2D,
@@ -1,4 +1,4 @@
import type { LevelNode } from '@pascal-app/core' import type { LevelNode } from '../schema'
export function getDefaultLevelName(level: number): string { export function getDefaultLevelName(level: number): string {
if (level === 0) return 'Ground Floor' if (level === 0) return 'Ground Floor'
@@ -0,0 +1,70 @@
import { describe, expect, test } from 'bun:test'
import type { CollectionId } from '../schema/collections'
import type { AnyNode, AnyNodeId } from '../schema/types'
import { forkSceneGraph, type SceneGraph } from './clone-scene-graph'
function makeNode(id: string, type: string, extra: Record<string, unknown> = {}): AnyNode {
return {
object: 'node',
id,
type,
parentId: null,
visible: true,
metadata: {},
...extra,
} as unknown as AnyNode
}
function makeSceneGraph(): SceneGraph {
const site = makeNode('site_1', 'site', { children: ['level_1'] })
const level = makeNode('level_1', 'level', {
parentId: 'site_1',
children: ['wall_1', 'scan_1', 'guide_1'],
})
const wall = makeNode('wall_1', 'wall', { parentId: 'level_1' })
const scan = makeNode('scan_1', 'scan', { parentId: 'level_1', url: 'scan.glb' })
const guide = makeNode('guide_1', 'guide', { parentId: 'level_1', url: 'guide.png' })
return {
nodes: {
['site_1' as AnyNodeId]: site,
['level_1' as AnyNodeId]: level,
['wall_1' as AnyNodeId]: wall,
['scan_1' as AnyNodeId]: scan,
['guide_1' as AnyNodeId]: guide,
},
rootNodeIds: ['site_1' as AnyNodeId],
collections: {
['collection_1' as CollectionId]: {
id: 'collection_1' as CollectionId,
name: 'References',
nodeIds: ['scan_1', 'guide_1'] as AnyNodeId[],
},
},
}
}
describe('forkSceneGraph', () => {
test('strips scan and guide nodes by default', () => {
const forked = forkSceneGraph(makeSceneGraph())
const nodes = Object.values(forked.nodes)
expect(nodes.some((node) => node.type === 'scan')).toBe(false)
expect(nodes.some((node) => node.type === 'guide')).toBe(false)
expect(nodes.some((node) => node.type === 'wall')).toBe(true)
expect(forked.collections).toEqual({})
})
test('preserves scan and guide nodes when requested', () => {
const forked = forkSceneGraph(makeSceneGraph(), { preserveScans: true })
const nodes = Object.values(forked.nodes)
expect(nodes.some((node) => node.type === 'scan')).toBe(true)
expect(nodes.some((node) => node.type === 'guide')).toBe(true)
expect(nodes.map((node) => node.id)).not.toContain('scan_1')
expect(nodes.map((node) => node.id)).not.toContain('guide_1')
expect(
Object.values(forked.collections ?? {}).flatMap((collection) => collection.nodeIds),
).toHaveLength(2)
})
})
+14 -4
View File
@@ -226,12 +226,22 @@ export function cloneLevelSubtree(
return { clonedNodes, newLevelId, idMap } return { clonedNodes, newLevelId, idMap }
} }
export type ForkSceneGraphOptions = {
preserveScans?: boolean
}
/** /**
* Forks a scene graph for use as a new project: clones with new IDs and strips * Forks a scene graph for use as a new project: clones with new IDs and, by
* scan and guide nodes (and their references) since those contain user-uploaded * default, strips scan and guide nodes since they contain user-uploaded imagery.
* imagery that shouldn't carry over to a forked project.
*/ */
export function forkSceneGraph(sceneGraph: SceneGraph): SceneGraph { export function forkSceneGraph(
sceneGraph: SceneGraph,
options: ForkSceneGraphOptions = {},
): SceneGraph {
if (options.preserveScans) {
return cloneSceneGraph(sceneGraph)
}
const { nodes, rootNodeIds, collections } = sceneGraph const { nodes, rootNodeIds, collections } = sceneGraph
// First, identify scan and guide node IDs to exclude (user-uploaded imagery) // First, identify scan and guide node IDs to exclude (user-uploaded imagery)
+8 -9
View File
@@ -1,6 +1,6 @@
{ {
"name": "@pascal-app/editor", "name": "@pascal-app/editor",
"version": "0.8.0", "version": "0.9.1",
"description": "Pascal building editor component", "description": "Pascal building editor component",
"type": "module", "type": "module",
"exports": { "exports": {
@@ -11,8 +11,8 @@
"check-types": "tsc --noEmit" "check-types": "tsc --noEmit"
}, },
"peerDependencies": { "peerDependencies": {
"@pascal-app/core": "^0.8.0", "@pascal-app/core": "^0.9.1",
"@pascal-app/viewer": "^0.8.0", "@pascal-app/viewer": "^0.9.1",
"@react-three/drei": "^10", "@react-three/drei": "^10",
"@react-three/fiber": "^9", "@react-three/fiber": "^9",
"next": ">=15", "next": ">=15",
@@ -25,7 +25,7 @@
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@iconify/react": "^6.0.2", "@iconify/react": "^6.0.2",
"@number-flow/react": "^0.5.14", "@number-flow/react": "^0.6.0",
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
@@ -38,8 +38,7 @@
"@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@react-three/uikit-lucide": "^1.0.62", "@visual-json/react": "^0.4.0",
"@visual-json/react": "latest",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
@@ -54,14 +53,14 @@
"three-mesh-bvh": "~0.9.8" "three-mesh-bvh": "~0.9.8"
}, },
"devDependencies": { "devDependencies": {
"@pascal-app/core": "^0.8.0", "@pascal-app/core": "^0.9.1",
"@pascal-app/viewer": "^0.8.0", "@pascal-app/viewer": "^0.9.1",
"@pascal/typescript-config": "*", "@pascal/typescript-config": "*",
"@types/bun": "^1.3.0", "@types/bun": "^1.3.0",
"@types/howler": "^2.2.12", "@types/howler": "^2.2.12",
"@types/react": "19.2.2", "@types/react": "19.2.2",
"@types/react-dom": "19.2.2", "@types/react-dom": "19.2.2",
"@types/three": "^0.184.0", "@types/three": "^0.184.0",
"typescript": "6.0.2" "typescript": "6.0.3"
} }
} }
@@ -948,10 +948,21 @@ export const CustomCameraControls = () => {
const sub = new Box3().setFromObject(obj) const sub = new Box3().setFromObject(obj)
if (!sub.isEmpty()) tempBox.union(sub) if (!sub.isEmpty()) tempBox.union(sub)
} }
if (tempBox.isEmpty()) return if (captureMode.framingBounds) {
const { center, max, min, size } = captureMode.framingBounds
const fallbackHeight = Math.max(Math.max(size[0], size[1]) * 0.35, 2.5)
const minY = tempBox.isEmpty() ? 0 : tempBox.min.y
const maxY = tempBox.isEmpty() ? fallbackHeight : tempBox.max.y
tempBox.min.set(min[0], minY, min[1])
tempBox.max.set(max[0], Math.max(maxY, minY + 0.1), max[1])
tempCenter.set(center[0], (tempBox.min.y + tempBox.max.y) / 2, center[1])
tempSize.set(size[0], tempBox.max.y - tempBox.min.y, size[1])
} else {
if (tempBox.isEmpty()) return
tempBox.getCenter(tempCenter) tempBox.getCenter(tempCenter)
tempBox.getSize(tempSize) tempBox.getSize(tempSize)
}
// Distance heuristic: fit the subject inside the 75%-of-shorter- // Distance heuristic: fit the subject inside the 75%-of-shorter-
// side square crop with comfortable padding. Multiplier 2.4 leaves // side square crop with comfortable padding. Multiplier 2.4 leaves
@@ -75,6 +75,7 @@ import {
type FloorplanNodeTransform as SharedFloorplanNodeTransform, type FloorplanNodeTransform as SharedFloorplanNodeTransform,
} from '../../lib/floorplan' } from '../../lib/floorplan'
import { guideEmitter } from '../../lib/guide-events' import { guideEmitter } from '../../lib/guide-events'
import { formatLinearMeasurement, linearUnitToMeters } from '../../lib/measurements'
import { sfxEmitter } from '../../lib/sfx-bus' import { sfxEmitter } from '../../lib/sfx-bus'
import { SITE_BOUNDARY_DRAG_LABEL } from '../../lib/site-boundary' import { SITE_BOUNDARY_DRAG_LABEL } from '../../lib/site-boundary'
import { resolveSlabPlanPointSnap } from '../../lib/slab-plan-snap' import { resolveSlabPlanPointSnap } from '../../lib/slab-plan-snap'
@@ -2616,14 +2617,7 @@ function formatMeasurement(
metersPerUnit: number | null = null, metersPerUnit: number | null = null,
) { ) {
const measuredValue = metersPerUnit && metersPerUnit > 0 ? value * metersPerUnit : value const measuredValue = metersPerUnit && metersPerUnit > 0 ? value * metersPerUnit : value
if (unit === 'imperial') { return formatLinearMeasurement(measuredValue, unit)
const feet = measuredValue * 3.280_84
const wholeFeet = Math.floor(feet)
const inches = Math.round((feet - wholeFeet) * 12)
if (inches === 12) return `${wholeFeet + 1}'0"`
return `${wholeFeet}'${inches}"`
}
return `${Number.parseFloat(measuredValue.toFixed(2))}m`
} }
function formatNumber(value: number, fractionDigits = 2) { function formatNumber(value: number, fractionDigits = 2) {
@@ -2635,7 +2629,7 @@ function convertReferenceLengthToMeters(value: number, unit: ReferenceScaleUnit)
case 'centimeters': case 'centimeters':
return value / 100 return value / 100
case 'feet': case 'feet':
return value * 0.3048 return linearUnitToMeters(value, 'imperial')
case 'inches': case 'inches':
return value * 0.0254 return value * 0.0254
default: default:
@@ -4527,7 +4521,17 @@ const FloorplanPolygonHandleLayer = memo(function FloorplanPolygonHandleLayer({
) )
}) })
export function FloorplanPanel() { export function FloorplanPanel({
/**
* Element to portal the compass button into. The 2D/3D navigation poses stay
* in sync (`navigationSyncPose`), so hosting the compass on the always-visible
* viewer-area container keeps it correct needle and align-to-north alike
* in 2d, 3d, and split modes, while this panel itself may be display:none.
*/
compassHost,
}: {
compassHost?: HTMLElement | null
}) {
const viewportHostRef = useRef<HTMLDivElement>(null) const viewportHostRef = useRef<HTMLDivElement>(null)
const svgRef = useRef<SVGSVGElement>(null) const svgRef = useRef<SVGSVGElement>(null)
const floorplanSceneRef = useRef<SVGGElement>(null) const floorplanSceneRef = useRef<SVGGElement>(null)
@@ -10180,12 +10184,21 @@ export function FloorplanPanel() {
only action menu the floor plan mounts. */} only action menu the floor plan mounts. */}
<FloorplanRegistryActionMenu /> <FloorplanRegistryActionMenu />
{(levelNode?.type === 'level' || hasAmbientBuildingLevel) && ( {(levelNode?.type === 'level' || hasAmbientBuildingLevel) &&
<FloorplanCompassButton (compassHost ? (
northRotationDeg={-floorplanUserRotationDeg} createPortal(
onAlignNorth={alignFloorplanViewToNorth} <FloorplanCompassButton
/> northRotationDeg={-floorplanUserRotationDeg}
)} onAlignNorth={alignFloorplanViewToNorth}
/>,
compassHost,
)
) : (
<FloorplanCompassButton
northRotationDeg={-floorplanUserRotationDeg}
onAlignNorth={alignFloorplanViewToNorth}
/>
))}
{referenceScaleDraft && ( {referenceScaleDraft && (
<div className="pointer-events-none absolute top-3 left-1/2 z-30 -translate-x-1/2 rounded-md border bg-background/95 px-3 py-2 text-center text-sm shadow-sm"> <div className="pointer-events-none absolute top-3 left-1/2 z-30 -translate-x-1/2 rounded-md border bg-background/95 px-3 py-2 text-center text-sm shadow-sm">
@@ -817,6 +817,13 @@ const ViewerCanvas = memo(function ViewerCanvas({
) )
const viewerAreaRef = useRef<HTMLDivElement>(null) const viewerAreaRef = useRef<HTMLDivElement>(null)
// State mirror of `viewerAreaRef` so the floorplan compass portal re-renders
// once the container exists (a plain ref mutation wouldn't trigger it).
const [viewerAreaEl, setViewerAreaEl] = useState<HTMLDivElement | null>(null)
const setViewerAreaNode = useCallback((el: HTMLDivElement | null) => {
viewerAreaRef.current = el
setViewerAreaEl(el)
}, [])
const viewer3dRef = useRef<HTMLDivElement>(null) const viewer3dRef = useRef<HTMLDivElement>(null)
const isResizingFloorplan = useRef(false) const isResizingFloorplan = useRef(false)
@@ -862,7 +869,9 @@ const ViewerCanvas = memo(function ViewerCanvas({
return ( return (
<ErrorBoundary fallback={<EditorSceneCrashFallback />}> <ErrorBoundary fallback={<EditorSceneCrashFallback />}>
<div className="flex h-full" ref={viewerAreaRef}> {/* `relative` so the floorplan compass (portaled here to stay visible in
2d / 3d / split alike) can anchor to this container's bottom-left. */}
<div className="relative flex h-full" ref={setViewerAreaNode}>
{/* 2D floorplan — always mounted once shown, hidden via CSS to preserve state */} {/* 2D floorplan — always mounted once shown, hidden via CSS to preserve state */}
<div <div
className="relative h-full flex-shrink-0" className="relative h-full flex-shrink-0"
@@ -872,7 +881,7 @@ const ViewerCanvas = memo(function ViewerCanvas({
}} }}
> >
<div className="h-full w-full overflow-hidden"> <div className="h-full w-full overflow-hidden">
<FloorplanPanel /> <FloorplanPanel compassHost={viewerAreaEl} />
</div> </div>
{viewMode === 'split' && ( {viewMode === 'split' && (
<div <div
@@ -7,9 +7,9 @@ import { Html } from '@react-three/drei'
import { createPortal, useFrame, useThree } from '@react-three/fiber' import { createPortal, useFrame, useThree } from '@react-three/fiber'
import { useCallback, useMemo, useRef, useState } from 'react' import { useCallback, useMemo, useRef, useState } from 'react'
import { type Camera, type Object3D, Vector3 } from 'three' import { type Camera, type Object3D, Vector3 } from 'three'
import { formatLinearMeasurement } from '../../lib/measurements'
import { SITE_BOUNDARY_DRAG_LABEL } from '../../lib/site-boundary' import { SITE_BOUNDARY_DRAG_LABEL } from '../../lib/site-boundary'
import useEditor from '../../store/use-editor' import useEditor from '../../store/use-editor'
import { formatMeasurement } from './measurement-pill'
type ViewportSize = { type ViewportSize = {
width: number width: number
@@ -110,7 +110,7 @@ export function SiteEdgeLabels() {
textShadow: `-1.5px -1.5px 0 ${shadowColor}, 1.5px -1.5px 0 ${shadowColor}, -1.5px 1.5px 0 ${shadowColor}, 1.5px 1.5px 0 ${shadowColor}, 0 0 4px ${shadowColor}, 0 0 4px ${shadowColor}`, textShadow: `-1.5px -1.5px 0 ${shadowColor}, 1.5px -1.5px 0 ${shadowColor}, -1.5px 1.5px 0 ${shadowColor}, 1.5px 1.5px 0 ${shadowColor}, 0 0 4px ${shadowColor}, 0 0 4px ${shadowColor}`,
}} }}
> >
{formatMeasurement(edge.dist, unit)} {formatLinearMeasurement(edge.dist, unit)}
</div> </div>
</Html> </Html>
))} ))}
@@ -23,6 +23,7 @@ import { Html } from '@react-three/drei'
import { createPortal, useFrame } from '@react-three/fiber' import { createPortal, useFrame } from '@react-three/fiber'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import * as THREE from 'three' import * as THREE from 'three'
import { formatLinearMeasurement } from '../../lib/measurements'
const GUIDE_Y_OFFSET = 0.08 const GUIDE_Y_OFFSET = 0.08
const LABEL_LIFT = 0.08 const LABEL_LIFT = 0.08
@@ -62,17 +63,6 @@ type WallFaceLine = {
end: Point2D end: Point2D
} }
function formatMeasurement(value: number, unit: 'metric' | 'imperial') {
if (unit === 'imperial') {
const feet = value * 3.280_84
const wholeFeet = Math.floor(feet)
const inches = Math.round((feet - wholeFeet) * 12)
if (inches === 12) return `${wholeFeet + 1}'0"`
return `${wholeFeet}'${inches}"`
}
return `${Number.parseFloat(value.toFixed(2))}m`
}
export function WallMeasurementLabel() { export function WallMeasurementLabel() {
const selectedIds = useViewer((state) => state.selection.selectedIds) const selectedIds = useViewer((state) => state.selection.selectedIds)
const nodes = useScene((state) => state.nodes) const nodes = useScene((state) => state.nodes)
@@ -547,8 +537,8 @@ function WallMeasurementAnnotation({ wall }: { wall: WallNode }) {
} }
return total return total
}, [guide, wall]) }, [guide, wall])
const label = formatMeasurement(length, unit) const label = formatLinearMeasurement(length, unit)
const heightLabel = `H ${formatMeasurement(wall.height ?? DEFAULT_WALL_HEIGHT, unit)}` const heightLabel = `H ${formatLinearMeasurement(wall.height ?? DEFAULT_WALL_HEIGHT, unit)}`
if (!(guide && Number.isFinite(length) && length >= 0.01)) return null if (!(guide && Number.isFinite(length) && length >= 0.01)) return null
@@ -41,6 +41,7 @@ import {
import { distance, smoothstep, uv, vec2 } from 'three/tsl' import { distance, smoothstep, uv, vec2 } from 'three/tsl'
import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu' import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
import { EDITOR_LAYER } from '../../../lib/constants' import { EDITOR_LAYER } from '../../../lib/constants'
import { formatLinearMeasurement } from '../../../lib/measurements'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import { resolveAlignmentForActiveBuilding } from '../../../lib/world-grid-snap' import { resolveAlignmentForActiveBuilding } from '../../../lib/world-grid-snap'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
@@ -70,17 +71,6 @@ const DEFAULT_DIMENSIONS: [number, number, number] = [1, 1, 1]
* floor-plan overlay and the 3D registry move tool. */ * floor-plan overlay and the 3D registry move tool. */
const ALIGNMENT_THRESHOLD_M = 0.08 const ALIGNMENT_THRESHOLD_M = 0.08
function formatMeasurement(value: number, unit: 'metric' | 'imperial') {
if (unit === 'imperial') {
const feet = value * 3.280_84
const wholeFeet = Math.floor(feet)
const inches = Math.round((feet - wholeFeet) * 12)
if (inches === 12) return `${wholeFeet + 1}'0"`
return `${wholeFeet}'${inches}"`
}
return `${Number.parseFloat(value.toFixed(2))}m`
}
/** /**
* Expand `bounds` outward so each axis is rounded up to the active grid step. * Expand `bounds` outward so each axis is rounded up to the active grid step.
* The wireframe stays centered on the original bounds centre on each axis we * The wireframe stays centered on the original bounds centre on each axis we
@@ -2025,9 +2015,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
const initialDepthGuideGeometry = useMemo(() => createLineGeometry(), []) const initialDepthGuideGeometry = useMemo(() => createLineGeometry(), [])
const initialHeightGuideGeometry = useMemo(() => createLineGeometry(), []) const initialHeightGuideGeometry = useMemo(() => createLineGeometry(), [])
const currentDimensionBounds = dimensionBounds ?? initialDimensionBounds const currentDimensionBounds = dimensionBounds ?? initialDimensionBounds
const widthLabel = formatMeasurement(currentDimensionBounds.dimensions[0], unit) const widthLabel = formatLinearMeasurement(currentDimensionBounds.dimensions[0], unit)
const depthLabel = formatMeasurement(currentDimensionBounds.dimensions[2], unit) const depthLabel = formatLinearMeasurement(currentDimensionBounds.dimensions[2], unit)
const heightLabel = formatMeasurement(currentDimensionBounds.dimensions[1], unit) const heightLabel = formatLinearMeasurement(currentDimensionBounds.dimensions[1], unit)
const widthLabelPosition: [number, number, number] = [ const widthLabelPosition: [number, number, number] = [
currentDimensionBounds.center[0], currentDimensionBounds.center[0],
0.04, 0.04,
@@ -360,6 +360,15 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
* AND scene updated) — never the original. * AND scene updated) — never the original.
*/ */
const commitAtCursor = (event: ClickTriggerEvent) => { const commitAtCursor = (event: ClickTriggerEvent) => {
// One physical click can reach here twice: node clicks (`slab:click`,
// `item:click`, …) are synthesized on *pointerup* (`use-node-events`),
// while `grid:click` rides the browser's native *click* event from a
// canvas DOM listener (`use-grid-events`) that deliberately ignores
// stopPropagation — and this effect stays subscribed until React
// re-renders after `exitMoveMode`. Without this guard the second pass
// finds the fresh draft already deleted and takes the orphan re-create
// path below, minting a hidden ghost copy and replaying the SFX.
if (committed) return
// Ignore a commit that fires before the cursor has moved into place — // Ignore a commit that fires before the cursor has moved into place —
// it's the stray trailing click of whatever armed this move, not a // it's the stray trailing click of whatever armed this move, not a
// deliberate drop. Prevents preset re-arm from double-placing. // deliberate drop. Prevents preset re-arm from double-placing.
@@ -184,7 +184,7 @@ export const ZoneTool: React.FC = () => {
const [gridX, gridZ] = snapWorldXZForActiveBuilding( const [gridX, gridZ] = snapWorldXZForActiveBuilding(
event.position[0], event.position[0],
event.position[2], event.position[2],
0.5, useEditor.getState().gridSnapStep,
).local ).local
cursorPosition = [gridX, gridZ] cursorPosition = [gridX, gridZ]
levelYRef.current = event.localPosition[1] levelYRef.current = event.localPosition[1]
@@ -217,7 +217,7 @@ export const ZoneTool: React.FC = () => {
const [gridX, gridZ] = snapWorldXZForActiveBuilding( const [gridX, gridZ] = snapWorldXZForActiveBuilding(
event.position[0], event.position[0],
event.position[2], event.position[2],
0.5, useEditor.getState().gridSnapStep,
).local ).local
let clickPoint: [number, number] = [gridX, gridZ] let clickPoint: [number, number] = [gridX, gridZ]
@@ -13,7 +13,7 @@ import { useViewer } from '@pascal-app/viewer'
import { Check, ChevronDown, Eye, EyeOff, Layers2, Plus, Trash2 } from 'lucide-react' import { Check, ChevronDown, Eye, EyeOff, Layers2, Plus, Trash2 } from 'lucide-react'
import { useCallback, useRef, useState } from 'react' import { useCallback, useRef, useState } from 'react'
import { useShallow } from 'zustand/react/shallow' import { useShallow } from 'zustand/react/shallow'
import { getLevelDisplayName } from '../../../lib/level-name' import { getLevelDisplayName } from '@pascal-app/core'
import { createLocalGuideImage } from '../../../lib/local-guide-image' import { createLocalGuideImage } from '../../../lib/local-guide-image'
import { cn } from '../../../lib/utils' import { cn } from '../../../lib/utils'
import useEditor, { type GridSnapStep } from '../../../store/use-editor' import useEditor, { type GridSnapStep } from '../../../store/use-editor'
@@ -10,7 +10,7 @@ import { useEffect, useState } from 'react'
import { create } from 'zustand' import { create } from 'zustand'
import { useShallow } from 'zustand/shallow' import { useShallow } from 'zustand/shallow'
import { Dialog, DialogContent, DialogTitle } from './../../../components/ui/primitives/dialog' import { Dialog, DialogContent, DialogTitle } from './../../../components/ui/primitives/dialog'
import { getLevelDisplayName } from '../../../lib/level-name' import { getLevelDisplayName } from '@pascal-app/core'
import { useCommandRegistry } from '../../../store/use-command-registry' import { useCommandRegistry } from '../../../store/use-command-registry'
import { usePaletteViewRegistry } from '../../../store/use-palette-view-registry' import { usePaletteViewRegistry } from '../../../store/use-palette-view-registry'
@@ -3,6 +3,11 @@
import { useScene } from '@pascal-app/core' import { useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import {
getLinearUnitLabel,
linearUnitToMeters,
metersToLinearUnit,
} from '../../../lib/measurements'
import { cn } from '../../../lib/utils' import { cn } from '../../../lib/utils'
interface MetricControlProps { interface MetricControlProps {
@@ -34,10 +39,30 @@ export function MetricControl({
}: MetricControlProps) { }: MetricControlProps) {
const viewerUnit = useViewer((state) => state.unit) const viewerUnit = useViewer((state) => state.unit)
const isImperial = viewerUnit === 'imperial' && unit === 'm' const isImperial = viewerUnit === 'imperial' && unit === 'm'
const multiplier = isImperial ? 3.280_84 : 1 const displayUnit = isImperial ? getLinearUnitLabel('imperial') : unit
const displayUnit = isImperial ? 'ft' : unit
const displayValue = value * multiplier const toDisplayValue = useCallback(
(storedValue: number) => (isImperial ? metersToLinearUnit(storedValue, 'imperial') : storedValue),
[isImperial],
)
const toStoredValue = useCallback(
(displayValue: number) =>
isImperial ? linearUnitToMeters(displayValue, 'imperial') : displayValue,
[isImperial],
)
const clamp = useCallback(
(val: number) => {
return Math.min(Math.max(val, min), max)
},
[min, max],
)
const roundStoredValueForDisplayPrecision = useCallback(
(storedValue: number) =>
clamp(toStoredValue(Number.parseFloat(toDisplayValue(storedValue).toFixed(precision)))),
[clamp, precision, toDisplayValue, toStoredValue],
)
const displayValue = toDisplayValue(value)
const [isEditing, setIsEditing] = useState(false) const [isEditing, setIsEditing] = useState(false)
const [isDragging, setIsDragging] = useState(false) const [isDragging, setIsDragging] = useState(false)
@@ -50,13 +75,6 @@ export function MetricControl({
const valueRef = useRef(value) const valueRef = useRef(value)
valueRef.current = value valueRef.current = value
const clamp = useCallback(
(val: number) => {
return Math.min(Math.max(val, min), max)
},
[min, max],
)
const applyCommittedValue = useCallback( const applyCommittedValue = useCallback(
(nextValue: number) => { (nextValue: number) => {
if (onCommit) { if (onCommit) {
@@ -84,12 +102,12 @@ export function MetricControl({
e.preventDefault() e.preventDefault()
const direction = e.deltaY < 0 ? 1 : -1 const direction = e.deltaY < 0 ? 1 : -1
let scrollStep = step / multiplier let scrollStep = toStoredValue(step)
if (e.shiftKey) scrollStep = (step * 10) / multiplier if (e.shiftKey) scrollStep = toStoredValue(step * 10)
else if (e.altKey) scrollStep = (step * 0.1) / multiplier else if (e.altKey) scrollStep = toStoredValue(step * 0.1)
const newValue = clamp(valueRef.current + direction * scrollStep) const newValue = clamp(valueRef.current + direction * scrollStep)
const finalValue = Number.parseFloat((newValue * multiplier).toFixed(precision)) / multiplier const finalValue = roundStoredValueForDisplayPrecision(newValue)
if (Math.abs(finalValue - valueRef.current) > 1e-6) { if (Math.abs(finalValue - valueRef.current) > 1e-6) {
applyCommittedValue(finalValue) applyCommittedValue(finalValue)
@@ -98,7 +116,7 @@ export function MetricControl({
container.addEventListener('wheel', handleWheel, { passive: false }) container.addEventListener('wheel', handleWheel, { passive: false })
return () => container.removeEventListener('wheel', handleWheel) return () => container.removeEventListener('wheel', handleWheel)
}, [isEditing, step, clamp, applyCommittedValue, precision, multiplier]) }, [isEditing, step, clamp, applyCommittedValue, toStoredValue, roundStoredValueForDisplayPrecision])
useEffect(() => { useEffect(() => {
if (!isHovered || isEditing) return if (!isHovered || isEditing) return
@@ -110,13 +128,12 @@ export function MetricControl({
if (direction !== 0) { if (direction !== 0) {
e.preventDefault() e.preventDefault()
let scrollStep = step / multiplier let scrollStep = toStoredValue(step)
if (e.shiftKey) scrollStep = (step * 10) / multiplier if (e.shiftKey) scrollStep = toStoredValue(step * 10)
else if (e.altKey) scrollStep = (step * 0.1) / multiplier else if (e.altKey) scrollStep = toStoredValue(step * 0.1)
const newValue = clamp(valueRef.current + direction * scrollStep) const newValue = clamp(valueRef.current + direction * scrollStep)
const finalValue = const finalValue = roundStoredValueForDisplayPrecision(newValue)
Number.parseFloat((newValue * multiplier).toFixed(precision)) / multiplier
if (Math.abs(finalValue - valueRef.current) > 1e-6) { if (Math.abs(finalValue - valueRef.current) > 1e-6) {
applyCommittedValue(finalValue) applyCommittedValue(finalValue)
@@ -126,7 +143,15 @@ export function MetricControl({
window.addEventListener('keydown', handleKeyDown) window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown)
}, [isHovered, isEditing, step, clamp, applyCommittedValue, precision, multiplier]) }, [
isHovered,
isEditing,
step,
clamp,
applyCommittedValue,
toStoredValue,
roundStoredValueForDisplayPrecision,
])
const handlePointerDown = useCallback( const handlePointerDown = useCallback(
(e: React.PointerEvent) => { (e: React.PointerEvent) => {
@@ -143,14 +168,13 @@ export function MetricControl({
const handlePointerMove = (moveEvent: PointerEvent) => { const handlePointerMove = (moveEvent: PointerEvent) => {
const deltaX = moveEvent.clientX - startXRef.current const deltaX = moveEvent.clientX - startXRef.current
let dragStep = step / multiplier let dragStep = toStoredValue(step)
if (moveEvent.shiftKey) dragStep = (step * 10) / multiplier if (moveEvent.shiftKey) dragStep = toStoredValue(step * 10)
else if (moveEvent.altKey) dragStep = (step * 0.1) / multiplier else if (moveEvent.altKey) dragStep = toStoredValue(step * 0.1)
const deltaValue = deltaX * dragStep const deltaValue = deltaX * dragStep
const newValue = clamp(startValueRef.current + deltaValue) const newValue = clamp(startValueRef.current + deltaValue)
const newFinalValue = const newFinalValue = roundStoredValueForDisplayPrecision(newValue)
Number.parseFloat((newValue * multiplier).toFixed(precision)) / multiplier
if (Math.abs(newFinalValue - finalValue) > 1e-6) { if (Math.abs(newFinalValue - finalValue) > 1e-6) {
finalValue = newFinalValue finalValue = newFinalValue
@@ -182,13 +206,23 @@ export function MetricControl({
document.addEventListener('pointermove', handlePointerMove) document.addEventListener('pointermove', handlePointerMove)
document.addEventListener('pointerup', handlePointerUp) document.addEventListener('pointerup', handlePointerUp)
}, },
[isEditing, value, onChange, onCommit, restoreOnCommit, clamp, precision, step, multiplier], [
isEditing,
value,
onChange,
onCommit,
restoreOnCommit,
clamp,
step,
toStoredValue,
roundStoredValueForDisplayPrecision,
],
) )
const handleValueClick = useCallback(() => { const handleValueClick = useCallback(() => {
setIsEditing(true) setIsEditing(true)
setInputValue((value * multiplier).toFixed(precision)) setInputValue(toDisplayValue(value).toFixed(precision))
}, [value, multiplier, precision]) }, [value, toDisplayValue, precision])
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => { const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value) setInputValue(e.target.value)
@@ -197,12 +231,12 @@ export function MetricControl({
const submitValue = useCallback(() => { const submitValue = useCallback(() => {
const numValue = Number.parseFloat(inputValue) const numValue = Number.parseFloat(inputValue)
if (Number.isNaN(numValue)) { if (Number.isNaN(numValue)) {
setInputValue((value * multiplier).toFixed(precision)) setInputValue(toDisplayValue(value).toFixed(precision))
} else { } else {
applyCommittedValue(clamp(numValue / multiplier)) applyCommittedValue(clamp(toStoredValue(numValue)))
} }
setIsEditing(false) setIsEditing(false)
}, [inputValue, applyCommittedValue, clamp, multiplier, value, precision]) }, [inputValue, applyCommittedValue, clamp, toStoredValue, value, precision, toDisplayValue])
const handleInputBlur = useCallback(() => { const handleInputBlur = useCallback(() => {
submitValue() submitValue()
@@ -213,21 +247,21 @@ export function MetricControl({
if (e.key === 'Enter') { if (e.key === 'Enter') {
submitValue() submitValue()
} else if (e.key === 'Escape') { } else if (e.key === 'Escape') {
setInputValue((value * multiplier).toFixed(precision)) setInputValue(toDisplayValue(value).toFixed(precision))
setIsEditing(false) setIsEditing(false)
} else if (e.key === 'ArrowUp') { } else if (e.key === 'ArrowUp') {
e.preventDefault() e.preventDefault()
const newV = clamp(value + step / multiplier) const newV = clamp(value + toStoredValue(step))
applyCommittedValue(newV) applyCommittedValue(newV)
setInputValue((newV * multiplier).toFixed(precision)) setInputValue(toDisplayValue(newV).toFixed(precision))
} else if (e.key === 'ArrowDown') { } else if (e.key === 'ArrowDown') {
e.preventDefault() e.preventDefault()
const newV = clamp(value - step / multiplier) const newV = clamp(value - toStoredValue(step))
applyCommittedValue(newV) applyCommittedValue(newV)
setInputValue((newV * multiplier).toFixed(precision)) setInputValue(toDisplayValue(newV).toFixed(precision))
} }
}, },
[submitValue, value, multiplier, precision, step, clamp, applyCommittedValue], [submitValue, value, toDisplayValue, precision, step, clamp, applyCommittedValue, toStoredValue],
) )
return ( return (
@@ -41,7 +41,7 @@ import {
buildLevelDuplicateCreateOps, buildLevelDuplicateCreateOps,
type LevelDuplicatePreset, type LevelDuplicatePreset,
} from '../../lib/level-duplication' } from '../../lib/level-duplication'
import { getDefaultLevelName, getLevelDisplayName } from '../../lib/level-name' import { getDefaultLevelName, getLevelDisplayName } from '@pascal-app/core'
import { deleteLevelWithFallbackSelection } from '../../lib/level-selection' import { deleteLevelWithFallbackSelection } from '../../lib/level-selection'
import { import {
getEditorClipboardSnapshot, getEditorClipboardSnapshot,
@@ -1,7 +1,7 @@
'use client' 'use client'
import type { AssetInput } from '@pascal-app/core' import type { AssetInput } from '@pascal-app/core'
import { resolveCdnUrl } from '@pascal-app/viewer' import { resolveCdnUrl, useViewer } from '@pascal-app/viewer'
import Image from 'next/image' import Image from 'next/image'
import { useEffect } from 'react' import { useEffect } from 'react'
import { import {
@@ -96,6 +96,10 @@ export function ItemCatalog({
key={index} key={index}
onClick={() => { onClick={() => {
triggerSFX('sfx:menu-click') triggerSFX('sfx:menu-click')
// Drop the current selection before arming placement — keeping
// it would route shortcuts (rotate & co) to both the ghost and
// the selected node.
useViewer.getState().setSelection({ selectedIds: [], zoneId: null })
setSelectedItem(item) setSelectedItem(item)
setTool('item') setTool('item')
setMode('build') setMode('build')
@@ -3,7 +3,7 @@
import type { LevelNode } from '@pascal-app/core' import type { LevelNode } from '@pascal-app/core'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import type { LevelDuplicatePreset } from '../../lib/level-duplication' import type { LevelDuplicatePreset } from '../../lib/level-duplication'
import { getLevelDisplayName } from '../../lib/level-name' import { getLevelDisplayName } from '@pascal-app/core'
import { cn } from '../../lib/utils' import { cn } from '../../lib/utils'
import { import {
Dialog, Dialog,
@@ -171,6 +171,8 @@ function MobilePanelLayer({
export function PanelManager({ inspectorFooter }: { inspectorFooter?: React.ReactNode }) { export function PanelManager({ inspectorFooter }: { inspectorFooter?: React.ReactNode }) {
const isMobile = useIsMobile() const isMobile = useIsMobile()
const selectedIds = useViewer((s) => s.selection.selectedIds) const selectedIds = useViewer((s) => s.selection.selectedIds)
const selectedZoneId = useViewer((s) => s.selection.zoneId)
const setSelection = useViewer((s) => s.setSelection)
const selectedReferenceId = useEditor((s) => s.selectedReferenceId) const selectedReferenceId = useEditor((s) => s.selectedReferenceId)
const isPaintPanelOpen = useEditor((s) => s.isPaintPanelOpen) const isPaintPanelOpen = useEditor((s) => s.isPaintPanelOpen)
const mode = useEditor((s) => s.mode) const mode = useEditor((s) => s.mode)
@@ -215,5 +217,16 @@ export function PanelManager({ inspectorFooter }: { inspectorFooter?: React.Reac
return <PaintPanel /> return <PaintPanel />
} }
if (selectedZoneId && selectedIds.length === 0) {
return (
<ParametricInspector
footer={inspectorFooter}
key={selectedZoneId}
nodeId={selectedZoneId as AnyNodeId}
onClose={() => setSelection({ zoneId: null })}
/>
)
}
return panelForType(selectedNodeType, inspectorFooter) return panelForType(selectedNodeType, inspectorFooter)
} }
@@ -8,12 +8,14 @@ import {
type ParamAction, type ParamAction,
type ParamField, type ParamField,
useScene, useScene,
type ZoneNode,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { Icon } from '@iconify/react' import { Icon } from '@iconify/react'
import { Move, Trash2 } from 'lucide-react' import { Move, Trash2 } from 'lucide-react'
import { type ComponentType, lazy, Suspense, useCallback } from 'react' import { type ComponentType, lazy, Suspense, useCallback } from 'react'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import { collectZoneContentIds } from '../../../lib/zone-content'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button' import { ActionButton, ActionGroup } from '../controls/action-button'
import { PanelSection } from '../controls/panel-section' import { PanelSection } from '../controls/panel-section'
@@ -38,8 +40,15 @@ import { InspectorFooterContext, PanelWrapper } from './panel-wrapper'
* `parametrics.customPanel?` escape hatch for kinds whose parametric editor * `parametrics.customPanel?` escape hatch for kinds whose parametric editor
* can't be auto-generated (topology editors etc.). * can't be auto-generated (topology editors etc.).
*/ */
export function ParametricInspector({ footer }: { footer?: React.ReactNode } = {}) { export function ParametricInspector({
const selectedId = useViewer((s) => s.selection.selectedIds[0]) as AnyNodeId | undefined footer,
nodeId,
onClose,
}: { footer?: React.ReactNode; nodeId?: AnyNodeId; onClose?: () => void } = {}) {
const selectedIdFromSelection = useViewer((s) => s.selection.selectedIds[0]) as
| AnyNodeId
| undefined
const selectedId = nodeId ?? selectedIdFromSelection
const setSelection = useViewer((s) => s.setSelection) const setSelection = useViewer((s) => s.setSelection)
// Subscribe only to the *type* — a string primitive that doesn't change // Subscribe only to the *type* — a string primitive that doesn't change
// when slider values change. Without this, every updateNode tick during // when slider values change. Without this, every updateNode tick during
@@ -58,9 +67,13 @@ export function ParametricInspector({ footer }: { footer?: React.ReactNode } = {
[selectedId], [selectedId],
) )
const handleClose = useCallback(() => { const clearSelection = useCallback(() => {
if (onClose) {
onClose()
return
}
setSelection({ selectedIds: [] }) setSelection({ selectedIds: [] })
}, [setSelection]) }, [onClose, setSelection])
const handleMove = useCallback(() => { const handleMove = useCallback(() => {
if (!selectedId) return if (!selectedId) return
@@ -68,15 +81,27 @@ export function ParametricInspector({ footer }: { footer?: React.ReactNode } = {
if (!node) return if (!node) return
sfxEmitter.emit('sfx:item-pick') sfxEmitter.emit('sfx:item-pick')
useEditor.getState().setMovingNode(node as any) useEditor.getState().setMovingNode(node as any)
setSelection({ selectedIds: [] }) clearSelection()
}, [selectedId, setSelection]) }, [selectedId, clearSelection])
const handleDelete = useCallback(() => { const handleDelete = useCallback(
if (!selectedId) return (withZoneContent = false) => {
sfxEmitter.emit('sfx:structure-delete') if (!selectedId) return
useScene.getState().deleteNode(selectedId) const scene = useScene.getState()
setSelection({ selectedIds: [] }) const node = scene.nodes[selectedId]
}, [selectedId, setSelection]) if (!node) return
const ids =
withZoneContent && node.type === 'zone'
? [selectedId, ...collectZoneContentIds(scene.nodes, node as ZoneNode)]
: [selectedId]
sfxEmitter.emit('sfx:structure-delete')
scene.deleteNodes(Array.from(new Set(ids)))
clearSelection()
},
[selectedId, clearSelection],
)
if (!selectedId || !def || !parametrics) return null if (!selectedId || !def || !parametrics) return null
@@ -104,13 +129,20 @@ export function ParametricInspector({ footer }: { footer?: React.ReactNode } = {
const iconNode = renderIcon(presentation?.icon) const iconNode = renderIcon(presentation?.icon)
const canMove = !!def.capabilities.movable const canMove = !!def.capabilities.movable
const canDelete = def.capabilities.deletable !== false const canDelete = def.capabilities.deletable !== false
const isZone = nodeType === 'zone'
const TrailingSection = parametrics.trailingSection const TrailingSection = parametrics.trailingSection
? resolveCustomPanel(parametrics.trailingSection) ? resolveCustomPanel(parametrics.trailingSection)
: null : null
return ( return (
<PanelWrapper footer={footer} icon={iconNode} onClose={handleClose} title={title} width={320}> <PanelWrapper
footer={footer}
icon={iconNode}
onClose={clearSelection}
title={title}
width={320}
>
{parametrics.groups.map((group, gi) => ( {parametrics.groups.map((group, gi) => (
<PanelSection key={`group-${gi}`} title={group.label}> <PanelSection key={`group-${gi}`} title={group.label}>
{group.fields.map((field, fi) => ( {group.fields.map((field, fi) => (
@@ -130,21 +162,37 @@ export function ParametricInspector({ footer }: { footer?: React.ReactNode } = {
)} )}
{(canMove || canDelete || (parametrics.actions && parametrics.actions.length > 0)) && ( {(canMove || canDelete || (parametrics.actions && parametrics.actions.length > 0)) && (
<PanelSection title="Actions"> <PanelSection title="Actions">
<ActionGroup> <ActionGroup className={isZone ? 'flex-col' : undefined}>
{canMove && ( {canMove && (
<ActionButton icon={<Move className="h-4 w-4" />} label="Move" onClick={handleMove} /> <ActionButton icon={<Move className="h-4 w-4" />} label="Move" onClick={handleMove} />
)} )}
{parametrics.actions?.map((action, i) => ( {parametrics.actions?.map((action, i) => (
<ParamActionButton action={action} key={`paramaction-${i}`} nodeId={selectedId} /> <ParamActionButton action={action} key={`paramaction-${i}`} nodeId={selectedId} />
))} ))}
{canDelete && ( {canDelete &&
<ActionButton (isZone ? (
className="border-red-500/40 text-red-200 hover:bg-red-500/15" <>
icon={<Trash2 className="h-4 w-4" />} <ActionButton
label="Delete" className="w-full flex-none"
onClick={handleDelete} icon={<Trash2 className="h-4 w-4 text-red-400" />}
/> label="Delete"
)} onClick={() => handleDelete(false)}
/>
<ActionButton
className="w-full flex-none"
icon={<Trash2 className="h-4 w-4 text-red-400" />}
label="Delete with contents"
onClick={() => handleDelete(true)}
/>
</>
) : (
<ActionButton
className="border-red-500/40 text-red-200 hover:bg-red-500/15"
icon={<Trash2 className="h-4 w-4" />}
label="Delete"
onClick={() => handleDelete()}
/>
))}
</ActionGroup> </ActionGroup>
</PanelSection> </PanelSection>
)} )}
@@ -36,7 +36,7 @@ import {
buildLevelDuplicateCreateOps, buildLevelDuplicateCreateOps,
type LevelDuplicatePreset, type LevelDuplicatePreset,
} from './../../../../../lib/level-duplication' } from './../../../../../lib/level-duplication'
import { getDefaultLevelName } from './../../../../../lib/level-name' import { getDefaultLevelName } from '@pascal-app/core'
import { deleteLevelWithFallbackSelection } from './../../../../../lib/level-selection' import { deleteLevelWithFallbackSelection } from './../../../../../lib/level-selection'
import { createLocalGuideImage } from './../../../../../lib/local-guide-image' import { createLocalGuideImage } from './../../../../../lib/local-guide-image'
import { cn } from './../../../../../lib/utils' import { cn } from './../../../../../lib/utils'
@@ -3,7 +3,7 @@ import { useViewer } from '@pascal-app/viewer'
import { Layers } from 'lucide-react' import { Layers } from 'lucide-react'
import { memo, useCallback, useState } from 'react' import { memo, useCallback, useState } from 'react'
import { useShallow } from 'zustand/react/shallow' import { useShallow } from 'zustand/react/shallow'
import { getDefaultLevelName } from '../../../../../lib/level-name' import { getDefaultLevelName } from '@pascal-app/core'
import { InlineRenameInput } from './inline-rename-input' import { InlineRenameInput } from './inline-rename-input'
import { focusTreeNode, TreeNode, TreeNodeWrapper } from './tree-node' import { focusTreeNode, TreeNode, TreeNodeWrapper } from './tree-node'
import { TreeNodeActions } from './tree-node-actions' import { TreeNodeActions } from './tree-node-actions'
@@ -1,7 +1,14 @@
import { emitter, useScene, type ZoneNode } from '@pascal-app/core' import {
type AnyNodeId,
emitter,
useScene,
type ZoneNode,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { Camera, Hexagon, Trash2 } from 'lucide-react' import { Camera, Hexagon, Save, Trash2 } from 'lucide-react'
import { useState } from 'react' import { useState } from 'react'
import { sfxEmitter } from './../../../../../lib/sfx-bus'
import { collectZoneContentIds } from './../../../../../lib/zone-content'
import { ColorDot } from './../../../../../components/ui/primitives/color-dot' import { ColorDot } from './../../../../../components/ui/primitives/color-dot'
import { import {
Popover, Popover,
@@ -10,6 +17,8 @@ import {
} from './../../../../../components/ui/primitives/popover' } from './../../../../../components/ui/primitives/popover'
import { cn } from './../../../../../lib/utils' import { cn } from './../../../../../lib/utils'
import useEditor from './../../../../../store/use-editor' import useEditor from './../../../../../store/use-editor'
import { ActionButton } from '../../../controls/action-button'
import { PanelSection } from '../../../controls/panel-section'
function ZoneItem({ zone }: { zone: ZoneNode }) { function ZoneItem({ zone }: { zone: ZoneNode }) {
const [cameraPopoverOpen, setCameraPopoverOpen] = useState(false) const [cameraPopoverOpen, setCameraPopoverOpen] = useState(false)
@@ -26,6 +35,7 @@ function ZoneItem({ zone }: { zone: ZoneNode }) {
const handleDelete = (e: React.MouseEvent) => { const handleDelete = (e: React.MouseEvent) => {
e.stopPropagation() e.stopPropagation()
sfxEmitter.emit('sfx:structure-delete')
deleteNode(zone.id) deleteNode(zone.id)
if (isSelected) { if (isSelected) {
setSelection({ zoneId: null }) setSelection({ zoneId: null })
@@ -125,6 +135,8 @@ function ZoneItem({ zone }: { zone: ZoneNode }) {
export function ZonePanel() { export function ZonePanel() {
const nodes = useScene((state) => state.nodes) const nodes = useScene((state) => state.nodes)
const currentLevelId = useViewer((state) => state.selection.levelId) const currentLevelId = useViewer((state) => state.selection.levelId)
const selectedZoneId = useViewer((state) => state.selection.zoneId)
const setSelection = useViewer((state) => state.setSelection)
const setPhase = useEditor((state) => state.setPhase) const setPhase = useEditor((state) => state.setPhase)
const setMode = useEditor((state) => state.setMode) const setMode = useEditor((state) => state.setMode)
const setTool = useEditor((state) => state.setTool) const setTool = useEditor((state) => state.setTool)
@@ -133,6 +145,7 @@ export function ZonePanel() {
const levelZones = Object.values(nodes).filter( const levelZones = Object.values(nodes).filter(
(node): node is ZoneNode => node.type === 'zone' && node.parentId === currentLevelId, (node): node is ZoneNode => node.type === 'zone' && node.parentId === currentLevelId,
) )
const selectedZone = levelZones.find((zone) => zone.id === selectedZoneId)
const handleAddZone = () => { const handleAddZone = () => {
if (currentLevelId) { if (currentLevelId) {
@@ -142,6 +155,17 @@ export function ZonePanel() {
} }
} }
const deleteSelectedZone = (withContent: boolean) => {
if (!selectedZone) return
const scene = useScene.getState()
const ids = withContent
? [selectedZone.id as AnyNodeId, ...collectZoneContentIds(scene.nodes, selectedZone)]
: [selectedZone.id as AnyNodeId]
sfxEmitter.emit('sfx:structure-delete')
scene.deleteNodes(Array.from(new Set(ids)))
setSelection({ selectedIds: [], zoneId: null })
}
if (!currentLevelId) { if (!currentLevelId) {
return ( return (
<div className="px-3 py-4 text-muted-foreground text-sm"> <div className="px-3 py-4 text-muted-foreground text-sm">
@@ -162,6 +186,31 @@ export function ZonePanel() {
) : ( ) : (
levelZones.map((zone) => <ZoneItem key={zone.id} zone={zone} />) levelZones.map((zone) => <ZoneItem key={zone.id} zone={zone} />)
)} )}
{selectedZone ? (
<PanelSection className="mt-2 border-t" title="Actions">
<ActionButton
className="w-full flex-none"
icon={<Save className="h-4 w-4" />}
label="Save to catalog"
onClick={() => emitter.emit('room-preset:create', { zoneId: selectedZone.id })}
type="button"
/>
<ActionButton
className="w-full flex-none"
icon={<Trash2 className="h-4 w-4 text-red-400" />}
label="Delete"
onClick={() => deleteSelectedZone(false)}
type="button"
/>
<ActionButton
className="w-full flex-none"
icon={<Trash2 className="h-4 w-4 text-red-400" />}
label="Delete with contents"
onClick={() => deleteSelectedZone(true)}
type="button"
/>
</PanelSection>
) : null}
</div> </div>
) )
} }
@@ -6,6 +6,7 @@ import {
type AnyNodeId, type AnyNodeId,
type BuildingNode, type BuildingNode,
emitter, emitter,
getLevelDisplayName,
type LevelNode, type LevelNode,
useScene, useScene,
type ZoneNode, type ZoneNode,
@@ -30,7 +31,6 @@ import {
} from 'lucide-react' } from 'lucide-react'
import Link from 'next/link' import Link from 'next/link'
import { useShallow } from 'zustand/react/shallow' import { useShallow } from 'zustand/react/shallow'
import { getLevelDisplayName } from '../lib/level-name'
import { cn } from '../lib/utils' import { cn } from '../lib/utils'
import { ActionButton } from './ui/action-menu/action-button' import { ActionButton } from './ui/action-menu/action-button'
import { import {
+9 -9
View File
@@ -298,15 +298,6 @@ export const useKeyboard = ({
} }
} }
// Delete selected zone
const selectedZoneId = useViewer.getState().selection.zoneId
if (selectedZoneId) {
sfxEmitter.emit('sfx:structure-delete')
useScene.getState().deleteNode(selectedZoneId as AnyNodeId)
useViewer.getState().setSelection({ zoneId: null })
return
}
const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[] const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
if (selectedNodeIds.length > 0) { if (selectedNodeIds.length > 0) {
@@ -328,6 +319,15 @@ export const useKeyboard = ({
} }
useScene.getState().deleteNodes(selectedNodeIds) useScene.getState().deleteNodes(selectedNodeIds)
return
}
// Delete selected zone when no explicit element selection is active.
const selectedZoneId = useViewer.getState().selection.zoneId
if (selectedZoneId) {
sfxEmitter.emit('sfx:structure-delete')
useScene.getState().deleteNode(selectedZoneId as AnyNodeId)
useViewer.getState().setSelection({ zoneId: null })
} }
} }
} }
+8
View File
@@ -219,6 +219,14 @@ export {
getActivePaintMaterialLabel, getActivePaintMaterialLabel,
hasActivePaintMaterial, hasActivePaintMaterial,
} from './lib/material-paint' } from './lib/material-paint'
export {
formatLinearMeasurement,
getLinearUnitLabel,
type LinearUnit,
linearControlValueToMeters,
linearUnitToMeters,
metersToLinearUnit,
} from './lib/measurements'
export { export {
addFreshPlacementMetadata, addFreshPlacementMetadata,
getPlacementMetadataRecord, getPlacementMetadataRecord,
@@ -0,0 +1,75 @@
import { describe, expect, test } from 'bun:test'
import {
formatLinearMeasurement,
getLinearUnitLabel,
linearControlValueToMeters,
linearUnitToMeters,
metersToLinearUnit,
} from './measurements'
describe('linear measurements', () => {
test('formats metric measurements in meters', () => {
expect(formatLinearMeasurement(3, 'metric')).toBe('3m')
expect(formatLinearMeasurement(3.456, 'metric')).toBe('3.46m')
})
test('formats imperial measurements as feet and inches', () => {
expect(formatLinearMeasurement(3.048, 'imperial')).toBe(`10'0"`)
expect(formatLinearMeasurement(3.2004, 'imperial')).toBe(`10'6"`)
})
test('carries rounded 12 inches into the next foot', () => {
expect(formatLinearMeasurement(3.047, 'imperial')).toBe(`10'0"`)
})
test('returns a placeholder for non-finite measurements', () => {
expect(formatLinearMeasurement(NaN, 'imperial')).toBe('--')
expect(formatLinearMeasurement(Infinity, 'imperial')).toBe('--')
expect(formatLinearMeasurement(NaN, 'metric')).toBe('--')
})
test('formats zero measurements', () => {
expect(formatLinearMeasurement(0, 'imperial')).toBe(`0'0"`)
expect(formatLinearMeasurement(0, 'metric')).toBe('0m')
})
test('formats sub-foot imperial measurements', () => {
expect(formatLinearMeasurement(0.1524, 'imperial')).toBe(`0'6"`)
})
test('formats negative measurements with a sign', () => {
expect(formatLinearMeasurement(-0.1524, 'imperial')).toBe(`-0'6"`)
expect(formatLinearMeasurement(-0.1524, 'metric')).toBe('-0.15m')
})
test('converts between meters and the active linear unit', () => {
expect(metersToLinearUnit(0, 'imperial')).toBe(0)
expect(linearUnitToMeters(0, 'imperial')).toBe(0)
expect(metersToLinearUnit(1, 'metric')).toBe(1)
expect(linearUnitToMeters(1, 'metric')).toBe(1)
expect(metersToLinearUnit(0.3048, 'imperial')).toBeCloseTo(1)
expect(linearUnitToMeters(1, 'imperial')).toBeCloseTo(0.3048)
})
test('converts numeric control input back to meters for wall panel edits', () => {
expect(linearControlValueToMeters(10, 'imperial')).toBeCloseTo(3.048)
expect(linearControlValueToMeters(0.5, 'imperial')).toBeCloseTo(0.1524)
expect(linearControlValueToMeters(-1, 'imperial')).toBeCloseTo(-0.3048)
expect(linearControlValueToMeters(3.5, 'metric')).toBe(3.5)
})
test('clamps numeric control input after converting to meters', () => {
expect(linearControlValueToMeters(0.1, 'imperial', { minMeters: 0.1 })).toBe(0.1)
expect(linearControlValueToMeters(0.3, 'imperial', { minMeters: 0.1 })).toBe(0.1)
expect(linearControlValueToMeters(19.7, 'imperial', { maxMeters: 6 })).toBe(6)
expect(linearControlValueToMeters(0.2, 'metric', { minMeters: 0.1 })).toBe(0.2)
expect(linearControlValueToMeters(0.2, 'metric', { maxMeters: 0.15 })).toBe(0.15)
})
test('returns the display label for numeric controls', () => {
expect(getLinearUnitLabel('metric')).toBe('m')
expect(getLinearUnitLabel('imperial')).toBe('ft')
})
})
+58
View File
@@ -0,0 +1,58 @@
export type LinearUnit = 'metric' | 'imperial'
const METERS_PER_FOOT = 0.3048
const FEET_PER_METER = 1 / METERS_PER_FOOT
type LinearControlValueOptions = {
minMeters?: number
maxMeters?: number
}
export function metersToLinearUnit(meters: number, unit: LinearUnit): number {
return unit === 'imperial' ? meters * FEET_PER_METER : meters
}
export function linearUnitToMeters(value: number, unit: LinearUnit): number {
return unit === 'imperial' ? value * METERS_PER_FOOT : value
}
export function linearControlValueToMeters(
value: number,
unit: LinearUnit,
options: LinearControlValueOptions = {},
): number {
const meters = linearUnitToMeters(value, unit)
const minMeters = options.minMeters ?? Number.NEGATIVE_INFINITY
const maxMeters = options.maxMeters ?? Number.POSITIVE_INFINITY
return Math.min(Math.max(meters, minMeters), maxMeters)
}
export function getLinearUnitLabel(unit: LinearUnit): string {
return unit === 'imperial' ? 'ft' : 'm'
}
export function formatLinearMeasurement(meters: number, unit: LinearUnit): string {
if (!Number.isFinite(meters)) return '--'
const absoluteMeters = Math.abs(meters)
if (unit === 'imperial') {
const feet = metersToLinearUnit(absoluteMeters, unit)
let wholeFeet = Math.floor(feet)
let inches = Math.round((feet - wholeFeet) * 12)
if (inches === 12) {
wholeFeet += 1
inches = 0
}
const sign = meters < 0 && (wholeFeet !== 0 || inches !== 0) ? '-' : ''
return `${sign}${wholeFeet}'${inches}"`
}
const roundedMeters = Number.parseFloat(absoluteMeters.toFixed(2))
const sign = meters < 0 && roundedMeters !== 0 ? '-' : ''
return `${sign}${roundedMeters}m`
}
+130
View File
@@ -0,0 +1,130 @@
import {
type AnyNode,
type AnyNodeId,
type CeilingNode,
type ItemNode,
pointInPolygon2D,
pointOnSegment,
type SlabNode,
type WallNode,
type ZoneNode,
} from '@pascal-app/core'
type Point2D = [number, number]
const POINT_TOLERANCE = 0.5
const COLLINEAR_TOLERANCE = 1e-6
const SURFACE_POLYGON_TOLERANCE = 0.15
function getPointToSegmentDistance(point: Point2D, start: Point2D, end: Point2D): number {
const dx = end[0] - start[0]
const dz = end[1] - start[1]
const lengthSq = dx * dx + dz * dz
if (lengthSq === 0) return Math.hypot(point[0] - start[0], point[1] - start[1])
const rawT = ((point[0] - start[0]) * dx + (point[1] - start[1]) * dz) / lengthSq
const t = Math.max(0, Math.min(1, rawT))
const projected: Point2D = [start[0] + t * dx, start[1] + t * dz]
return Math.hypot(point[0] - projected[0], point[1] - projected[1])
}
function pointInPolygonWithTolerance(point: Point2D, polygon: Point2D[]): boolean {
if (pointInPolygon2D(point, polygon, { includeBoundary: true })) return true
return polygon.some((start, index) => {
const end = polygon[(index + 1) % polygon.length]
return end ? getPointToSegmentDistance(point, start, end) <= POINT_TOLERANCE : false
})
}
function polygonContainsWithTolerance(
outer: Point2D[],
inner: Point2D[],
tolerance: number,
): boolean {
return inner.every((point) => {
if (pointInPolygon2D(point, outer, { includeBoundary: true })) return true
return outer.some((start, index) => {
const end = outer[(index + 1) % outer.length]
return end ? getPointToSegmentDistance(point, start, end) <= tolerance : false
})
})
}
function polygonMatchesZoneFootprint(surfacePolygon: Point2D[], footprint: Point2D[]): boolean {
if (surfacePolygon.length < 3) return false
return (
polygonContainsWithTolerance(footprint, surfacePolygon, SURFACE_POLYGON_TOLERANCE) &&
polygonContainsWithTolerance(surfacePolygon, footprint, SURFACE_POLYGON_TOLERANCE)
)
}
function areSegmentsCollinear(a: Point2D, b: Point2D, c: Point2D, d: Point2D): boolean {
const abx = b[0] - a[0]
const abz = b[1] - a[1]
const acx = c[0] - a[0]
const acz = c[1] - a[1]
const adx = d[0] - a[0]
const adz = d[1] - a[1]
const crossC = abx * acz - abz * acx
const crossD = abx * adz - abz * adx
return Math.abs(crossC) <= COLLINEAR_TOLERANCE && Math.abs(crossD) <= COLLINEAR_TOLERANCE
}
function segmentsOverlap(a: Point2D, b: Point2D, c: Point2D, d: Point2D): boolean {
const useX = Math.abs(b[0] - a[0]) >= Math.abs(b[1] - a[1])
const a0 = useX ? a[0] : a[1]
const a1 = useX ? b[0] : b[1]
const c0 = useX ? c[0] : c[1]
const c1 = useX ? d[0] : d[1]
const minA = Math.min(a0, a1)
const maxA = Math.max(a0, a1)
const minC = Math.min(c0, c1)
const maxC = Math.max(c0, c1)
return Math.max(minA, minC) <= Math.min(maxA, maxC) + COLLINEAR_TOLERANCE
}
function wallLiesOnZoneBoundary(wall: WallNode, polygon: Point2D[]): boolean {
return polygon.some((start, index) => {
const end = polygon[(index + 1) % polygon.length]
if (!end) return false
return (
areSegmentsCollinear(wall.start, wall.end, start, end) &&
segmentsOverlap(wall.start, wall.end, start, end) &&
pointOnSegment(wall.start, start, end, POINT_TOLERANCE) &&
pointOnSegment(wall.end, start, end, POINT_TOLERANCE)
)
})
}
export function collectZoneContentIds(
nodes: Readonly<Record<AnyNodeId, AnyNode>>,
zone: ZoneNode,
): AnyNodeId[] {
const levelId = zone.parentId
if (!levelId) return []
const footprint = zone.polygon.map((point) => [point[0], point[1]] as Point2D)
const boundaryWalls = Object.values(nodes)
.filter((node): node is WallNode => node.type === 'wall' && node.parentId === levelId)
.filter((wall) => wallLiesOnZoneBoundary(wall, footprint))
const surfaces = Object.values(nodes)
.filter(
(node): node is SlabNode | CeilingNode =>
(node.type === 'slab' || node.type === 'ceiling') && node.parentId === levelId,
)
.filter((surface) => {
const polygon = surface.polygon.map((point) => [point[0], point[1]] as Point2D)
return polygonMatchesZoneFootprint(polygon, footprint)
})
const floorItems = Object.values(nodes)
.filter((node): node is ItemNode => node.type === 'item' && node.parentId === levelId)
.filter((item) => pointInPolygonWithTolerance([item.position[0], item.position[2]], footprint))
return Array.from(
new Set<AnyNodeId>([
...boundaryWalls.map((wall) => wall.id as AnyNodeId),
...surfaces.map((surface) => surface.id as AnyNodeId),
...floorItems.map((item) => item.id as AnyNodeId),
]),
)
}
+10 -1
View File
@@ -62,7 +62,16 @@ export type WorkspaceMode = 'edit' | 'studio'
export type CaptureMode = export type CaptureMode =
| { mode: 'idle' } | { mode: 'idle' }
| { mode: 'standard' } | { mode: 'standard' }
| { mode: 'preset'; isolated: AnyNodeId[] } | {
mode: 'preset'
isolated: AnyNodeId[]
framingBounds?: {
min: [number, number]
max: [number, number]
center: [number, number]
size: [number, number]
}
}
export type Phase = 'site' | 'structure' | 'furnish' export type Phase = 'site' | 'structure' | 'furnish'
+1 -1
View File
@@ -18,7 +18,7 @@
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-turbo": "^2.7.1", "eslint-plugin-turbo": "^2.7.1",
"globals": "^16.5.0", "globals": "^16.5.0",
"typescript": "^5.9.2", "typescript": "6.0.3",
"typescript-eslint": "^8.50.0" "typescript-eslint": "^8.50.0"
} }
} }
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@pascal-app/ifc-converter", "name": "@pascal-app/ifc-converter",
"version": "0.1.0", "version": "0.1.1",
"description": "IFC → Pascal scene-graph conversion. Pure logic — no DOM, no React.", "description": "IFC → Pascal scene-graph conversion. Pure logic — no DOM, no React.",
"type": "module", "type": "module",
"main": "./dist/index.js", "main": "./dist/index.js",
@@ -29,7 +29,7 @@
"devDependencies": { "devDependencies": {
"@pascal/typescript-config": "*", "@pascal/typescript-config": "*",
"@types/bun": "^1.3.0", "@types/bun": "^1.3.0",
"typescript": "6.0.2" "typescript": "6.0.3"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
+5 -5
View File
@@ -1,6 +1,6 @@
{ {
"name": "@pascal-app/mcp", "name": "@pascal-app/mcp",
"version": "0.3.0", "version": "0.3.1",
"description": "Model Context Protocol server for Pascal 3D editor", "description": "Model Context Protocol server for Pascal 3D editor",
"type": "module", "type": "module",
"main": "./dist/index.js", "main": "./dist/index.js",
@@ -55,17 +55,17 @@
"prepublishOnly": "bun run build && bun test" "prepublishOnly": "bun run build && bun test"
}, },
"peerDependencies": { "peerDependencies": {
"@pascal-app/core": "^0.8.0" "@pascal-app/core": "^0.9.1"
}, },
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0", "@modelcontextprotocol/sdk": "^1.29.0",
"zod": "^4.3.5" "zod": "^4.3.5"
}, },
"devDependencies": { "devDependencies": {
"@pascal-app/core": "^0.8.0", "@pascal-app/core": "^0.9.1",
"@pascal/typescript-config": "*", "@pascal/typescript-config": "*",
"@types/node": "^25.5.0", "@types/node": "^22.19.20",
"typescript": "5.9.3" "typescript": "6.0.3"
}, },
"keywords": [ "keywords": [
"mcp", "mcp",
+7 -7
View File
@@ -23,9 +23,9 @@
"prepublishOnly": "bun run build && bun test" "prepublishOnly": "bun run build && bun test"
}, },
"peerDependencies": { "peerDependencies": {
"@pascal-app/core": "^0.8.0", "@pascal-app/core": "^0.9.0",
"@pascal-app/editor": "^0.8.0", "@pascal-app/editor": "^0.9.0",
"@pascal-app/viewer": "^0.8.0", "@pascal-app/viewer": "^0.9.0",
"@react-three/drei": "^10", "@react-three/drei": "^10",
"@react-three/fiber": "^9", "@react-three/fiber": "^9",
"lucide-react": "^1", "lucide-react": "^1",
@@ -34,15 +34,15 @@
"zustand": "^5" "zustand": "^5"
}, },
"devDependencies": { "devDependencies": {
"@pascal-app/core": "^0.8.0", "@pascal-app/core": "^0.9.0",
"@pascal-app/editor": "^0.8.0", "@pascal-app/editor": "^0.9.0",
"@pascal-app/viewer": "^0.8.0", "@pascal-app/viewer": "^0.9.0",
"@pascal/typescript-config": "*", "@pascal/typescript-config": "*",
"@types/bun": "^1.3.0", "@types/bun": "^1.3.0",
"@types/node": "^22.19.12", "@types/node": "^22.19.12",
"@types/react": "^19.2.2", "@types/react": "^19.2.2",
"@types/three": "^0.184.0", "@types/three": "^0.184.0",
"typescript": "6.0.2" "typescript": "6.0.3"
}, },
"keywords": [ "keywords": [
"pascal", "pascal",
+2 -3
View File
@@ -126,9 +126,8 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
deltaRef.current = [deltaX, deltaZ] deltaRef.current = [deltaX, deltaZ]
setMeshOffset(ceilingId as AnyNodeId, deltaX, deltaZ, height) setMeshOffset(ceilingId as AnyNodeId, deltaX, deltaZ, height)
// Aligned with slab/fence: the delta matches the direct mesh // Aligned with slab/fence: the delta matches the direct mesh
// mutation. CeilingRenderer doesn't bind position via React, so // mutation. CeilingRenderer also consumes this store so external
// this entry isn't consumed for rendering, but kept consistent // movers can preview ceilings without rebuilding the polygon.
// in case other systems read it.
useLiveTransforms.getState().set(ceilingId, { useLiveTransforms.getState().set(ceilingId, {
position: [deltaX, 0, deltaZ], position: [deltaX, 0, deltaZ],
rotation: 0, rotation: 0,
+14 -1
View File
@@ -4,6 +4,7 @@ import {
type CeilingNode, type CeilingNode,
getMaterialPresetByRef, getMaterialPresetByRef,
resolveMaterial, resolveMaterial,
useLiveTransforms,
useRegistry, useRegistry,
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
@@ -79,6 +80,13 @@ export const CeilingRenderer = ({ node }: { node: CeilingNode }) => {
const textures = useViewer((s) => s.textures) const textures = useViewer((s) => s.textures)
const colorPreset = useViewer((s) => s.colorPreset) const colorPreset = useViewer((s) => s.colorPreset)
const sceneTheme = useViewer((s) => s.sceneTheme) const sceneTheme = useViewer((s) => s.sceneTheme)
const liveTransform = useLiveTransforms((s) => s.get(node.id))
const ceilingY = (node.height ?? 2.5) - 0.01 + (liveTransform?.position[1] ?? 0)
const position: [number, number, number] = [
liveTransform?.position[0] ?? 0,
ceilingY,
liveTransform?.position[2] ?? 0,
]
useEffect( useEffect(
() => () => { () => () => {
@@ -124,7 +132,12 @@ export const CeilingRenderer = ({ node }: { node: CeilingNode }) => {
]) ])
return ( return (
<mesh geometry={placeholderGeometry} material={materials.bottomMaterial} ref={ref}> <mesh
geometry={placeholderGeometry}
material={materials.bottomMaterial}
position={position}
ref={ref}
>
<mesh <mesh
geometry={gridPlaceholderGeometry} geometry={gridPlaceholderGeometry}
material={materials.topMaterial} material={materials.topMaterial}
+2 -12
View File
@@ -20,6 +20,7 @@ import {
EDITOR_LAYER, EDITOR_LAYER,
type FencePlanPoint, type FencePlanPoint,
formatAngleRadians, formatAngleRadians,
formatLinearMeasurement,
getAngleArcToSegmentReference, getAngleArcToSegmentReference,
getAngleToSegmentReference, getAngleToSegmentReference,
getSegmentAngleReferenceAtPoint, getSegmentAngleReferenceAtPoint,
@@ -94,17 +95,6 @@ type AngleSource = {
draftVector: FencePlanPoint draftVector: FencePlanPoint
} }
function formatMeasurement(value: number, unit: 'metric' | 'imperial') {
if (unit === 'imperial') {
const feet = value * 3.280_84
const wholeFeet = Math.floor(feet)
const inches = Math.round((feet - wholeFeet) * 12)
if (inches === 12) return `${wholeFeet + 1}'0"`
return `${wholeFeet}'${inches}"`
}
return `${Number.parseFloat(value.toFixed(2))}m`
}
function clamp(value: number, min: number, max: number) { function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value)) return Math.min(max, Math.max(min, value))
} }
@@ -365,7 +355,7 @@ function getDraftMeasurementState(
const length = Math.hypot(dx, dz) const length = Math.hypot(dx, dz)
if (length < 0.01) return null if (length < 0.01) return null
return { return {
lengthLabel: formatMeasurement(length, unit), lengthLabel: formatLinearMeasurement(length, unit),
lengthPosition: [ lengthPosition: [
(start[0] + end[0]) / 2, (start[0] + end[0]) / 2,
baseY + previewHeight + DRAFT_LABEL_Y_OFFSET, baseY + previewHeight + DRAFT_LABEL_Y_OFFSET,
+37 -8
View File
@@ -3,6 +3,7 @@
import { import {
type AnimationEffect, type AnimationEffect,
type AnyNodeId, type AnyNodeId,
getScaledDimensions,
type Interactive, type Interactive,
type ItemNode, type ItemNode,
type LightEffect, type LightEffect,
@@ -92,17 +93,25 @@ export const ItemRenderer = ({ node: storeNode }: { node: ItemNode }) => {
() => (liveOverrides ? ({ ...storeNode, ...liveOverrides } as ItemNode) : storeNode), () => (liveOverrides ? ({ ...storeNode, ...liveOverrides } as ItemNode) : storeNode),
[storeNode, liveOverrides], [storeNode, liveOverrides],
) )
const roomClearPreview =
(node as ItemNode & { roomClearPreview?: unknown }).roomClearPreview === true
const content = ( const content = (
<group position={node.position} ref={ref} rotation={node.rotation} visible={node.visible}> <group position={node.position} ref={ref} rotation={node.rotation} visible={node.visible}>
<ErrorBoundary fallback={<BrokenItemFallback node={node} />}> {roomClearPreview ? (
<Suspense fallback={<PreviewModel node={node} />}> <ClearPreviewModel node={node} />
<ModelRenderer node={node} /> ) : (
</Suspense> <>
</ErrorBoundary> <ErrorBoundary fallback={<BrokenItemFallback node={node} />}>
{node.children?.map((childId) => ( <Suspense fallback={<PreviewModel node={node} />}>
<NodeRenderer key={childId} nodeId={childId} /> <ModelRenderer node={node} />
))} </Suspense>
</ErrorBoundary>
{node.children?.map((childId) => (
<NodeRenderer key={childId} nodeId={childId} />
))}
</>
)}
</group> </group>
) )
@@ -141,6 +150,26 @@ const PreviewModel = ({ node }: { node: ItemNode }) => {
) )
} }
const ClearPreviewModel = ({ node }: { node: ItemNode }) => {
const shading = useViewer((s) => s.shading)
const [w, h, d] = getScaledDimensions(node)
const material = useMemo(() => {
const next = createDefaultMaterial('#ef4444', 1, shading) as MutableMaterial
next.depthTest = false
next.opacity = 0.35
next.transparent = true
next.wireframe = true
next.needsUpdate = true
return next
}, [shading])
return (
<mesh material={material} position-y={h / 2}>
<boxGeometry args={[w, h, d]} />
</mesh>
)
}
const multiplyScales = ( const multiplyScales = (
a: [number, number, number], a: [number, number, number],
b: [number, number, number], b: [number, number, number],
+4
View File
@@ -76,5 +76,9 @@ export const shelfParametrics: ParametricDescriptor<ShelfNode> = {
{ key: 'height', kind: 'number', unit: 'm', min: 0.05, max: 2.5, step: 0.05 }, { key: 'height', kind: 'number', unit: 'm', min: 0.05, max: 2.5, step: 0.05 },
], ],
}, },
{
label: 'Position',
fields: [{ key: 'position', kind: 'vec3' }],
},
], ],
} }
+54 -23
View File
@@ -14,6 +14,9 @@ import {
import { import {
ActionButton, ActionButton,
ActionGroup, ActionGroup,
getLinearUnitLabel,
linearControlValueToMeters,
metersToLinearUnit,
PanelSection, PanelSection,
PanelWrapper, PanelWrapper,
SliderControl, SliderControl,
@@ -26,6 +29,7 @@ import { useCallback, useMemo, useRef } from 'react'
export default function WallPanel() { export default function WallPanel() {
const selectedId = useViewer((s) => s.selection.selectedIds[0]) const selectedId = useViewer((s) => s.selection.selectedIds[0])
const unit = useViewer((s) => s.unit)
const setSelection = useViewer((s) => s.setSelection) const setSelection = useViewer((s) => s.setSelection)
const setCurvingWall = useEditor((s) => s.setCurvingWall) const setCurvingWall = useEditor((s) => s.setCurvingWall)
@@ -117,14 +121,19 @@ export default function WallPanel() {
if (!(node && node.type === 'wall' && selectedId)) return null if (!(node && node.type === 'wall' && selectedId)) return null
const dx = node.end[0] - node.start[0]
const dz = node.end[1] - node.start[1]
const length = getWallCurveLength(node) const length = getWallCurveLength(node)
const height = node.height ?? 2.5 const height = node.height ?? 2.5
const thickness = node.thickness ?? 0.1 const thickness = node.thickness ?? 0.1
const curveOffset = getClampedWallCurveOffset(node) const curveOffset = getClampedWallCurveOffset(node)
const maxCurveOffset = getMaxWallCurveOffset(node) const maxCurveOffset = getMaxWallCurveOffset(node)
const unitLabel = getLinearUnitLabel(unit)
const displayLength = metersToLinearUnit(length, unit)
const displayHeight = metersToLinearUnit(height, unit)
const displayThickness = metersToLinearUnit(thickness, unit)
const displayCurveOffset = metersToLinearUnit(curveOffset, unit)
const displayMaxCurveOffset = metersToLinearUnit(maxCurveOffset, unit)
const curveOffsetLimit = Math.max(0.01, maxCurveOffset)
return ( return (
<PanelWrapper <PanelWrapper
@@ -136,44 +145,66 @@ export default function WallPanel() {
<PanelSection title="Dimensions"> <PanelSection title="Dimensions">
<SliderControl <SliderControl
label="Length" label="Length"
max={20} max={metersToLinearUnit(20, unit)}
min={0.1} min={metersToLinearUnit(0.1, unit)}
onChange={handleUpdateLength} onChange={(value) =>
handleUpdateLength(
linearControlValueToMeters(value, unit, { maxMeters: 20, minMeters: 0.1 }),
)
}
precision={2} precision={2}
step={0.01} step={unit === 'imperial' ? 0.1 : 0.01}
unit="m" unit={unitLabel}
value={length} value={displayLength}
/> />
<SliderControl <SliderControl
label="Height" label="Height"
max={6} max={metersToLinearUnit(6, unit)}
min={0.1} min={metersToLinearUnit(0.1, unit)}
onChange={(v) => handleUpdate({ height: Math.max(0.1, v) })} onChange={(v) =>
handleUpdate({
height: linearControlValueToMeters(v, unit, { maxMeters: 6, minMeters: 0.1 }),
})
}
precision={2} precision={2}
step={0.1} step={0.1}
unit="m" unit={unitLabel}
value={Math.round(height * 100) / 100} value={Math.round(displayHeight * 100) / 100}
/> />
<SliderControl <SliderControl
label="Thickness" label="Thickness"
max={1} max={metersToLinearUnit(1, unit)}
min={0.05} min={metersToLinearUnit(0.05, unit)}
onChange={(v) => handleUpdate({ thickness: Math.max(0.05, v) })} onChange={(v) =>
handleUpdate({
thickness: linearControlValueToMeters(v, unit, { maxMeters: 1, minMeters: 0.05 }),
})
}
precision={3} precision={3}
step={0.01} step={0.01}
unit="m" unit={unitLabel}
value={Math.round(thickness * 1000) / 1000} value={Math.round(displayThickness * 1000) / 1000}
/> />
{!hasWallChildrenBlockingCurve && ( {!hasWallChildrenBlockingCurve && (
<SliderControl <SliderControl
label="Curve" label="Curve"
max={Math.max(0.01, maxCurveOffset)} max={Math.max(metersToLinearUnit(0.01, unit), displayMaxCurveOffset)}
min={-Math.max(0.01, maxCurveOffset)} min={-Math.max(metersToLinearUnit(0.01, unit), displayMaxCurveOffset)}
onChange={(v) => handleUpdate({ curveOffset: normalizeWallCurveOffset(node, v) })} onChange={(v) =>
handleUpdate({
curveOffset: normalizeWallCurveOffset(
node,
linearControlValueToMeters(v, unit, {
maxMeters: curveOffsetLimit,
minMeters: -curveOffsetLimit,
}),
),
})
}
precision={2} precision={2}
step={0.1} step={0.1}
unit="m" unit={unitLabel}
value={Math.round(curveOffset * 100) / 100} value={Math.round(displayCurveOffset * 100) / 100}
/> />
)} )}
</PanelSection> </PanelSection>
+2 -12
View File
@@ -16,6 +16,7 @@ import {
createWallOnCurrentLevel, createWallOnCurrentLevel,
EDITOR_LAYER, EDITOR_LAYER,
formatAngleRadians, formatAngleRadians,
formatLinearMeasurement,
getAngleArcToSegmentReference, getAngleArcToSegmentReference,
getAngleToSegmentReference, getAngleToSegmentReference,
getSegmentAngleReferenceAtPoint, getSegmentAngleReferenceAtPoint,
@@ -124,17 +125,6 @@ type AngleSource = {
draftVector: WallPlanPoint draftVector: WallPlanPoint
} }
function formatMeasurement(value: number, unit: 'metric' | 'imperial') {
if (unit === 'imperial') {
const feet = value * 3.280_84
const wholeFeet = Math.floor(feet)
const inches = Math.round((feet - wholeFeet) * 12)
if (inches === 12) return `${wholeFeet + 1}'0"`
return `${wholeFeet}'${inches}"`
}
return `${Number.parseFloat(value.toFixed(2))}m`
}
function clamp(value: number, min: number, max: number) { function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value)) return Math.min(max, Math.max(min, value))
} }
@@ -426,7 +416,7 @@ function getDraftMeasurementState(
const length = Math.hypot(dx, dz) const length = Math.hypot(dx, dz)
if (length < 0.01) return null if (length < 0.01) return null
return { return {
lengthLabel: formatMeasurement(length, unit), lengthLabel: formatLinearMeasurement(length, unit),
lengthPosition: [ lengthPosition: [
(start[0] + end[0]) / 2, (start[0] + end[0]) / 2,
baseY + previewHeight + DRAFT_LABEL_Y_OFFSET, baseY + previewHeight + DRAFT_LABEL_Y_OFFSET,
+1 -1
View File
@@ -17,7 +17,7 @@
"@types/react": "19.2.2", "@types/react": "19.2.2",
"@types/react-dom": "19.2.2", "@types/react-dom": "19.2.2",
"eslint": "^9.39.1", "eslint": "^9.39.1",
"typescript": "5.9.3" "typescript": "6.0.3"
}, },
"dependencies": { "dependencies": {
"react": "^19.2.0", "react": "^19.2.0",
+3 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "@pascal-app/viewer", "name": "@pascal-app/viewer",
"version": "0.8.0", "version": "0.9.1",
"description": "3D viewer component for Pascal building editor", "description": "3D viewer component for Pascal building editor",
"type": "module", "type": "module",
"main": "./dist/index.js", "main": "./dist/index.js",
@@ -22,7 +22,7 @@
"prepublishOnly": "npm run build" "prepublishOnly": "npm run build"
}, },
"peerDependencies": { "peerDependencies": {
"@pascal-app/core": "^0.8.0", "@pascal-app/core": "^0.9.1",
"@react-three/drei": "^10", "@react-three/drei": "^10",
"@react-three/fiber": "^9", "@react-three/fiber": "^9",
"react": "^18 || ^19", "react": "^18 || ^19",
@@ -38,7 +38,7 @@
"@types/node": "^22", "@types/node": "^22",
"@types/react": "^19.2.2", "@types/react": "^19.2.2",
"@types/three": "^0.184.0", "@types/three": "^0.184.0",
"typescript": "6.0.2" "typescript": "6.0.3"
}, },
"keywords": [ "keywords": [
"3d", "3d",
@@ -4,6 +4,7 @@ import {
getEffectiveNode, getEffectiveNode,
nodeRegistry, nodeRegistry,
sceneRegistry, sceneRegistry,
useLiveTransforms,
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useFrame } from '@react-three/fiber' import { useFrame } from '@react-three/fiber'
@@ -104,9 +105,10 @@ function updateCeilingGeometry(
// canonical position after the rebuild. Matches the pattern used by // canonical position after the rebuild. Matches the pattern used by
// FenceSystem.updateFenceGeometry / GeometrySystem (both fully reset // FenceSystem.updateFenceGeometry / GeometrySystem (both fully reset
// position+rotation after rebuild). // position+rotation after rebuild).
mesh.position.x = 0 const liveTransform = useLiveTransforms.getState().get(node.id)
mesh.position.z = 0 mesh.position.x = liveTransform?.position[0] ?? 0
mesh.position.y = (node.height ?? 2.5) - 0.01 // Slight offset to avoid z-fighting with upper-level slabs mesh.position.z = liveTransform?.position[2] ?? 0
mesh.position.y = (node.height ?? 2.5) - 0.01 + (liveTransform?.position[1] ?? 0) // Slight offset to avoid z-fighting with upper-level slabs
} }
/** /**