Merge remote-tracking branch 'origin/main' into feat/editor-ux-rendering-placement
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@pascal-app/editor",
|
||||
"version": "0.8.0",
|
||||
"version": "0.9.1",
|
||||
"description": "Pascal building editor component",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
@@ -11,8 +11,8 @@
|
||||
"check-types": "tsc --noEmit"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@pascal-app/core": "^0.8.0",
|
||||
"@pascal-app/viewer": "^0.8.0",
|
||||
"@pascal-app/core": "^0.9.1",
|
||||
"@pascal-app/viewer": "^0.9.1",
|
||||
"@react-three/drei": "^10",
|
||||
"@react-three/fiber": "^9",
|
||||
"next": ">=15",
|
||||
@@ -25,7 +25,7 @@
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.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-context-menu": "^2.2.16",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
@@ -38,8 +38,7 @@
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@react-three/uikit-lucide": "^1.0.62",
|
||||
"@visual-json/react": "latest",
|
||||
"@visual-json/react": "^0.4.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
@@ -54,14 +53,14 @@
|
||||
"three-mesh-bvh": "~0.9.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@pascal-app/core": "^0.8.0",
|
||||
"@pascal-app/viewer": "^0.8.0",
|
||||
"@pascal-app/core": "^0.9.1",
|
||||
"@pascal-app/viewer": "^0.9.1",
|
||||
"@pascal/typescript-config": "*",
|
||||
"@types/bun": "^1.3.0",
|
||||
"@types/howler": "^2.2.12",
|
||||
"@types/react": "19.2.2",
|
||||
"@types/react-dom": "19.2.2",
|
||||
"@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)
|
||||
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.getSize(tempSize)
|
||||
tempBox.getCenter(tempCenter)
|
||||
tempBox.getSize(tempSize)
|
||||
}
|
||||
|
||||
// Distance heuristic: fit the subject inside the 75%-of-shorter-
|
||||
// side square crop with comfortable padding. Multiplier 2.4 leaves
|
||||
|
||||
@@ -75,6 +75,7 @@ import {
|
||||
type FloorplanNodeTransform as SharedFloorplanNodeTransform,
|
||||
} from '../../lib/floorplan'
|
||||
import { guideEmitter } from '../../lib/guide-events'
|
||||
import { formatLinearMeasurement, linearUnitToMeters } from '../../lib/measurements'
|
||||
import { sfxEmitter } from '../../lib/sfx-bus'
|
||||
import { SITE_BOUNDARY_DRAG_LABEL } from '../../lib/site-boundary'
|
||||
import { resolveSlabPlanPointSnap } from '../../lib/slab-plan-snap'
|
||||
@@ -2616,14 +2617,7 @@ function formatMeasurement(
|
||||
metersPerUnit: number | null = null,
|
||||
) {
|
||||
const measuredValue = metersPerUnit && metersPerUnit > 0 ? value * metersPerUnit : value
|
||||
if (unit === 'imperial') {
|
||||
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`
|
||||
return formatLinearMeasurement(measuredValue, unit)
|
||||
}
|
||||
|
||||
function formatNumber(value: number, fractionDigits = 2) {
|
||||
@@ -2635,7 +2629,7 @@ function convertReferenceLengthToMeters(value: number, unit: ReferenceScaleUnit)
|
||||
case 'centimeters':
|
||||
return value / 100
|
||||
case 'feet':
|
||||
return value * 0.3048
|
||||
return linearUnitToMeters(value, 'imperial')
|
||||
case 'inches':
|
||||
return value * 0.0254
|
||||
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 svgRef = useRef<SVGSVGElement>(null)
|
||||
const floorplanSceneRef = useRef<SVGGElement>(null)
|
||||
@@ -10180,12 +10184,21 @@ export function FloorplanPanel() {
|
||||
only action menu the floor plan mounts. */}
|
||||
<FloorplanRegistryActionMenu />
|
||||
|
||||
{(levelNode?.type === 'level' || hasAmbientBuildingLevel) && (
|
||||
<FloorplanCompassButton
|
||||
northRotationDeg={-floorplanUserRotationDeg}
|
||||
onAlignNorth={alignFloorplanViewToNorth}
|
||||
/>
|
||||
)}
|
||||
{(levelNode?.type === 'level' || hasAmbientBuildingLevel) &&
|
||||
(compassHost ? (
|
||||
createPortal(
|
||||
<FloorplanCompassButton
|
||||
northRotationDeg={-floorplanUserRotationDeg}
|
||||
onAlignNorth={alignFloorplanViewToNorth}
|
||||
/>,
|
||||
compassHost,
|
||||
)
|
||||
) : (
|
||||
<FloorplanCompassButton
|
||||
northRotationDeg={-floorplanUserRotationDeg}
|
||||
onAlignNorth={alignFloorplanViewToNorth}
|
||||
/>
|
||||
))}
|
||||
|
||||
{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">
|
||||
|
||||
@@ -817,6 +817,13 @@ const ViewerCanvas = memo(function ViewerCanvas({
|
||||
)
|
||||
|
||||
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 isResizingFloorplan = useRef(false)
|
||||
|
||||
@@ -862,7 +869,9 @@ const ViewerCanvas = memo(function ViewerCanvas({
|
||||
|
||||
return (
|
||||
<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 */}
|
||||
<div
|
||||
className="relative h-full flex-shrink-0"
|
||||
@@ -872,7 +881,7 @@ const ViewerCanvas = memo(function ViewerCanvas({
|
||||
}}
|
||||
>
|
||||
<div className="h-full w-full overflow-hidden">
|
||||
<FloorplanPanel />
|
||||
<FloorplanPanel compassHost={viewerAreaEl} />
|
||||
</div>
|
||||
{viewMode === 'split' && (
|
||||
<div
|
||||
|
||||
@@ -7,9 +7,9 @@ import { Html } from '@react-three/drei'
|
||||
import { createPortal, useFrame, useThree } from '@react-three/fiber'
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { type Camera, type Object3D, Vector3 } from 'three'
|
||||
import { formatLinearMeasurement } from '../../lib/measurements'
|
||||
import { SITE_BOUNDARY_DRAG_LABEL } from '../../lib/site-boundary'
|
||||
import useEditor from '../../store/use-editor'
|
||||
import { formatMeasurement } from './measurement-pill'
|
||||
|
||||
type ViewportSize = {
|
||||
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}`,
|
||||
}}
|
||||
>
|
||||
{formatMeasurement(edge.dist, unit)}
|
||||
{formatLinearMeasurement(edge.dist, unit)}
|
||||
</div>
|
||||
</Html>
|
||||
))}
|
||||
|
||||
@@ -23,6 +23,7 @@ import { Html } from '@react-three/drei'
|
||||
import { createPortal, useFrame } from '@react-three/fiber'
|
||||
import { useMemo, useState } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { formatLinearMeasurement } from '../../lib/measurements'
|
||||
|
||||
const GUIDE_Y_OFFSET = 0.08
|
||||
const LABEL_LIFT = 0.08
|
||||
@@ -62,17 +63,6 @@ type WallFaceLine = {
|
||||
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() {
|
||||
const selectedIds = useViewer((state) => state.selection.selectedIds)
|
||||
const nodes = useScene((state) => state.nodes)
|
||||
@@ -547,8 +537,8 @@ function WallMeasurementAnnotation({ wall }: { wall: WallNode }) {
|
||||
}
|
||||
return total
|
||||
}, [guide, wall])
|
||||
const label = formatMeasurement(length, unit)
|
||||
const heightLabel = `H ${formatMeasurement(wall.height ?? DEFAULT_WALL_HEIGHT, unit)}`
|
||||
const label = formatLinearMeasurement(length, unit)
|
||||
const heightLabel = `H ${formatLinearMeasurement(wall.height ?? DEFAULT_WALL_HEIGHT, unit)}`
|
||||
|
||||
if (!(guide && Number.isFinite(length) && length >= 0.01)) return null
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
import { distance, smoothstep, uv, vec2 } from 'three/tsl'
|
||||
import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
|
||||
import { EDITOR_LAYER } from '../../../lib/constants'
|
||||
import { formatLinearMeasurement } from '../../../lib/measurements'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import { resolveAlignmentForActiveBuilding } from '../../../lib/world-grid-snap'
|
||||
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. */
|
||||
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.
|
||||
* 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 initialHeightGuideGeometry = useMemo(() => createLineGeometry(), [])
|
||||
const currentDimensionBounds = dimensionBounds ?? initialDimensionBounds
|
||||
const widthLabel = formatMeasurement(currentDimensionBounds.dimensions[0], unit)
|
||||
const depthLabel = formatMeasurement(currentDimensionBounds.dimensions[2], unit)
|
||||
const heightLabel = formatMeasurement(currentDimensionBounds.dimensions[1], unit)
|
||||
const widthLabel = formatLinearMeasurement(currentDimensionBounds.dimensions[0], unit)
|
||||
const depthLabel = formatLinearMeasurement(currentDimensionBounds.dimensions[2], unit)
|
||||
const heightLabel = formatLinearMeasurement(currentDimensionBounds.dimensions[1], unit)
|
||||
const widthLabelPosition: [number, number, number] = [
|
||||
currentDimensionBounds.center[0],
|
||||
0.04,
|
||||
|
||||
@@ -360,6 +360,15 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
||||
* AND scene updated) — never the original.
|
||||
*/
|
||||
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 —
|
||||
// it's the stray trailing click of whatever armed this move, not a
|
||||
// deliberate drop. Prevents preset re-arm from double-placing.
|
||||
|
||||
@@ -184,7 +184,7 @@ export const ZoneTool: React.FC = () => {
|
||||
const [gridX, gridZ] = snapWorldXZForActiveBuilding(
|
||||
event.position[0],
|
||||
event.position[2],
|
||||
0.5,
|
||||
useEditor.getState().gridSnapStep,
|
||||
).local
|
||||
cursorPosition = [gridX, gridZ]
|
||||
levelYRef.current = event.localPosition[1]
|
||||
@@ -217,7 +217,7 @@ export const ZoneTool: React.FC = () => {
|
||||
const [gridX, gridZ] = snapWorldXZForActiveBuilding(
|
||||
event.position[0],
|
||||
event.position[2],
|
||||
0.5,
|
||||
useEditor.getState().gridSnapStep,
|
||||
).local
|
||||
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 { useCallback, useRef, useState } from 'react'
|
||||
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 { cn } from '../../../lib/utils'
|
||||
import useEditor, { type GridSnapStep } from '../../../store/use-editor'
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useEffect, useState } from 'react'
|
||||
import { create } from 'zustand'
|
||||
import { useShallow } from 'zustand/shallow'
|
||||
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 { usePaletteViewRegistry } from '../../../store/use-palette-view-registry'
|
||||
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
import { useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
getLinearUnitLabel,
|
||||
linearUnitToMeters,
|
||||
metersToLinearUnit,
|
||||
} from '../../../lib/measurements'
|
||||
import { cn } from '../../../lib/utils'
|
||||
|
||||
interface MetricControlProps {
|
||||
@@ -34,10 +39,30 @@ export function MetricControl({
|
||||
}: MetricControlProps) {
|
||||
const viewerUnit = useViewer((state) => state.unit)
|
||||
const isImperial = viewerUnit === 'imperial' && unit === 'm'
|
||||
const multiplier = isImperial ? 3.280_84 : 1
|
||||
const displayUnit = isImperial ? 'ft' : unit
|
||||
const displayUnit = isImperial ? getLinearUnitLabel('imperial') : 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 [isDragging, setIsDragging] = useState(false)
|
||||
@@ -50,13 +75,6 @@ export function MetricControl({
|
||||
const valueRef = useRef(value)
|
||||
valueRef.current = value
|
||||
|
||||
const clamp = useCallback(
|
||||
(val: number) => {
|
||||
return Math.min(Math.max(val, min), max)
|
||||
},
|
||||
[min, max],
|
||||
)
|
||||
|
||||
const applyCommittedValue = useCallback(
|
||||
(nextValue: number) => {
|
||||
if (onCommit) {
|
||||
@@ -84,12 +102,12 @@ export function MetricControl({
|
||||
e.preventDefault()
|
||||
|
||||
const direction = e.deltaY < 0 ? 1 : -1
|
||||
let scrollStep = step / multiplier
|
||||
if (e.shiftKey) scrollStep = (step * 10) / multiplier
|
||||
else if (e.altKey) scrollStep = (step * 0.1) / multiplier
|
||||
let scrollStep = toStoredValue(step)
|
||||
if (e.shiftKey) scrollStep = toStoredValue(step * 10)
|
||||
else if (e.altKey) scrollStep = toStoredValue(step * 0.1)
|
||||
|
||||
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) {
|
||||
applyCommittedValue(finalValue)
|
||||
@@ -98,7 +116,7 @@ export function MetricControl({
|
||||
|
||||
container.addEventListener('wheel', handleWheel, { passive: false })
|
||||
return () => container.removeEventListener('wheel', handleWheel)
|
||||
}, [isEditing, step, clamp, applyCommittedValue, precision, multiplier])
|
||||
}, [isEditing, step, clamp, applyCommittedValue, toStoredValue, roundStoredValueForDisplayPrecision])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isHovered || isEditing) return
|
||||
@@ -110,13 +128,12 @@ export function MetricControl({
|
||||
|
||||
if (direction !== 0) {
|
||||
e.preventDefault()
|
||||
let scrollStep = step / multiplier
|
||||
if (e.shiftKey) scrollStep = (step * 10) / multiplier
|
||||
else if (e.altKey) scrollStep = (step * 0.1) / multiplier
|
||||
let scrollStep = toStoredValue(step)
|
||||
if (e.shiftKey) scrollStep = toStoredValue(step * 10)
|
||||
else if (e.altKey) scrollStep = toStoredValue(step * 0.1)
|
||||
|
||||
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) {
|
||||
applyCommittedValue(finalValue)
|
||||
@@ -126,7 +143,15 @@ export function MetricControl({
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [isHovered, isEditing, step, clamp, applyCommittedValue, precision, multiplier])
|
||||
}, [
|
||||
isHovered,
|
||||
isEditing,
|
||||
step,
|
||||
clamp,
|
||||
applyCommittedValue,
|
||||
toStoredValue,
|
||||
roundStoredValueForDisplayPrecision,
|
||||
])
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
@@ -143,14 +168,13 @@ export function MetricControl({
|
||||
const handlePointerMove = (moveEvent: PointerEvent) => {
|
||||
const deltaX = moveEvent.clientX - startXRef.current
|
||||
|
||||
let dragStep = step / multiplier
|
||||
if (moveEvent.shiftKey) dragStep = (step * 10) / multiplier
|
||||
else if (moveEvent.altKey) dragStep = (step * 0.1) / multiplier
|
||||
let dragStep = toStoredValue(step)
|
||||
if (moveEvent.shiftKey) dragStep = toStoredValue(step * 10)
|
||||
else if (moveEvent.altKey) dragStep = toStoredValue(step * 0.1)
|
||||
|
||||
const deltaValue = deltaX * dragStep
|
||||
const newValue = clamp(startValueRef.current + deltaValue)
|
||||
const newFinalValue =
|
||||
Number.parseFloat((newValue * multiplier).toFixed(precision)) / multiplier
|
||||
const newFinalValue = roundStoredValueForDisplayPrecision(newValue)
|
||||
|
||||
if (Math.abs(newFinalValue - finalValue) > 1e-6) {
|
||||
finalValue = newFinalValue
|
||||
@@ -182,13 +206,23 @@ export function MetricControl({
|
||||
document.addEventListener('pointermove', handlePointerMove)
|
||||
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(() => {
|
||||
setIsEditing(true)
|
||||
setInputValue((value * multiplier).toFixed(precision))
|
||||
}, [value, multiplier, precision])
|
||||
setInputValue(toDisplayValue(value).toFixed(precision))
|
||||
}, [value, toDisplayValue, precision])
|
||||
|
||||
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInputValue(e.target.value)
|
||||
@@ -197,12 +231,12 @@ export function MetricControl({
|
||||
const submitValue = useCallback(() => {
|
||||
const numValue = Number.parseFloat(inputValue)
|
||||
if (Number.isNaN(numValue)) {
|
||||
setInputValue((value * multiplier).toFixed(precision))
|
||||
setInputValue(toDisplayValue(value).toFixed(precision))
|
||||
} else {
|
||||
applyCommittedValue(clamp(numValue / multiplier))
|
||||
applyCommittedValue(clamp(toStoredValue(numValue)))
|
||||
}
|
||||
setIsEditing(false)
|
||||
}, [inputValue, applyCommittedValue, clamp, multiplier, value, precision])
|
||||
}, [inputValue, applyCommittedValue, clamp, toStoredValue, value, precision, toDisplayValue])
|
||||
|
||||
const handleInputBlur = useCallback(() => {
|
||||
submitValue()
|
||||
@@ -213,21 +247,21 @@ export function MetricControl({
|
||||
if (e.key === 'Enter') {
|
||||
submitValue()
|
||||
} else if (e.key === 'Escape') {
|
||||
setInputValue((value * multiplier).toFixed(precision))
|
||||
setInputValue(toDisplayValue(value).toFixed(precision))
|
||||
setIsEditing(false)
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
const newV = clamp(value + step / multiplier)
|
||||
const newV = clamp(value + toStoredValue(step))
|
||||
applyCommittedValue(newV)
|
||||
setInputValue((newV * multiplier).toFixed(precision))
|
||||
setInputValue(toDisplayValue(newV).toFixed(precision))
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
const newV = clamp(value - step / multiplier)
|
||||
const newV = clamp(value - toStoredValue(step))
|
||||
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 (
|
||||
|
||||
@@ -41,7 +41,7 @@ import {
|
||||
buildLevelDuplicateCreateOps,
|
||||
type LevelDuplicatePreset,
|
||||
} 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 {
|
||||
getEditorClipboardSnapshot,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
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 { useEffect } from 'react'
|
||||
import {
|
||||
@@ -96,6 +96,10 @@ export function ItemCatalog({
|
||||
key={index}
|
||||
onClick={() => {
|
||||
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)
|
||||
setTool('item')
|
||||
setMode('build')
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import type { LevelNode } from '@pascal-app/core'
|
||||
import { useEffect, useState } from 'react'
|
||||
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 {
|
||||
Dialog,
|
||||
|
||||
@@ -171,6 +171,8 @@ function MobilePanelLayer({
|
||||
export function PanelManager({ inspectorFooter }: { inspectorFooter?: React.ReactNode }) {
|
||||
const isMobile = useIsMobile()
|
||||
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 isPaintPanelOpen = useEditor((s) => s.isPaintPanelOpen)
|
||||
const mode = useEditor((s) => s.mode)
|
||||
@@ -215,5 +217,16 @@ export function PanelManager({ inspectorFooter }: { inspectorFooter?: React.Reac
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -8,12 +8,14 @@ import {
|
||||
type ParamAction,
|
||||
type ParamField,
|
||||
useScene,
|
||||
type ZoneNode,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Icon } from '@iconify/react'
|
||||
import { Move, Trash2 } from 'lucide-react'
|
||||
import { type ComponentType, lazy, Suspense, useCallback } from 'react'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import { collectZoneContentIds } from '../../../lib/zone-content'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { ActionButton, ActionGroup } from '../controls/action-button'
|
||||
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
|
||||
* can't be auto-generated (topology editors etc.).
|
||||
*/
|
||||
export function ParametricInspector({ footer }: { footer?: React.ReactNode } = {}) {
|
||||
const selectedId = useViewer((s) => s.selection.selectedIds[0]) as AnyNodeId | undefined
|
||||
export function ParametricInspector({
|
||||
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)
|
||||
// Subscribe only to the *type* — a string primitive that doesn't change
|
||||
// when slider values change. Without this, every updateNode tick during
|
||||
@@ -58,9 +67,13 @@ export function ParametricInspector({ footer }: { footer?: React.ReactNode } = {
|
||||
[selectedId],
|
||||
)
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
const clearSelection = useCallback(() => {
|
||||
if (onClose) {
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [setSelection])
|
||||
}, [onClose, setSelection])
|
||||
|
||||
const handleMove = useCallback(() => {
|
||||
if (!selectedId) return
|
||||
@@ -68,15 +81,27 @@ export function ParametricInspector({ footer }: { footer?: React.ReactNode } = {
|
||||
if (!node) return
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
useEditor.getState().setMovingNode(node as any)
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [selectedId, setSelection])
|
||||
clearSelection()
|
||||
}, [selectedId, clearSelection])
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
if (!selectedId) return
|
||||
sfxEmitter.emit('sfx:structure-delete')
|
||||
useScene.getState().deleteNode(selectedId)
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [selectedId, setSelection])
|
||||
const handleDelete = useCallback(
|
||||
(withZoneContent = false) => {
|
||||
if (!selectedId) return
|
||||
const scene = useScene.getState()
|
||||
const node = scene.nodes[selectedId]
|
||||
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
|
||||
|
||||
@@ -104,13 +129,20 @@ export function ParametricInspector({ footer }: { footer?: React.ReactNode } = {
|
||||
const iconNode = renderIcon(presentation?.icon)
|
||||
const canMove = !!def.capabilities.movable
|
||||
const canDelete = def.capabilities.deletable !== false
|
||||
const isZone = nodeType === 'zone'
|
||||
|
||||
const TrailingSection = parametrics.trailingSection
|
||||
? resolveCustomPanel(parametrics.trailingSection)
|
||||
: null
|
||||
|
||||
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) => (
|
||||
<PanelSection key={`group-${gi}`} title={group.label}>
|
||||
{group.fields.map((field, fi) => (
|
||||
@@ -130,21 +162,37 @@ export function ParametricInspector({ footer }: { footer?: React.ReactNode } = {
|
||||
)}
|
||||
{(canMove || canDelete || (parametrics.actions && parametrics.actions.length > 0)) && (
|
||||
<PanelSection title="Actions">
|
||||
<ActionGroup>
|
||||
<ActionGroup className={isZone ? 'flex-col' : undefined}>
|
||||
{canMove && (
|
||||
<ActionButton icon={<Move className="h-4 w-4" />} label="Move" onClick={handleMove} />
|
||||
)}
|
||||
{parametrics.actions?.map((action, i) => (
|
||||
<ParamActionButton action={action} key={`paramaction-${i}`} nodeId={selectedId} />
|
||||
))}
|
||||
{canDelete && (
|
||||
<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}
|
||||
/>
|
||||
)}
|
||||
{canDelete &&
|
||||
(isZone ? (
|
||||
<>
|
||||
<ActionButton
|
||||
className="w-full flex-none"
|
||||
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>
|
||||
</PanelSection>
|
||||
)}
|
||||
|
||||
@@ -36,7 +36,7 @@ import {
|
||||
buildLevelDuplicateCreateOps,
|
||||
type LevelDuplicatePreset,
|
||||
} from './../../../../../lib/level-duplication'
|
||||
import { getDefaultLevelName } from './../../../../../lib/level-name'
|
||||
import { getDefaultLevelName } from '@pascal-app/core'
|
||||
import { deleteLevelWithFallbackSelection } from './../../../../../lib/level-selection'
|
||||
import { createLocalGuideImage } from './../../../../../lib/local-guide-image'
|
||||
import { cn } from './../../../../../lib/utils'
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useViewer } from '@pascal-app/viewer'
|
||||
import { Layers } from 'lucide-react'
|
||||
import { memo, useCallback, useState } from 'react'
|
||||
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 { focusTreeNode, TreeNode, TreeNodeWrapper } from './tree-node'
|
||||
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 { Camera, Hexagon, Trash2 } from 'lucide-react'
|
||||
import { Camera, Hexagon, Save, Trash2 } from 'lucide-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 {
|
||||
Popover,
|
||||
@@ -10,6 +17,8 @@ import {
|
||||
} from './../../../../../components/ui/primitives/popover'
|
||||
import { cn } from './../../../../../lib/utils'
|
||||
import useEditor from './../../../../../store/use-editor'
|
||||
import { ActionButton } from '../../../controls/action-button'
|
||||
import { PanelSection } from '../../../controls/panel-section'
|
||||
|
||||
function ZoneItem({ zone }: { zone: ZoneNode }) {
|
||||
const [cameraPopoverOpen, setCameraPopoverOpen] = useState(false)
|
||||
@@ -26,6 +35,7 @@ function ZoneItem({ zone }: { zone: ZoneNode }) {
|
||||
|
||||
const handleDelete = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
sfxEmitter.emit('sfx:structure-delete')
|
||||
deleteNode(zone.id)
|
||||
if (isSelected) {
|
||||
setSelection({ zoneId: null })
|
||||
@@ -125,6 +135,8 @@ function ZoneItem({ zone }: { zone: ZoneNode }) {
|
||||
export function ZonePanel() {
|
||||
const nodes = useScene((state) => state.nodes)
|
||||
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 setMode = useEditor((state) => state.setMode)
|
||||
const setTool = useEditor((state) => state.setTool)
|
||||
@@ -133,6 +145,7 @@ export function ZonePanel() {
|
||||
const levelZones = Object.values(nodes).filter(
|
||||
(node): node is ZoneNode => node.type === 'zone' && node.parentId === currentLevelId,
|
||||
)
|
||||
const selectedZone = levelZones.find((zone) => zone.id === selectedZoneId)
|
||||
|
||||
const handleAddZone = () => {
|
||||
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) {
|
||||
return (
|
||||
<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} />)
|
||||
)}
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type AnyNodeId,
|
||||
type BuildingNode,
|
||||
emitter,
|
||||
getLevelDisplayName,
|
||||
type LevelNode,
|
||||
useScene,
|
||||
type ZoneNode,
|
||||
@@ -30,7 +31,6 @@ import {
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { getLevelDisplayName } from '../lib/level-name'
|
||||
import { cn } from '../lib/utils'
|
||||
import { ActionButton } from './ui/action-menu/action-button'
|
||||
import {
|
||||
|
||||
@@ -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[]
|
||||
|
||||
if (selectedNodeIds.length > 0) {
|
||||
@@ -328,6 +319,15 @@ export const useKeyboard = ({
|
||||
}
|
||||
|
||||
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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,6 +219,14 @@ export {
|
||||
getActivePaintMaterialLabel,
|
||||
hasActivePaintMaterial,
|
||||
} from './lib/material-paint'
|
||||
export {
|
||||
formatLinearMeasurement,
|
||||
getLinearUnitLabel,
|
||||
type LinearUnit,
|
||||
linearControlValueToMeters,
|
||||
linearUnitToMeters,
|
||||
metersToLinearUnit,
|
||||
} from './lib/measurements'
|
||||
export {
|
||||
addFreshPlacementMetadata,
|
||||
getPlacementMetadataRecord,
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import type { LevelNode } from '@pascal-app/core'
|
||||
|
||||
export function getDefaultLevelName(level: number): string {
|
||||
if (level === 0) return 'Ground Floor'
|
||||
if (level > 0) return `Floor ${level}`
|
||||
return `Basement ${-level}`
|
||||
}
|
||||
|
||||
export function getLevelDisplayName(level: Pick<LevelNode, 'name' | 'level'>): string {
|
||||
return level.name || getDefaultLevelName(level.level)
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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`
|
||||
}
|
||||
@@ -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),
|
||||
]),
|
||||
)
|
||||
}
|
||||
@@ -62,7 +62,16 @@ export type WorkspaceMode = 'edit' | 'studio'
|
||||
export type CaptureMode =
|
||||
| { mode: 'idle' }
|
||||
| { 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'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user