feat: add production measurement tools (#505)
* feat: add persistent measurement tools * feat: make measurements associative * fix: finish measurements with Escape * feat: improve measurement snapping guides * feat: clarify measurement axis feedback * feat: smart measure lens, zone reports, and direct measurement editing - Smart measurement lens: registry-owned wall/slab/zone hover reports with a single top-center HUD, click-to-pin, latest-event back pressure, and no scene writes - Conservative derived zone quantities (footprint, perimeter, proven enclosure, gross wall/floor surface, flat-room volume) with the selected-zone blueprint panel - Direct editing of committed measurements via selected-only 2D/3D vertex affordances with midpoint insertion, cancellation, and one-write history - Shared measurement surface-query session; 2D tracing joins the slab/ceiling magnetic pipeline with registered-corner snapping - Angle arcs on the smaller angle, indigo active/black resting hierarchy, screen-sized normal-aligned contact rings Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: make measurement snapping always magnetic Measurement drafting and committed-edit paths gated wall, semantic, and axis magnetism on isMagneticSnapActive(), which is only true in the 'lines' snapping mode — in the default 'grid' mode corners and wall intersections barely attracted (3D association fell to the 0.012 m verify tolerance, 2D wall radii to the 0.05 m connect stick). Measurement is an analysis tool whose anchors exist to bind real geometry, so its snapping no longer consults the construction snapping-mode chip: 2D/3D drafting and committed vertex edits are always magnetic, Alt is the temporary bypass in both views (releasing the axis pull, wall magnetism, and the 2D projected-geometry pull, and shrinking association to contact tolerance). A discrete 2D wall snap (endpoint / midpoint / crossing) now outranks the locked axis pull, and committed 2D edits route the fallback through the raw pointer so free drags no longer quantize to the construction grid. Volume extrusion height keeps its mode-driven grid quantize. Codex adversarial review confirmed the diagnosis and plumbing; its 2D Alt-depth and grid-quantize findings are applied. New surface-plan-snap tests pin the magnetic override seam. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(measurement): stabilize area surface intent --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
22c9472066
commit
ae87ca5475
@@ -3,9 +3,11 @@ import type {
|
||||
HandleDescriptor,
|
||||
NodeDefinition,
|
||||
} from '@pascal-app/core'
|
||||
import { polygonMeasurementFeatures } from '../shared/polygon-measurement'
|
||||
import { buildCeilingFloorplan } from './floorplan'
|
||||
import {
|
||||
ceilingAddVertexAffordance,
|
||||
ceilingDeleteVertexAffordance,
|
||||
ceilingMoveEdgeAffordance,
|
||||
ceilingMoveVertexAffordance,
|
||||
} from './floorplan-affordances'
|
||||
@@ -118,6 +120,15 @@ export const ceilingDefinition: NodeDefinition<typeof CeilingNode> = {
|
||||
|
||||
parametrics: ceilingParametrics,
|
||||
handles: ceilingHandles,
|
||||
measurement: {
|
||||
features: (node) =>
|
||||
polygonMeasurementFeatures({
|
||||
featurePrefix: 'ceiling',
|
||||
height: node.height,
|
||||
label: 'Ceiling',
|
||||
polygon: node.polygon,
|
||||
}),
|
||||
},
|
||||
|
||||
// Stage D: kind-owned placement tool. Multi-click polygon drawing
|
||||
// with a vertical TSL-gradient connector + ground-shadow lines.
|
||||
@@ -152,6 +163,7 @@ export const ceilingDefinition: NodeDefinition<typeof CeilingNode> = {
|
||||
'move-vertex': ceilingMoveVertexAffordance,
|
||||
'add-vertex': ceilingAddVertexAffordance,
|
||||
'move-edge': ceilingMoveEdgeAffordance,
|
||||
'delete-vertex': ceilingDeleteVertexAffordance,
|
||||
},
|
||||
|
||||
toolHints: [
|
||||
|
||||
@@ -2,18 +2,20 @@ import { type AnyNode, type CeilingNode, resolveLevelId } from '@pascal-app/core
|
||||
import { resolveCeilingPlanPointSnap } from '@pascal-app/editor'
|
||||
import {
|
||||
createPolygonAddVertexAffordance,
|
||||
createPolygonDeleteVertexAffordance,
|
||||
createPolygonMoveEdgeAffordance,
|
||||
createPolygonVertexAffordance,
|
||||
type PolygonAffordanceSnapContext,
|
||||
} from '../shared/polygon-vertex-affordance'
|
||||
|
||||
/**
|
||||
* 2D drag affordances for ceiling. Same three operations as slab
|
||||
* (`move-vertex`, `add-vertex`, `move-edge`), each accepting an
|
||||
* 2D affordances for ceiling. Same four operations as slab
|
||||
* (`move-vertex`, `add-vertex`, `move-edge`, `delete-vertex`), each accepting an
|
||||
* optional `holeIndex`. See `slab/floorplan-affordances.ts` for the
|
||||
* full contract.
|
||||
*/
|
||||
const ceilingSnapOptions = {
|
||||
boundaryCommitData: { autoFromWalls: false },
|
||||
resolvePlanPoint({
|
||||
node,
|
||||
nodes,
|
||||
@@ -45,3 +47,7 @@ export const ceilingMoveEdgeAffordance = createPolygonMoveEdgeAffordance<Ceiling
|
||||
'ceiling',
|
||||
ceilingSnapOptions,
|
||||
)
|
||||
export const ceilingDeleteVertexAffordance = createPolygonDeleteVertexAffordance<CeilingNode>(
|
||||
'ceiling',
|
||||
ceilingSnapOptions,
|
||||
)
|
||||
|
||||
@@ -11,4 +11,9 @@ import { createPolygonCentroidMoveTarget } from '../shared/polygon-centroid-move
|
||||
* split view.
|
||||
*/
|
||||
export const ceilingFloorplanMoveTarget: FloorplanMoveTarget<CeilingNode> = ({ node, nodes }) =>
|
||||
createPolygonCentroidMoveTarget({ node, nodes, meshY: (node.height ?? 2.5) - 0.01 })
|
||||
createPolygonCentroidMoveTarget({
|
||||
node,
|
||||
nodes,
|
||||
meshY: (node.height ?? 2.5) - 0.01,
|
||||
extraCommitData: node.autoFromWalls ? { autoFromWalls: false } : undefined,
|
||||
})
|
||||
|
||||
@@ -22,6 +22,7 @@ import { itemDefinition } from './item'
|
||||
import { levelDefinition } from './level'
|
||||
import { linesetDefinition } from './lineset'
|
||||
import { liquidLineDefinition } from './liquid-line'
|
||||
import { measurementDefinition } from './measurement'
|
||||
import { pipeFittingDefinition } from './pipe-fitting'
|
||||
import { pipeSegmentDefinition } from './pipe-segment'
|
||||
import { pipeTrapDefinition } from './pipe-trap'
|
||||
@@ -88,6 +89,7 @@ export const builtinPlugin: Plugin = {
|
||||
levelDefinition as unknown as AnyNodeDefinition,
|
||||
guideDefinition as unknown as AnyNodeDefinition,
|
||||
scanDefinition as unknown as AnyNodeDefinition,
|
||||
measurementDefinition as unknown as AnyNodeDefinition,
|
||||
// Roof-mounted accessories (custom renderer + bespoke roof-event tool).
|
||||
boxVentDefinition as unknown as AnyNodeDefinition,
|
||||
ridgeVentDefinition as unknown as AnyNodeDefinition,
|
||||
@@ -145,6 +147,7 @@ export { itemDefinition } from './item'
|
||||
export { levelDefinition } from './level'
|
||||
export { linesetDefinition } from './lineset'
|
||||
export { liquidLineDefinition, useLiquidLineToolOptions } from './liquid-line'
|
||||
export { measurementDefinition } from './measurement'
|
||||
export { pipeFittingDefinition } from './pipe-fitting'
|
||||
export { pipeSegmentDefinition } from './pipe-segment'
|
||||
export { pipeTrapDefinition } from './pipe-trap'
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { afterAll, afterEach, describe, expect, mock, spyOn, test } from 'bun:test'
|
||||
import { LoadingManager } from 'three'
|
||||
import {
|
||||
cancelItemModelLoad,
|
||||
classifyItemModelLoadFailure,
|
||||
getUnavailableItemAsset,
|
||||
ItemGLTFLoader,
|
||||
} from './model-loader'
|
||||
|
||||
const originalFetch = globalThis.fetch
|
||||
const originalProgressEvent = globalThis.ProgressEvent
|
||||
|
||||
if (typeof globalThis.ProgressEvent === 'undefined') {
|
||||
globalThis.ProgressEvent = class TestProgressEvent extends Event {} as typeof ProgressEvent
|
||||
}
|
||||
|
||||
afterAll(() => {
|
||||
globalThis.ProgressEvent = originalProgressEvent
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch
|
||||
})
|
||||
|
||||
const load = (loader: ItemGLTFLoader, url: string) =>
|
||||
new Promise<
|
||||
| { kind: 'loaded'; unavailable: ReturnType<typeof getUnavailableItemAsset> }
|
||||
| { error: unknown; kind: 'error' }
|
||||
>((resolve) => {
|
||||
loader.load(
|
||||
url,
|
||||
(gltf) => resolve({ kind: 'loaded', unavailable: getUnavailableItemAsset(gltf) }),
|
||||
undefined,
|
||||
(error) => resolve({ error, kind: 'error' }),
|
||||
)
|
||||
})
|
||||
|
||||
describe('classifyItemModelLoadFailure', () => {
|
||||
test('distinguishes unavailable, retryable, and unexpected failures', () => {
|
||||
expect(
|
||||
classifyItemModelLoadFailure(
|
||||
Object.assign(new Error('missing'), { response: { status: 404 } }),
|
||||
),
|
||||
).toBe('unavailable')
|
||||
expect(
|
||||
classifyItemModelLoadFailure(
|
||||
Object.assign(new Error('temporary'), { response: { status: 503 } }),
|
||||
),
|
||||
).toBe('retryable')
|
||||
expect(
|
||||
classifyItemModelLoadFailure(
|
||||
Object.assign(new Error('forbidden'), { response: { status: 403 } }),
|
||||
),
|
||||
).toBe('unavailable')
|
||||
expect(classifyItemModelLoadFailure(new TypeError('Failed to fetch'))).toBe('retryable')
|
||||
expect(classifyItemModelLoadFailure(new Error('Malformed glTF'))).toBe('unexpected')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ItemGLTFLoader', () => {
|
||||
test('resolves missing responses as an unavailable item instead of rejecting', async () => {
|
||||
const consoleError = spyOn(console, 'error').mockImplementation(() => {})
|
||||
try {
|
||||
globalThis.fetch = mock(async () => new Response(null, { status: 404 })) as typeof fetch
|
||||
|
||||
const result = await load(
|
||||
new ItemGLTFLoader(undefined, []),
|
||||
'https://example.test/missing.glb',
|
||||
)
|
||||
|
||||
expect(result.kind).toBe('loaded')
|
||||
if (result.kind !== 'loaded') return
|
||||
expect(result.unavailable).toMatchObject({ url: 'https://example.test/missing.glb' })
|
||||
expect(consoleError).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
consoleError.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
test('resolves exhausted network failures as an unavailable item', async () => {
|
||||
const consoleError = spyOn(console, 'error').mockImplementation(() => {})
|
||||
try {
|
||||
globalThis.fetch = mock(async () => {
|
||||
throw new TypeError('Failed to fetch')
|
||||
}) as typeof fetch
|
||||
|
||||
const result = await load(
|
||||
new ItemGLTFLoader(undefined, []),
|
||||
'https://example.test/offline.glb',
|
||||
)
|
||||
|
||||
expect(result.kind).toBe('loaded')
|
||||
if (result.kind !== 'loaded') return
|
||||
expect(result.unavailable?.message).toBe('Failed to fetch')
|
||||
expect(consoleError).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
consoleError.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
test('keeps malformed model data on the unexpected error path', async () => {
|
||||
globalThis.fetch = mock(
|
||||
async () => new Response(new Uint8Array([1, 2, 3]), { status: 200 }),
|
||||
) as typeof fetch
|
||||
|
||||
const result = await load(new ItemGLTFLoader(undefined, []), 'https://example.test/broken.glb')
|
||||
|
||||
expect(result.kind).toBe('error')
|
||||
})
|
||||
|
||||
test('retries a transient response and can recover', async () => {
|
||||
const validGltf = JSON.stringify({ asset: { version: '2.0' }, scene: 0, scenes: [{}] })
|
||||
let attempt = 0
|
||||
globalThis.fetch = mock(async () => {
|
||||
attempt += 1
|
||||
return attempt === 1
|
||||
? new Response(null, { status: 503 })
|
||||
: new Response(validGltf, { status: 200 })
|
||||
}) as typeof fetch
|
||||
|
||||
const manager = new LoadingManager()
|
||||
let hostErrors = 0
|
||||
let hostLoads = 0
|
||||
manager.onError = () => {
|
||||
hostErrors += 1
|
||||
}
|
||||
manager.onLoad = () => {
|
||||
hostLoads += 1
|
||||
}
|
||||
|
||||
const result = await load(new ItemGLTFLoader(manager, [0]), 'https://example.test/retry.glb')
|
||||
|
||||
expect(result).toEqual({ kind: 'loaded', unavailable: null })
|
||||
expect(attempt).toBe(2)
|
||||
expect(hostErrors).toBe(0)
|
||||
expect(hostLoads).toBe(1)
|
||||
})
|
||||
|
||||
test('does not retry after the last consumer cancels a missing asset', async () => {
|
||||
const url = 'https://example.test/cancelled.glb'
|
||||
const request = mock(async () => {
|
||||
throw new TypeError('Failed to fetch')
|
||||
})
|
||||
globalThis.fetch = request as typeof fetch
|
||||
const manager = new LoadingManager()
|
||||
let hostLoads = 0
|
||||
manager.onLoad = () => {
|
||||
hostLoads += 1
|
||||
}
|
||||
|
||||
new ItemGLTFLoader(manager, [10]).load(url, () => {
|
||||
throw new Error('cancelled load must not resolve')
|
||||
})
|
||||
await Bun.sleep(0)
|
||||
cancelItemModelLoad(url)
|
||||
await Bun.sleep(20)
|
||||
|
||||
expect(request).toHaveBeenCalledTimes(1)
|
||||
expect(hostLoads).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,156 @@
|
||||
import { DefaultLoadingManager, Group, LoadingManager } from 'three'
|
||||
import { type GLTF, GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
|
||||
|
||||
const ITEM_ASSET_UNAVAILABLE_KEY = 'pascalItemAssetUnavailable'
|
||||
const DEFAULT_RETRY_DELAYS_MS = [1_000, 3_000] as const
|
||||
const itemLoadGenerations = new Map<string, number>()
|
||||
|
||||
type HttpErrorLike = Error & {
|
||||
response?: { status?: number }
|
||||
}
|
||||
|
||||
export type ItemAssetUnavailable = {
|
||||
message: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export type ItemModelLoadFailureKind = 'retryable' | 'unavailable' | 'unexpected'
|
||||
|
||||
export function classifyItemModelLoadFailure(error: unknown): ItemModelLoadFailureKind {
|
||||
if (!(error instanceof Error)) return 'unexpected'
|
||||
|
||||
const status = (error as HttpErrorLike).response?.status
|
||||
if (
|
||||
status === 408 ||
|
||||
status === 425 ||
|
||||
status === 429 ||
|
||||
(status !== undefined && status >= 500)
|
||||
) {
|
||||
return 'retryable'
|
||||
}
|
||||
if (status !== undefined && status >= 400 && status < 500) return 'unavailable'
|
||||
if (error instanceof TypeError && /failed to fetch/i.test(error.message)) return 'retryable'
|
||||
|
||||
return 'unexpected'
|
||||
}
|
||||
|
||||
export function createUnavailableItemGltf(url: string, error: unknown): GLTF {
|
||||
const unavailable: ItemAssetUnavailable = {
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
url,
|
||||
}
|
||||
const scene = new Group()
|
||||
scene.userData[ITEM_ASSET_UNAVAILABLE_KEY] = unavailable
|
||||
|
||||
return {
|
||||
animations: [],
|
||||
asset: { version: '2.0' },
|
||||
cameras: [],
|
||||
parser: null as never,
|
||||
scene,
|
||||
scenes: [scene],
|
||||
userData: { [ITEM_ASSET_UNAVAILABLE_KEY]: unavailable },
|
||||
}
|
||||
}
|
||||
|
||||
export function getUnavailableItemAsset(gltf: GLTF): ItemAssetUnavailable | null {
|
||||
const value = gltf.userData?.[ITEM_ASSET_UNAVAILABLE_KEY]
|
||||
if (!value || typeof value !== 'object') return null
|
||||
const candidate = value as Partial<ItemAssetUnavailable>
|
||||
return typeof candidate.url === 'string' && typeof candidate.message === 'string'
|
||||
? { url: candidate.url, message: candidate.message }
|
||||
: null
|
||||
}
|
||||
|
||||
export function cancelItemModelLoad(url: string) {
|
||||
itemLoadGenerations.set(url, (itemLoadGenerations.get(url) ?? 0) + 1)
|
||||
}
|
||||
|
||||
export class ItemGLTFLoader extends GLTFLoader {
|
||||
readonly hostManager: LoadingManager
|
||||
readonly retryDelaysMs: readonly number[]
|
||||
|
||||
constructor(manager?: LoadingManager, retryDelaysMs = DEFAULT_RETRY_DELAYS_MS) {
|
||||
super(new LoadingManager())
|
||||
this.hostManager = manager ?? DefaultLoadingManager
|
||||
this.retryDelaysMs = retryDelaysMs
|
||||
}
|
||||
|
||||
override load(
|
||||
url: string,
|
||||
onLoad: (gltf: GLTF) => void,
|
||||
onProgress?: (event: ProgressEvent) => void,
|
||||
onError?: (error: unknown) => void,
|
||||
): void {
|
||||
const generation = itemLoadGenerations.get(url) ?? 0
|
||||
let retryCount = 0
|
||||
let finished = false
|
||||
|
||||
const wasCancelled = () => (itemLoadGenerations.get(url) ?? 0) !== generation
|
||||
|
||||
const cancel = () => {
|
||||
if (finished) return
|
||||
finished = true
|
||||
this.hostManager.itemEnd(url)
|
||||
}
|
||||
|
||||
const complete = (gltf: GLTF) => {
|
||||
if (finished) return
|
||||
if (wasCancelled()) {
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
finished = true
|
||||
try {
|
||||
onLoad(gltf)
|
||||
} finally {
|
||||
this.hostManager.itemEnd(url)
|
||||
}
|
||||
}
|
||||
|
||||
const fail = (error: unknown) => {
|
||||
if (finished) return
|
||||
if (wasCancelled()) {
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
finished = true
|
||||
try {
|
||||
if (onError) onError(error)
|
||||
else console.error(error)
|
||||
} finally {
|
||||
this.hostManager.itemError(url)
|
||||
this.hostManager.itemEnd(url)
|
||||
}
|
||||
}
|
||||
|
||||
const attempt = () => {
|
||||
if (wasCancelled()) {
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
super.load(url, complete, onProgress, (error) => {
|
||||
if (wasCancelled()) {
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
const kind = classifyItemModelLoadFailure(error)
|
||||
if (kind === 'unexpected') {
|
||||
fail(error)
|
||||
return
|
||||
}
|
||||
if (kind === 'unavailable' || retryCount >= this.retryDelaysMs.length) {
|
||||
complete(createUnavailableItemGltf(url, error))
|
||||
return
|
||||
}
|
||||
|
||||
const delay = this.retryDelaysMs[retryCount] ?? 0
|
||||
retryCount += 1
|
||||
setTimeout(attempt, delay)
|
||||
})
|
||||
}
|
||||
|
||||
this.hostManager.itemStart(url)
|
||||
attempt()
|
||||
}
|
||||
}
|
||||
@@ -34,13 +34,16 @@ import {
|
||||
} from '@pascal-app/viewer'
|
||||
import { useAnimations } from '@react-three/drei'
|
||||
import { Clone } from '@react-three/drei/core/Clone'
|
||||
import { useGLTF } from '@react-three/drei/core/Gltf'
|
||||
import { useFrame } from '@react-three/fiber'
|
||||
import { useFrame, useLoader } from '@react-three/fiber'
|
||||
import { Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { AnimationAction, Group, Material, Mesh } from 'three'
|
||||
import type { AnimationAction, Group, Material, Mesh, Object3D } from 'three'
|
||||
import { MathUtils } from 'three'
|
||||
import { MeshoptDecoder } from 'three/examples/jsm/libs/meshopt_decoder.module.js'
|
||||
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js'
|
||||
import type { GLTF } from 'three/examples/jsm/loaders/GLTFLoader.js'
|
||||
import { positionLocal, smoothstep, time } from 'three/tsl'
|
||||
import { RoofFaceHostFrame } from '../shared/roof-face-host'
|
||||
import { cancelItemModelLoad, getUnavailableItemAsset, ItemGLTFLoader } from './model-loader'
|
||||
|
||||
type MutableMaterial = Material & {
|
||||
depthTest?: boolean
|
||||
@@ -182,7 +185,7 @@ const BrokenItemFallback = ({ node }: { node: ItemNode }) => {
|
||||
const handlers = useNodeEvents(node, 'item')
|
||||
const shading = useViewer((s) => s.shading)
|
||||
const isExporting = useViewer((s) => s.isExporting)
|
||||
const [w, h, d] = node.asset.dimensions
|
||||
const [w, h, d] = getScaledDimensions(node)
|
||||
const material = useMemo(() => {
|
||||
const next = createDefaultMaterial('#ef4444', 1, shading) as MutableMaterial
|
||||
next.opacity = 0.6
|
||||
@@ -204,16 +207,99 @@ const BrokenItemFallback = ({ node }: { node: ItemNode }) => {
|
||||
)
|
||||
}
|
||||
|
||||
const MODEL_RETRY_DELAYS_MS = [1_000, 3_000]
|
||||
let itemDracoLoader: DRACOLoader | null = null
|
||||
|
||||
const configureItemModelLoader = (loader: ItemGLTFLoader) => {
|
||||
if (!itemDracoLoader) {
|
||||
itemDracoLoader = new DRACOLoader(loader.manager)
|
||||
itemDracoLoader.setDecoderPath('https://www.gstatic.com/draco/versioned/decoders/1.5.5/')
|
||||
}
|
||||
loader.setDRACOLoader(itemDracoLoader)
|
||||
loader.setMeshoptDecoder(MeshoptDecoder)
|
||||
}
|
||||
|
||||
type LoadedItemGltf = GLTF & {
|
||||
materials: Record<string, Material>
|
||||
nodes: Record<string, Object3D>
|
||||
}
|
||||
|
||||
const useItemGltf = (url: string): LoadedItemGltf =>
|
||||
useLoader(ItemGLTFLoader, url, configureItemModelLoader) as LoadedItemGltf
|
||||
|
||||
type DeferredUnavailableCleanup = {
|
||||
consumers: number
|
||||
timer: ReturnType<typeof setTimeout> | null
|
||||
}
|
||||
|
||||
const unavailableAssetConsumers = new Map<string, DeferredUnavailableCleanup>()
|
||||
const unavailableFailureConsumers = new Map<string, DeferredUnavailableCleanup>()
|
||||
|
||||
const retainUnavailableConsumer = (
|
||||
entries: Map<string, DeferredUnavailableCleanup>,
|
||||
key: string,
|
||||
) => {
|
||||
const entry = entries.get(key) ?? { consumers: 0, timer: null }
|
||||
if (entry.timer !== null) {
|
||||
clearTimeout(entry.timer)
|
||||
entry.timer = null
|
||||
}
|
||||
entry.consumers += 1
|
||||
entries.set(key, entry)
|
||||
}
|
||||
|
||||
const releaseUnavailableConsumer = (
|
||||
entries: Map<string, DeferredUnavailableCleanup>,
|
||||
key: string,
|
||||
onLastRelease: () => void,
|
||||
) => {
|
||||
const entry = entries.get(key)
|
||||
if (!entry) return
|
||||
entry.consumers = Math.max(0, entry.consumers - 1)
|
||||
if (entry.consumers > 0 || entry.timer !== null) return
|
||||
|
||||
// A zero-delay release distinguishes a real unmount from Strict Mode's
|
||||
// immediate setup-cleanup-setup cycle and same-tick replacements.
|
||||
entry.timer = setTimeout(() => {
|
||||
if (entry.consumers > 0 || entries.get(key) !== entry) return
|
||||
entries.delete(key)
|
||||
onLastRelease()
|
||||
}, 0)
|
||||
}
|
||||
|
||||
const UnavailableItemModel = ({
|
||||
markSettled,
|
||||
node,
|
||||
url,
|
||||
}: {
|
||||
markSettled: () => void
|
||||
node: ItemNode
|
||||
url: string
|
||||
}) => {
|
||||
useEffect(() => {
|
||||
retainUnavailableConsumer(unavailableFailureConsumers, node.id)
|
||||
if (url) retainUnavailableConsumer(unavailableAssetConsumers, url)
|
||||
markSettled()
|
||||
useViewer.getState().reportItemLoadFailure(node.id, url)
|
||||
return () => {
|
||||
releaseUnavailableConsumer(unavailableFailureConsumers, node.id, () =>
|
||||
useViewer.getState().clearItemLoadFailure(node.id),
|
||||
)
|
||||
if (url) {
|
||||
releaseUnavailableConsumer(unavailableAssetConsumers, url, () => {
|
||||
cancelItemModelLoad(url)
|
||||
useLoader.clear(ItemGLTFLoader, url)
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [markSettled, node.id, url])
|
||||
|
||||
return <BrokenItemFallback node={node} />
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the item model with bounded retries. drei's `useGLTF` caches a rejected
|
||||
* load by URL, so a transient fetch failure (e.g. a storage 504 under the bake
|
||||
* page's asset-request burst) would otherwise stay broken for the whole
|
||||
* session — clear the cache entry and re-mount. After the retries are
|
||||
* exhausted the item settles as SKIPPED: it renders the debug box (nothing
|
||||
* during exports) and lands in `useViewer.itemLoadFailures` so a bake host can
|
||||
* record which items are missing from the artifact.
|
||||
* Expected network failures resolve through ItemGLTFLoader as an unavailable
|
||||
* scene so they never become React render errors. Parse and renderer failures
|
||||
* still reach this boundary and remain visible to developers.
|
||||
*/
|
||||
const ModelWithRetry = ({
|
||||
node,
|
||||
@@ -222,48 +308,30 @@ const ModelWithRetry = ({
|
||||
node: ItemNode
|
||||
setSettled: (value: boolean) => void
|
||||
}) => {
|
||||
// `failures` counts boundary catches; `epoch` bumps after each cache clear
|
||||
// to reset the boundary and re-mount the loader. The retry timer is owned by
|
||||
// an effect (not the error handler) so StrictMode's synthetic
|
||||
// unmount/remount re-arms it instead of silently discarding it. The host
|
||||
// keys this component by asset URL, so a model swap starts from a clean
|
||||
// retry budget — and the mount effect below un-settles the item so the new
|
||||
// load is awaited too.
|
||||
const [failures, setFailures] = useState(0)
|
||||
const [epoch, setEpoch] = useState(0)
|
||||
const [renderFailed, setRenderFailed] = useState(false)
|
||||
const url = resolveCdnUrl(node.asset.src) || ''
|
||||
const gaveUp = !url || failures > MODEL_RETRY_DELAYS_MS.length
|
||||
const markSettled = useCallback(() => setSettled(true), [setSettled])
|
||||
|
||||
const handleError = useCallback(() => setFailures((current) => current + 1), [])
|
||||
|
||||
useEffect(() => {
|
||||
// Clear before child passive completion effects; a parent passive clear would run after them.
|
||||
useLayoutEffect(() => {
|
||||
setSettled(false)
|
||||
}, [setSettled])
|
||||
|
||||
useEffect(() => {
|
||||
if (failures === 0 || gaveUp) return
|
||||
const delay = MODEL_RETRY_DELAYS_MS[failures - 1] ?? 0
|
||||
const timer = setTimeout(() => {
|
||||
console.log(`[item] retrying model load (${failures}/${MODEL_RETRY_DELAYS_MS.length}) ${url}`)
|
||||
useGLTF.clear(url)
|
||||
setEpoch((current) => current + 1)
|
||||
}, delay)
|
||||
return () => clearTimeout(timer)
|
||||
}, [failures, gaveUp, url])
|
||||
|
||||
const markSettled = useCallback(() => setSettled(true), [setSettled])
|
||||
|
||||
useEffect(() => {
|
||||
if (!gaveUp) return
|
||||
if (!renderFailed) return
|
||||
markSettled()
|
||||
useViewer.getState().reportItemLoadFailure(node.id, url)
|
||||
return () => useViewer.getState().clearItemLoadFailure(node.id)
|
||||
}, [gaveUp, markSettled, node.id, url])
|
||||
}, [markSettled, node.id, renderFailed, url])
|
||||
|
||||
if (gaveUp) return <BrokenItemFallback node={node} />
|
||||
if (!url) return <UnavailableItemModel markSettled={markSettled} node={node} url={url} />
|
||||
|
||||
return (
|
||||
<ErrorBoundary fallback={<PreviewModel node={node} />} onError={handleError} resetKey={epoch}>
|
||||
<ErrorBoundary
|
||||
fallback={<BrokenItemFallback node={node} />}
|
||||
onError={() => setRenderFailed(true)}
|
||||
scope="item-model"
|
||||
>
|
||||
<Suspense fallback={<PreviewModel node={node} />}>
|
||||
<ModelRenderer markSettled={markSettled} node={node} />
|
||||
</Suspense>
|
||||
@@ -381,15 +449,31 @@ const multiplyScales = (
|
||||
b: [number, number, number],
|
||||
): [number, number, number] => [a[0] * b[0], a[1] * b[1], a[2] * b[2]]
|
||||
|
||||
const ModelRenderer = ({ node, markSettled }: { node: ItemNode; markSettled?: () => void }) => {
|
||||
const { scene, nodes, animations } = useGLTF(resolveCdnUrl(node.asset.src) || '')
|
||||
const ModelRenderer = ({ node, markSettled }: { node: ItemNode; markSettled: () => void }) => {
|
||||
const gltf = useItemGltf(resolveCdnUrl(node.asset.src) || '')
|
||||
const unavailable = getUnavailableItemAsset(gltf)
|
||||
if (unavailable) {
|
||||
return <UnavailableItemModel markSettled={markSettled} node={node} url={unavailable.url} />
|
||||
}
|
||||
return <LoadedModelRenderer gltf={gltf} markSettled={markSettled} node={node} />
|
||||
}
|
||||
|
||||
const LoadedModelRenderer = ({
|
||||
gltf: { scene, nodes, animations },
|
||||
node,
|
||||
markSettled,
|
||||
}: {
|
||||
gltf: LoadedItemGltf
|
||||
node: ItemNode
|
||||
markSettled: () => void
|
||||
}) => {
|
||||
const ref = useRef<Group>(null!)
|
||||
const { actions } = useAnimations(animations, ref)
|
||||
|
||||
// Mounting past the suspense gate means the GLB resolved — the item's build
|
||||
// work is done (`ItemSystem` may clear its dirty mark, scene-ready may fire).
|
||||
useEffect(() => {
|
||||
markSettled?.()
|
||||
markSettled()
|
||||
}, [markSettled])
|
||||
const shading = useViewer((s) => s.shading)
|
||||
const textures = useViewer((s) => s.textures)
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { measurementDefinition } from './definition'
|
||||
|
||||
describe('measurementDefinition', () => {
|
||||
test('registers a transient-free analysis annotation contract', () => {
|
||||
expect(measurementDefinition.kind).toBe('measurement')
|
||||
expect(measurementDefinition.category).toBe('analysis')
|
||||
expect(measurementDefinition.bake).toBe('strip')
|
||||
expect(measurementDefinition.snapProfile).toBe('structural')
|
||||
expect(measurementDefinition.dirtyTracking).toBe(false)
|
||||
expect(measurementDefinition.capabilities).toMatchObject({
|
||||
selectable: { hitVolume: 'bbox' },
|
||||
deletable: true,
|
||||
duplicable: true,
|
||||
presettable: false,
|
||||
})
|
||||
expect(typeof measurementDefinition.tool).toBe('function')
|
||||
expect(typeof measurementDefinition.affordanceTools?.selection).toBe('function')
|
||||
expect(typeof measurementDefinition.floorplanAffordances?.['move-measurement-vertex']).toBe(
|
||||
'object',
|
||||
)
|
||||
expect(measurementDefinition.presentation?.actionMenu).toBe(false)
|
||||
expect(measurementDefinition.parametrics).toBeUndefined()
|
||||
expect(measurementDefinition.toolHints?.map((hint) => hint.key)).toEqual([
|
||||
'Left click',
|
||||
'Enter',
|
||||
'Backspace',
|
||||
'Esc',
|
||||
])
|
||||
expect(measurementDefinition.toolHints?.at(-1)?.label).toBe('Finish and continue')
|
||||
})
|
||||
|
||||
test('produces schema-valid defaults', () => {
|
||||
expect(
|
||||
measurementDefinition.schema.safeParse({
|
||||
id: 'measurement_default',
|
||||
type: 'measurement',
|
||||
...measurementDefinition.defaults(),
|
||||
}).success,
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
import { measurementReferenceNodeIds, type NodeDefinition } from '@pascal-app/core'
|
||||
import { buildMeasurementFloorplan } from './floorplan'
|
||||
import { measurementMoveVertexAffordance } from './floorplan-affordance'
|
||||
import { MeasurementNode } from './schema'
|
||||
|
||||
export const measurementDefinition: NodeDefinition<typeof MeasurementNode> = {
|
||||
kind: 'measurement',
|
||||
bake: 'strip',
|
||||
snapProfile: 'structural',
|
||||
schemaVersion: 2,
|
||||
schema: MeasurementNode,
|
||||
category: 'analysis',
|
||||
|
||||
defaults: () => ({
|
||||
object: 'node',
|
||||
parentId: null,
|
||||
visible: true,
|
||||
metadata: {},
|
||||
measurement: {
|
||||
kind: 'distance',
|
||||
points: [
|
||||
[0, 0, 0],
|
||||
[1, 0, 0],
|
||||
],
|
||||
},
|
||||
}),
|
||||
|
||||
capabilities: {
|
||||
selectable: { hitVolume: 'bbox' },
|
||||
deletable: true,
|
||||
duplicable: true,
|
||||
presettable: false,
|
||||
},
|
||||
|
||||
dirtyTracking: false,
|
||||
|
||||
renderer: {
|
||||
kind: 'parametric',
|
||||
module: () => import('./renderer'),
|
||||
},
|
||||
floorplan: buildMeasurementFloorplan,
|
||||
floorplanDependencies: (node) => measurementReferenceNodeIds(node.measurement),
|
||||
floorplanAffordances: {
|
||||
'move-measurement-vertex': measurementMoveVertexAffordance,
|
||||
},
|
||||
affordanceTools: {
|
||||
selection: () => import('./selection'),
|
||||
},
|
||||
tool: () => import('./tool-router'),
|
||||
toolHints: [
|
||||
{ key: 'Left click', label: 'Place measurement point' },
|
||||
{ key: 'Enter', label: 'Finish measurement' },
|
||||
{ key: 'Backspace', label: 'Remove last point' },
|
||||
{ key: 'Esc', label: 'Finish and continue' },
|
||||
],
|
||||
|
||||
presentation: {
|
||||
label: 'Measurement',
|
||||
description: 'A persistent distance, angle, area, perimeter, or volume annotation.',
|
||||
icon: { kind: 'iconify', name: 'lucide:ruler' },
|
||||
hidden: true,
|
||||
actionMenu: false,
|
||||
},
|
||||
|
||||
mcp: {
|
||||
description:
|
||||
'A persistent level-local distance, angle, area, perimeter, or volume measurement.',
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { MeasurementNode, measurementDistance } from '@pascal-app/core'
|
||||
import {
|
||||
constrainMeasurementPlanEditPoint,
|
||||
constrainMeasurementSpatialEditPoint,
|
||||
refreshMeasurementAnchorFallbacks,
|
||||
replaceMeasurementAnchor,
|
||||
} from './edit'
|
||||
import { resolveMeasurementNode } from './resolve'
|
||||
|
||||
describe('measurement committed vertex editing', () => {
|
||||
test('replaces only the moved anchor and preserves other associations', () => {
|
||||
const node = MeasurementNode.parse({
|
||||
id: 'measurement_edit_distance',
|
||||
type: 'measurement',
|
||||
measurement: {
|
||||
kind: 'distance',
|
||||
points: [
|
||||
{
|
||||
kind: 'feature',
|
||||
reference: { nodeId: 'wall_a', featureId: 'wall:start' },
|
||||
fallback: [0, 0, 0],
|
||||
},
|
||||
{
|
||||
kind: 'feature',
|
||||
reference: { nodeId: 'wall_b', featureId: 'wall:end' },
|
||||
fallback: [2, 0, 0],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
const next = replaceMeasurementAnchor(node.measurement, 0, [1, 0, 0])
|
||||
expect(next?.kind).toBe('distance')
|
||||
if (next?.kind !== 'distance') return
|
||||
expect(next.points[0]).toEqual([1, 0, 0])
|
||||
expect(next.points[1]).toEqual(node.measurement.points[1])
|
||||
})
|
||||
|
||||
test('refreshes semantic fallbacks from the live resolved geometry before editing', () => {
|
||||
const node = MeasurementNode.parse({
|
||||
id: 'measurement_edit_fallback',
|
||||
type: 'measurement',
|
||||
measurement: {
|
||||
kind: 'distance',
|
||||
points: [
|
||||
{
|
||||
kind: 'feature',
|
||||
reference: { nodeId: 'missing', featureId: 'wall:start' },
|
||||
fallback: [0, 0, 0],
|
||||
},
|
||||
[2, 0, 0],
|
||||
],
|
||||
},
|
||||
})
|
||||
const resolved = resolveMeasurementNode(node, () => undefined)
|
||||
resolved.payload.points[0] = [3, 1, 4]
|
||||
const refreshed = refreshMeasurementAnchorFallbacks(node.measurement, resolved.payload)
|
||||
expect(refreshed.kind === 'distance' && refreshed.points[0]).toMatchObject({
|
||||
fallback: [3, 1, 4],
|
||||
})
|
||||
})
|
||||
|
||||
test('keeps plan edits on horizontal, sloped, and vertical polygon planes', () => {
|
||||
expect(
|
||||
constrainMeasurementPlanEditPoint(
|
||||
{
|
||||
kind: 'area',
|
||||
base: [
|
||||
[0, 2, 0],
|
||||
[2, 2, 0],
|
||||
[2, 2, 2],
|
||||
],
|
||||
},
|
||||
1,
|
||||
[4, 5],
|
||||
),
|
||||
).toEqual([4, 2, 5])
|
||||
|
||||
const sloped = constrainMeasurementPlanEditPoint(
|
||||
{
|
||||
kind: 'area',
|
||||
base: [
|
||||
[0, 0, 0],
|
||||
[2, 2, 0],
|
||||
[2, 2, 2],
|
||||
],
|
||||
},
|
||||
1,
|
||||
[4, 3],
|
||||
)
|
||||
expect(sloped).toEqual([4, 4, 3])
|
||||
|
||||
const vertical = constrainMeasurementPlanEditPoint(
|
||||
{
|
||||
kind: 'area',
|
||||
base: [
|
||||
[1, 0, 0],
|
||||
[1, 2, 0],
|
||||
[1, 2, 2],
|
||||
],
|
||||
},
|
||||
1,
|
||||
[4, 3],
|
||||
)
|
||||
expect(vertical?.[0]).toBeCloseTo(1)
|
||||
expect(vertical?.[1]).toBe(2)
|
||||
expect(vertical?.[2]).toBeCloseTo(3)
|
||||
})
|
||||
|
||||
test('projects spatial polygon edits onto the original arbitrary plane', () => {
|
||||
const point = constrainMeasurementSpatialEditPoint(
|
||||
{
|
||||
kind: 'area',
|
||||
base: [
|
||||
[0, 0, 0],
|
||||
[2, 2, 0],
|
||||
[2, 2, 2],
|
||||
],
|
||||
},
|
||||
[3, 0, 1],
|
||||
)
|
||||
expect(measurementDistance(point, [1.5, 1.5, 1])).toBeLessThan(1e-9)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,139 @@
|
||||
import {
|
||||
type MeasurementAnchor,
|
||||
type MeasurementPayload,
|
||||
type MeasurementPoint,
|
||||
measurementDistance,
|
||||
measurementNormal,
|
||||
} from '@pascal-app/core'
|
||||
import type { ResolvedMeasurementPayload } from './resolve'
|
||||
|
||||
export function measurementResolvedEditPoints(
|
||||
measurement: ResolvedMeasurementPayload,
|
||||
): MeasurementPoint[] {
|
||||
return measurement.kind === 'distance' || measurement.kind === 'angle'
|
||||
? measurement.points.map((point) => [...point] as MeasurementPoint)
|
||||
: measurement.base.map((point) => [...point] as MeasurementPoint)
|
||||
}
|
||||
|
||||
function mapMeasurementAnchors(
|
||||
measurement: MeasurementPayload,
|
||||
map: (anchor: MeasurementAnchor, index: number) => MeasurementAnchor,
|
||||
): MeasurementPayload {
|
||||
if (measurement.kind === 'distance') {
|
||||
return {
|
||||
...measurement,
|
||||
points: [map(measurement.points[0], 0), map(measurement.points[1], 1)],
|
||||
}
|
||||
}
|
||||
if (measurement.kind === 'angle') {
|
||||
return {
|
||||
...measurement,
|
||||
points: [
|
||||
map(measurement.points[0], 0),
|
||||
map(measurement.points[1], 1),
|
||||
map(measurement.points[2], 2),
|
||||
],
|
||||
}
|
||||
}
|
||||
return {
|
||||
...measurement,
|
||||
base: measurement.base.map(map),
|
||||
}
|
||||
}
|
||||
|
||||
export function refreshMeasurementAnchorFallbacks(
|
||||
measurement: MeasurementPayload,
|
||||
resolved: ResolvedMeasurementPayload,
|
||||
): MeasurementPayload {
|
||||
const points = measurementResolvedEditPoints(resolved)
|
||||
return mapMeasurementAnchors(measurement, (anchor, index) => {
|
||||
if (Array.isArray(anchor)) return anchor
|
||||
const fallback = points[index]
|
||||
return fallback ? { ...anchor, fallback: [...fallback] } : anchor
|
||||
})
|
||||
}
|
||||
|
||||
export function replaceMeasurementAnchor(
|
||||
measurement: MeasurementPayload,
|
||||
index: number,
|
||||
anchor: MeasurementAnchor,
|
||||
): MeasurementPayload | null {
|
||||
const count =
|
||||
measurement.kind === 'distance' || measurement.kind === 'angle'
|
||||
? measurement.points.length
|
||||
: measurement.base.length
|
||||
if (!Number.isInteger(index) || index < 0 || index >= count) return null
|
||||
return mapMeasurementAnchors(measurement, (current, currentIndex) =>
|
||||
currentIndex === index ? anchor : current,
|
||||
)
|
||||
}
|
||||
|
||||
function isPolygonMeasurement(measurement: ResolvedMeasurementPayload): boolean {
|
||||
return (
|
||||
measurement.kind === 'area' || measurement.kind === 'perimeter' || measurement.kind === 'volume'
|
||||
)
|
||||
}
|
||||
|
||||
export function constrainMeasurementSpatialEditPoint(
|
||||
measurement: ResolvedMeasurementPayload,
|
||||
point: MeasurementPoint,
|
||||
): MeasurementPoint {
|
||||
if (!isPolygonMeasurement(measurement)) return [...point]
|
||||
const points = measurementResolvedEditPoints(measurement)
|
||||
const origin = points[0]
|
||||
const normal = measurementNormal(points)
|
||||
if (!(origin && normal)) return [...point]
|
||||
const distance =
|
||||
(point[0] - origin[0]) * normal[0] +
|
||||
(point[1] - origin[1]) * normal[1] +
|
||||
(point[2] - origin[2]) * normal[2]
|
||||
return [
|
||||
point[0] - normal[0] * distance,
|
||||
point[1] - normal[1] * distance,
|
||||
point[2] - normal[2] * distance,
|
||||
]
|
||||
}
|
||||
|
||||
export function constrainMeasurementPlanEditPoint(
|
||||
measurement: ResolvedMeasurementPayload,
|
||||
index: number,
|
||||
planPoint: readonly [number, number],
|
||||
): MeasurementPoint | null {
|
||||
const points = measurementResolvedEditPoints(measurement)
|
||||
const current = points[index]
|
||||
if (!current) return null
|
||||
if (!isPolygonMeasurement(measurement)) return [planPoint[0], current[1], planPoint[1]]
|
||||
|
||||
const origin = points[0]
|
||||
const normal = measurementNormal(points)
|
||||
if (!(origin && normal)) return [planPoint[0], current[1], planPoint[1]]
|
||||
if (Math.abs(normal[1]) > 1e-6) {
|
||||
const y =
|
||||
origin[1] -
|
||||
(normal[0] * (planPoint[0] - origin[0]) + normal[2] * (planPoint[1] - origin[2])) / normal[1]
|
||||
return [planPoint[0], y, planPoint[1]]
|
||||
}
|
||||
|
||||
const normalLengthSq = normal[0] * normal[0] + normal[2] * normal[2]
|
||||
if (normalLengthSq <= 1e-12) return [planPoint[0], current[1], planPoint[1]]
|
||||
const offset =
|
||||
(normal[0] * (planPoint[0] - origin[0]) + normal[2] * (planPoint[1] - origin[2])) /
|
||||
normalLengthSq
|
||||
return [planPoint[0] - normal[0] * offset, current[1], planPoint[1] - normal[2] * offset]
|
||||
}
|
||||
|
||||
export function measurementEditAnchor(
|
||||
measurement: ResolvedMeasurementPayload,
|
||||
point: MeasurementPoint,
|
||||
associatedAnchor?: MeasurementAnchor,
|
||||
): MeasurementAnchor {
|
||||
const constrained = constrainMeasurementSpatialEditPoint(measurement, point)
|
||||
if (
|
||||
associatedAnchor &&
|
||||
!Array.isArray(associatedAnchor) &&
|
||||
measurementDistance(constrained, point) <= 0.012
|
||||
) {
|
||||
return { ...associatedAnchor, fallback: constrained }
|
||||
}
|
||||
return constrained
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import {
|
||||
type FloorplanAffordance,
|
||||
type FloorplanAffordanceSession,
|
||||
type MeasurementAnchor,
|
||||
MeasurementNode,
|
||||
type MeasurementNode as MeasurementNodeType,
|
||||
type MeasurementPoint,
|
||||
resolveLevelId,
|
||||
useLiveNodeOverrides,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { resolveSurfacePlanPointSnap } from '@pascal-app/editor'
|
||||
import {
|
||||
constrainMeasurementPlanEditPoint,
|
||||
measurementEditAnchor,
|
||||
refreshMeasurementAnchorFallbacks,
|
||||
replaceMeasurementAnchor,
|
||||
} from './edit'
|
||||
import { matchMeasurementFeatureForNode, resolveMeasurementNode } from './resolve'
|
||||
|
||||
const SEMANTIC_FEATURE_SNAP_DISTANCE = 0.2
|
||||
// Alt-bypass association mirrors the 3D tool's surface-verify tolerance: a
|
||||
// feature binds only when the point already sits on it, never by attraction.
|
||||
const SEMANTIC_FEATURE_BYPASS_DISTANCE = 0.012
|
||||
|
||||
function semanticWallAnchor(
|
||||
point: MeasurementPoint,
|
||||
wallIds: readonly string[],
|
||||
nodes: Parameters<typeof resolveLevelId>[1],
|
||||
maxDistance: number,
|
||||
): { anchor?: MeasurementAnchor; point: MeasurementPoint } {
|
||||
const matches = wallIds.flatMap((id) => {
|
||||
const node = nodes[id]
|
||||
if (!node) return []
|
||||
const match = matchMeasurementFeatureForNode(
|
||||
node,
|
||||
(nodeId) => nodes[nodeId],
|
||||
point,
|
||||
maxDistance,
|
||||
)
|
||||
return match ? [{ match, node }] : []
|
||||
})
|
||||
const closest = matches.sort((a, b) => a.match.distance - b.match.distance)[0]
|
||||
if (!closest) return { point }
|
||||
return {
|
||||
anchor: {
|
||||
kind: 'feature',
|
||||
reference: {
|
||||
nodeId: closest.node.id,
|
||||
featureId: closest.match.feature.id,
|
||||
parameters: closest.match.parameters,
|
||||
},
|
||||
fallback: closest.match.point,
|
||||
},
|
||||
point: closest.match.point,
|
||||
}
|
||||
}
|
||||
|
||||
export const measurementMoveVertexAffordance: FloorplanAffordance<MeasurementNodeType> = {
|
||||
start({ node, nodes, payload }): FloorplanAffordanceSession {
|
||||
const vertexIndex = (payload as { vertexIndex?: unknown }).vertexIndex
|
||||
const resolved = resolveMeasurementNode(node, (id) => nodes[id])
|
||||
const original = refreshMeasurementAnchorFallbacks(node.measurement, resolved.payload)
|
||||
const levelId = resolveLevelId(node, nodes)
|
||||
let latest: MeasurementNodeType['measurement'] | null = null
|
||||
|
||||
if (!Number.isInteger(vertexIndex)) {
|
||||
return {
|
||||
affectedIds: [node.id],
|
||||
apply() {},
|
||||
canCommit: () => false,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
affectedIds: [node.id],
|
||||
apply({ planPoint, modifiers }) {
|
||||
// Measurement anchors always bind to real geometry — the construction
|
||||
// snapping-mode chip doesn't govern this analysis tool. Alt bypasses.
|
||||
// The raw fallback keeps free drags free: measurement geometry follows
|
||||
// the pointer, never the construction grid lattice.
|
||||
const snapped = resolveSurfacePlanPointSnap({
|
||||
rawPoint: [planPoint[0], planPoint[1]],
|
||||
fallbackPoint: [planPoint[0], planPoint[1]],
|
||||
excludeId: node.id,
|
||||
levelId,
|
||||
movingId: node.id,
|
||||
nodes,
|
||||
magnetic: !modifiers.altKey,
|
||||
})
|
||||
const point = constrainMeasurementPlanEditPoint(
|
||||
resolved.payload,
|
||||
vertexIndex as number,
|
||||
snapped.point,
|
||||
)
|
||||
const associated = point
|
||||
? semanticWallAnchor(
|
||||
point,
|
||||
snapped.wallIds,
|
||||
nodes,
|
||||
modifiers.altKey ? SEMANTIC_FEATURE_BYPASS_DISTANCE : SEMANTIC_FEATURE_SNAP_DISTANCE,
|
||||
)
|
||||
: null
|
||||
const anchor =
|
||||
point && associated
|
||||
? measurementEditAnchor(resolved.payload, associated.point, associated.anchor)
|
||||
: null
|
||||
const next = anchor
|
||||
? replaceMeasurementAnchor(original, vertexIndex as number, anchor)
|
||||
: null
|
||||
if (!next || !MeasurementNode.safeParse({ ...node, measurement: next }).success) {
|
||||
latest = null
|
||||
useLiveNodeOverrides.getState().clear(node.id)
|
||||
return
|
||||
}
|
||||
latest = next
|
||||
useLiveNodeOverrides.getState().set(node.id, { measurement: next })
|
||||
},
|
||||
canCommit: () => latest !== null,
|
||||
commit() {
|
||||
const measurement = latest
|
||||
useLiveNodeOverrides.getState().clear(node.id)
|
||||
if (measurement) useScene.getState().updateNode(node.id, { measurement })
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { type FloorplanGeometry, type GeometryContext, MeasurementNode } from '@pascal-app/core'
|
||||
import { MEASUREMENT_ACTIVE_COLOR, MEASUREMENT_FLOORPLAN_COLOR } from '@pascal-app/editor'
|
||||
import { buildMeasurementFloorplan } from './floorplan'
|
||||
|
||||
const palette = {
|
||||
selectedStroke: '#2563eb',
|
||||
selectedFill: '#dbeafe',
|
||||
selectedHatch: '#93c5fd',
|
||||
wallHoverStroke: '#60a5fa',
|
||||
endpointHandleFill: '#f97316',
|
||||
endpointHandleStroke: '#ffffff',
|
||||
endpointHandleHoverStroke: '#fdba74',
|
||||
endpointHandleActiveFill: '#ea580c',
|
||||
endpointHandleActiveStroke: '#ffffff',
|
||||
curveHandleFill: '#14b8a6',
|
||||
curveHandleStroke: '#ffffff',
|
||||
curveHandleHoverStroke: '#5eead4',
|
||||
measurementStroke: '#0f766e',
|
||||
measurementLabelBackground: '#ffffff',
|
||||
measurementLabelText: '#0f172a',
|
||||
}
|
||||
|
||||
const context = (unit: 'metric' | 'imperial', selected = false): GeometryContext => ({
|
||||
resolve: () => undefined,
|
||||
children: [],
|
||||
siblings: [],
|
||||
parent: null,
|
||||
viewState: {
|
||||
selected,
|
||||
unit,
|
||||
highlighted: false,
|
||||
hovered: false,
|
||||
moving: false,
|
||||
palette,
|
||||
},
|
||||
})
|
||||
|
||||
const labels = (geometry: FloorplanGeometry): string[] => {
|
||||
if (geometry.kind === 'dimension-label') return [geometry.text]
|
||||
if (geometry.kind === 'group') return geometry.children.flatMap(labels)
|
||||
return []
|
||||
}
|
||||
|
||||
const flattenGeometry = (geometry: FloorplanGeometry): FloorplanGeometry[] =>
|
||||
geometry.kind === 'group' ? [geometry, ...geometry.children.flatMap(flattenGeometry)] : [geometry]
|
||||
|
||||
describe('buildMeasurementFloorplan', () => {
|
||||
test('formats distance labels with the active floorplan unit', () => {
|
||||
const node = MeasurementNode.parse({
|
||||
id: 'measurement_distance',
|
||||
type: 'measurement',
|
||||
measurement: {
|
||||
kind: 'distance',
|
||||
points: [
|
||||
[0, 0, 0],
|
||||
[3.048, 0, 0],
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const metric = buildMeasurementFloorplan(node, context('metric'))
|
||||
const imperial = buildMeasurementFloorplan(node, context('imperial'))
|
||||
|
||||
expect(metric && labels(metric)).toEqual(['3.05m'])
|
||||
expect(imperial && labels(imperial)).toEqual([`10'0"`])
|
||||
expect(
|
||||
metric && flattenGeometry(metric).find((entry) => entry.kind === 'dimension-label'),
|
||||
).toMatchObject({ appearance: 'outlined' })
|
||||
})
|
||||
|
||||
test('uses indigo analysis colors in plan view', () => {
|
||||
const node = MeasurementNode.parse({
|
||||
id: 'measurement_appearance',
|
||||
type: 'measurement',
|
||||
measurement: {
|
||||
kind: 'distance',
|
||||
points: [
|
||||
[0, 0, 0],
|
||||
[1, 0, 0],
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const persistent = buildMeasurementFloorplan(node, context('metric'))
|
||||
const active = buildMeasurementFloorplan(node, context('metric', true))
|
||||
expect(
|
||||
persistent && flattenGeometry(persistent).find((entry) => entry.kind === 'line'),
|
||||
).toMatchObject({ stroke: MEASUREMENT_FLOORPLAN_COLOR })
|
||||
expect(active && flattenGeometry(active).find((entry) => entry.kind === 'line')).toMatchObject({
|
||||
stroke: MEASUREMENT_ACTIVE_COLOR,
|
||||
})
|
||||
expect(
|
||||
persistent && flattenGeometry(persistent).filter((entry) => entry.kind === 'endpoint-handle'),
|
||||
).toHaveLength(0)
|
||||
expect(
|
||||
active && flattenGeometry(active).filter((entry) => entry.kind === 'endpoint-handle'),
|
||||
).toHaveLength(2)
|
||||
})
|
||||
|
||||
test('emits semantic polygon geometry and derived area and volume labels', () => {
|
||||
const area = MeasurementNode.parse({
|
||||
id: 'measurement_area',
|
||||
type: 'measurement',
|
||||
measurement: {
|
||||
kind: 'area',
|
||||
base: [
|
||||
[0, 0, 0],
|
||||
[2, 0, 0],
|
||||
[2, 0, 3],
|
||||
[0, 0, 3],
|
||||
],
|
||||
},
|
||||
})
|
||||
const volume = MeasurementNode.parse({
|
||||
id: 'measurement_volume',
|
||||
type: 'measurement',
|
||||
measurement: {
|
||||
kind: 'volume',
|
||||
base: area.measurement.kind === 'area' ? area.measurement.base : [],
|
||||
extrusion: [0, 2, 0],
|
||||
},
|
||||
})
|
||||
|
||||
const areaGeometry = buildMeasurementFloorplan(area, context('metric'))
|
||||
const volumeGeometry = buildMeasurementFloorplan(volume, context('metric'))
|
||||
|
||||
expect(areaGeometry?.kind).toBe('group')
|
||||
expect(areaGeometry && labels(areaGeometry)).toEqual(['A 6.0m²'])
|
||||
expect(volumeGeometry && labels(volumeGeometry)).toEqual(['V 12.0m³'])
|
||||
expect(
|
||||
areaGeometry &&
|
||||
flattenGeometry(areaGeometry).find((entry) => entry.kind === 'dimension-label'),
|
||||
).toMatchObject({ appearance: 'outlined', screenUpright: true })
|
||||
expect(
|
||||
volumeGeometry &&
|
||||
flattenGeometry(volumeGeometry).find((entry) => entry.kind === 'dimension-label'),
|
||||
).toMatchObject({ appearance: 'outlined', screenUpright: true })
|
||||
})
|
||||
|
||||
test('renders angle and perimeter as first-class measurement kinds', () => {
|
||||
const angle = MeasurementNode.parse({
|
||||
id: 'measurement_angle',
|
||||
type: 'measurement',
|
||||
measurement: {
|
||||
kind: 'angle',
|
||||
points: [
|
||||
[1, 0, 0],
|
||||
[0, 0, 0],
|
||||
[0, 0, 1],
|
||||
],
|
||||
},
|
||||
})
|
||||
const perimeter = MeasurementNode.parse({
|
||||
id: 'measurement_perimeter',
|
||||
type: 'measurement',
|
||||
measurement: {
|
||||
kind: 'perimeter',
|
||||
base: [
|
||||
[0, 0, 0],
|
||||
[3, 0, 0],
|
||||
[3, 0, 4],
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const angleGeometry = buildMeasurementFloorplan(angle, context('metric'))
|
||||
const perimeterGeometry = buildMeasurementFloorplan(perimeter, context('metric'))
|
||||
expect(angleGeometry && labels(angleGeometry)).toEqual(['90°'])
|
||||
const anglePolylines = angleGeometry
|
||||
? flattenGeometry(angleGeometry).filter((entry) => entry.kind === 'polyline')
|
||||
: []
|
||||
expect(anglePolylines).toHaveLength(2)
|
||||
expect(anglePolylines[1]).toMatchObject({ strokeWidth: 3 })
|
||||
if (anglePolylines[1]?.kind === 'polyline') {
|
||||
expect(anglePolylines[1].points.length).toBeGreaterThan(4)
|
||||
}
|
||||
expect(perimeterGeometry && labels(perimeterGeometry)).toEqual(['P 12m'])
|
||||
})
|
||||
|
||||
test('marks a missing semantic feature as unlinked instead of freezing silently', () => {
|
||||
const node = MeasurementNode.parse({
|
||||
id: 'measurement_unlinked',
|
||||
type: 'measurement',
|
||||
measurement: {
|
||||
kind: 'distance',
|
||||
points: [
|
||||
{
|
||||
kind: 'feature',
|
||||
reference: { nodeId: 'wall_missing', featureId: 'wall:start' },
|
||||
fallback: [0, 0, 0],
|
||||
},
|
||||
[2, 0, 0],
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const geometry = buildMeasurementFloorplan(node, context('metric'))
|
||||
expect(geometry && labels(geometry)).toEqual(['Unlinked · 2m'])
|
||||
expect(
|
||||
geometry && flattenGeometry(geometry).find((entry) => entry.kind === 'line'),
|
||||
).toMatchObject({ stroke: '#dc2626' })
|
||||
})
|
||||
|
||||
test('omits hidden measurements', () => {
|
||||
const node = MeasurementNode.parse({
|
||||
id: 'measurement_hidden',
|
||||
type: 'measurement',
|
||||
visible: false,
|
||||
measurement: {
|
||||
kind: 'distance',
|
||||
points: [
|
||||
[0, 0, 0],
|
||||
[1, 0, 0],
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
expect(buildMeasurementFloorplan(node, context('metric'))).toBeNull()
|
||||
})
|
||||
|
||||
test('keeps a vertically projected distance selectable in plan view', () => {
|
||||
const node = MeasurementNode.parse({
|
||||
id: 'measurement_vertical',
|
||||
type: 'measurement',
|
||||
measurement: {
|
||||
kind: 'distance',
|
||||
points: [
|
||||
[1, 0, 2],
|
||||
[1, 3, 2],
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const geometry = buildMeasurementFloorplan(node, context('metric'))
|
||||
expect(geometry).not.toBeNull()
|
||||
if (!geometry) return
|
||||
|
||||
const hitTarget = flattenGeometry(geometry).find(
|
||||
(entry) => entry.kind === 'circle' && entry.pointerEvents === 'all',
|
||||
)
|
||||
expect(hitTarget).toMatchObject({
|
||||
kind: 'circle',
|
||||
cx: 1,
|
||||
cy: 2,
|
||||
fill: 'transparent',
|
||||
pointerEvents: 'all',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,236 @@
|
||||
import {
|
||||
type FloorplanGeometry,
|
||||
type FloorplanPoint,
|
||||
type FloorplanStyle,
|
||||
type GeometryContext,
|
||||
type MeasurementNode,
|
||||
type MeasurementPoint,
|
||||
measurementAngle,
|
||||
measurementArea,
|
||||
measurementDistance,
|
||||
measurementPerimeter,
|
||||
measurementPrismVolume,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
buildMeasurementAngleArcPoints,
|
||||
formatAngleRadians,
|
||||
formatAreaLabel,
|
||||
formatLinearMeasurement,
|
||||
formatVolumeLabel,
|
||||
measurementFloorplanPresentationColor,
|
||||
measurementPolygonLabelAnchor,
|
||||
} from '@pascal-app/editor'
|
||||
import { measurementResolvedEditPoints } from './edit'
|
||||
import { resolveMeasurementNode } from './resolve'
|
||||
|
||||
const projectPoint = (point: MeasurementPoint): FloorplanPoint => [point[0], point[2]]
|
||||
|
||||
const add = (point: MeasurementPoint, offset: MeasurementPoint): MeasurementPoint => [
|
||||
point[0] + offset[0],
|
||||
point[1] + offset[1],
|
||||
point[2] + offset[2],
|
||||
]
|
||||
|
||||
const lineStyle = (stroke: string): FloorplanStyle => ({
|
||||
stroke,
|
||||
strokeWidth: 2,
|
||||
vectorEffect: 'non-scaling-stroke',
|
||||
strokeLinecap: 'round',
|
||||
strokeLinejoin: 'round',
|
||||
})
|
||||
|
||||
export function buildMeasurementFloorplan(
|
||||
node: MeasurementNode,
|
||||
ctx: GeometryContext,
|
||||
): FloorplanGeometry | null {
|
||||
if (node.visible === false) return null
|
||||
|
||||
const unit = ctx.viewState?.unit ?? 'metric'
|
||||
const resolved = resolveMeasurementNode(node, (id) => ctx.resolve(id))
|
||||
const measurement = resolved.payload
|
||||
const selected = ctx.viewState?.selected || ctx.viewState?.highlighted
|
||||
const editable = ctx.viewState?.selected === true
|
||||
const stroke = measurementFloorplanPresentationColor(
|
||||
resolved.dangling.length > 0,
|
||||
Boolean(selected),
|
||||
)
|
||||
const style = lineStyle(stroke)
|
||||
const statusPrefix = resolved.dangling.length > 0 ? 'Unlinked · ' : ''
|
||||
const editHandles: FloorplanGeometry[] = editable
|
||||
? measurementResolvedEditPoints(measurement).map((point, vertexIndex) => ({
|
||||
kind: 'endpoint-handle',
|
||||
point: projectPoint(point),
|
||||
state: 'idle',
|
||||
affordance: 'move-measurement-vertex',
|
||||
payload: { vertexIndex },
|
||||
}))
|
||||
: []
|
||||
|
||||
if (measurement.kind === 'distance') {
|
||||
const [start, end] = measurement.points
|
||||
const [x1, y1] = projectPoint(start)
|
||||
const [x2, y2] = projectPoint(end)
|
||||
const collapsedHitTarget: FloorplanGeometry[] =
|
||||
Math.hypot(x2 - x1, y2 - y1) <= 1e-9
|
||||
? [
|
||||
{
|
||||
kind: 'circle',
|
||||
cx: x1,
|
||||
cy: y1,
|
||||
r: 0.1,
|
||||
fill: 'transparent',
|
||||
pointerEvents: 'all',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
]
|
||||
: []
|
||||
|
||||
return {
|
||||
kind: 'group',
|
||||
children: [
|
||||
{ kind: 'line', x1, y1, x2, y2, ...style },
|
||||
{ kind: 'hit-line', x1, y1, x2, y2, strokeWidthPx: 12 },
|
||||
...collapsedHitTarget,
|
||||
{
|
||||
kind: 'circle',
|
||||
cx: x1,
|
||||
cy: y1,
|
||||
r: 0.045,
|
||||
fill: stroke,
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
{
|
||||
kind: 'circle',
|
||||
cx: x2,
|
||||
cy: y2,
|
||||
r: 0.045,
|
||||
fill: stroke,
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
{
|
||||
kind: 'dimension-label',
|
||||
appearance: 'outlined',
|
||||
cx: (x1 + x2) / 2,
|
||||
cy: (y1 + y2) / 2,
|
||||
text: `${statusPrefix}${formatLinearMeasurement(measurementDistance(start, end), unit)}`,
|
||||
angle: Math.atan2(y2 - y1, x2 - x1),
|
||||
offsetPx: 14,
|
||||
},
|
||||
...editHandles,
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
if (measurement.kind === 'angle') {
|
||||
const [start, vertex, end] = measurement.points
|
||||
const angleArc = buildMeasurementAngleArcPoints(start, vertex, end)
|
||||
const labelPoint = angleArc[Math.floor(angleArc.length / 2)] ?? vertex
|
||||
return {
|
||||
kind: 'group',
|
||||
children: [
|
||||
{
|
||||
kind: 'polyline',
|
||||
points: [projectPoint(start), projectPoint(vertex), projectPoint(end)],
|
||||
...style,
|
||||
},
|
||||
...(angleArc.length >= 2
|
||||
? [
|
||||
{
|
||||
kind: 'polyline' as const,
|
||||
points: angleArc.map(projectPoint),
|
||||
...style,
|
||||
strokeWidth: 3,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
kind: 'dimension-label',
|
||||
appearance: 'outlined',
|
||||
cx: labelPoint[0],
|
||||
cy: labelPoint[2],
|
||||
text: `${statusPrefix}${formatAngleRadians(measurementAngle(start, vertex, end))}`,
|
||||
angle: 0,
|
||||
offsetPx: 10,
|
||||
screenUpright: true,
|
||||
},
|
||||
...editHandles,
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
if (measurement.kind === 'area' || measurement.kind === 'perimeter') {
|
||||
const centroid = measurementPolygonLabelAnchor(measurement.base) ?? measurement.base[0]!
|
||||
const label =
|
||||
measurement.kind === 'area'
|
||||
? `A ${formatAreaLabel(measurementArea(measurement.base), unit)}`
|
||||
: `P ${formatLinearMeasurement(measurementPerimeter(measurement.base), unit)}`
|
||||
|
||||
return {
|
||||
kind: 'group',
|
||||
children: [
|
||||
{
|
||||
kind: 'polygon',
|
||||
points: measurement.base.map(projectPoint),
|
||||
fill: stroke,
|
||||
fillOpacity: measurement.kind === 'area' ? 0.08 : 0,
|
||||
pointerEvents: 'all',
|
||||
...style,
|
||||
},
|
||||
{
|
||||
kind: 'dimension-label',
|
||||
appearance: 'outlined',
|
||||
cx: centroid[0],
|
||||
cy: centroid[2],
|
||||
text: `${statusPrefix}${label}`,
|
||||
angle: 0,
|
||||
screenUpright: true,
|
||||
},
|
||||
...editHandles,
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
const volume = measurement
|
||||
const top = volume.base.map((point) => add(point, volume.extrusion))
|
||||
const baseCentroid = measurementPolygonLabelAnchor(volume.base) ?? volume.base[0]!
|
||||
const labelPoint = add(baseCentroid, [
|
||||
volume.extrusion[0] / 2,
|
||||
volume.extrusion[1] / 2,
|
||||
volume.extrusion[2] / 2,
|
||||
])
|
||||
const children: FloorplanGeometry[] = [
|
||||
{
|
||||
kind: 'polygon',
|
||||
points: volume.base.map(projectPoint),
|
||||
fill: stroke,
|
||||
fillOpacity: 0.05,
|
||||
pointerEvents: 'all',
|
||||
...style,
|
||||
},
|
||||
{
|
||||
kind: 'polygon',
|
||||
points: top.map(projectPoint),
|
||||
fill: 'none',
|
||||
...style,
|
||||
},
|
||||
]
|
||||
|
||||
for (let index = 0; index < volume.base.length; index++) {
|
||||
const [x1, y1] = projectPoint(volume.base[index]!)
|
||||
const [x2, y2] = projectPoint(top[index]!)
|
||||
children.push({ kind: 'line', x1, y1, x2, y2, ...style })
|
||||
}
|
||||
|
||||
children.push({
|
||||
kind: 'dimension-label',
|
||||
appearance: 'outlined',
|
||||
cx: labelPoint[0],
|
||||
cy: labelPoint[2],
|
||||
text: `${statusPrefix}V ${formatVolumeLabel(measurementPrismVolume(volume.base, volume.extrusion), unit)}`,
|
||||
angle: 0,
|
||||
screenUpright: true,
|
||||
})
|
||||
children.push(...editHandles)
|
||||
|
||||
return { kind: 'group', children }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export { measurementDefinition } from './definition'
|
||||
export {
|
||||
closestMeasurementFeature,
|
||||
detachMeasurementPayload,
|
||||
freeMeasurementPoint,
|
||||
type MeasurementFeatureMatch,
|
||||
matchMeasurementFeatureForNode,
|
||||
measurementDependencyIds,
|
||||
measurementFeaturePoint,
|
||||
measurementFeaturesForNode,
|
||||
type ResolvedMeasurement,
|
||||
type ResolvedMeasurementPayload,
|
||||
remapMeasurementReferences,
|
||||
resolveMeasurementNode,
|
||||
} from './resolve'
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { Group } from 'three'
|
||||
import { areMeasurementAncestorsVisible } from './renderer'
|
||||
|
||||
describe('measurement renderer visibility', () => {
|
||||
test('follows hidden ancestors used for level visibility', () => {
|
||||
const scene = new Group()
|
||||
const level = new Group()
|
||||
const measurement = new Group()
|
||||
scene.add(level)
|
||||
level.add(measurement)
|
||||
|
||||
expect(areMeasurementAncestorsVisible(measurement)).toBe(true)
|
||||
|
||||
level.visible = false
|
||||
expect(areMeasurementAncestorsVisible(measurement)).toBe(false)
|
||||
|
||||
level.visible = true
|
||||
scene.visible = false
|
||||
expect(areMeasurementAncestorsVisible(measurement)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,458 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type AnyNode,
|
||||
type MeasurementNode,
|
||||
type MeasurementPoint,
|
||||
measurementAngle,
|
||||
measurementArea,
|
||||
measurementDistance,
|
||||
measurementNormal,
|
||||
measurementPerimeter,
|
||||
measurementPrismVolume,
|
||||
useLiveNodeOverrides,
|
||||
useRegistry,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
buildMeasurementAngleArcPoints,
|
||||
formatAngleRadians,
|
||||
formatAreaLabel,
|
||||
formatLinearMeasurement,
|
||||
formatVolumeLabel,
|
||||
measurementPolygonLabelAnchor,
|
||||
measurementPresentationColor,
|
||||
triangulateMeasurementPolygon,
|
||||
} from '@pascal-app/editor'
|
||||
import { OVERLAY_LAYER, useNodeEvents, useViewer } from '@pascal-app/viewer'
|
||||
import { Html } from '@react-three/drei'
|
||||
import { useFrame } from '@react-three/fiber'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
BufferGeometry,
|
||||
DoubleSide,
|
||||
Float32BufferAttribute,
|
||||
type Group,
|
||||
MathUtils,
|
||||
type Object3D,
|
||||
type OrthographicCamera,
|
||||
type PerspectiveCamera,
|
||||
Quaternion,
|
||||
Vector3,
|
||||
} from 'three'
|
||||
import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import {
|
||||
measurementDependencyIds,
|
||||
type ResolvedMeasurementPayload,
|
||||
resolveMeasurementNode,
|
||||
} from './resolve'
|
||||
|
||||
type MeasurementRenderData = {
|
||||
fillGeometry: BufferGeometry | null
|
||||
labelPosition: MeasurementPoint
|
||||
lineGeometry: BufferGeometry
|
||||
markerPoints: MeasurementPoint[]
|
||||
}
|
||||
|
||||
const MARKER_PLANE_NORMAL = new Vector3(0, 0, 1)
|
||||
|
||||
function fallbackMarkerNormal(
|
||||
measurement: ResolvedMeasurementPayload,
|
||||
index: number,
|
||||
): MeasurementPoint {
|
||||
if (
|
||||
measurement.kind === 'area' ||
|
||||
measurement.kind === 'perimeter' ||
|
||||
measurement.kind === 'volume'
|
||||
) {
|
||||
return measurementNormal(measurement.base) ?? [0, 1, 0]
|
||||
}
|
||||
if (measurement.kind === 'angle') {
|
||||
const [start, vertex, end] = measurement.points
|
||||
const normal = new Vector3(...start)
|
||||
.sub(new Vector3(...vertex))
|
||||
.cross(new Vector3(...end).sub(new Vector3(...vertex)))
|
||||
return normal.lengthSq() > 1e-12 ? normal.normalize().toArray() : [0, 1, 0]
|
||||
}
|
||||
|
||||
const [start, end] = measurement.points
|
||||
if (Math.abs(start[1]) < 0.05 && Math.abs(end[1]) < 0.05) return [0, 1, 0]
|
||||
const direction = new Vector3(...end).sub(new Vector3(...start)).normalize()
|
||||
const horizontalNormal = direction.cross(new Vector3(0, 1, 0))
|
||||
if (horizontalNormal.lengthSq() > 1e-12) {
|
||||
const normal = horizontalNormal.normalize()
|
||||
if (index > 0) normal.negate()
|
||||
return normal.toArray()
|
||||
}
|
||||
return [0, 0, 1]
|
||||
}
|
||||
|
||||
function SurfaceContactMarker({
|
||||
color,
|
||||
normal,
|
||||
point,
|
||||
}: {
|
||||
color: string
|
||||
normal: MeasurementPoint
|
||||
point: MeasurementPoint
|
||||
}) {
|
||||
const ref = useRef<Group>(null)
|
||||
const worldPosition = useMemo(() => new Vector3(), [])
|
||||
const cameraSpacePosition = useMemo(() => new Vector3(), [])
|
||||
const rotation = useMemo(() => {
|
||||
const resolvedNormal = new Vector3(...normal)
|
||||
if (resolvedNormal.lengthSq() <= 1e-12) resolvedNormal.copy(MARKER_PLANE_NORMAL)
|
||||
return new Quaternion().setFromUnitVectors(MARKER_PLANE_NORMAL, resolvedNormal.normalize())
|
||||
}, [normal])
|
||||
const materials = useMemo(
|
||||
() => ({
|
||||
halo: new MeshBasicNodeMaterial({
|
||||
color: '#f8fafc',
|
||||
depthTest: true,
|
||||
depthWrite: false,
|
||||
opacity: 0.92,
|
||||
polygonOffset: true,
|
||||
polygonOffsetFactor: -2,
|
||||
polygonOffsetUnits: -2,
|
||||
side: DoubleSide,
|
||||
transparent: true,
|
||||
}),
|
||||
target: new MeshBasicNodeMaterial({
|
||||
color,
|
||||
depthTest: true,
|
||||
depthWrite: false,
|
||||
polygonOffset: true,
|
||||
polygonOffsetFactor: -3,
|
||||
polygonOffsetUnits: -3,
|
||||
side: DoubleSide,
|
||||
}),
|
||||
}),
|
||||
[color],
|
||||
)
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
materials.halo.dispose()
|
||||
materials.target.dispose()
|
||||
},
|
||||
[materials],
|
||||
)
|
||||
|
||||
useFrame(({ camera, size }) => {
|
||||
const group = ref.current
|
||||
if (!group) return
|
||||
group.getWorldPosition(worldPosition)
|
||||
let worldUnitsPerPixel = 0.01
|
||||
if ((camera as PerspectiveCamera).isPerspectiveCamera) {
|
||||
const perspective = camera as PerspectiveCamera
|
||||
const depth = Math.abs(
|
||||
cameraSpacePosition.copy(worldPosition).applyMatrix4(perspective.matrixWorldInverse).z,
|
||||
)
|
||||
worldUnitsPerPixel =
|
||||
(2 * depth * Math.tan(MathUtils.degToRad(perspective.getEffectiveFOV() * 0.5))) /
|
||||
Math.max(size.height, 1)
|
||||
} else if ((camera as OrthographicCamera).isOrthographicCamera) {
|
||||
const orthographic = camera as OrthographicCamera
|
||||
worldUnitsPerPixel =
|
||||
(orthographic.top - orthographic.bottom) / Math.max(orthographic.zoom * size.height, 1)
|
||||
}
|
||||
const scale = worldUnitsPerPixel * 7
|
||||
if (Number.isFinite(scale)) group.scale.setScalar(MathUtils.clamp(scale, 0.002, 0.24))
|
||||
})
|
||||
|
||||
return (
|
||||
<group position={point} quaternion={rotation} ref={ref}>
|
||||
<mesh layers={OVERLAY_LAYER} material={materials.halo} renderOrder={1002}>
|
||||
<ringGeometry args={[0.48, 1, 40]} />
|
||||
</mesh>
|
||||
<mesh layers={OVERLAY_LAYER} material={materials.target} renderOrder={1003}>
|
||||
<ringGeometry args={[0.62, 0.86, 40]} />
|
||||
</mesh>
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
const add = (point: MeasurementPoint, offset: MeasurementPoint): MeasurementPoint => [
|
||||
point[0] + offset[0],
|
||||
point[1] + offset[1],
|
||||
point[2] + offset[2],
|
||||
]
|
||||
|
||||
const midpoint = (start: MeasurementPoint, end: MeasurementPoint): MeasurementPoint => [
|
||||
(start[0] + end[0]) / 2,
|
||||
(start[1] + end[1]) / 2,
|
||||
(start[2] + end[2]) / 2,
|
||||
]
|
||||
|
||||
function buildFillGeometry(measurement: ResolvedMeasurementPayload): BufferGeometry | null {
|
||||
if (
|
||||
measurement.kind === 'distance' ||
|
||||
measurement.kind === 'angle' ||
|
||||
measurement.kind === 'perimeter'
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const triangles = triangulateMeasurementPolygon(measurement.base)
|
||||
if (triangles.length === 0) return null
|
||||
|
||||
const top =
|
||||
measurement.kind === 'volume'
|
||||
? measurement.base.map((point) => add(point, measurement.extrusion))
|
||||
: []
|
||||
const points = measurement.kind === 'volume' ? [...measurement.base, ...top] : measurement.base
|
||||
const indices: number[] = []
|
||||
|
||||
for (const triangle of triangles) {
|
||||
indices.push(triangle[0]!, triangle[1]!, triangle[2]!)
|
||||
if (measurement.kind === 'volume') {
|
||||
const offset = measurement.base.length
|
||||
indices.push(triangle[0]! + offset, triangle[1]! + offset, triangle[2]! + offset)
|
||||
}
|
||||
}
|
||||
|
||||
if (measurement.kind === 'volume') {
|
||||
const offset = measurement.base.length
|
||||
for (let index = 0; index < offset; index++) {
|
||||
const next = (index + 1) % offset
|
||||
indices.push(index, next, next + offset, index, next + offset, index + offset)
|
||||
}
|
||||
}
|
||||
|
||||
const geometry = new BufferGeometry()
|
||||
geometry.setAttribute('position', new Float32BufferAttribute(points.flat(), 3))
|
||||
geometry.setIndex(indices)
|
||||
geometry.computeVertexNormals()
|
||||
return geometry
|
||||
}
|
||||
|
||||
function buildRenderData(measurement: ResolvedMeasurementPayload): MeasurementRenderData {
|
||||
const linePositions: number[] = []
|
||||
const pushSegment = (start: MeasurementPoint, end: MeasurementPoint) => {
|
||||
linePositions.push(...start, ...end)
|
||||
}
|
||||
|
||||
let markerPoints: MeasurementPoint[]
|
||||
let labelPosition: MeasurementPoint
|
||||
|
||||
if (measurement.kind === 'distance') {
|
||||
const [start, end] = measurement.points
|
||||
pushSegment(start, end)
|
||||
markerPoints = [start, end]
|
||||
labelPosition = midpoint(start, end)
|
||||
} else if (measurement.kind === 'angle') {
|
||||
const [start, vertex, end] = measurement.points
|
||||
pushSegment(start, vertex)
|
||||
pushSegment(vertex, end)
|
||||
const angleArc = buildMeasurementAngleArcPoints(start, vertex, end)
|
||||
for (let index = 1; index < angleArc.length; index++) {
|
||||
pushSegment(angleArc[index - 1]!, angleArc[index]!)
|
||||
}
|
||||
markerPoints = [start, vertex, end]
|
||||
labelPosition = angleArc[Math.floor(angleArc.length / 2)] ?? vertex
|
||||
} else if (measurement.kind === 'area' || measurement.kind === 'perimeter') {
|
||||
for (let index = 0; index < measurement.base.length; index++) {
|
||||
pushSegment(
|
||||
measurement.base[index]!,
|
||||
measurement.base[(index + 1) % measurement.base.length]!,
|
||||
)
|
||||
}
|
||||
markerPoints = measurement.base
|
||||
labelPosition = measurementPolygonLabelAnchor(measurement.base) ?? measurement.base[0]!
|
||||
} else {
|
||||
const top = measurement.base.map((point) => add(point, measurement.extrusion))
|
||||
for (let index = 0; index < measurement.base.length; index++) {
|
||||
const next = (index + 1) % measurement.base.length
|
||||
pushSegment(measurement.base[index]!, measurement.base[next]!)
|
||||
pushSegment(top[index]!, top[next]!)
|
||||
pushSegment(measurement.base[index]!, top[index]!)
|
||||
}
|
||||
markerPoints = [...measurement.base, ...top]
|
||||
const centroid = measurementPolygonLabelAnchor(measurement.base) ?? measurement.base[0]!
|
||||
labelPosition = add(centroid, [
|
||||
measurement.extrusion[0] / 2,
|
||||
measurement.extrusion[1] / 2,
|
||||
measurement.extrusion[2] / 2,
|
||||
])
|
||||
}
|
||||
|
||||
const lineGeometry = new BufferGeometry()
|
||||
lineGeometry.setAttribute('position', new Float32BufferAttribute(linePositions, 3))
|
||||
|
||||
return {
|
||||
fillGeometry: buildFillGeometry(measurement),
|
||||
labelPosition,
|
||||
lineGeometry,
|
||||
markerPoints,
|
||||
}
|
||||
}
|
||||
|
||||
function formatMeasurement(
|
||||
measurement: ResolvedMeasurementPayload,
|
||||
unit: 'metric' | 'imperial',
|
||||
): string {
|
||||
if (measurement.kind === 'distance') {
|
||||
return formatLinearMeasurement(measurementDistance(...measurement.points), unit)
|
||||
}
|
||||
if (measurement.kind === 'angle') {
|
||||
return formatAngleRadians(measurementAngle(...measurement.points))
|
||||
}
|
||||
if (measurement.kind === 'area') {
|
||||
return `A ${formatAreaLabel(measurementArea(measurement.base), unit)}`
|
||||
}
|
||||
if (measurement.kind === 'perimeter') {
|
||||
return `P ${formatLinearMeasurement(measurementPerimeter(measurement.base), unit)}`
|
||||
}
|
||||
return `V ${formatVolumeLabel(measurementPrismVolume(measurement.base, measurement.extrusion), unit)}`
|
||||
}
|
||||
|
||||
export function areMeasurementAncestorsVisible(object: Object3D | null): boolean {
|
||||
let ancestor = object?.parent ?? null
|
||||
while (ancestor) {
|
||||
if (!ancestor.visible) return false
|
||||
ancestor = ancestor.parent
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export const MeasurementRenderer = ({ node }: { node: MeasurementNode }) => {
|
||||
const ref = useRef<Group>(null!)
|
||||
const ancestorVisibilityRef = useRef(true)
|
||||
const [ancestorsVisible, setAncestorsVisible] = useState(true)
|
||||
useRegistry(node.id, 'measurement', ref)
|
||||
|
||||
const handlers = useNodeEvents(node, 'measurement')
|
||||
const showMeasurements = useViewer((state) => state.showMeasurements)
|
||||
const unit = useViewer((state) => state.unit)
|
||||
const active = useViewer(
|
||||
(state) =>
|
||||
state.hoveredId === node.id || state.selection.selectedIds.some((id) => id === node.id),
|
||||
)
|
||||
const ownOverride = useLiveNodeOverrides((state) => state.overrides.get(node.id)) as
|
||||
| Partial<MeasurementNode>
|
||||
| undefined
|
||||
const effectiveNode = useMemo(
|
||||
() => (ownOverride ? ({ ...node, ...ownOverride } as MeasurementNode) : node),
|
||||
[node, ownOverride],
|
||||
)
|
||||
const dependencyIds = measurementDependencyIds(
|
||||
effectiveNode.measurement,
|
||||
(id) => useScene.getState().nodes[id],
|
||||
)
|
||||
useScene(useShallow((state) => dependencyIds.map((id) => state.nodes[id])))
|
||||
useLiveNodeOverrides(useShallow((state) => dependencyIds.map((id) => state.overrides.get(id))))
|
||||
const resolved = resolveMeasurementNode(effectiveNode, (id) => {
|
||||
const referencedNode = useScene.getState().nodes[id]
|
||||
if (!referencedNode) return undefined
|
||||
const liveOverride = useLiveNodeOverrides.getState().overrides.get(id)
|
||||
return liveOverride ? ({ ...referencedNode, ...liveOverride } as AnyNode) : referencedNode
|
||||
})
|
||||
const data = buildRenderData(resolved.payload)
|
||||
const label = useMemo(() => {
|
||||
const value = formatMeasurement(resolved.payload, unit)
|
||||
return resolved.dangling.length > 0 ? `Unlinked · ${value}` : value
|
||||
}, [resolved, unit])
|
||||
const color = measurementPresentationColor(resolved.dangling.length > 0, active)
|
||||
const lineMaterial = useMemo(
|
||||
() =>
|
||||
new LineBasicNodeMaterial({
|
||||
color,
|
||||
linewidth: 2,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
}),
|
||||
[color],
|
||||
)
|
||||
const fillMaterial = useMemo(
|
||||
() =>
|
||||
new MeshBasicNodeMaterial({
|
||||
color,
|
||||
transparent: true,
|
||||
opacity: 0.12,
|
||||
side: DoubleSide,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
}),
|
||||
[color],
|
||||
)
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
data.fillGeometry?.dispose()
|
||||
data.lineGeometry.dispose()
|
||||
},
|
||||
[data],
|
||||
)
|
||||
useEffect(
|
||||
() => () => {
|
||||
lineMaterial.dispose()
|
||||
fillMaterial.dispose()
|
||||
},
|
||||
[fillMaterial, lineMaterial],
|
||||
)
|
||||
|
||||
useFrame(() => {
|
||||
const visible = areMeasurementAncestorsVisible(ref.current)
|
||||
if (visible === ancestorVisibilityRef.current) return
|
||||
ancestorVisibilityRef.current = visible
|
||||
setAncestorsVisible(visible)
|
||||
})
|
||||
|
||||
const shouldShow = showMeasurements && effectiveNode.visible !== false && ancestorsVisible
|
||||
|
||||
return (
|
||||
<group ref={ref} {...handlers} userData={{ labelPosition: data.labelPosition }}>
|
||||
{shouldShow && (
|
||||
<>
|
||||
{data.fillGeometry && (
|
||||
<mesh
|
||||
frustumCulled={false}
|
||||
geometry={data.fillGeometry}
|
||||
layers={OVERLAY_LAYER}
|
||||
material={fillMaterial}
|
||||
renderOrder={1000}
|
||||
userData={{ excludeFromBvh: true }}
|
||||
/>
|
||||
)}
|
||||
<lineSegments
|
||||
frustumCulled={false}
|
||||
geometry={data.lineGeometry}
|
||||
layers={OVERLAY_LAYER}
|
||||
material={lineMaterial}
|
||||
renderOrder={1001}
|
||||
/>
|
||||
{data.markerPoints.map((point, index) => (
|
||||
<SurfaceContactMarker
|
||||
color={color}
|
||||
key={`${point.join(':')}:${index}`}
|
||||
normal={
|
||||
resolved.anchorNormals[index] ?? fallbackMarkerNormal(resolved.payload, index)
|
||||
}
|
||||
point={point}
|
||||
/>
|
||||
))}
|
||||
<Html
|
||||
center
|
||||
position={data.labelPosition}
|
||||
style={{ pointerEvents: 'none' }}
|
||||
zIndexRange={[30, 0]}
|
||||
>
|
||||
<div
|
||||
className={`whitespace-nowrap font-medium text-base text-white ${
|
||||
effectiveNode.measurement.kind === 'distance' ? '-translate-y-3' : ''
|
||||
}`}
|
||||
style={{
|
||||
textShadow: `-1px -1px 0 ${color}, 1px -1px 0 ${color}, -1px 1px 0 ${color}, 1px 1px 0 ${color}`,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
</Html>
|
||||
</>
|
||||
)}
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
export default MeasurementRenderer
|
||||
@@ -0,0 +1,219 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
measurementArea,
|
||||
measurementDistance,
|
||||
nodeRegistry,
|
||||
type RoofNode,
|
||||
type RoofSegmentNode,
|
||||
registerNode,
|
||||
type WallNode,
|
||||
} from '@pascal-app/core'
|
||||
import { roofSegmentDefinition } from '../roof-segment/definition'
|
||||
import { wallDefinition } from '../wall/definition'
|
||||
import { remapMeasurementReferences, resolveMeasurementNode } from './resolve'
|
||||
|
||||
const wall = (end: [number, number]): WallNode =>
|
||||
({
|
||||
id: 'wall_host',
|
||||
type: 'wall',
|
||||
parentId: 'level_a',
|
||||
start: [0, 0],
|
||||
end,
|
||||
children: [],
|
||||
}) as WallNode
|
||||
|
||||
const resolveFrom = (nodes: AnyNode[]) => {
|
||||
const byId = Object.fromEntries(nodes.map((node) => [node.id, node])) as Record<
|
||||
AnyNodeId,
|
||||
AnyNode
|
||||
>
|
||||
return (id: AnyNodeId) => byId[id]
|
||||
}
|
||||
|
||||
describe('associative measurement resolution', () => {
|
||||
beforeEach(() => {
|
||||
nodeRegistry._reset()
|
||||
registerNode(wallDefinition)
|
||||
registerNode(roofSegmentDefinition)
|
||||
})
|
||||
|
||||
afterEach(() => nodeRegistry._reset())
|
||||
|
||||
test('tracks wall centerline edits without mutating the measurement payload', () => {
|
||||
const measurement = {
|
||||
measurement: {
|
||||
kind: 'distance' as const,
|
||||
points: [
|
||||
{
|
||||
kind: 'feature' as const,
|
||||
reference: { nodeId: 'wall_host', featureId: 'wall:centerline', parameters: { t: 0 } },
|
||||
fallback: [0, 0, 0] as [number, number, number],
|
||||
},
|
||||
{
|
||||
kind: 'feature' as const,
|
||||
reference: { nodeId: 'wall_host', featureId: 'wall:centerline', parameters: { t: 1 } },
|
||||
fallback: [3, 0, 0] as [number, number, number],
|
||||
},
|
||||
] as const,
|
||||
},
|
||||
}
|
||||
|
||||
const first = resolveMeasurementNode(measurement, resolveFrom([wall([3, 0])]))
|
||||
const edited = resolveMeasurementNode(measurement, resolveFrom([wall([5, 0])]))
|
||||
|
||||
expect(first.dangling).toEqual([])
|
||||
expect(edited.dangling).toEqual([])
|
||||
expect(measurementDistance(...first.payload.points)).toBe(3)
|
||||
expect(measurementDistance(...edited.payload.points)).toBeCloseTo(5)
|
||||
expect(measurement.measurement.points[1].fallback).toEqual([3, 0, 0])
|
||||
})
|
||||
|
||||
test('expands an area whose corners are bound to moved wall endpoints', () => {
|
||||
const makeWall = (id: WallNode['id'], start: [number, number], end: [number, number]) => ({
|
||||
...wall(end),
|
||||
id,
|
||||
start,
|
||||
})
|
||||
const before = [
|
||||
makeWall('wall_south', [0, 0], [4, 0]),
|
||||
makeWall('wall_east', [4, 0], [4, 3]),
|
||||
makeWall('wall_north', [4, 3], [0, 3]),
|
||||
makeWall('wall_west', [0, 3], [0, 0]),
|
||||
]
|
||||
const after = [
|
||||
makeWall('wall_south', [0, 0], [5, 0]),
|
||||
makeWall('wall_east', [5, 0], [5, 3]),
|
||||
makeWall('wall_north', [5, 3], [0, 3]),
|
||||
before[3]!,
|
||||
]
|
||||
const measurement = {
|
||||
measurement: {
|
||||
kind: 'area' as const,
|
||||
base: before.map((host) => ({
|
||||
kind: 'feature' as const,
|
||||
reference: { nodeId: host.id, featureId: 'wall:start' },
|
||||
fallback: [host.start[0], 0, host.start[1]] as [number, number, number],
|
||||
})),
|
||||
},
|
||||
}
|
||||
|
||||
const original = resolveMeasurementNode(measurement, resolveFrom(before))
|
||||
const expanded = resolveMeasurementNode(measurement, resolveFrom(after))
|
||||
|
||||
expect(original.payload.kind).toBe('area')
|
||||
expect(expanded.payload.kind).toBe('area')
|
||||
if (original.payload.kind === 'area' && expanded.payload.kind === 'area') {
|
||||
expect(measurementArea(original.payload.base)).toBeCloseTo(12)
|
||||
expect(measurementArea(expanded.payload.base)).toBeCloseTo(15)
|
||||
}
|
||||
})
|
||||
|
||||
test('resolves live wall-face normals for surface-aligned endpoint markers', () => {
|
||||
const host = wall([3, 0])
|
||||
const measurement = {
|
||||
measurement: {
|
||||
kind: 'distance' as const,
|
||||
points: [
|
||||
{
|
||||
kind: 'feature' as const,
|
||||
reference: {
|
||||
nodeId: host.id,
|
||||
featureId: 'wall:face:left',
|
||||
parameters: { t: 0.25, height: 1 },
|
||||
},
|
||||
fallback: [0.75, 1, 0.05] as [number, number, number],
|
||||
},
|
||||
{
|
||||
kind: 'feature' as const,
|
||||
reference: {
|
||||
nodeId: host.id,
|
||||
featureId: 'wall:face:right',
|
||||
parameters: { t: 0.75, height: 1 },
|
||||
},
|
||||
fallback: [2.25, 1, -0.05] as [number, number, number],
|
||||
},
|
||||
] as const,
|
||||
},
|
||||
}
|
||||
|
||||
const resolved = resolveMeasurementNode(measurement, resolveFrom([host]))
|
||||
|
||||
expect(resolved.anchorNormals[0]?.[0]).toBeCloseTo(0)
|
||||
expect(resolved.anchorNormals[0]?.[1]).toBeCloseTo(0)
|
||||
expect(resolved.anchorNormals[0]?.[2]).toBeCloseTo(1)
|
||||
expect(resolved.anchorNormals[1]?.[0]).toBeCloseTo(0)
|
||||
expect(resolved.anchorNormals[1]?.[1]).toBeCloseTo(0)
|
||||
expect(resolved.anchorNormals[1]?.[2]).toBeCloseTo(-1)
|
||||
})
|
||||
|
||||
test('resolves roof ridge endpoints through segment and parent transforms', () => {
|
||||
const roof = {
|
||||
id: 'roof_a',
|
||||
type: 'roof',
|
||||
parentId: 'level_a',
|
||||
children: ['roof-segment_a'],
|
||||
position: [10, 1, 5],
|
||||
rotation: 0,
|
||||
} as RoofNode
|
||||
const segment = {
|
||||
id: 'roof-segment_a',
|
||||
type: 'roof-segment',
|
||||
parentId: roof.id,
|
||||
children: [],
|
||||
position: [0, 0, 0],
|
||||
rotation: 0,
|
||||
width: 8,
|
||||
depth: 6,
|
||||
wallHeight: 2.5,
|
||||
roofType: 'gable',
|
||||
pitch: 40,
|
||||
} as RoofSegmentNode
|
||||
const featureAnchor = (t: number) => ({
|
||||
kind: 'feature' as const,
|
||||
reference: {
|
||||
nodeId: segment.id,
|
||||
featureId: 'roof:ridge:0',
|
||||
parameters: { t },
|
||||
},
|
||||
fallback: [0, 0, 0] as [number, number, number],
|
||||
})
|
||||
const resolved = resolveMeasurementNode(
|
||||
{
|
||||
measurement: {
|
||||
kind: 'distance',
|
||||
points: [featureAnchor(0), featureAnchor(1)],
|
||||
},
|
||||
},
|
||||
resolveFrom([roof, segment]),
|
||||
)
|
||||
|
||||
expect(resolved.dangling).toEqual([])
|
||||
expect(measurementDistance(...resolved.payload.points)).toBeCloseTo(8)
|
||||
expect(resolved.dependencies).toEqual([segment.id, roof.id])
|
||||
})
|
||||
|
||||
test('falls back visibly when a reference dangles and remaps internal clone references', () => {
|
||||
const measurement = {
|
||||
kind: 'distance' as const,
|
||||
points: [
|
||||
{
|
||||
kind: 'feature' as const,
|
||||
reference: { nodeId: 'wall_host', featureId: 'wall:start' },
|
||||
fallback: [1, 2, 3] as [number, number, number],
|
||||
},
|
||||
[4, 2, 3] as [number, number, number],
|
||||
] as const,
|
||||
}
|
||||
const resolved = resolveMeasurementNode({ measurement }, () => undefined)
|
||||
const remapped = remapMeasurementReferences(measurement, new Map([['wall_host', 'wall_clone']]))
|
||||
|
||||
expect(resolved.payload.points[0]).toEqual([1, 2, 3])
|
||||
expect(resolved.dangling).toHaveLength(1)
|
||||
expect(Array.isArray(remapped.points[0])).toBe(false)
|
||||
if (!Array.isArray(remapped.points[0])) {
|
||||
expect(remapped.points[0].reference.nodeId).toBe('wall_clone')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,289 @@
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
closestMeasurementFeatureBinding,
|
||||
type GeometryContext,
|
||||
type MeasurementAnchor,
|
||||
type MeasurementFeature,
|
||||
type MeasurementFeatureReference,
|
||||
type MeasurementNode,
|
||||
type MeasurementPayload,
|
||||
type MeasurementPoint,
|
||||
measurementAnchorFallback,
|
||||
nodeRegistry,
|
||||
remapMeasurementReferences,
|
||||
} from '@pascal-app/core'
|
||||
|
||||
export type ResolvedMeasurementPayload =
|
||||
| { kind: 'distance'; points: [MeasurementPoint, MeasurementPoint] }
|
||||
| { kind: 'angle'; points: [MeasurementPoint, MeasurementPoint, MeasurementPoint] }
|
||||
| { kind: 'area'; base: MeasurementPoint[] }
|
||||
| { kind: 'perimeter'; base: MeasurementPoint[] }
|
||||
| { kind: 'volume'; base: MeasurementPoint[]; extrusion: MeasurementPoint }
|
||||
|
||||
export type ResolvedMeasurement = {
|
||||
payload: ResolvedMeasurementPayload
|
||||
dangling: MeasurementFeatureReference[]
|
||||
dependencies: AnyNodeId[]
|
||||
anchorNormals: Array<MeasurementPoint | null>
|
||||
}
|
||||
|
||||
type NodeResolver = (id: AnyNodeId) => AnyNode | undefined
|
||||
|
||||
export type MeasurementFeatureMatch = {
|
||||
feature: MeasurementFeature
|
||||
point: MeasurementPoint
|
||||
t: number
|
||||
parameters: Record<string, string | number | boolean>
|
||||
distance: number
|
||||
}
|
||||
|
||||
function childIds(node: AnyNode): AnyNodeId[] {
|
||||
return 'children' in node && Array.isArray(node.children) ? (node.children as AnyNodeId[]) : []
|
||||
}
|
||||
|
||||
function geometryContext(node: AnyNode, resolve: NodeResolver): GeometryContext {
|
||||
const parent = node.parentId ? (resolve(node.parentId as AnyNodeId) ?? null) : null
|
||||
const children = childIds(node)
|
||||
.map(resolve)
|
||||
.filter((child): child is AnyNode => child !== undefined)
|
||||
const siblings = parent
|
||||
? childIds(parent)
|
||||
.map(resolve)
|
||||
.filter(
|
||||
(sibling): sibling is AnyNode => sibling !== undefined && sibling.type === node.type,
|
||||
)
|
||||
: []
|
||||
|
||||
const contextResolve: GeometryContext['resolve'] = <N = AnyNode>(id: AnyNodeId) =>
|
||||
resolve(id) as N | undefined
|
||||
return { resolve: contextResolve, parent, children, siblings }
|
||||
}
|
||||
|
||||
export function measurementFeaturesForNode(node: AnyNode, resolve: NodeResolver) {
|
||||
const contribution = nodeRegistry.get(node.type)?.measurement
|
||||
return contribution ? contribution.features(node, geometryContext(node, resolve)) : []
|
||||
}
|
||||
|
||||
export function matchMeasurementFeatureForNode(
|
||||
node: AnyNode,
|
||||
resolve: NodeResolver,
|
||||
point: MeasurementPoint,
|
||||
maxDistance: number,
|
||||
): MeasurementFeatureMatch | null {
|
||||
const contribution = nodeRegistry.get(node.type)?.measurement
|
||||
if (!contribution) return null
|
||||
const context = geometryContext(node, resolve)
|
||||
const custom = contribution.match?.(node, context, point, maxDistance)
|
||||
if (custom) {
|
||||
const reference: MeasurementFeatureReference = {
|
||||
nodeId: node.id,
|
||||
featureId: custom.featureId,
|
||||
parameters: custom.parameters,
|
||||
}
|
||||
const feature =
|
||||
contribution.resolve?.(node, context, reference) ??
|
||||
contribution.features(node, context).find((candidate) => candidate.id === custom.featureId) ??
|
||||
null
|
||||
if (feature) {
|
||||
const t = typeof custom.parameters?.t === 'number' ? custom.parameters.t : 0.5
|
||||
return {
|
||||
feature,
|
||||
point: custom.point,
|
||||
t,
|
||||
parameters: custom.parameters ?? { t },
|
||||
distance: custom.distance,
|
||||
}
|
||||
}
|
||||
}
|
||||
return closestMeasurementFeature(contribution.features(node, context), point, maxDistance)
|
||||
}
|
||||
|
||||
export function closestMeasurementFeature(
|
||||
features: readonly MeasurementFeature[],
|
||||
point: MeasurementPoint,
|
||||
maxDistance: number,
|
||||
): MeasurementFeatureMatch | null {
|
||||
const binding = closestMeasurementFeatureBinding(features, point, maxDistance)
|
||||
if (!binding) return null
|
||||
const feature = features.find((candidate) => candidate.id === binding.featureId)
|
||||
if (!feature) return null
|
||||
const t = typeof binding.parameters?.t === 'number' ? binding.parameters.t : 0.5
|
||||
return {
|
||||
feature,
|
||||
point: binding.point,
|
||||
t,
|
||||
parameters: binding.parameters ?? { t },
|
||||
distance: binding.distance,
|
||||
}
|
||||
}
|
||||
|
||||
function pointOnPath(points: readonly MeasurementPoint[], t: number, closed: boolean) {
|
||||
if (points.length === 0) return null
|
||||
if (points.length === 1) return points[0]!
|
||||
|
||||
const segmentCount = closed ? points.length : points.length - 1
|
||||
const lengths: number[] = []
|
||||
let total = 0
|
||||
for (let index = 0; index < segmentCount; index++) {
|
||||
const start = points[index]!
|
||||
const end = points[(index + 1) % points.length]!
|
||||
const length = Math.hypot(end[0] - start[0], end[1] - start[1], end[2] - start[2])
|
||||
lengths.push(length)
|
||||
total += length
|
||||
}
|
||||
if (total <= 1e-9) return points[0]!
|
||||
|
||||
let remaining = Math.max(0, Math.min(1, t)) * total
|
||||
for (let index = 0; index < segmentCount; index++) {
|
||||
const length = lengths[index]!
|
||||
if (remaining <= length || index === segmentCount - 1) {
|
||||
const start = points[index]!
|
||||
const end = points[(index + 1) % points.length]!
|
||||
const localT = length <= 1e-9 ? 0 : remaining / length
|
||||
return [
|
||||
start[0] + (end[0] - start[0]) * localT,
|
||||
start[1] + (end[1] - start[1]) * localT,
|
||||
start[2] + (end[2] - start[2]) * localT,
|
||||
] satisfies MeasurementPoint
|
||||
}
|
||||
remaining -= length
|
||||
}
|
||||
return points[points.length - 1]!
|
||||
}
|
||||
|
||||
export function measurementFeaturePoint(
|
||||
feature: MeasurementFeature,
|
||||
reference: MeasurementFeatureReference,
|
||||
): MeasurementPoint | null {
|
||||
const tValue = reference.parameters?.t
|
||||
const t = typeof tValue === 'number' ? tValue : 0.5
|
||||
switch (feature.geometry.kind) {
|
||||
case 'point':
|
||||
return feature.geometry.point
|
||||
case 'segment':
|
||||
return pointOnPath([feature.geometry.start, feature.geometry.end], t, false)
|
||||
case 'path':
|
||||
return pointOnPath(feature.geometry.points, t, feature.geometry.closed === true)
|
||||
case 'polygon':
|
||||
return pointOnPath(feature.geometry.points, t, true)
|
||||
}
|
||||
}
|
||||
|
||||
function resolveAnchor(
|
||||
anchor: MeasurementAnchor,
|
||||
resolve: NodeResolver,
|
||||
): {
|
||||
point: MeasurementPoint
|
||||
normal: MeasurementPoint | null
|
||||
dangling: MeasurementFeatureReference | null
|
||||
} {
|
||||
if (Array.isArray(anchor)) return { point: anchor, normal: null, dangling: null }
|
||||
|
||||
const referencedNode = resolve(anchor.reference.nodeId as AnyNodeId)
|
||||
const contribution = referencedNode
|
||||
? nodeRegistry.get(referencedNode.type)?.measurement
|
||||
: undefined
|
||||
if (!referencedNode || !contribution) {
|
||||
return { point: anchor.fallback, normal: null, dangling: anchor.reference }
|
||||
}
|
||||
|
||||
const context = geometryContext(referencedNode, resolve)
|
||||
const feature =
|
||||
contribution.resolve?.(referencedNode, context, anchor.reference) ??
|
||||
contribution
|
||||
.features(referencedNode, context)
|
||||
.find((candidate) => candidate.id === anchor.reference.featureId) ??
|
||||
null
|
||||
const point = feature ? measurementFeaturePoint(feature, anchor.reference) : null
|
||||
return point
|
||||
? { point, normal: feature?.normal ?? null, dangling: null }
|
||||
: { point: anchor.fallback, normal: null, dangling: anchor.reference }
|
||||
}
|
||||
|
||||
function anchorsFor(payload: MeasurementPayload): readonly MeasurementAnchor[] {
|
||||
return payload.kind === 'distance' || payload.kind === 'angle' ? payload.points : payload.base
|
||||
}
|
||||
|
||||
export function measurementDependencyIds(
|
||||
measurement: MeasurementPayload,
|
||||
resolve?: NodeResolver,
|
||||
): AnyNodeId[] {
|
||||
const ids = new Set<AnyNodeId>()
|
||||
for (const anchor of anchorsFor(measurement)) {
|
||||
if (Array.isArray(anchor)) continue
|
||||
const nodeId = anchor.reference.nodeId as AnyNodeId
|
||||
ids.add(nodeId)
|
||||
const node = resolve?.(nodeId)
|
||||
if (node?.parentId) ids.add(node.parentId as AnyNodeId)
|
||||
}
|
||||
return [...ids]
|
||||
}
|
||||
|
||||
export function resolveMeasurementNode(
|
||||
node: Pick<MeasurementNode, 'measurement'>,
|
||||
resolve: NodeResolver,
|
||||
): ResolvedMeasurement {
|
||||
const dangling: MeasurementFeatureReference[] = []
|
||||
const anchorNormals: Array<MeasurementPoint | null> = []
|
||||
const point = (anchor: MeasurementAnchor) => {
|
||||
const result = resolveAnchor(anchor, resolve)
|
||||
if (result.dangling) dangling.push(result.dangling)
|
||||
anchorNormals.push(result.normal)
|
||||
return result.point
|
||||
}
|
||||
const measurement = node.measurement
|
||||
let payload: ResolvedMeasurementPayload
|
||||
|
||||
switch (measurement.kind) {
|
||||
case 'distance':
|
||||
payload = {
|
||||
kind: 'distance',
|
||||
points: [point(measurement.points[0]), point(measurement.points[1])],
|
||||
}
|
||||
break
|
||||
case 'angle':
|
||||
payload = {
|
||||
kind: 'angle',
|
||||
points: [
|
||||
point(measurement.points[0]),
|
||||
point(measurement.points[1]),
|
||||
point(measurement.points[2]),
|
||||
],
|
||||
}
|
||||
break
|
||||
case 'area':
|
||||
payload = { kind: 'area', base: measurement.base.map(point) }
|
||||
break
|
||||
case 'perimeter':
|
||||
payload = { kind: 'perimeter', base: measurement.base.map(point) }
|
||||
break
|
||||
case 'volume':
|
||||
payload = {
|
||||
kind: 'volume',
|
||||
base: measurement.base.map(point),
|
||||
extrusion: measurement.extrusion,
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
return {
|
||||
payload,
|
||||
dangling,
|
||||
dependencies: measurementDependencyIds(measurement, resolve),
|
||||
anchorNormals,
|
||||
}
|
||||
}
|
||||
|
||||
export function detachMeasurementPayload(
|
||||
node: Pick<MeasurementNode, 'measurement'>,
|
||||
resolve: NodeResolver,
|
||||
): MeasurementPayload {
|
||||
return resolveMeasurementNode(node, resolve).payload
|
||||
}
|
||||
|
||||
export function freeMeasurementPoint(anchor: MeasurementAnchor): MeasurementPoint {
|
||||
return measurementAnchorFallback(anchor)
|
||||
}
|
||||
|
||||
export { remapMeasurementReferences }
|
||||
@@ -0,0 +1 @@
|
||||
export { MeasurementNode } from '@pascal-app/core'
|
||||
@@ -0,0 +1,419 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type AlignmentAnchor,
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
collectAlignmentAnchors,
|
||||
emitter,
|
||||
MeasurementNode,
|
||||
type MeasurementPayload,
|
||||
type MeasurementPoint,
|
||||
resolveLevelId,
|
||||
sceneRegistry,
|
||||
useLiveNodeOverrides,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
boundaryReshapeScope,
|
||||
EDITOR_LAYER,
|
||||
isAlignmentGuideActive,
|
||||
MEASUREMENT_ACTIVE_COLOR,
|
||||
type MeasurementAxis,
|
||||
type MeasurementAxisGuide,
|
||||
swallowNextClick,
|
||||
useInteractionScope,
|
||||
} from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Html } from '@react-three/drei'
|
||||
import { createPortal, type ThreeEvent, useFrame, useThree } from '@react-three/fiber'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
BufferGeometry,
|
||||
Float32BufferAttribute,
|
||||
type Group,
|
||||
MathUtils,
|
||||
type Object3D,
|
||||
type OrthographicCamera,
|
||||
type PerspectiveCamera,
|
||||
Vector3,
|
||||
} from 'three'
|
||||
import { MeshBasicNodeMaterial } from 'three/webgpu'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import {
|
||||
measurementEditAnchor,
|
||||
measurementResolvedEditPoints,
|
||||
refreshMeasurementAnchorFallbacks,
|
||||
replaceMeasurementAnchor,
|
||||
} from './edit'
|
||||
import { measurementDependencyIds, resolveMeasurementNode } from './resolve'
|
||||
import {
|
||||
associateSurfaceHit,
|
||||
createMeasurementSurfaceQuerySession,
|
||||
measurementVertexSnapAnchors,
|
||||
} from './surface-query'
|
||||
|
||||
const HANDLE_RADIUS_PX = 7
|
||||
const GUIDE_COLORS: Record<MeasurementAxis, string> = {
|
||||
x: '#ef4444',
|
||||
y: '#22c55e',
|
||||
z: '#3b82f6',
|
||||
}
|
||||
const NO_RAYCAST = () => {}
|
||||
|
||||
function MeasurementEditHandle({
|
||||
active,
|
||||
onPointerDown,
|
||||
position,
|
||||
}: {
|
||||
active: boolean
|
||||
onPointerDown: (event: ThreeEvent<PointerEvent>) => void
|
||||
position: MeasurementPoint
|
||||
}) {
|
||||
const ref = useRef<Group>(null)
|
||||
const worldPosition = useMemo(() => new Vector3(), [])
|
||||
const cameraPosition = useMemo(() => new Vector3(), [])
|
||||
const materials = useMemo(
|
||||
() => ({
|
||||
halo: new MeshBasicNodeMaterial({
|
||||
color: '#f8fafc',
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
}),
|
||||
point: new MeshBasicNodeMaterial({
|
||||
color: MEASUREMENT_ACTIVE_COLOR,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
}),
|
||||
hit: new MeshBasicNodeMaterial({
|
||||
visible: false,
|
||||
}),
|
||||
}),
|
||||
[],
|
||||
)
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
materials.halo.dispose()
|
||||
materials.point.dispose()
|
||||
materials.hit.dispose()
|
||||
},
|
||||
[materials],
|
||||
)
|
||||
|
||||
useFrame(({ camera, size }) => {
|
||||
const group = ref.current
|
||||
if (!group) return
|
||||
group.getWorldPosition(worldPosition)
|
||||
let worldUnitsPerPixel = 0.01
|
||||
if ((camera as PerspectiveCamera).isPerspectiveCamera) {
|
||||
const perspective = camera as PerspectiveCamera
|
||||
const depth = Math.abs(
|
||||
cameraPosition.copy(worldPosition).applyMatrix4(perspective.matrixWorldInverse).z,
|
||||
)
|
||||
worldUnitsPerPixel =
|
||||
(2 * depth * Math.tan(MathUtils.degToRad(perspective.getEffectiveFOV() * 0.5))) /
|
||||
Math.max(size.height, 1)
|
||||
} else if ((camera as OrthographicCamera).isOrthographicCamera) {
|
||||
const orthographic = camera as OrthographicCamera
|
||||
worldUnitsPerPixel =
|
||||
(orthographic.top - orthographic.bottom) / Math.max(orthographic.zoom * size.height, 1)
|
||||
}
|
||||
const scale = worldUnitsPerPixel * HANDLE_RADIUS_PX * (active ? 1.2 : 1)
|
||||
if (Number.isFinite(scale)) group.scale.setScalar(MathUtils.clamp(scale, 0.004, 0.3))
|
||||
})
|
||||
|
||||
return (
|
||||
<group position={position} ref={ref} userData={{ measurementSurface: false }}>
|
||||
<mesh layers={EDITOR_LAYER} material={materials.halo} raycast={NO_RAYCAST} renderOrder={1010}>
|
||||
<sphereGeometry args={[1, 16, 12]} />
|
||||
</mesh>
|
||||
<mesh
|
||||
layers={EDITOR_LAYER}
|
||||
material={materials.point}
|
||||
raycast={NO_RAYCAST}
|
||||
renderOrder={1011}
|
||||
scale={0.62}
|
||||
>
|
||||
<sphereGeometry args={[1, 16, 12]} />
|
||||
</mesh>
|
||||
<mesh
|
||||
layers={EDITOR_LAYER}
|
||||
material={materials.hit}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerEnter={() => {
|
||||
document.body.style.cursor = 'grab'
|
||||
}}
|
||||
onPointerLeave={() => {
|
||||
if (!active) document.body.style.cursor = ''
|
||||
}}
|
||||
renderOrder={1012}
|
||||
scale={1.8}
|
||||
>
|
||||
<sphereGeometry args={[1, 12, 8]} />
|
||||
</mesh>
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
function MeasurementEditGuide({ guide }: { guide: MeasurementAxisGuide }) {
|
||||
const geometry = useMemo(() => {
|
||||
const next = new BufferGeometry()
|
||||
next.setAttribute('position', new Float32BufferAttribute([...guide.from, ...guide.to], 3))
|
||||
return next
|
||||
}, [guide])
|
||||
useEffect(() => () => geometry.dispose(), [geometry])
|
||||
|
||||
return (
|
||||
<>
|
||||
<lineSegments
|
||||
frustumCulled={false}
|
||||
geometry={geometry}
|
||||
layers={EDITOR_LAYER}
|
||||
raycast={NO_RAYCAST}
|
||||
renderOrder={1009}
|
||||
userData={{ measurementSurface: false }}
|
||||
>
|
||||
<lineBasicNodeMaterial
|
||||
color={GUIDE_COLORS[guide.axis]}
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
linewidth={guide.snapped ? 3 : 2}
|
||||
opacity={guide.snapped ? 1 : 0.72}
|
||||
transparent
|
||||
/>
|
||||
</lineSegments>
|
||||
<Html center position={guide.to} style={{ pointerEvents: 'none' }} zIndexRange={[80, 0]}>
|
||||
<div className="-translate-y-4 whitespace-nowrap rounded-full border border-indigo-400/70 bg-background/95 px-2.5 py-1 font-mono font-semibold text-[11px] text-foreground shadow-sm backdrop-blur">
|
||||
{guide.proximity ? 'Align ' : ''}
|
||||
{guide.axis.toUpperCase()}
|
||||
</div>
|
||||
</Html>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function MeasurementEditHandles({
|
||||
levelId,
|
||||
levelObject,
|
||||
node,
|
||||
}: {
|
||||
levelId: string
|
||||
levelObject: Object3D
|
||||
node: MeasurementNode
|
||||
}) {
|
||||
const { camera, gl, scene } = useThree()
|
||||
const ownOverride = useLiveNodeOverrides((state) => state.overrides.get(node.id)) as
|
||||
| Partial<MeasurementNode>
|
||||
| undefined
|
||||
const effectiveNode = useMemo(
|
||||
() => (ownOverride ? ({ ...node, ...ownOverride } as MeasurementNode) : node),
|
||||
[node, ownOverride],
|
||||
)
|
||||
const dependencyIds = measurementDependencyIds(
|
||||
effectiveNode.measurement,
|
||||
(id) => useScene.getState().nodes[id],
|
||||
)
|
||||
useScene(useShallow((state) => dependencyIds.map((id) => state.nodes[id])))
|
||||
useLiveNodeOverrides(useShallow((state) => dependencyIds.map((id) => state.overrides.get(id))))
|
||||
const resolved = resolveMeasurementNode(effectiveNode, (id) => {
|
||||
const dependency = useScene.getState().nodes[id]
|
||||
if (!dependency) return undefined
|
||||
const override = useLiveNodeOverrides.getState().overrides.get(id)
|
||||
return override ? ({ ...dependency, ...override } as AnyNode) : dependency
|
||||
})
|
||||
const points = measurementResolvedEditPoints(resolved.payload)
|
||||
const polygon =
|
||||
resolved.payload.kind === 'area' ||
|
||||
resolved.payload.kind === 'perimeter' ||
|
||||
resolved.payload.kind === 'volume'
|
||||
const surfaceQuery = useMemo(() => createMeasurementSurfaceQuerySession(scene), [scene])
|
||||
const proximityCache = useRef<{ anchors: AlignmentAnchor[]; timestamp: number }>({
|
||||
anchors: [],
|
||||
timestamp: Number.NEGATIVE_INFINITY,
|
||||
})
|
||||
const [activeIndex, setActiveIndex] = useState<number | null>(null)
|
||||
const [axisGuide, setAxisGuide] = useState<MeasurementAxisGuide | null>(null)
|
||||
const endDragRef = useRef<(commit: boolean) => void>(() => {})
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
endDragRef.current(false)
|
||||
surfaceQuery.dispose()
|
||||
useLiveNodeOverrides.getState().clear(node.id)
|
||||
document.body.style.cursor = ''
|
||||
},
|
||||
[node.id, surfaceQuery],
|
||||
)
|
||||
|
||||
const startDrag = useCallback(
|
||||
(index: number, event: ThreeEvent<PointerEvent>) => {
|
||||
if (event.button !== 0 || useViewer.getState().cameraDragging) return
|
||||
event.stopPropagation()
|
||||
event.nativeEvent.preventDefault()
|
||||
event.nativeEvent.stopImmediatePropagation()
|
||||
|
||||
const baseResolved = resolveMeasurementNode(node, (id) => {
|
||||
const dependency = useScene.getState().nodes[id]
|
||||
if (!dependency) return undefined
|
||||
const override = useLiveNodeOverrides.getState().overrides.get(id)
|
||||
return override ? ({ ...dependency, ...override } as AnyNode) : dependency
|
||||
})
|
||||
const basePayload = refreshMeasurementAnchorFallbacks(node.measurement, baseResolved.payload)
|
||||
const basePoints = measurementResolvedEditPoints(baseResolved.payload)
|
||||
if (!basePoints[index]) return
|
||||
|
||||
const previousInputDragging = useViewer.getState().inputDragging
|
||||
const previousCursor = document.body.style.cursor
|
||||
let latestPayload: MeasurementPayload | null = null
|
||||
let latestGuide: MeasurementAxisGuide | null = null
|
||||
const pointerId = event.pointerId
|
||||
setActiveIndex(index)
|
||||
useViewer.getState().setInputDragging(true)
|
||||
useInteractionScope.getState().begin(boundaryReshapeScope(node.id))
|
||||
document.body.style.cursor = 'grabbing'
|
||||
|
||||
const getProximityAnchors = () => {
|
||||
const now = performance.now()
|
||||
if (now - proximityCache.current.timestamp > 120) {
|
||||
proximityCache.current = {
|
||||
anchors: collectAlignmentAnchors(useScene.getState().nodes, node.id, levelId),
|
||||
timestamp: now,
|
||||
}
|
||||
}
|
||||
return proximityCache.current.anchors
|
||||
}
|
||||
|
||||
const onMove = (pointerEvent: PointerEvent) => {
|
||||
if (pointerEvent.pointerId !== pointerId) return
|
||||
pointerEvent.preventDefault()
|
||||
pointerEvent.stopPropagation()
|
||||
const anchors = measurementVertexSnapAnchors(basePoints, index, polygon)
|
||||
// Measurement anchors always bind to real geometry — the construction
|
||||
// snapping-mode chip doesn't govern this analysis tool. Alt bypasses.
|
||||
const applyMagneticSnap = !pointerEvent.altKey
|
||||
const surface = surfaceQuery.resolvePointer({
|
||||
event: pointerEvent,
|
||||
camera,
|
||||
canvas: gl.domElement,
|
||||
levelObject,
|
||||
anchorOrAnchors: anchors,
|
||||
lockedGuide: applyMagneticSnap && latestGuide?.snapped === true ? latestGuide : null,
|
||||
planarProximityAnchors: getProximityAnchors(),
|
||||
applyMagneticSnap,
|
||||
showAlignmentGuides: isAlignmentGuideActive(),
|
||||
})
|
||||
if (!surface) return
|
||||
const associated = associateSurfaceHit(surface.hit, applyMagneticSnap ? 0.2 : 0.012)
|
||||
const anchor = measurementEditAnchor(
|
||||
baseResolved.payload,
|
||||
associated.point,
|
||||
associated.anchor,
|
||||
)
|
||||
const next = replaceMeasurementAnchor(basePayload, index, anchor)
|
||||
if (!next || !MeasurementNode.safeParse({ ...node, measurement: next }).success) return
|
||||
latestPayload = next
|
||||
latestGuide = surface.guide
|
||||
useLiveNodeOverrides.getState().set(node.id, { measurement: next })
|
||||
setAxisGuide(surface.guide)
|
||||
}
|
||||
|
||||
const cleanup = (commit: boolean) => {
|
||||
window.removeEventListener('pointermove', onMove)
|
||||
window.removeEventListener('pointerup', onUp)
|
||||
window.removeEventListener('pointercancel', onCancel)
|
||||
window.removeEventListener('blur', onBlur)
|
||||
emitter.off('tool:cancel', onToolCancel)
|
||||
useLiveNodeOverrides.getState().clear(node.id)
|
||||
useViewer.getState().setInputDragging(previousInputDragging)
|
||||
useInteractionScope
|
||||
.getState()
|
||||
.endIf(
|
||||
(scope) =>
|
||||
scope.kind === 'reshaping' &&
|
||||
scope.reshape === 'boundary' &&
|
||||
scope.nodeId === node.id,
|
||||
)
|
||||
document.body.style.cursor = previousCursor
|
||||
setActiveIndex(null)
|
||||
setAxisGuide(null)
|
||||
const payload = latestPayload
|
||||
latestPayload = null
|
||||
latestGuide = null
|
||||
endDragRef.current = () => {}
|
||||
if (commit && payload) useScene.getState().updateNode(node.id, { measurement: payload })
|
||||
}
|
||||
|
||||
const onUp = (pointerEvent: PointerEvent) => {
|
||||
if (pointerEvent.pointerId !== pointerId) return
|
||||
pointerEvent.preventDefault()
|
||||
swallowNextClick()
|
||||
cleanup(true)
|
||||
}
|
||||
const onCancel = (pointerEvent: PointerEvent) => {
|
||||
if (pointerEvent.pointerId === pointerId) cleanup(false)
|
||||
}
|
||||
const onBlur = () => cleanup(false)
|
||||
const onToolCancel = () => cleanup(false)
|
||||
|
||||
endDragRef.current = cleanup
|
||||
emitter.on('tool:cancel', onToolCancel)
|
||||
window.addEventListener('pointermove', onMove)
|
||||
window.addEventListener('pointerup', onUp)
|
||||
window.addEventListener('pointercancel', onCancel)
|
||||
window.addEventListener('blur', onBlur)
|
||||
},
|
||||
[camera, gl.domElement, levelId, levelObject, node, polygon, surfaceQuery],
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{axisGuide ? <MeasurementEditGuide guide={axisGuide} /> : null}
|
||||
{points.map((point, index) => (
|
||||
<MeasurementEditHandle
|
||||
active={activeIndex === index}
|
||||
key={index}
|
||||
onPointerDown={(event) => startDrag(index, event)}
|
||||
position={point}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const MeasurementSelectionAffordance = () => {
|
||||
const selectedIds = useViewer((state) => state.selection.selectedIds)
|
||||
const showMeasurements = useViewer((state) => state.showMeasurements)
|
||||
const node = useScene((state) => {
|
||||
if (selectedIds.length !== 1) return null
|
||||
const selected = state.nodes[selectedIds[0] as AnyNodeId]
|
||||
return selected?.type === 'measurement' ? selected : null
|
||||
}) as MeasurementNode | null
|
||||
const levelId = node ? resolveLevelId(node, useScene.getState().nodes) : null
|
||||
const [levelObject, setLevelObject] = useState<Object3D | null>(() =>
|
||||
levelId ? (sceneRegistry.nodes.get(levelId) ?? null) : null,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!levelId) {
|
||||
setLevelObject(null)
|
||||
return
|
||||
}
|
||||
let frameId = 0
|
||||
const resolve = () => {
|
||||
const next = sceneRegistry.nodes.get(levelId) ?? null
|
||||
setLevelObject((current) => (current === next ? current : next))
|
||||
if (!next) frameId = window.requestAnimationFrame(resolve)
|
||||
}
|
||||
resolve()
|
||||
return () => window.cancelAnimationFrame(frameId)
|
||||
}, [levelId])
|
||||
|
||||
if (!(showMeasurements && node && node.visible !== false && levelId && levelObject)) return null
|
||||
return createPortal(
|
||||
<MeasurementEditHandles levelId={levelId} levelObject={levelObject} node={node} />,
|
||||
levelObject,
|
||||
)
|
||||
}
|
||||
|
||||
export default MeasurementSelectionAffordance
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { SlabNode, WallNode, ZoneNode } from '@pascal-app/core'
|
||||
import { resolveSmartMeasurementSurfaceHit } from './smart-surface'
|
||||
import type { LocalSurfaceHit } from './surface-query'
|
||||
|
||||
const zone = ZoneNode.parse({
|
||||
id: 'zone_room',
|
||||
name: 'Room',
|
||||
parentId: 'level_main',
|
||||
polygon: [
|
||||
[0, 0],
|
||||
[4, 0],
|
||||
[4, 3],
|
||||
[0, 3],
|
||||
],
|
||||
})
|
||||
const slab = SlabNode.parse({
|
||||
id: 'slab_floor',
|
||||
parentId: 'level_main',
|
||||
polygon: zone.polygon,
|
||||
})
|
||||
const wall = WallNode.parse({
|
||||
end: [4, 0],
|
||||
id: 'wall_front',
|
||||
parentId: 'level_main',
|
||||
start: [0, 0],
|
||||
})
|
||||
|
||||
function surfaceHit(targetNodeId: string, normal: [number, number, number]): LocalSurfaceHit {
|
||||
return { normal, point: [2, 0, 1], targetNodeId }
|
||||
}
|
||||
|
||||
describe('smart measurement zone targeting', () => {
|
||||
test('resolves a floor hit inside an active-level zone to that zone', () => {
|
||||
const hit = resolveSmartMeasurementSurfaceHit(
|
||||
surfaceHit(slab.id, [0, 1, 0]),
|
||||
{ [slab.id]: slab, [zone.id]: zone },
|
||||
'level_main',
|
||||
)
|
||||
|
||||
expect(hit.targetNodeId).toBe(zone.id)
|
||||
})
|
||||
|
||||
test('does not replace a wall hit with its enclosing zone', () => {
|
||||
const hit = resolveSmartMeasurementSurfaceHit(
|
||||
surfaceHit(wall.id, [0, 0, 1]),
|
||||
{ [wall.id]: wall, [zone.id]: zone },
|
||||
'level_main',
|
||||
)
|
||||
|
||||
expect(hit.targetNodeId).toBe(wall.id)
|
||||
})
|
||||
|
||||
test('does not target a zone attached to another level', () => {
|
||||
const hit = resolveSmartMeasurementSurfaceHit(
|
||||
surfaceHit(slab.id, [0, 1, 0]),
|
||||
{ [slab.id]: slab, [zone.id]: { ...zone, parentId: 'level_other' } },
|
||||
'level_main',
|
||||
)
|
||||
|
||||
expect(hit.targetNodeId).toBe(slab.id)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
import { type AnyNode, pointInPolygon2D, type ZoneNode } from '@pascal-app/core'
|
||||
import type { LocalSurfaceHit } from './surface-query'
|
||||
|
||||
function polygonArea(polygon: ReadonlyArray<readonly [number, number]>) {
|
||||
let area = 0
|
||||
for (let index = 0; index < polygon.length; index += 1) {
|
||||
const current = polygon[index]!
|
||||
const next = polygon[(index + 1) % polygon.length]!
|
||||
area += current[0] * next[1] - next[0] * current[1]
|
||||
}
|
||||
return Math.abs(area) * 0.5
|
||||
}
|
||||
|
||||
export function resolveSmartMeasurementSurfaceHit(
|
||||
hit: LocalSurfaceHit,
|
||||
nodes: Readonly<Record<string, AnyNode | undefined>>,
|
||||
levelId: string,
|
||||
): LocalSurfaceHit {
|
||||
const target = hit.targetNodeId ? nodes[hit.targetNodeId] : undefined
|
||||
if (target?.type !== 'slab' || Math.abs(hit.normal[1]) < 0.75) return hit
|
||||
|
||||
const zone = Object.values(nodes)
|
||||
.filter(
|
||||
(node): node is ZoneNode =>
|
||||
node?.type === 'zone' &&
|
||||
node.parentId === levelId &&
|
||||
pointInPolygon2D([hit.point[0], hit.point[2]], node.polygon),
|
||||
)
|
||||
.sort((left, right) => polygonArea(left!.polygon) - polygonArea(right!.polygon))[0]
|
||||
|
||||
return zone ? { ...hit, targetNodeId: zone.id } : hit
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
'use client'
|
||||
|
||||
import { type AnyNodeId, sceneRegistry, useScene } from '@pascal-app/core'
|
||||
import {
|
||||
activateQuickMeasurementHudSource,
|
||||
clearQuickMeasurementHudSource,
|
||||
createQuickMeasurementPointerScheduler,
|
||||
EDITOR_LAYER,
|
||||
NO_RAYCAST,
|
||||
publishQuickMeasurementHudSource,
|
||||
resolveQuickMeasurementReport,
|
||||
useInteractionScope,
|
||||
} from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useFrame, useThree } from '@react-three/fiber'
|
||||
import { memo, type RefObject, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
DoubleSide,
|
||||
type Group,
|
||||
MathUtils,
|
||||
type OrthographicCamera,
|
||||
type PerspectiveCamera,
|
||||
Quaternion,
|
||||
Vector3,
|
||||
} from 'three'
|
||||
import { MeshBasicNodeMaterial } from 'three/webgpu'
|
||||
import { resolveSmartMeasurementSurfaceHit } from './smart-surface'
|
||||
import { createMeasurementSurfaceQuerySession, type LocalSurfaceHit } from './surface-query'
|
||||
|
||||
const MARKER_NORMAL = new Vector3(0, 0, 1)
|
||||
const MARKER_ORIGIN = new Vector3()
|
||||
const MARKER_USER_DATA = { measurementSurface: false }
|
||||
const HALO_RING_ARGS: [number, number, number] = [0.5, 1, 48]
|
||||
const LIVE_TARGET_RING_ARGS: [number, number, number] = [0.64, 0.84, 48]
|
||||
const PINNED_TARGET_RING_ARGS: [number, number, number] = [0.48, 0.84, 48]
|
||||
const PINNED_CENTER_ARGS: [number, number] = [0.22, 32]
|
||||
|
||||
function localPointToBuildingFrame(
|
||||
levelObject: Group,
|
||||
buildingObject: Group | null,
|
||||
point: readonly [number, number, number],
|
||||
): Vector3 {
|
||||
const worldPoint = levelObject.localToWorld(new Vector3(...point))
|
||||
return buildingObject ? buildingObject.worldToLocal(worldPoint) : worldPoint
|
||||
}
|
||||
|
||||
function localNormalToBuildingFrame(
|
||||
levelObject: Group,
|
||||
buildingObject: Group | null,
|
||||
normal: readonly [number, number, number],
|
||||
): Vector3 {
|
||||
const value = new Vector3(...normal).applyQuaternion(
|
||||
levelObject.getWorldQuaternion(new Quaternion()),
|
||||
)
|
||||
if (buildingObject)
|
||||
value.applyQuaternion(buildingObject.getWorldQuaternion(new Quaternion()).invert())
|
||||
return value.normalize()
|
||||
}
|
||||
|
||||
const SmartSurfaceMarker = memo(function SmartSurfaceMarker({
|
||||
position,
|
||||
normal,
|
||||
pinned,
|
||||
markerRef,
|
||||
}: {
|
||||
position?: Vector3
|
||||
normal?: Vector3
|
||||
pinned: boolean
|
||||
markerRef?: RefObject<Group | null>
|
||||
}) {
|
||||
const localRef = useRef<Group>(null)
|
||||
const ref = markerRef ?? localRef
|
||||
const worldPosition = useMemo(() => new Vector3(), [])
|
||||
const cameraPosition = useMemo(() => new Vector3(), [])
|
||||
const rotation = useMemo(
|
||||
() =>
|
||||
new Quaternion().setFromUnitVectors(
|
||||
MARKER_NORMAL,
|
||||
normal && normal.lengthSq() > 1e-12 ? normal.clone().normalize() : MARKER_NORMAL,
|
||||
),
|
||||
[normal],
|
||||
)
|
||||
const materials = useMemo(
|
||||
() => ({
|
||||
halo: new MeshBasicNodeMaterial({
|
||||
color: '#f8fafc',
|
||||
depthTest: true,
|
||||
depthWrite: false,
|
||||
opacity: 0.96,
|
||||
polygonOffset: true,
|
||||
polygonOffsetFactor: -2,
|
||||
polygonOffsetUnits: -2,
|
||||
side: DoubleSide,
|
||||
transparent: true,
|
||||
}),
|
||||
target: new MeshBasicNodeMaterial({
|
||||
color: pinned ? '#0e7490' : '#0891b2',
|
||||
depthTest: true,
|
||||
depthWrite: false,
|
||||
polygonOffset: true,
|
||||
polygonOffsetFactor: -3,
|
||||
polygonOffsetUnits: -3,
|
||||
side: DoubleSide,
|
||||
}),
|
||||
center: new MeshBasicNodeMaterial({
|
||||
color: '#0e7490',
|
||||
depthTest: true,
|
||||
depthWrite: false,
|
||||
polygonOffset: true,
|
||||
polygonOffsetFactor: -4,
|
||||
polygonOffsetUnits: -4,
|
||||
side: DoubleSide,
|
||||
}),
|
||||
}),
|
||||
[pinned],
|
||||
)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!position && ref.current) ref.current.visible = false
|
||||
}, [position, ref])
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
materials.halo.dispose()
|
||||
materials.target.dispose()
|
||||
materials.center.dispose()
|
||||
},
|
||||
[materials],
|
||||
)
|
||||
|
||||
useFrame(({ camera, size }) => {
|
||||
const group = ref.current
|
||||
if (!group) return
|
||||
group.getWorldPosition(worldPosition)
|
||||
let worldUnitsPerPixel = 0.01
|
||||
if ((camera as PerspectiveCamera).isPerspectiveCamera) {
|
||||
const perspective = camera as PerspectiveCamera
|
||||
const depth = Math.abs(
|
||||
cameraPosition.copy(worldPosition).applyMatrix4(perspective.matrixWorldInverse).z,
|
||||
)
|
||||
worldUnitsPerPixel =
|
||||
(2 * depth * Math.tan(MathUtils.degToRad(perspective.getEffectiveFOV() * 0.5))) /
|
||||
Math.max(size.height, 1)
|
||||
} else if ((camera as OrthographicCamera).isOrthographicCamera) {
|
||||
const orthographic = camera as OrthographicCamera
|
||||
worldUnitsPerPixel =
|
||||
(orthographic.top - orthographic.bottom) / Math.max(orthographic.zoom * size.height, 1)
|
||||
}
|
||||
const scale = worldUnitsPerPixel * 13
|
||||
if (Number.isFinite(scale)) group.scale.setScalar(MathUtils.clamp(scale, 0.003, 0.42))
|
||||
})
|
||||
|
||||
return (
|
||||
<group
|
||||
position={position ?? MARKER_ORIGIN}
|
||||
quaternion={rotation}
|
||||
ref={ref}
|
||||
userData={MARKER_USER_DATA}
|
||||
>
|
||||
<mesh layers={EDITOR_LAYER} material={materials.halo} raycast={NO_RAYCAST} renderOrder={1002}>
|
||||
<ringGeometry args={HALO_RING_ARGS} />
|
||||
</mesh>
|
||||
<mesh
|
||||
layers={EDITOR_LAYER}
|
||||
material={materials.target}
|
||||
raycast={NO_RAYCAST}
|
||||
renderOrder={1003}
|
||||
>
|
||||
<ringGeometry args={pinned ? PINNED_TARGET_RING_ARGS : LIVE_TARGET_RING_ARGS} />
|
||||
</mesh>
|
||||
{pinned ? (
|
||||
<mesh
|
||||
layers={EDITOR_LAYER}
|
||||
material={materials.center}
|
||||
raycast={NO_RAYCAST}
|
||||
renderOrder={1004}
|
||||
>
|
||||
<circleGeometry args={PINNED_CENTER_ARGS} />
|
||||
</mesh>
|
||||
) : null}
|
||||
</group>
|
||||
)
|
||||
})
|
||||
|
||||
function showSmartSurfaceMarker(
|
||||
marker: Group | null,
|
||||
levelObject: Group,
|
||||
buildingObject: Group | null,
|
||||
hit: LocalSurfaceHit,
|
||||
) {
|
||||
if (!marker) return
|
||||
levelObject.updateWorldMatrix(true, false)
|
||||
buildingObject?.updateWorldMatrix(true, false)
|
||||
marker.position.copy(localPointToBuildingFrame(levelObject, buildingObject, hit.point))
|
||||
marker.quaternion.setFromUnitVectors(
|
||||
MARKER_NORMAL,
|
||||
localNormalToBuildingFrame(levelObject, buildingObject, hit.normal),
|
||||
)
|
||||
marker.visible = true
|
||||
}
|
||||
|
||||
function hideSmartSurfaceMarker(marker: Group | null) {
|
||||
if (marker) marker.visible = false
|
||||
}
|
||||
|
||||
export function SmartMeasurementTool() {
|
||||
const { camera, gl, scene } = useThree()
|
||||
const buildingId = useViewer((state) => state.selection.buildingId)
|
||||
const levelId = useViewer((state) => state.selection.levelId)
|
||||
const levelRef = useRef(levelId)
|
||||
const nodes = useScene((state) => state.nodes)
|
||||
const hoverRef = useRef<LocalSurfaceHit | null>(null)
|
||||
const hoverNodeIdRef = useRef<string | null>(null)
|
||||
const candidateNodeIdRef = useRef<string | null | undefined>(undefined)
|
||||
const candidateHasReportRef = useRef(false)
|
||||
const candidateNodesRef = useRef(nodes)
|
||||
const hoverMarkerRef = useRef<Group>(null)
|
||||
const [hoverNodeId, setHoverNodeId] = useState<string | null>(null)
|
||||
const [pinned, setPinned] = useState<LocalSurfaceHit | null>(null)
|
||||
const surfaceQuery = useMemo(
|
||||
() => createMeasurementSurfaceQuerySession(scene, { includeZoneLayer: true }),
|
||||
[scene],
|
||||
)
|
||||
const hoverReport = useMemo(
|
||||
() => resolveQuickMeasurementReport(hoverNodeId, nodes),
|
||||
[hoverNodeId, nodes],
|
||||
)
|
||||
const pinnedReport = useMemo(
|
||||
() => resolveQuickMeasurementReport(pinned?.targetNodeId ?? null, nodes),
|
||||
[pinned?.targetNodeId, nodes],
|
||||
)
|
||||
|
||||
useEffect(() => () => surfaceQuery.dispose(), [surfaceQuery])
|
||||
|
||||
useEffect(() => {
|
||||
if (levelRef.current === levelId) return
|
||||
levelRef.current = levelId
|
||||
hoverRef.current = null
|
||||
hoverNodeIdRef.current = null
|
||||
candidateNodeIdRef.current = undefined
|
||||
candidateHasReportRef.current = false
|
||||
hideSmartSurfaceMarker(hoverMarkerRef.current)
|
||||
setHoverNodeId(null)
|
||||
setPinned(null)
|
||||
}, [levelId])
|
||||
|
||||
useEffect(() => {
|
||||
const scope = useInteractionScope.getState()
|
||||
scope.begin({ kind: 'drafting', tool: 'measurement' })
|
||||
return () => {
|
||||
useInteractionScope
|
||||
.getState()
|
||||
.endIf((active) => active.kind === 'drafting' && active.tool === 'measurement')
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = gl.domElement
|
||||
const updateHover = (
|
||||
next: LocalSurfaceHit | null,
|
||||
levelObject?: Group,
|
||||
buildingObject?: Group | null,
|
||||
) => {
|
||||
hoverRef.current = next
|
||||
if (next && levelObject) {
|
||||
showSmartSurfaceMarker(hoverMarkerRef.current, levelObject, buildingObject ?? null, next)
|
||||
} else {
|
||||
hideSmartSurfaceMarker(hoverMarkerRef.current)
|
||||
}
|
||||
const nextNodeId = next?.targetNodeId ?? null
|
||||
if (nextNodeId === hoverNodeIdRef.current) return
|
||||
hoverNodeIdRef.current = nextNodeId
|
||||
setHoverNodeId(nextNodeId)
|
||||
}
|
||||
const processPointerMove = (event: PointerEvent) => {
|
||||
activateQuickMeasurementHudSource('3d')
|
||||
if (useViewer.getState().cameraDragging || !levelId) {
|
||||
updateHover(null)
|
||||
return
|
||||
}
|
||||
const levelObject = sceneRegistry.nodes.get(levelId)
|
||||
if (!levelObject) {
|
||||
updateHover(null)
|
||||
return
|
||||
}
|
||||
const buildingObject = buildingId
|
||||
? ((sceneRegistry.nodes.get(buildingId as AnyNodeId) as Group | undefined) ?? null)
|
||||
: null
|
||||
const resolved = surfaceQuery.resolvePointer({
|
||||
event,
|
||||
camera,
|
||||
canvas,
|
||||
levelObject,
|
||||
anchorOrAnchors: null,
|
||||
applyMagneticSnap: false,
|
||||
showAlignmentGuides: false,
|
||||
})
|
||||
const sceneNodes = useScene.getState().nodes
|
||||
const next = resolved
|
||||
? resolveSmartMeasurementSurfaceHit(resolved.hit, sceneNodes, levelId)
|
||||
: null
|
||||
if (candidateNodesRef.current !== sceneNodes) {
|
||||
candidateNodesRef.current = sceneNodes
|
||||
candidateNodeIdRef.current = undefined
|
||||
}
|
||||
const candidateNodeId = next?.targetNodeId ?? null
|
||||
if (candidateNodeId !== candidateNodeIdRef.current) {
|
||||
candidateNodeIdRef.current = candidateNodeId
|
||||
candidateHasReportRef.current = Boolean(
|
||||
resolveQuickMeasurementReport(candidateNodeId, sceneNodes),
|
||||
)
|
||||
}
|
||||
updateHover(candidateHasReportRef.current ? next : null, levelObject as Group, buildingObject)
|
||||
}
|
||||
const pointerScheduler = createQuickMeasurementPointerScheduler(processPointerMove)
|
||||
const onPointerMove = (event: PointerEvent) => pointerScheduler.enqueue(event)
|
||||
const clear = () => {
|
||||
pointerScheduler.clear()
|
||||
updateHover(null)
|
||||
}
|
||||
const onPointerLeave = (event: PointerEvent) => {
|
||||
if (document.elementFromPoint(event.clientX, event.clientY) === canvas) return
|
||||
clear()
|
||||
}
|
||||
const onClick = (event: MouseEvent) => {
|
||||
const next = hoverRef.current
|
||||
if (!(next && event.button === 0) || useViewer.getState().cameraDragging) return
|
||||
event.preventDefault()
|
||||
event.stopImmediatePropagation()
|
||||
activateQuickMeasurementHudSource('3d')
|
||||
setPinned(next)
|
||||
}
|
||||
|
||||
canvas.addEventListener('pointermove', onPointerMove, true)
|
||||
canvas.addEventListener('pointerleave', onPointerLeave, true)
|
||||
canvas.addEventListener('click', onClick, true)
|
||||
return () => {
|
||||
canvas.removeEventListener('pointermove', onPointerMove, true)
|
||||
canvas.removeEventListener('pointerleave', onPointerLeave, true)
|
||||
canvas.removeEventListener('click', onClick, true)
|
||||
pointerScheduler.clear()
|
||||
}
|
||||
}, [buildingId, camera, gl, levelId, surfaceQuery])
|
||||
|
||||
const pinnedPreview = useMemo(() => {
|
||||
if (!levelId) return null
|
||||
const levelObject = sceneRegistry.nodes.get(levelId) as Group | undefined
|
||||
if (!levelObject) return null
|
||||
const buildingObject = buildingId
|
||||
? ((sceneRegistry.nodes.get(buildingId as AnyNodeId) as Group | undefined) ?? null)
|
||||
: null
|
||||
levelObject.updateWorldMatrix(true, false)
|
||||
buildingObject?.updateWorldMatrix(true, false)
|
||||
return pinned && pinnedReport
|
||||
? {
|
||||
normal: localNormalToBuildingFrame(levelObject, buildingObject, pinned.normal),
|
||||
position: localPointToBuildingFrame(levelObject, buildingObject, pinned.point),
|
||||
}
|
||||
: null
|
||||
}, [buildingId, levelId, pinned, pinnedReport])
|
||||
|
||||
const activeHit = hoverReport ? hoverRef.current : pinnedReport ? pinned : null
|
||||
const report = hoverReport ?? pinnedReport
|
||||
const lensState =
|
||||
pinnedReport && activeHit?.targetNodeId === pinned?.targetNodeId
|
||||
? ('pinned' as const)
|
||||
: ('live' as const)
|
||||
|
||||
useEffect(() => {
|
||||
publishQuickMeasurementHudSource('3d', report ? { lensState, report } : null)
|
||||
}, [lensState, report])
|
||||
|
||||
useEffect(() => () => clearQuickMeasurementHudSource('3d'), [])
|
||||
|
||||
return (
|
||||
<group>
|
||||
{pinnedPreview ? (
|
||||
<SmartSurfaceMarker
|
||||
normal={pinnedPreview.normal}
|
||||
pinned
|
||||
position={pinnedPreview.position}
|
||||
/>
|
||||
) : null}
|
||||
<SmartSurfaceMarker markerRef={hoverMarkerRef} pinned={false} />
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
export default SmartMeasurementTool
|
||||
@@ -0,0 +1,188 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test'
|
||||
import { useScene } from '@pascal-app/core'
|
||||
import {
|
||||
DoubleSide,
|
||||
Group,
|
||||
Mesh,
|
||||
MeshBasicMaterial,
|
||||
PlaneGeometry,
|
||||
Raycaster,
|
||||
Vector3,
|
||||
} from 'three'
|
||||
import {
|
||||
castVisibleMeasurementSurface,
|
||||
selectClosestVerifiedAxisProjection,
|
||||
selectMeasurementSurfaceHit,
|
||||
} from './surface-query'
|
||||
|
||||
afterEach(() => {
|
||||
useScene.setState({ nodes: {} } as never)
|
||||
})
|
||||
|
||||
function createSurface(z: number) {
|
||||
const surface = new Mesh(new PlaneGeometry(2, 2), new MeshBasicMaterial({ side: DoubleSide }))
|
||||
surface.position.z = z
|
||||
return surface
|
||||
}
|
||||
|
||||
describe('smart measurement surface priority', () => {
|
||||
test('prefers a zone over a nearly coplanar slab', () => {
|
||||
const root = new Group()
|
||||
const slab = createSurface(0.04)
|
||||
const zone = createSurface(0)
|
||||
root.add(slab, zone)
|
||||
root.updateMatrixWorld(true)
|
||||
useScene.setState({
|
||||
nodes: {
|
||||
slab_1: { type: 'slab' },
|
||||
zone_1: { type: 'zone' },
|
||||
},
|
||||
} as never)
|
||||
|
||||
const hit = castVisibleMeasurementSurface(
|
||||
new Raycaster(new Vector3(0, 0, 1), new Vector3(0, 0, -1)),
|
||||
{
|
||||
includeZoneLayer: true,
|
||||
ownerByObject: new Map([
|
||||
[slab, 'slab_1'],
|
||||
[zone, 'zone_1'],
|
||||
]),
|
||||
roots: [slab, zone],
|
||||
},
|
||||
)
|
||||
|
||||
expect(hit?.targetNodeId).toBe('zone_1')
|
||||
slab.geometry.dispose()
|
||||
slab.material.dispose()
|
||||
zone.geometry.dispose()
|
||||
zone.material.dispose()
|
||||
})
|
||||
|
||||
test('keeps a wall even when the zone is nearly coplanar', () => {
|
||||
const root = new Group()
|
||||
const wall = createSurface(0.04)
|
||||
const zone = createSurface(0)
|
||||
root.add(wall, zone)
|
||||
root.updateMatrixWorld(true)
|
||||
useScene.setState({
|
||||
nodes: {
|
||||
wall_1: { type: 'wall' },
|
||||
zone_1: { type: 'zone' },
|
||||
},
|
||||
} as never)
|
||||
|
||||
const hit = castVisibleMeasurementSurface(
|
||||
new Raycaster(new Vector3(0, 0, 1), new Vector3(0, 0, -1)),
|
||||
{
|
||||
includeZoneLayer: true,
|
||||
ownerByObject: new Map([
|
||||
[wall, 'wall_1'],
|
||||
[zone, 'zone_1'],
|
||||
]),
|
||||
roots: [wall, zone],
|
||||
},
|
||||
)
|
||||
|
||||
expect(hit?.targetNodeId).toBe('wall_1')
|
||||
wall.geometry.dispose()
|
||||
wall.material.dispose()
|
||||
zone.geometry.dispose()
|
||||
zone.material.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('measurement axis acquisition', () => {
|
||||
test('acquires a verified axis within sixteen screen pixels', () => {
|
||||
expect(
|
||||
selectClosestVerifiedAxisProjection([
|
||||
{
|
||||
axis: 'z',
|
||||
point: [1, 0, 7],
|
||||
screenDistance: 15,
|
||||
verified: true,
|
||||
},
|
||||
]),
|
||||
).toEqual({ axis: 'z', point: [1, 0, 7] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('polygon measurement surface intent', () => {
|
||||
test('prefers a nearby floor at a wall corner but keeps a deliberate wall-face pick', () => {
|
||||
const level = new Group()
|
||||
const material = new MeshBasicMaterial({ side: DoubleSide })
|
||||
const wall = new Mesh(new PlaneGeometry(4, 4), material)
|
||||
const slab = new Mesh(new PlaneGeometry(16, 16), material)
|
||||
wall.position.z = 0.1
|
||||
slab.rotation.x = -Math.PI / 2
|
||||
level.add(wall, slab)
|
||||
level.updateMatrixWorld(true)
|
||||
useScene.setState({
|
||||
nodes: {
|
||||
wall_1: { type: 'wall' },
|
||||
slab_1: { type: 'slab' },
|
||||
},
|
||||
} as never)
|
||||
|
||||
const hitsFor = (target: Vector3) =>
|
||||
new Raycaster(
|
||||
new Vector3(0, 1, 2),
|
||||
target
|
||||
.clone()
|
||||
.sub(new Vector3(0, 1, 2))
|
||||
.normalize(),
|
||||
)
|
||||
.intersectObjects([wall, slab])
|
||||
.map((intersection) => ({
|
||||
intersection,
|
||||
targetNodeId: intersection.object === wall ? 'wall_1' : 'slab_1',
|
||||
}))
|
||||
|
||||
const cornerHits = hitsFor(new Vector3(0, 0, 0))
|
||||
expect(cornerHits[0]?.targetNodeId).toBe('wall_1')
|
||||
expect(
|
||||
selectMeasurementSurfaceHit(cornerHits, level, { kind: 'horizontal' })?.targetNodeId,
|
||||
).toBe('slab_1')
|
||||
expect(
|
||||
selectMeasurementSurfaceHit(cornerHits, level, {
|
||||
kind: 'plane',
|
||||
point: [0, 0, 0],
|
||||
normal: [0, 1, 0],
|
||||
})?.targetNodeId,
|
||||
).toBe('slab_1')
|
||||
|
||||
const wallFaceHits = hitsFor(new Vector3(0, 0.7, 0))
|
||||
expect(
|
||||
selectMeasurementSurfaceHit(wallFaceHits, level, { kind: 'horizontal' })?.targetNodeId,
|
||||
).toBe('wall_1')
|
||||
const tableTop = new Mesh(new PlaneGeometry(4, 4), material)
|
||||
tableTop.position.y = 0.3
|
||||
tableTop.rotation.x = -Math.PI / 2
|
||||
level.add(tableTop)
|
||||
level.updateMatrixWorld(true)
|
||||
useScene.setState({
|
||||
nodes: { ...useScene.getState().nodes, item_1: { type: 'item' } },
|
||||
} as never)
|
||||
const horizontalOccluderHits = new Raycaster(
|
||||
new Vector3(0, 1, 2),
|
||||
new Vector3(0, -1, -2).normalize(),
|
||||
)
|
||||
.intersectObjects([wall, slab, tableTop])
|
||||
.map((intersection) => ({
|
||||
intersection,
|
||||
targetNodeId:
|
||||
intersection.object === wall
|
||||
? 'wall_1'
|
||||
: intersection.object === slab
|
||||
? 'slab_1'
|
||||
: 'item_1',
|
||||
}))
|
||||
expect(
|
||||
selectMeasurementSurfaceHit(horizontalOccluderHits, level, { kind: 'horizontal' })
|
||||
?.targetNodeId,
|
||||
).toBe('item_1')
|
||||
wall.geometry.dispose()
|
||||
slab.geometry.dispose()
|
||||
tableTop.geometry.dispose()
|
||||
material.dispose()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,855 @@
|
||||
import {
|
||||
type AlignmentAnchor,
|
||||
type AnyNodeId,
|
||||
type MeasurementFeatureAnchor,
|
||||
type MeasurementSnapKind,
|
||||
measurementDistance,
|
||||
measurementFeatureLength,
|
||||
sceneRegistry,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import type { MeasurementAxis, MeasurementAxisGuide, MeasurementPoint } from '@pascal-app/editor'
|
||||
import { SCENE_LAYER, ZONE_LAYER } from '@pascal-app/viewer'
|
||||
import {
|
||||
type Camera,
|
||||
type InstancedMesh,
|
||||
type Intersection,
|
||||
type Material,
|
||||
Matrix3,
|
||||
Matrix4,
|
||||
type Object3D,
|
||||
Quaternion,
|
||||
Raycaster,
|
||||
Vector2,
|
||||
Vector3,
|
||||
} from 'three'
|
||||
import { matchMeasurementFeatureForNode } from './resolve'
|
||||
|
||||
const AXIS_SNAP_DISTANCE_PX = 16
|
||||
const AXIS_SNAP_RELEASE_DISTANCE_PX = 24
|
||||
const PROXIMITY_GUIDE_DISTANCE_PX = 40
|
||||
const VERTEX_HANDLE_DISTANCE_PX = 12
|
||||
const SURFACE_VERIFY_HALF_SPAN = 0.08
|
||||
const SURFACE_VERIFY_TOLERANCE = 0.012
|
||||
const SEMANTIC_FEATURE_SNAP_DISTANCE = 0.2
|
||||
const AXIS_INTERSECTION_MIN_DISTANCE = 0.05
|
||||
const MAX_AXIS_INTERSECTIONS_PER_DIRECTION = 4
|
||||
const UNREGISTERED_ROOT_REFRESH_MS = 500
|
||||
const ZONE_SURFACE_PRIORITY_DISTANCE = 0.08
|
||||
const SURFACE_INTENT_MAX_OCCLUSION_DISTANCE = 0.45
|
||||
const SURFACE_INTENT_MIN_NORMAL_ALIGNMENT = 0.94
|
||||
const SURFACE_INTENT_PLANE_TOLERANCE = 0.05
|
||||
const HORIZONTAL_SURFACE_MIN_NORMAL_Y = 0.85
|
||||
const HORIZONTAL_SURFACE_MAX_OCCLUDER_NORMAL_Y = 0.5
|
||||
const HORIZONTAL_SURFACE_TYPES = new Set(['slab', 'ceiling', 'site'])
|
||||
|
||||
export type MeasurementRaycastContext = {
|
||||
ownerByObject: Map<Object3D, string>
|
||||
roots: Object3D[]
|
||||
includeZoneLayer?: boolean
|
||||
}
|
||||
|
||||
export type WorldSurfaceHit = {
|
||||
intersection: Intersection<Object3D>
|
||||
targetNodeId: string | null
|
||||
}
|
||||
|
||||
export type LocalSurfaceHit = {
|
||||
point: MeasurementPoint
|
||||
normal: MeasurementPoint
|
||||
targetNodeId: string | null
|
||||
}
|
||||
|
||||
export type MeasurementSurfacePreference =
|
||||
| { kind: 'horizontal' }
|
||||
| { kind: 'plane'; point: MeasurementPoint; normal: MeasurementPoint }
|
||||
|
||||
export type MeasurementAxisProjection = {
|
||||
axis: MeasurementAxis
|
||||
point: MeasurementPoint
|
||||
}
|
||||
|
||||
export type MeasurementAxisSurfaceIntersection = {
|
||||
axis: MeasurementAxis
|
||||
normal: MeasurementPoint
|
||||
point: MeasurementPoint
|
||||
}
|
||||
|
||||
export type MeasurementAxisCandidate = MeasurementAxisProjection & {
|
||||
anchor?: MeasurementPoint
|
||||
proximity?: boolean
|
||||
screenDistance: number
|
||||
verified: boolean
|
||||
}
|
||||
|
||||
export type MeasurementSurfaceQuerySession = {
|
||||
resolvePointer(args: {
|
||||
event: MouseEvent | PointerEvent
|
||||
camera: Camera
|
||||
canvas: HTMLCanvasElement
|
||||
levelObject: Object3D
|
||||
anchorOrAnchors: MeasurementPoint | readonly MeasurementPoint[] | null
|
||||
lockedGuide?: MeasurementAxisGuide | null
|
||||
planarProximityAnchors?: readonly AlignmentAnchor[]
|
||||
surfacePreference?: MeasurementSurfacePreference | null
|
||||
applyMagneticSnap: boolean
|
||||
showAlignmentGuides: boolean
|
||||
}): { hit: LocalSurfaceHit; guide: MeasurementAxisGuide | null } | null
|
||||
collectAxisIntersections(args: {
|
||||
levelObject: Object3D
|
||||
anchor: MeasurementPoint
|
||||
maxDistance?: number
|
||||
}): MeasurementAxisSurfaceIntersection[]
|
||||
invalidate(): void
|
||||
dispose(): void
|
||||
}
|
||||
|
||||
function areSameMeasurementPoint(
|
||||
first: MeasurementPoint | undefined,
|
||||
second: MeasurementPoint | null,
|
||||
): boolean {
|
||||
return Boolean(
|
||||
first &&
|
||||
second &&
|
||||
Math.abs(first[0] - second[0]) <= 1e-9 &&
|
||||
Math.abs(first[1] - second[1]) <= 1e-9 &&
|
||||
Math.abs(first[2] - second[2]) <= 1e-9,
|
||||
)
|
||||
}
|
||||
|
||||
function selectClosestAxisCandidate<T extends MeasurementAxisCandidate>(
|
||||
candidates: readonly T[],
|
||||
threshold: number,
|
||||
lockedAxis: MeasurementAxis | null,
|
||||
releaseThreshold: number,
|
||||
lockedFrom: MeasurementPoint | null = null,
|
||||
): T | null {
|
||||
if (lockedAxis) {
|
||||
const locked = candidates.reduce<T | null>((closest, candidate) => {
|
||||
if (
|
||||
candidate.axis !== lockedAxis ||
|
||||
(lockedFrom && !areSameMeasurementPoint(candidate.anchor, lockedFrom)) ||
|
||||
!candidate.verified ||
|
||||
candidate.screenDistance > releaseThreshold
|
||||
) {
|
||||
return closest
|
||||
}
|
||||
return !closest || candidate.screenDistance < closest.screenDistance ? candidate : closest
|
||||
}, null)
|
||||
if (locked) return locked
|
||||
}
|
||||
|
||||
return candidates.reduce<T | null>((closest, candidate) => {
|
||||
if (!candidate.verified || candidate.screenDistance > threshold) return closest
|
||||
return !closest || candidate.screenDistance < closest.screenDistance ? candidate : closest
|
||||
}, null)
|
||||
}
|
||||
|
||||
export function projectMeasurementPointToAxes(
|
||||
anchor: MeasurementPoint,
|
||||
point: MeasurementPoint,
|
||||
): MeasurementAxisProjection[] {
|
||||
return [
|
||||
{ axis: 'x', point: [point[0], anchor[1], anchor[2]] },
|
||||
{ axis: 'y', point: [anchor[0], point[1], anchor[2]] },
|
||||
{ axis: 'z', point: [anchor[0], anchor[1], point[2]] },
|
||||
]
|
||||
}
|
||||
|
||||
export function projectMeasurementPointToPlanarAxes(
|
||||
anchor: MeasurementPoint,
|
||||
point: MeasurementPoint,
|
||||
): MeasurementAxisProjection[] {
|
||||
return projectMeasurementPointToAxes(anchor, point).filter(
|
||||
(candidate) => candidate.axis === 'x' || candidate.axis === 'z',
|
||||
)
|
||||
}
|
||||
|
||||
export function selectClosestVerifiedAxisProjection(
|
||||
candidates: readonly MeasurementAxisCandidate[],
|
||||
threshold = AXIS_SNAP_DISTANCE_PX,
|
||||
lockedAxis: MeasurementAxis | null = null,
|
||||
releaseThreshold = AXIS_SNAP_RELEASE_DISTANCE_PX,
|
||||
lockedFrom: MeasurementPoint | null = null,
|
||||
): MeasurementAxisProjection | null {
|
||||
const closest = selectClosestAxisCandidate(
|
||||
candidates,
|
||||
threshold,
|
||||
lockedAxis,
|
||||
releaseThreshold,
|
||||
lockedFrom,
|
||||
)
|
||||
return closest ? { axis: closest.axis, point: [...closest.point] } : null
|
||||
}
|
||||
|
||||
export function selectAxisCandidateForSurfaceVerification<T extends MeasurementAxisCandidate>(
|
||||
candidates: readonly T[],
|
||||
threshold = AXIS_SNAP_DISTANCE_PX,
|
||||
lockedAxis: MeasurementAxis | null = null,
|
||||
releaseThreshold = AXIS_SNAP_RELEASE_DISTANCE_PX,
|
||||
lockedFrom: MeasurementPoint | null = null,
|
||||
): T | null {
|
||||
if (lockedAxis) {
|
||||
const locked = candidates.reduce<T | null>((closest, candidate) => {
|
||||
if (
|
||||
candidate.axis !== lockedAxis ||
|
||||
(lockedFrom && !areSameMeasurementPoint(candidate.anchor, lockedFrom)) ||
|
||||
candidate.screenDistance > releaseThreshold
|
||||
) {
|
||||
return closest
|
||||
}
|
||||
return !closest || candidate.screenDistance < closest.screenDistance ? candidate : closest
|
||||
}, null)
|
||||
if (locked) return locked
|
||||
}
|
||||
|
||||
return candidates.reduce<T | null>((closest, candidate) => {
|
||||
if (candidate.screenDistance > threshold) return closest
|
||||
return !closest || candidate.screenDistance < closest.screenDistance ? candidate : closest
|
||||
}, null)
|
||||
}
|
||||
|
||||
export function measurementVertexSnapAnchors(
|
||||
points: readonly MeasurementPoint[],
|
||||
index: number,
|
||||
polygon: boolean,
|
||||
): MeasurementPoint[] {
|
||||
if (!Number.isInteger(index) || index < 0 || index >= points.length || points.length < 2) {
|
||||
return []
|
||||
}
|
||||
const neighborIndices =
|
||||
polygon && points.length >= 3
|
||||
? [(index - 1 + points.length) % points.length, (index + 1) % points.length]
|
||||
: [index - 1, index + 1]
|
||||
return Array.from(new Set(neighborIndices))
|
||||
.filter(
|
||||
(neighborIndex) =>
|
||||
neighborIndex >= 0 && neighborIndex < points.length && neighborIndex !== index,
|
||||
)
|
||||
.map((neighborIndex) => [...points[neighborIndex]!] as MeasurementPoint)
|
||||
}
|
||||
|
||||
export function selectClosestMeasurementVertexIndex(
|
||||
screenDistances: readonly number[],
|
||||
threshold = VERTEX_HANDLE_DISTANCE_PX,
|
||||
): number | null {
|
||||
let closestIndex: number | null = null
|
||||
let closestDistance = threshold
|
||||
for (let index = 0; index < screenDistances.length; index += 1) {
|
||||
const distance = screenDistances[index]!
|
||||
if (!Number.isFinite(distance) || distance > closestDistance) continue
|
||||
closestDistance = distance
|
||||
closestIndex = index
|
||||
}
|
||||
return closestIndex
|
||||
}
|
||||
|
||||
function isEffectivelyVisible(object: Object3D): boolean {
|
||||
let current: Object3D | null = object
|
||||
while (current) {
|
||||
if (!current.visible) return false
|
||||
current = current.parent
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export function isMeasurementSurfaceMaterialVisible(object: Object3D, materialIndex = 0): boolean {
|
||||
const material = (object as Object3D & { material?: Material | Material[] }).material
|
||||
if (!material) return true
|
||||
const hitMaterial = Array.isArray(material) ? material[materialIndex] : material
|
||||
return Boolean(
|
||||
hitMaterial?.visible &&
|
||||
hitMaterial.opacity > 0.001 &&
|
||||
hitMaterial.colorWrite &&
|
||||
hitMaterial.depthTest,
|
||||
)
|
||||
}
|
||||
|
||||
function isMeasurementSurfaceEligible(object: Object3D): boolean {
|
||||
let current: Object3D | null = object
|
||||
while (current) {
|
||||
if (current.userData.measurementSurface === false) return false
|
||||
current = current.parent
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function nearestRegisteredOwner(
|
||||
object: Object3D,
|
||||
ownerByObject: Map<Object3D, string>,
|
||||
): string | null {
|
||||
let current: Object3D | null = object
|
||||
while (current) {
|
||||
const owner = ownerByObject.get(current)
|
||||
if (owner) return owner
|
||||
current = current.parent
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function collectMeasurementSurfaceRoots(
|
||||
scene: Object3D,
|
||||
registeredRoots: readonly Object3D[],
|
||||
): Object3D[] {
|
||||
const roots = [...registeredRoots]
|
||||
scene.traverse((object) => {
|
||||
if (object.userData.measurementSurface !== true) return
|
||||
let ancestor: Object3D | null = object
|
||||
while (ancestor) {
|
||||
if (roots.includes(ancestor)) return
|
||||
ancestor = ancestor.parent
|
||||
}
|
||||
roots.push(object)
|
||||
})
|
||||
return roots
|
||||
}
|
||||
|
||||
export function createMeasurementRaycastContext(
|
||||
scene: Object3D,
|
||||
options: { includeZoneLayer?: boolean } = {},
|
||||
): MeasurementRaycastContext {
|
||||
const entries = Array.from(sceneRegistry.nodes.entries())
|
||||
const ownerByObject = new Map(entries.map(([id, object]) => [object, id]))
|
||||
const registeredObjects = new Set(entries.map(([, object]) => object))
|
||||
const nodes = useScene.getState().nodes as Record<string, { type: string } | undefined>
|
||||
const registeredRoots = entries
|
||||
.filter(([id, object]) => {
|
||||
const node = nodes[id]
|
||||
if (!node || node.type === 'measurement' || node.type === 'guide' || node.type === 'scan') {
|
||||
return false
|
||||
}
|
||||
if (!isEffectivelyVisible(object)) return false
|
||||
|
||||
let parent = object.parent
|
||||
while (parent) {
|
||||
if (registeredObjects.has(parent)) return false
|
||||
parent = parent.parent
|
||||
}
|
||||
return true
|
||||
})
|
||||
.map(([, object]) => object)
|
||||
|
||||
return {
|
||||
ownerByObject,
|
||||
roots: collectMeasurementSurfaceRoots(scene, registeredRoots),
|
||||
includeZoneLayer: options.includeZoneLayer,
|
||||
}
|
||||
}
|
||||
|
||||
export function castVisibleMeasurementSurface(
|
||||
raycaster: Raycaster,
|
||||
context: MeasurementRaycastContext,
|
||||
): WorldSurfaceHit | null {
|
||||
const hits = collectVisibleMeasurementSurfaceHits(raycaster, context)
|
||||
const nearest = hits[0] ?? null
|
||||
if (!(context.includeZoneLayer && nearest)) return nearest
|
||||
|
||||
const nodes = useScene.getState().nodes as Record<string, { type: string } | undefined>
|
||||
const nearestType = nearest.targetNodeId ? nodes[nearest.targetNodeId]?.type : undefined
|
||||
if (nearestType !== 'slab') return nearest
|
||||
return (
|
||||
hits.find(
|
||||
(hit) =>
|
||||
hit.targetNodeId !== null &&
|
||||
nodes[hit.targetNodeId]?.type === 'zone' &&
|
||||
hit.intersection.distance <= nearest.intersection.distance + ZONE_SURFACE_PRIORITY_DISTANCE,
|
||||
) ?? nearest
|
||||
)
|
||||
}
|
||||
|
||||
function collectVisibleMeasurementSurfaceHits(
|
||||
raycaster: Raycaster,
|
||||
context: MeasurementRaycastContext,
|
||||
): WorldSurfaceHit[] {
|
||||
const nodes = useScene.getState().nodes as Record<string, { type: string } | undefined>
|
||||
const intersections = raycaster.intersectObjects(context.roots, true)
|
||||
const hits: WorldSurfaceHit[] = []
|
||||
for (const intersection of intersections) {
|
||||
if (!intersection.face) continue
|
||||
const targetNodeId = nearestRegisteredOwner(intersection.object, context.ownerByObject)
|
||||
const targetType = targetNodeId ? nodes[targetNodeId]?.type : undefined
|
||||
if (
|
||||
!isEffectivelyVisible(intersection.object) ||
|
||||
!isMeasurementSurfaceEligible(intersection.object) ||
|
||||
(!isMeasurementSurfaceMaterialVisible(intersection.object, intersection.face.materialIndex) &&
|
||||
!(context.includeZoneLayer && targetType === 'zone'))
|
||||
) {
|
||||
continue
|
||||
}
|
||||
if (targetNodeId) {
|
||||
if (
|
||||
!targetType ||
|
||||
targetType === 'measurement' ||
|
||||
targetType === 'guide' ||
|
||||
targetType === 'scan'
|
||||
) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
hits.push({ intersection, targetNodeId })
|
||||
}
|
||||
return hits
|
||||
}
|
||||
|
||||
export function measurementIntersectionWorldNormal(intersection: Intersection<Object3D>): Vector3 {
|
||||
const object = intersection.object
|
||||
const worldMatrix = object.matrixWorld.clone()
|
||||
const instancedMesh = object as InstancedMesh
|
||||
if (instancedMesh.isInstancedMesh && intersection.instanceId !== undefined) {
|
||||
const instanceMatrix = new Matrix4()
|
||||
instancedMesh.getMatrixAt(intersection.instanceId, instanceMatrix)
|
||||
worldMatrix.multiply(instanceMatrix)
|
||||
}
|
||||
return intersection
|
||||
.face!.normal.clone()
|
||||
.applyNormalMatrix(new Matrix3().getNormalMatrix(worldMatrix))
|
||||
.normalize()
|
||||
}
|
||||
|
||||
function toLocalSurfaceHit(hit: WorldSurfaceHit, levelObject: Object3D): LocalSurfaceHit {
|
||||
hit.intersection.object.updateWorldMatrix(true, false)
|
||||
levelObject.updateWorldMatrix(true, false)
|
||||
|
||||
const point = levelObject.worldToLocal(hit.intersection.point.clone())
|
||||
const normal = measurementIntersectionWorldNormal(hit.intersection)
|
||||
const inverseLevelRotation = levelObject.getWorldQuaternion(new Quaternion()).invert()
|
||||
normal.applyQuaternion(inverseLevelRotation).normalize()
|
||||
|
||||
return {
|
||||
point: [point.x, point.y, point.z],
|
||||
normal: [normal.x, normal.y, normal.z],
|
||||
targetNodeId: hit.targetNodeId,
|
||||
}
|
||||
}
|
||||
|
||||
export function selectMeasurementSurfaceHit(
|
||||
hits: readonly WorldSurfaceHit[],
|
||||
levelObject: Object3D,
|
||||
preference: MeasurementSurfacePreference | null,
|
||||
): WorldSurfaceHit | null {
|
||||
const nearest = hits[0] ?? null
|
||||
if (!(nearest && preference)) return nearest
|
||||
|
||||
const nearby = hits.filter(
|
||||
(hit) =>
|
||||
hit.intersection.distance <=
|
||||
nearest.intersection.distance + SURFACE_INTENT_MAX_OCCLUSION_DISTANCE,
|
||||
)
|
||||
if (preference.kind === 'horizontal') {
|
||||
if (
|
||||
Math.abs(toLocalSurfaceHit(nearest, levelObject).normal[1]) >=
|
||||
HORIZONTAL_SURFACE_MAX_OCCLUDER_NORMAL_Y
|
||||
) {
|
||||
return nearest
|
||||
}
|
||||
const nodes = useScene.getState().nodes as Record<string, { type: string } | undefined>
|
||||
return (
|
||||
nearby.find((hit) => {
|
||||
const type = hit.targetNodeId ? nodes[hit.targetNodeId]?.type : undefined
|
||||
return (
|
||||
Boolean(type && HORIZONTAL_SURFACE_TYPES.has(type)) &&
|
||||
Math.abs(toLocalSurfaceHit(hit, levelObject).normal[1]) >= HORIZONTAL_SURFACE_MIN_NORMAL_Y
|
||||
)
|
||||
}) ?? nearest
|
||||
)
|
||||
}
|
||||
|
||||
const preferredNormal = new Vector3(...preference.normal)
|
||||
if (preferredNormal.lengthSq() <= 1e-12) return nearest
|
||||
preferredNormal.normalize()
|
||||
const preferredPoint = new Vector3(...preference.point)
|
||||
return (
|
||||
nearby.find((hit) => {
|
||||
const localHit = toLocalSurfaceHit(hit, levelObject)
|
||||
const normalAlignment = Math.abs(preferredNormal.dot(new Vector3(...localHit.normal)))
|
||||
const planeDistance = Math.abs(
|
||||
new Vector3(...localHit.point).sub(preferredPoint).dot(preferredNormal),
|
||||
)
|
||||
return (
|
||||
normalAlignment >= SURFACE_INTENT_MIN_NORMAL_ALIGNMENT &&
|
||||
planeDistance <= SURFACE_INTENT_PLANE_TOLERANCE
|
||||
)
|
||||
}) ?? nearest
|
||||
)
|
||||
}
|
||||
|
||||
function setRayFromPointer(
|
||||
raycaster: Raycaster,
|
||||
pointer: Vector2,
|
||||
event: MouseEvent | PointerEvent,
|
||||
camera: Camera,
|
||||
canvas: HTMLCanvasElement,
|
||||
) {
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
pointer.set(
|
||||
((event.clientX - rect.left) / rect.width) * 2 - 1,
|
||||
-((event.clientY - rect.top) / rect.height) * 2 + 1,
|
||||
)
|
||||
raycaster.setFromCamera(pointer, camera)
|
||||
}
|
||||
|
||||
export function worldPointScreenDistance(
|
||||
point: Vector3,
|
||||
event: MouseEvent | PointerEvent,
|
||||
camera: Camera,
|
||||
canvas: HTMLCanvasElement,
|
||||
): number {
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const projected = point.clone().project(camera)
|
||||
if (!Number.isFinite(projected.z) || projected.z < -1 || projected.z > 1) {
|
||||
return Number.POSITIVE_INFINITY
|
||||
}
|
||||
const x = rect.left + ((projected.x + 1) / 2) * rect.width
|
||||
const y = rect.top + ((1 - projected.y) / 2) * rect.height
|
||||
return Math.hypot(event.clientX - x, event.clientY - y)
|
||||
}
|
||||
|
||||
function axisGuideToPoint(
|
||||
axis: MeasurementAxis,
|
||||
from: MeasurementPoint,
|
||||
point: MeasurementPoint,
|
||||
snapped: boolean,
|
||||
proximity = false,
|
||||
): MeasurementAxisGuide {
|
||||
const to: MeasurementPoint = [...from]
|
||||
const index = axis === 'x' ? 0 : axis === 'y' ? 1 : 2
|
||||
to[index] = point[index]
|
||||
return { axis, from: [...from], to, snapped, ...(proximity ? { proximity: true } : {}) }
|
||||
}
|
||||
|
||||
function verifyProjectedSurfacePoint(
|
||||
candidateWorld: Vector3,
|
||||
surfaceNormalWorld: Vector3,
|
||||
raycaster: Raycaster,
|
||||
context: MeasurementRaycastContext,
|
||||
): WorldSurfaceHit | null {
|
||||
for (const sign of [-1, 1] as const) {
|
||||
const direction = surfaceNormalWorld.clone().multiplyScalar(-sign)
|
||||
raycaster.set(
|
||||
candidateWorld.clone().addScaledVector(surfaceNormalWorld, SURFACE_VERIFY_HALF_SPAN * sign),
|
||||
direction,
|
||||
)
|
||||
raycaster.near = 0
|
||||
raycaster.far = SURFACE_VERIFY_HALF_SPAN * 2
|
||||
const hit = castVisibleMeasurementSurface(raycaster, context)
|
||||
if (hit && hit.intersection.point.distanceTo(candidateWorld) <= SURFACE_VERIFY_TOLERANCE) {
|
||||
return hit
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function resolveSurfacePoint(
|
||||
args: {
|
||||
event: MouseEvent | PointerEvent
|
||||
camera: Camera
|
||||
canvas: HTMLCanvasElement
|
||||
levelObject: Object3D
|
||||
anchorOrAnchors: MeasurementPoint | readonly MeasurementPoint[] | null
|
||||
lockedGuide: MeasurementAxisGuide | null
|
||||
planarProximityAnchors: readonly AlignmentAnchor[]
|
||||
surfacePreference: MeasurementSurfacePreference | null
|
||||
applyMagneticSnap: boolean
|
||||
showAlignmentGuides: boolean
|
||||
},
|
||||
context: MeasurementRaycastContext,
|
||||
pointerRaycaster: Raycaster,
|
||||
verificationRaycaster: Raycaster,
|
||||
pointer: Vector2,
|
||||
): { hit: LocalSurfaceHit; guide: MeasurementAxisGuide | null } | null {
|
||||
if (context.roots.length === 0) return null
|
||||
|
||||
pointerRaycaster.near = 0
|
||||
pointerRaycaster.far = Number.POSITIVE_INFINITY
|
||||
setRayFromPointer(pointerRaycaster, pointer, args.event, args.camera, args.canvas)
|
||||
const rawWorldHit = args.surfacePreference
|
||||
? selectMeasurementSurfaceHit(
|
||||
collectVisibleMeasurementSurfaceHits(pointerRaycaster, context),
|
||||
args.levelObject,
|
||||
args.surfacePreference,
|
||||
)
|
||||
: castVisibleMeasurementSurface(pointerRaycaster, context)
|
||||
if (!rawWorldHit) return null
|
||||
const rawHit = toLocalSurfaceHit(rawWorldHit, args.levelObject)
|
||||
const anchors: readonly MeasurementPoint[] = !args.anchorOrAnchors
|
||||
? []
|
||||
: typeof args.anchorOrAnchors[0] === 'number'
|
||||
? [args.anchorOrAnchors as MeasurementPoint]
|
||||
: (args.anchorOrAnchors as readonly MeasurementPoint[])
|
||||
const supportsPlanarProximity = Math.abs(rawHit.normal[1]) >= 0.65
|
||||
if (
|
||||
(!args.showAlignmentGuides && !args.applyMagneticSnap) ||
|
||||
(anchors.length === 0 && (!supportsPlanarProximity || args.planarProximityAnchors.length === 0))
|
||||
) {
|
||||
return { hit: rawHit, guide: null }
|
||||
}
|
||||
|
||||
args.levelObject.updateWorldMatrix(true, false)
|
||||
const levelRotation = args.levelObject.getWorldQuaternion(new Quaternion())
|
||||
const rawNormalWorld = new Vector3(...rawHit.normal).applyQuaternion(levelRotation).normalize()
|
||||
const projectedCandidates = [
|
||||
...anchors.flatMap((anchor) =>
|
||||
projectMeasurementPointToAxes(anchor, rawHit.point).map((candidate) => ({
|
||||
...candidate,
|
||||
anchor,
|
||||
proximity: false,
|
||||
})),
|
||||
),
|
||||
...(supportsPlanarProximity
|
||||
? args.planarProximityAnchors.flatMap((proximityAnchor) => {
|
||||
const anchor: MeasurementPoint = [proximityAnchor.x, rawHit.point[1], proximityAnchor.z]
|
||||
return projectMeasurementPointToPlanarAxes(anchor, rawHit.point).map((candidate) => ({
|
||||
...candidate,
|
||||
anchor,
|
||||
proximity: true,
|
||||
}))
|
||||
})
|
||||
: []),
|
||||
].map((candidate) => {
|
||||
const candidateWorld = args.levelObject.localToWorld(new Vector3(...candidate.point))
|
||||
return {
|
||||
...candidate,
|
||||
candidateWorld,
|
||||
screenDistance: worldPointScreenDistance(
|
||||
candidateWorld,
|
||||
args.event,
|
||||
args.camera,
|
||||
args.canvas,
|
||||
),
|
||||
verified: false,
|
||||
verifiedHit: null as WorldSurfaceHit | null,
|
||||
}
|
||||
})
|
||||
const lockedGuide = args.applyMagneticSnap ? args.lockedGuide : null
|
||||
if (args.applyMagneticSnap) {
|
||||
const candidateToVerify = selectAxisCandidateForSurfaceVerification(
|
||||
projectedCandidates,
|
||||
AXIS_SNAP_DISTANCE_PX,
|
||||
lockedGuide?.axis ?? null,
|
||||
AXIS_SNAP_RELEASE_DISTANCE_PX,
|
||||
lockedGuide?.from ?? null,
|
||||
)
|
||||
if (candidateToVerify) {
|
||||
const verifiedHit = verifyProjectedSurfacePoint(
|
||||
candidateToVerify.candidateWorld,
|
||||
rawNormalWorld,
|
||||
verificationRaycaster,
|
||||
{ ownerByObject: context.ownerByObject, roots: [rawWorldHit.intersection.object] },
|
||||
)
|
||||
candidateToVerify.verified = verifiedHit !== null
|
||||
candidateToVerify.verifiedHit = verifiedHit
|
||||
}
|
||||
}
|
||||
const selected = args.applyMagneticSnap
|
||||
? selectClosestAxisCandidate(
|
||||
projectedCandidates,
|
||||
AXIS_SNAP_DISTANCE_PX,
|
||||
lockedGuide?.axis ?? null,
|
||||
AXIS_SNAP_RELEASE_DISTANCE_PX,
|
||||
lockedGuide?.from ?? null,
|
||||
)
|
||||
: null
|
||||
|
||||
if (selected?.verifiedHit) {
|
||||
const surfaceHit = toLocalSurfaceHit(selected.verifiedHit, args.levelObject)
|
||||
const guide = axisGuideToPoint(
|
||||
selected.axis,
|
||||
selected.anchor!,
|
||||
selected.point,
|
||||
true,
|
||||
selected.proximity,
|
||||
)
|
||||
return {
|
||||
hit: { ...surfaceHit, point: [...selected.point] },
|
||||
guide: args.showAlignmentGuides ? guide : null,
|
||||
}
|
||||
}
|
||||
|
||||
const passive = projectedCandidates
|
||||
.filter(
|
||||
(candidate) =>
|
||||
!candidate.proximity || candidate.screenDistance <= PROXIMITY_GUIDE_DISTANCE_PX,
|
||||
)
|
||||
.reduce<(typeof projectedCandidates)[number] | null>(
|
||||
(closest, candidate) =>
|
||||
!closest || candidate.screenDistance < closest.screenDistance ? candidate : closest,
|
||||
null,
|
||||
)
|
||||
return {
|
||||
hit: rawHit,
|
||||
guide:
|
||||
args.showAlignmentGuides && passive
|
||||
? axisGuideToPoint(passive.axis, passive.anchor!, passive.point, false, passive.proximity)
|
||||
: null,
|
||||
}
|
||||
}
|
||||
|
||||
function collectMeasurementAxisSurfaceIntersections(
|
||||
context: MeasurementRaycastContext,
|
||||
levelObject: Object3D,
|
||||
anchor: MeasurementPoint,
|
||||
raycaster: Raycaster,
|
||||
maxDistance: number,
|
||||
): MeasurementAxisSurfaceIntersection[] {
|
||||
if (!(Number.isFinite(maxDistance) && maxDistance > AXIS_INTERSECTION_MIN_DISTANCE)) return []
|
||||
if (context.roots.length === 0) return []
|
||||
|
||||
levelObject.updateWorldMatrix(true, false)
|
||||
const origin = levelObject.localToWorld(new Vector3(...anchor))
|
||||
const levelRotation = levelObject.getWorldQuaternion(new Quaternion())
|
||||
const inverseLevelRotation = levelRotation.clone().invert()
|
||||
raycaster.layers.set(SCENE_LAYER)
|
||||
raycaster.near = 0
|
||||
raycaster.far = maxDistance
|
||||
const intersections: MeasurementAxisSurfaceIntersection[] = []
|
||||
|
||||
for (const axis of ['x', 'y', 'z'] as const) {
|
||||
const localDirection =
|
||||
axis === 'x'
|
||||
? new Vector3(1, 0, 0)
|
||||
: axis === 'y'
|
||||
? new Vector3(0, 1, 0)
|
||||
: new Vector3(0, 0, 1)
|
||||
for (const sign of [-1, 1] as const) {
|
||||
const direction = localDirection
|
||||
.clone()
|
||||
.multiplyScalar(sign)
|
||||
.applyQuaternion(levelRotation)
|
||||
.normalize()
|
||||
raycaster.set(
|
||||
origin.clone().addScaledVector(direction, AXIS_INTERSECTION_MIN_DISTANCE),
|
||||
direction,
|
||||
)
|
||||
const hits = collectVisibleMeasurementSurfaceHits(raycaster, context)
|
||||
let accepted = 0
|
||||
for (const hit of hits) {
|
||||
const worldDistance = hit.intersection.point.distanceTo(origin)
|
||||
if (
|
||||
worldDistance < AXIS_INTERSECTION_MIN_DISTANCE ||
|
||||
worldDistance > maxDistance + AXIS_INTERSECTION_MIN_DISTANCE
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const local = levelObject.worldToLocal(hit.intersection.point.clone())
|
||||
const point: MeasurementPoint = [local.x, local.y, local.z]
|
||||
if (
|
||||
intersections.some(
|
||||
(candidate) =>
|
||||
candidate.axis === axis && measurementDistance(candidate.point, point) < 0.025,
|
||||
)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const normal = measurementIntersectionWorldNormal(hit.intersection)
|
||||
.applyQuaternion(inverseLevelRotation)
|
||||
.normalize()
|
||||
intersections.push({ axis, point, normal: [normal.x, normal.y, normal.z] })
|
||||
accepted += 1
|
||||
if (accepted >= MAX_AXIS_INTERSECTIONS_PER_DIRECTION) break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return intersections
|
||||
}
|
||||
|
||||
export function createMeasurementSurfaceQuerySession(
|
||||
scene: Object3D,
|
||||
options: { includeZoneLayer?: boolean } = {},
|
||||
): MeasurementSurfaceQuerySession {
|
||||
const pointerRaycaster = new Raycaster()
|
||||
const verificationRaycaster = new Raycaster()
|
||||
const axisRaycaster = new Raycaster()
|
||||
const pointer = new Vector2()
|
||||
pointerRaycaster.layers.set(SCENE_LAYER)
|
||||
verificationRaycaster.layers.set(SCENE_LAYER)
|
||||
axisRaycaster.layers.set(SCENE_LAYER)
|
||||
if (options.includeZoneLayer) pointerRaycaster.layers.enable(ZONE_LAYER)
|
||||
|
||||
let context: MeasurementRaycastContext | null = null
|
||||
let revision = -1
|
||||
let refreshedAt = Number.NEGATIVE_INFINITY
|
||||
|
||||
const getContext = () => {
|
||||
const now = performance.now()
|
||||
if (
|
||||
!context ||
|
||||
revision !== sceneRegistry.revision ||
|
||||
now - refreshedAt >= UNREGISTERED_ROOT_REFRESH_MS
|
||||
) {
|
||||
context = createMeasurementRaycastContext(scene, options)
|
||||
revision = sceneRegistry.revision
|
||||
refreshedAt = now
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
const invalidate = () => {
|
||||
context = null
|
||||
revision = -1
|
||||
refreshedAt = Number.NEGATIVE_INFINITY
|
||||
}
|
||||
|
||||
return {
|
||||
resolvePointer: (args) =>
|
||||
resolveSurfacePoint(
|
||||
{
|
||||
...args,
|
||||
lockedGuide: args.lockedGuide ?? null,
|
||||
planarProximityAnchors: args.planarProximityAnchors ?? [],
|
||||
surfacePreference: args.surfacePreference ?? null,
|
||||
},
|
||||
getContext(),
|
||||
pointerRaycaster,
|
||||
verificationRaycaster,
|
||||
pointer,
|
||||
),
|
||||
collectAxisIntersections: ({ levelObject, anchor, maxDistance = 20 }) =>
|
||||
collectMeasurementAxisSurfaceIntersections(
|
||||
getContext(),
|
||||
levelObject,
|
||||
anchor,
|
||||
axisRaycaster,
|
||||
maxDistance,
|
||||
),
|
||||
invalidate,
|
||||
dispose: invalidate,
|
||||
}
|
||||
}
|
||||
|
||||
export function associateSurfaceHit(
|
||||
hit: LocalSurfaceHit,
|
||||
maxDistance = SEMANTIC_FEATURE_SNAP_DISTANCE,
|
||||
): LocalSurfaceHit & {
|
||||
anchor?: MeasurementFeatureAnchor
|
||||
semantic?: {
|
||||
label: string
|
||||
length: number | null
|
||||
snapKind: MeasurementSnapKind
|
||||
}
|
||||
} {
|
||||
if (!hit.targetNodeId) return hit
|
||||
const nodes = useScene.getState().nodes
|
||||
const node = nodes[hit.targetNodeId as AnyNodeId]
|
||||
if (!node) return hit
|
||||
const match = matchMeasurementFeatureForNode(node, (id) => nodes[id], hit.point, maxDistance)
|
||||
if (!match) return hit
|
||||
return {
|
||||
...hit,
|
||||
point: match.point,
|
||||
anchor: {
|
||||
kind: 'feature',
|
||||
reference: {
|
||||
nodeId: node.id,
|
||||
featureId: match.feature.id,
|
||||
parameters: match.parameters,
|
||||
},
|
||||
fallback: match.point,
|
||||
},
|
||||
semantic: {
|
||||
label: match.feature.label,
|
||||
length: measurementFeatureLength(match.feature),
|
||||
snapKind: match.feature.snapKind,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import { useEditor } from '@pascal-app/editor'
|
||||
import SmartMeasurementTool from './smart-tool'
|
||||
import MeasurementTool from './tool'
|
||||
|
||||
export default function MeasurementToolRouter() {
|
||||
const kind = useEditor((state) => state.toolDefaults.measurement?.kind)
|
||||
return kind === 'smart' ? <SmartMeasurementTool /> : <MeasurementTool />
|
||||
}
|
||||
@@ -0,0 +1,567 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test'
|
||||
import { sceneRegistry, useScene } from '@pascal-app/core'
|
||||
import {
|
||||
BoxGeometry,
|
||||
DoubleSide,
|
||||
Group,
|
||||
InstancedMesh,
|
||||
Matrix4,
|
||||
Mesh,
|
||||
MeshBasicMaterial,
|
||||
PerspectiveCamera,
|
||||
PlaneGeometry,
|
||||
Quaternion,
|
||||
Raycaster,
|
||||
Vector2,
|
||||
Vector3,
|
||||
} from 'three'
|
||||
import {
|
||||
buildMeasurementDraftLinePositions,
|
||||
castVisibleMeasurementSurface,
|
||||
closestMeasurementExtrusionHeight,
|
||||
collectMeasurementAxisSurfaceIntersections,
|
||||
collectMeasurementSurfaceRoots,
|
||||
isMeasurementSurfaceMaterialVisible,
|
||||
localNormalToPreviewFrame,
|
||||
measurementIntersectionWorldNormal,
|
||||
measurementVertexSnapAnchors,
|
||||
parseMeasurementExtrusionHeight,
|
||||
projectMeasurementPointToAxes,
|
||||
projectMeasurementPointToPlanarAxes,
|
||||
resolveSurfacePoint,
|
||||
selectAxisCandidateForSurfaceVerification,
|
||||
selectClosestMeasurementVertexIndex,
|
||||
selectClosestVerifiedAxisProjection,
|
||||
} from './tool'
|
||||
|
||||
afterEach(() => {
|
||||
sceneRegistry.clear()
|
||||
useScene.setState({ nodes: {} } as never)
|
||||
})
|
||||
|
||||
function createRegisteredMeasurementSurface() {
|
||||
const scene = new Group()
|
||||
const level = new Group()
|
||||
const geometry = new PlaneGeometry(10, 10)
|
||||
const material = new MeshBasicMaterial({ side: DoubleSide })
|
||||
const surface = new Mesh(geometry, material)
|
||||
|
||||
level.position.set(3, 4, -2)
|
||||
level.rotation.set(0.2, 0.7, -0.1)
|
||||
surface.position.set(1, 2, 0)
|
||||
level.add(surface)
|
||||
scene.add(level)
|
||||
scene.updateMatrixWorld(true)
|
||||
|
||||
sceneRegistry.nodes.set('wall_surface', surface)
|
||||
useScene.setState({ nodes: { wall_surface: { type: 'wall' } } } as never)
|
||||
|
||||
const center = surface.localToWorld(new Vector3())
|
||||
const worldNormal = new Vector3(0, 0, 1).applyQuaternion(
|
||||
surface.getWorldQuaternion(new Quaternion()),
|
||||
)
|
||||
const camera = new PerspectiveCamera(50, 1, 0.1, 100)
|
||||
camera.position.copy(center.clone().addScaledVector(worldNormal, 10))
|
||||
camera.lookAt(center)
|
||||
camera.updateProjectionMatrix()
|
||||
camera.updateMatrixWorld(true)
|
||||
|
||||
return {
|
||||
camera,
|
||||
canvas: {
|
||||
getBoundingClientRect: () => ({ height: 200, left: 0, top: 0, width: 200 }),
|
||||
} as unknown as HTMLCanvasElement,
|
||||
cleanup: () => {
|
||||
geometry.dispose()
|
||||
material.dispose()
|
||||
},
|
||||
event: { clientX: 100, clientY: 100 } as PointerEvent,
|
||||
level,
|
||||
scene,
|
||||
}
|
||||
}
|
||||
|
||||
describe('measurement surface visibility', () => {
|
||||
test('rejects invisible raycast proxy materials', () => {
|
||||
const proxy = new Mesh(
|
||||
new BoxGeometry(1, 1, 1),
|
||||
new MeshBasicMaterial({ colorWrite: false, depthWrite: false }),
|
||||
)
|
||||
|
||||
expect(isMeasurementSurfaceMaterialVisible(proxy)).toBe(false)
|
||||
|
||||
proxy.geometry.dispose()
|
||||
proxy.material.dispose()
|
||||
})
|
||||
|
||||
test('checks the material used by the intersected face', () => {
|
||||
const hidden = new MeshBasicMaterial({ colorWrite: false })
|
||||
const rendered = new MeshBasicMaterial()
|
||||
const mesh = new Mesh(new BoxGeometry(1, 1, 1), [hidden, rendered])
|
||||
|
||||
expect(isMeasurementSurfaceMaterialVisible(mesh, 0)).toBe(false)
|
||||
expect(isMeasurementSurfaceMaterialVisible(mesh, 1)).toBe(true)
|
||||
rendered.depthTest = false
|
||||
expect(isMeasurementSurfaceMaterialVisible(mesh, 1)).toBe(false)
|
||||
|
||||
mesh.geometry.dispose()
|
||||
hidden.dispose()
|
||||
rendered.dispose()
|
||||
})
|
||||
|
||||
test('accepts visible scene geometry without a registered node owner', () => {
|
||||
const scene = new Group()
|
||||
const registeredRoot = new Group()
|
||||
const editorHelperRig = new Group()
|
||||
const proxy = new Mesh(
|
||||
new BoxGeometry(1, 1, 1),
|
||||
new MeshBasicMaterial({ colorWrite: false, depthWrite: false }),
|
||||
)
|
||||
const editorHelper = new Mesh(new BoxGeometry(1, 1, 1), new MeshBasicMaterial())
|
||||
const systemMesh = new Mesh(new BoxGeometry(1, 1, 1), new MeshBasicMaterial())
|
||||
proxy.position.z = 1.5
|
||||
editorHelper.position.z = 1
|
||||
editorHelperRig.userData.measurementSurface = false
|
||||
systemMesh.userData.measurementSurface = true
|
||||
registeredRoot.add(proxy)
|
||||
editorHelperRig.add(editorHelper)
|
||||
registeredRoot.add(editorHelperRig)
|
||||
scene.add(registeredRoot)
|
||||
scene.add(systemMesh)
|
||||
scene.updateMatrixWorld(true)
|
||||
|
||||
const roots = collectMeasurementSurfaceRoots(scene, [registeredRoot])
|
||||
expect(roots).toContain(systemMesh)
|
||||
|
||||
const hit = castVisibleMeasurementSurface(
|
||||
new Raycaster(new Vector3(0, 0, 2), new Vector3(0, 0, -1)),
|
||||
{ ownerByObject: new Map(), roots },
|
||||
)
|
||||
|
||||
expect(hit?.intersection.object).toBe(systemMesh)
|
||||
expect(hit?.targetNodeId).toBeNull()
|
||||
|
||||
proxy.geometry.dispose()
|
||||
proxy.material.dispose()
|
||||
editorHelper.geometry.dispose()
|
||||
editorHelper.material.dispose()
|
||||
systemMesh.geometry.dispose()
|
||||
systemMesh.material.dispose()
|
||||
})
|
||||
|
||||
test('transforms normals by the intersected instance matrix', () => {
|
||||
const geometry = new PlaneGeometry(1, 1)
|
||||
const material = new MeshBasicMaterial({ side: DoubleSide })
|
||||
const mesh = new InstancedMesh(geometry, material, 1)
|
||||
mesh.setMatrixAt(0, new Matrix4().makeRotationY(Math.PI / 2))
|
||||
mesh.instanceMatrix.needsUpdate = true
|
||||
mesh.updateMatrixWorld(true)
|
||||
|
||||
const intersection = new Raycaster(new Vector3(2, 0, 0), new Vector3(-1, 0, 0)).intersectObject(
|
||||
mesh,
|
||||
)[0]
|
||||
expect(intersection).toBeDefined()
|
||||
if (!intersection) return
|
||||
|
||||
const normal = measurementIntersectionWorldNormal(intersection)
|
||||
expect(normal.x).toBeCloseTo(1)
|
||||
expect(normal.y).toBeCloseTo(0)
|
||||
expect(normal.z).toBeCloseTo(0)
|
||||
|
||||
geometry.dispose()
|
||||
material.dispose()
|
||||
})
|
||||
|
||||
test('resolves a registered rendered surface into the active level frame', () => {
|
||||
const { camera, canvas, cleanup, event, level, scene } = createRegisteredMeasurementSurface()
|
||||
|
||||
const resolved = resolveSurfacePoint(
|
||||
event,
|
||||
camera,
|
||||
canvas,
|
||||
new Raycaster(),
|
||||
new Vector2(),
|
||||
scene,
|
||||
level,
|
||||
null,
|
||||
)
|
||||
|
||||
expect(resolved?.hit.targetNodeId).toBe('wall_surface')
|
||||
expect(resolved?.hit.point[0]).toBeCloseTo(1)
|
||||
expect(resolved?.hit.point[1]).toBeCloseTo(2)
|
||||
expect(resolved?.hit.point[2]).toBeCloseTo(0)
|
||||
expect(resolved?.hit.normal[0]).toBeCloseTo(0)
|
||||
expect(resolved?.hit.normal[1]).toBeCloseTo(0)
|
||||
expect(resolved?.hit.normal[2]).toBeCloseTo(1)
|
||||
cleanup()
|
||||
})
|
||||
|
||||
test('recasts a nearby axis projection onto the registered surface', () => {
|
||||
const { camera, canvas, cleanup, event, level, scene } = createRegisteredMeasurementSurface()
|
||||
|
||||
const resolved = resolveSurfacePoint(
|
||||
event,
|
||||
camera,
|
||||
canvas,
|
||||
new Raycaster(),
|
||||
new Vector2(),
|
||||
scene,
|
||||
level,
|
||||
[0.9, 1.9, 0],
|
||||
)
|
||||
|
||||
expect(resolved?.hit.targetNodeId).toBe('wall_surface')
|
||||
expect(resolved?.guide?.snapped).toBe(true)
|
||||
expect(resolved?.hit.point).toEqual(resolved?.guide?.to)
|
||||
cleanup()
|
||||
})
|
||||
|
||||
test('magnetically aligns a horizontal surface hit to a nearby scene anchor', () => {
|
||||
const scene = new Group()
|
||||
const level = new Group()
|
||||
const geometry = new PlaneGeometry(10, 10)
|
||||
const material = new MeshBasicMaterial({ side: DoubleSide })
|
||||
const surface = new Mesh(geometry, material)
|
||||
surface.rotation.x = -Math.PI / 2
|
||||
level.add(surface)
|
||||
scene.add(level)
|
||||
scene.updateMatrixWorld(true)
|
||||
sceneRegistry.nodes.set('slab_surface', surface)
|
||||
useScene.setState({ nodes: { slab_surface: { type: 'slab' } } } as never)
|
||||
|
||||
const camera = new PerspectiveCamera(50, 1, 0.1, 100)
|
||||
camera.position.set(0, 10, 0)
|
||||
camera.up.set(0, 0, -1)
|
||||
camera.lookAt(0, 0, 0)
|
||||
camera.updateProjectionMatrix()
|
||||
camera.updateMatrixWorld(true)
|
||||
const resolved = resolveSurfacePoint(
|
||||
{ clientX: 100, clientY: 100 } as PointerEvent,
|
||||
camera,
|
||||
{
|
||||
getBoundingClientRect: () => ({ height: 200, left: 0, top: 0, width: 200 }),
|
||||
} as unknown as HTMLCanvasElement,
|
||||
new Raycaster(),
|
||||
new Vector2(),
|
||||
scene,
|
||||
level,
|
||||
null,
|
||||
null,
|
||||
[{ nodeId: 'wall_1', kind: 'corner', x: 2, z: 0.1 }],
|
||||
)
|
||||
|
||||
expect(resolved?.guide).toMatchObject({ axis: 'x', proximity: true, snapped: true })
|
||||
expect(resolved?.hit.point).toEqual(resolved?.guide?.to)
|
||||
geometry.dispose()
|
||||
material.dispose()
|
||||
})
|
||||
|
||||
test('finds the visible surfaces crossed by each anchor axis', () => {
|
||||
const scene = new Group()
|
||||
const level = new Group()
|
||||
const material = new MeshBasicMaterial({ side: DoubleSide })
|
||||
const surfaces = [
|
||||
new Mesh(new PlaneGeometry(6, 6), material),
|
||||
new Mesh(new PlaneGeometry(6, 6), material),
|
||||
new Mesh(new PlaneGeometry(6, 6), material),
|
||||
]
|
||||
surfaces[0]!.position.x = 2
|
||||
surfaces[0]!.rotation.y = Math.PI / 2
|
||||
surfaces[1]!.position.y = 3
|
||||
surfaces[1]!.rotation.x = Math.PI / 2
|
||||
surfaces[2]!.position.z = 4
|
||||
level.add(...surfaces)
|
||||
scene.add(level)
|
||||
scene.updateMatrixWorld(true)
|
||||
surfaces.forEach((surface, index) => {
|
||||
sceneRegistry.nodes.set(`surface_${index}`, surface)
|
||||
})
|
||||
useScene.setState({
|
||||
nodes: {
|
||||
surface_0: { type: 'wall' },
|
||||
surface_1: { type: 'ceiling' },
|
||||
surface_2: { type: 'wall' },
|
||||
},
|
||||
} as never)
|
||||
|
||||
const intersections = collectMeasurementAxisSurfaceIntersections(scene, level, [0, 0, 0])
|
||||
|
||||
const x = intersections.find(({ axis }) => axis === 'x')?.point
|
||||
const y = intersections.find(({ axis }) => axis === 'y')?.point
|
||||
const z = intersections.find(({ axis }) => axis === 'z')?.point
|
||||
expect(x?.[0]).toBeCloseTo(2)
|
||||
expect(y?.[1]).toBeCloseTo(3)
|
||||
expect(z?.[2]).toBeCloseTo(4)
|
||||
surfaces.forEach((surface) => {
|
||||
surface.geometry.dispose()
|
||||
})
|
||||
material.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('closestMeasurementExtrusionHeight', () => {
|
||||
test('returns the signed point on the extrusion axis nearest the pointer ray', () => {
|
||||
expect(
|
||||
closestMeasurementExtrusionHeight([5, 3, 0], [-1, 0, 0], [0, 0, 0], [0, 1, 0]),
|
||||
).toBeCloseTo(3)
|
||||
expect(
|
||||
closestMeasurementExtrusionHeight([5, -2, 0], [-1, 0, 0], [0, 0, 0], [0, 1, 0]),
|
||||
).toBeCloseTo(-2)
|
||||
})
|
||||
|
||||
test('returns null when the pointer ray is parallel to the extrusion axis', () => {
|
||||
expect(closestMeasurementExtrusionHeight([0, 0, 0], [0, 1, 0], [1, 0, 0], [0, 1, 0])).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseMeasurementExtrusionHeight', () => {
|
||||
test('converts numeric metric and imperial input to meters', () => {
|
||||
expect(parseMeasurementExtrusionHeight('2.5', 'metric')).toBeCloseTo(2.5)
|
||||
expect(parseMeasurementExtrusionHeight('10', 'imperial')).toBeCloseTo(3.048)
|
||||
expect(parseMeasurementExtrusionHeight('', 'metric')).toBeNull()
|
||||
expect(parseMeasurementExtrusionHeight('not-a-number', 'imperial')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildMeasurementDraftLinePositions', () => {
|
||||
test('expands polylines into finite non-indexed segment pairs', () => {
|
||||
expect(
|
||||
buildMeasurementDraftLinePositions([
|
||||
new Vector3(0, 0, 0),
|
||||
new Vector3(1, 0, 0),
|
||||
new Vector3(1, 2, 0),
|
||||
]),
|
||||
).toEqual([0, 0, 0, 1, 0, 0, 1, 0, 0, 1, 2, 0])
|
||||
|
||||
expect(
|
||||
buildMeasurementDraftLinePositions([
|
||||
new Vector3(0, 0, 0),
|
||||
new Vector3(Number.POSITIVE_INFINITY, 0, 0),
|
||||
]),
|
||||
).toEqual([])
|
||||
expect(
|
||||
buildMeasurementDraftLinePositions([new Vector3(0, 0, 0), new Vector3(1e100, 0, 0)]),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test('builds dashed guides from bounded line segments', () => {
|
||||
const positions = buildMeasurementDraftLinePositions(
|
||||
[new Vector3(0, 0, 0), new Vector3(0.3, 0, 0)],
|
||||
0.1,
|
||||
0.05,
|
||||
)
|
||||
|
||||
expect(positions).toHaveLength(12)
|
||||
expect(positions[0]).toBeCloseTo(0)
|
||||
expect(positions[3]).toBeCloseTo(0.1)
|
||||
expect(positions[6]).toBeCloseTo(0.15)
|
||||
expect(positions[9]).toBeCloseTo(0.25)
|
||||
expect(positions.every(Number.isFinite)).toBe(true)
|
||||
})
|
||||
|
||||
test('bounds dash geometry while covering very long guides', () => {
|
||||
const positions = buildMeasurementDraftLinePositions(
|
||||
[new Vector3(0, 0, 0), new Vector3(10_000, 0, 0)],
|
||||
0.08,
|
||||
0.05,
|
||||
)
|
||||
|
||||
expect(positions).toHaveLength(512 * 6)
|
||||
expect(positions.at(-3)).toBeGreaterThan(9_900)
|
||||
expect(positions.every(Number.isFinite)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('measurement axis projection', () => {
|
||||
test('projects X and Z candidates onto the same horizontal surface', () => {
|
||||
expect(projectMeasurementPointToAxes([1, 0, 2], [4, 0, 7])).toEqual([
|
||||
{ axis: 'x', point: [4, 0, 2] },
|
||||
{ axis: 'y', point: [1, 0, 2] },
|
||||
{ axis: 'z', point: [1, 0, 7] },
|
||||
])
|
||||
expect(projectMeasurementPointToPlanarAxes([1, 0, 2], [4, 0, 7])).toEqual([
|
||||
{ axis: 'x', point: [4, 0, 2] },
|
||||
{ axis: 'z', point: [1, 0, 7] },
|
||||
])
|
||||
})
|
||||
|
||||
test('selects only the nearest verified projection inside the screen threshold', () => {
|
||||
const candidates = [
|
||||
{
|
||||
axis: 'x' as const,
|
||||
point: [4, 0, 2] as [number, number, number],
|
||||
screenDistance: 8,
|
||||
verified: true,
|
||||
},
|
||||
{
|
||||
axis: 'y' as const,
|
||||
point: [1, 0, 2] as [number, number, number],
|
||||
screenDistance: 3,
|
||||
verified: false,
|
||||
},
|
||||
{
|
||||
axis: 'z' as const,
|
||||
point: [1, 0, 7] as [number, number, number],
|
||||
screenDistance: 5,
|
||||
verified: true,
|
||||
},
|
||||
]
|
||||
expect(selectClosestVerifiedAxisProjection(candidates)).toEqual({
|
||||
axis: 'z',
|
||||
point: [1, 0, 7],
|
||||
})
|
||||
expect(
|
||||
selectClosestVerifiedAxisProjection(
|
||||
candidates.map((candidate) => ({ ...candidate, screenDistance: 20 })),
|
||||
),
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
test('uses the stronger default magnetic acquisition envelope', () => {
|
||||
expect(
|
||||
selectClosestVerifiedAxisProjection([
|
||||
{
|
||||
axis: 'x',
|
||||
point: [4, 0, 2],
|
||||
screenDistance: 15,
|
||||
verified: true,
|
||||
},
|
||||
]),
|
||||
).toEqual({ axis: 'x', point: [4, 0, 2] })
|
||||
})
|
||||
|
||||
test('keeps a locked axis through a wider magnetic release threshold', () => {
|
||||
const candidates = [
|
||||
{
|
||||
axis: 'x' as const,
|
||||
point: [4, 0, 2] as [number, number, number],
|
||||
screenDistance: 16,
|
||||
verified: true,
|
||||
},
|
||||
{
|
||||
axis: 'z' as const,
|
||||
point: [1, 0, 7] as [number, number, number],
|
||||
screenDistance: 4,
|
||||
verified: true,
|
||||
},
|
||||
]
|
||||
|
||||
expect(selectClosestVerifiedAxisProjection(candidates, 12, 'x', 18)).toEqual({
|
||||
axis: 'x',
|
||||
point: [4, 0, 2],
|
||||
})
|
||||
expect(
|
||||
selectClosestVerifiedAxisProjection(
|
||||
candidates.map((candidate) =>
|
||||
candidate.axis === 'x' ? { ...candidate, screenDistance: 19 } : candidate,
|
||||
),
|
||||
12,
|
||||
'x',
|
||||
18,
|
||||
),
|
||||
).toEqual({ axis: 'z', point: [1, 0, 7] })
|
||||
})
|
||||
|
||||
test('keeps a drag lock on the same adjacent anchor', () => {
|
||||
const firstAnchor = [0, 0, 0] as [number, number, number]
|
||||
const secondAnchor = [5, 1, 2] as [number, number, number]
|
||||
const candidates = [
|
||||
{
|
||||
anchor: firstAnchor,
|
||||
axis: 'x' as const,
|
||||
point: [3, 0, 0] as [number, number, number],
|
||||
screenDistance: 15,
|
||||
verified: true,
|
||||
},
|
||||
{
|
||||
anchor: secondAnchor,
|
||||
axis: 'x' as const,
|
||||
point: [3, 1, 2] as [number, number, number],
|
||||
screenDistance: 3,
|
||||
verified: true,
|
||||
},
|
||||
]
|
||||
|
||||
expect(selectClosestVerifiedAxisProjection(candidates, 12, 'x', 18, firstAnchor)).toEqual({
|
||||
axis: 'x',
|
||||
point: [3, 0, 0],
|
||||
})
|
||||
expect(
|
||||
selectClosestVerifiedAxisProjection(
|
||||
candidates.map((candidate) =>
|
||||
candidate.anchor === firstAnchor ? { ...candidate, screenDistance: 19 } : candidate,
|
||||
),
|
||||
12,
|
||||
'x',
|
||||
18,
|
||||
firstAnchor,
|
||||
),
|
||||
).toEqual({ axis: 'x', point: [3, 1, 2] })
|
||||
})
|
||||
|
||||
test('verifies only the nearest in-range candidate while preserving a magnetic lock', () => {
|
||||
const firstAnchor = [0, 0, 0] as [number, number, number]
|
||||
const secondAnchor = [4, 0, 0] as [number, number, number]
|
||||
const candidates = [
|
||||
{
|
||||
anchor: firstAnchor,
|
||||
axis: 'x' as const,
|
||||
point: [2, 0, 0] as [number, number, number],
|
||||
screenDistance: 16,
|
||||
verified: false,
|
||||
},
|
||||
{
|
||||
anchor: secondAnchor,
|
||||
axis: 'z' as const,
|
||||
point: [4, 0, 2] as [number, number, number],
|
||||
screenDistance: 4,
|
||||
verified: false,
|
||||
},
|
||||
]
|
||||
|
||||
expect(selectAxisCandidateForSurfaceVerification(candidates)).toBe(candidates[1])
|
||||
expect(selectAxisCandidateForSurfaceVerification(candidates, 12, 'x', 18, firstAnchor)).toBe(
|
||||
candidates[0],
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('measurement draft vertex affordances', () => {
|
||||
test('selects the closest handle inside its screen threshold', () => {
|
||||
expect(selectClosestMeasurementVertexIndex([18, 7, 10])).toBe(1)
|
||||
expect(selectClosestMeasurementVertexIndex([18, 13, 20])).toBeNull()
|
||||
})
|
||||
|
||||
test('uses both adjacent polygon vertices as drag snap anchors', () => {
|
||||
const points = [
|
||||
[0, 0, 0],
|
||||
[2, 0, 0],
|
||||
[2, 0, 2],
|
||||
[0, 0, 2],
|
||||
] as [number, number, number][]
|
||||
|
||||
expect(measurementVertexSnapAnchors(points, 0, true)).toEqual([
|
||||
[0, 0, 2],
|
||||
[2, 0, 0],
|
||||
])
|
||||
expect(measurementVertexSnapAnchors(points, 2, true)).toEqual([
|
||||
[2, 0, 0],
|
||||
[0, 0, 2],
|
||||
])
|
||||
expect(measurementVertexSnapAnchors(points.slice(0, 2), 0, false)).toEqual([[2, 0, 0]])
|
||||
})
|
||||
|
||||
test('transforms a local surface normal into the preview parent frame', () => {
|
||||
const building = new Group()
|
||||
const level = new Group()
|
||||
building.rotation.y = 0.7
|
||||
level.rotation.z = Math.PI / 2
|
||||
building.add(level)
|
||||
building.updateMatrixWorld(true)
|
||||
|
||||
const normal = localNormalToPreviewFrame(level, building, [1, 0, 0])
|
||||
expect(normal.x).toBeCloseTo(0)
|
||||
expect(normal.y).toBeCloseTo(1)
|
||||
expect(normal.z).toBeCloseTo(0)
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,7 @@ import {
|
||||
roofSegmentResizeAffordance,
|
||||
roofSegmentRotateAffordance,
|
||||
} from './floorplan-affordances'
|
||||
import { matchRoofSegmentMeasurementFeature, roofSegmentMeasurementFeatures } from './measurement'
|
||||
import { roofSegmentParametrics } from './parametrics'
|
||||
import { RoofSegmentNode } from './schema'
|
||||
|
||||
@@ -303,6 +304,17 @@ export const roofSegmentDefinition: NodeDefinition<typeof RoofSegmentNode> = {
|
||||
module: () => import('./renderer'),
|
||||
},
|
||||
floorplan: buildRoofSegmentFloorplan,
|
||||
measurement: {
|
||||
features: (node, ctx) =>
|
||||
roofSegmentMeasurementFeatures(node, ctx.parent?.type === 'roof' ? ctx.parent : null),
|
||||
match: (node, ctx, point, maxDistance) =>
|
||||
matchRoofSegmentMeasurementFeature(
|
||||
node,
|
||||
ctx.parent?.type === 'roof' ? ctx.parent : null,
|
||||
point,
|
||||
maxDistance,
|
||||
),
|
||||
},
|
||||
// Body-move target. The generic Path 2 fallback writes plan coords
|
||||
// directly to `position`, which is wrong here because the segment's
|
||||
// position is roof-local. `roofSegmentMoveTarget` inverts the parent
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import {
|
||||
getRoofSegmentSurfaceY,
|
||||
type MeasurementFeature,
|
||||
type MeasurementFeatureBinding,
|
||||
type RoofNode,
|
||||
type RoofSegmentNode,
|
||||
} from '@pascal-app/core'
|
||||
import { getRoofSegmentPlanLinework, type PlanPt, type PlanSeg } from './floorplan'
|
||||
|
||||
function transformPoint(node: RoofSegmentNode, roof: RoofNode, [x, z]: PlanPt) {
|
||||
const segmentCos = Math.cos(node.rotation)
|
||||
const segmentSin = Math.sin(node.rotation)
|
||||
const segmentX = node.position[0] + x * segmentCos + z * segmentSin
|
||||
const segmentZ = node.position[2] - x * segmentSin + z * segmentCos
|
||||
const roofCos = Math.cos(roof.rotation)
|
||||
const roofSin = Math.sin(roof.rotation)
|
||||
return [
|
||||
roof.position[0] + segmentX * roofCos + segmentZ * roofSin,
|
||||
roof.position[1] + node.position[1] + getRoofSegmentSurfaceY(node, x, z),
|
||||
roof.position[2] - segmentX * roofSin + segmentZ * roofCos,
|
||||
] as [number, number, number]
|
||||
}
|
||||
|
||||
function segmentFeature(
|
||||
node: RoofSegmentNode,
|
||||
roof: RoofNode,
|
||||
id: string,
|
||||
label: string,
|
||||
snapKind: 'ridge' | 'edge',
|
||||
segment: PlanSeg,
|
||||
): MeasurementFeature {
|
||||
return {
|
||||
id,
|
||||
label,
|
||||
snapKind,
|
||||
priority: snapKind === 'ridge' ? 100 : 75,
|
||||
geometry: {
|
||||
kind: 'segment',
|
||||
start: transformPoint(node, roof, segment[0]),
|
||||
end: transformPoint(node, roof, segment[1]),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function roofSegmentMeasurementFeatures(
|
||||
node: RoofSegmentNode,
|
||||
roof: RoofNode | null,
|
||||
): MeasurementFeature[] {
|
||||
if (roof?.type !== 'roof') return []
|
||||
const linework = getRoofSegmentPlanLinework(node)
|
||||
return [
|
||||
...linework.ridges.map((segment, index) =>
|
||||
segmentFeature(node, roof, `roof:ridge:${index}`, 'Roof ridge', 'ridge', segment),
|
||||
),
|
||||
...linework.hips.map((segment, index) =>
|
||||
segmentFeature(node, roof, `roof:hip:${index}`, 'Roof hip', 'edge', segment),
|
||||
),
|
||||
...linework.breaks.map((segment, index) =>
|
||||
segmentFeature(node, roof, `roof:break:${index}`, 'Roof break', 'edge', segment),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
export function matchRoofSegmentMeasurementFeature(
|
||||
node: RoofSegmentNode,
|
||||
roof: RoofNode | null,
|
||||
hit: [number, number, number],
|
||||
maxDistance: number,
|
||||
): MeasurementFeatureBinding | null {
|
||||
let best: MeasurementFeatureBinding | null = null
|
||||
for (const feature of roofSegmentMeasurementFeatures(node, roof)) {
|
||||
if (feature.geometry.kind !== 'segment') continue
|
||||
const { start, end } = feature.geometry
|
||||
const dx = end[0] - start[0]
|
||||
const dy = end[1] - start[1]
|
||||
const dz = end[2] - start[2]
|
||||
const lengthSquared = dx * dx + dy * dy + dz * dz
|
||||
const t =
|
||||
lengthSquared <= 1e-12
|
||||
? 0
|
||||
: Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
1,
|
||||
((hit[0] - start[0]) * dx + (hit[1] - start[1]) * dy + (hit[2] - start[2]) * dz) /
|
||||
lengthSquared,
|
||||
),
|
||||
)
|
||||
const point: [number, number, number] = [
|
||||
start[0] + dx * t,
|
||||
start[1] + dy * t,
|
||||
start[2] + dz * t,
|
||||
]
|
||||
const distance = Math.hypot(hit[0] - point[0], hit[1] - point[1], hit[2] - point[2])
|
||||
if (distance <= maxDistance && (!best || distance < best.distance)) {
|
||||
best = { featureId: feature.id, point, parameters: { t }, distance }
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { measurementFeatureLength } from '@pascal-app/core'
|
||||
import { closestMeasurementFeature, measurementFeaturePoint } from '../measurement/resolve'
|
||||
import { polygonMeasurementFeatures } from './polygon-measurement'
|
||||
|
||||
describe('polygonMeasurementFeatures', () => {
|
||||
const features = polygonMeasurementFeatures({
|
||||
featurePrefix: 'slab',
|
||||
height: 0.2,
|
||||
label: 'Slab',
|
||||
polygon: [
|
||||
[0, 0],
|
||||
[4, 0],
|
||||
[4, 2],
|
||||
[0, 2],
|
||||
],
|
||||
})
|
||||
|
||||
test('exposes stable corners, the whole boundary, and center', () => {
|
||||
expect(features.map((feature) => feature.id)).toEqual([
|
||||
'slab:vertex:0',
|
||||
'slab:vertex:1',
|
||||
'slab:vertex:2',
|
||||
'slab:vertex:3',
|
||||
'slab:boundary',
|
||||
'slab:center',
|
||||
])
|
||||
expect(measurementFeatureLength(features[4]!)).toBe(12)
|
||||
expect(features[5]!.geometry).toEqual({ kind: 'point', point: [2, 0.2, 1] })
|
||||
})
|
||||
|
||||
test('binds by normalized perimeter position rather than vertex index', () => {
|
||||
const match = closestMeasurementFeature(features, [3, 0.2, 0.05], 0.1)
|
||||
expect(match?.feature.id).toBe('slab:boundary')
|
||||
expect(match?.parameters.t).toBeCloseTo(0.25)
|
||||
expect(
|
||||
measurementFeaturePoint(match!.feature, {
|
||||
nodeId: 'slab_test' as never,
|
||||
featureId: match!.feature.id,
|
||||
parameters: match!.parameters,
|
||||
}),
|
||||
).toEqual([3, 0.2, 0])
|
||||
|
||||
expect(
|
||||
measurementFeaturePoint(match!.feature, {
|
||||
nodeId: 'slab_test' as never,
|
||||
featureId: match!.feature.id,
|
||||
parameters: { t: 0.75 },
|
||||
}),
|
||||
).toEqual([1, 0.2, 2])
|
||||
})
|
||||
|
||||
test('keeps a corner binding on that corner when the polygon changes shape', () => {
|
||||
const match = closestMeasurementFeature(features, [4, 0.2, 0], 0.1)
|
||||
expect(match?.feature.id).toBe('slab:vertex:1')
|
||||
|
||||
const resized = polygonMeasurementFeatures({
|
||||
featurePrefix: 'slab',
|
||||
height: 0.2,
|
||||
label: 'Slab',
|
||||
polygon: [
|
||||
[0, 0],
|
||||
[6, 0],
|
||||
[6, 2],
|
||||
[0, 2],
|
||||
],
|
||||
})
|
||||
const corner = resized.find((feature) => feature.id === match?.feature.id)
|
||||
expect(corner?.geometry).toEqual({ kind: 'point', point: [6, 0.2, 0] })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { MeasurementFeature, MeasurementPoint } from '@pascal-app/core'
|
||||
|
||||
type PolygonPoint = readonly [number, number]
|
||||
|
||||
function polygonCenter(polygon: readonly PolygonPoint[], height: number): MeasurementPoint {
|
||||
if (polygon.length === 0) return [0, height, 0]
|
||||
const [x, z] = polygon.reduce(([sumX, sumZ], point) => [sumX + point[0], sumZ + point[1]], [0, 0])
|
||||
return [x / polygon.length, height, z / polygon.length]
|
||||
}
|
||||
|
||||
export function polygonMeasurementFeatures({
|
||||
featurePrefix,
|
||||
height,
|
||||
label,
|
||||
polygon,
|
||||
}: {
|
||||
featurePrefix: string
|
||||
height: number
|
||||
label: string
|
||||
polygon: readonly PolygonPoint[]
|
||||
}): MeasurementFeature[] {
|
||||
const points = polygon.map(([x, z]) => [x, height, z] satisfies MeasurementPoint)
|
||||
return [
|
||||
...points.map(
|
||||
(point, index) =>
|
||||
({
|
||||
id: `${featurePrefix}:vertex:${index}`,
|
||||
label: `${label} corner`,
|
||||
snapKind: 'endpoint',
|
||||
priority: 110,
|
||||
normal: [0, 1, 0],
|
||||
geometry: { kind: 'point', point },
|
||||
}) satisfies MeasurementFeature,
|
||||
),
|
||||
{
|
||||
id: `${featurePrefix}:boundary`,
|
||||
label: `${label} boundary`,
|
||||
snapKind: 'edge',
|
||||
priority: 90,
|
||||
normal: [0, 1, 0],
|
||||
geometry: { kind: 'polygon', points },
|
||||
},
|
||||
{
|
||||
id: `${featurePrefix}:center`,
|
||||
label: `${label} center`,
|
||||
snapKind: 'center',
|
||||
priority: 70,
|
||||
normal: [0, 1, 0],
|
||||
geometry: { kind: 'point', point: polygonCenter(polygon, height) },
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { beforeEach, describe, expect, test } from 'bun:test'
|
||||
import { type AnyNode, type AnyNodeId, useScene } from '@pascal-app/core'
|
||||
import { ceilingDefinition } from '../ceiling/definition'
|
||||
import { slabDefinition } from '../slab/definition'
|
||||
import { zoneDefinition } from '../zone/definition'
|
||||
import { createPolygonDeleteVertexAffordance } from './polygon-vertex-affordance'
|
||||
|
||||
globalThis.requestAnimationFrame = () => 1
|
||||
globalThis.cancelAnimationFrame = () => {}
|
||||
|
||||
type PolygonTestNode = AnyNode & {
|
||||
polygon: Array<[number, number]>
|
||||
holes?: Array<Array<[number, number]>>
|
||||
}
|
||||
|
||||
function polygonNode(
|
||||
polygon: Array<[number, number]>,
|
||||
holes: Array<Array<[number, number]>> = [],
|
||||
): PolygonTestNode {
|
||||
return {
|
||||
id: 'slab_polygon-test' as AnyNodeId,
|
||||
type: 'slab',
|
||||
object: 'node',
|
||||
parentId: null,
|
||||
visible: true,
|
||||
metadata: {},
|
||||
polygon,
|
||||
holes,
|
||||
holeMetadata: [],
|
||||
elevation: 0.05,
|
||||
autoFromWalls: false,
|
||||
} as PolygonTestNode
|
||||
}
|
||||
|
||||
function startDelete(
|
||||
node: PolygonTestNode,
|
||||
payload: unknown,
|
||||
boundaryCommitData?: Partial<PolygonTestNode>,
|
||||
) {
|
||||
useScene.setState({ nodes: { [node.id]: node } } as never)
|
||||
return createPolygonDeleteVertexAffordance<PolygonTestNode>('slab', { boundaryCommitData }).start(
|
||||
{
|
||||
node,
|
||||
payload,
|
||||
nodes: useScene.getState().nodes,
|
||||
initialPlanPoint: [0, 0],
|
||||
gridSnapStep: 0.5,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
describe('polygon delete-vertex floorplan affordance', () => {
|
||||
beforeEach(() => {
|
||||
useScene.setState({ nodes: {}, rootNodeIds: [] } as never)
|
||||
})
|
||||
|
||||
test('deletes an outer vertex and stops at the three-vertex minimum', () => {
|
||||
const node = polygonNode([
|
||||
[0, 0],
|
||||
[4, 0],
|
||||
[4, 4],
|
||||
[0, 4],
|
||||
])
|
||||
const session = startDelete(node, { vertexIndex: 1 })
|
||||
|
||||
expect(session.canCommit()).toBe(true)
|
||||
session.commit?.()
|
||||
expect((useScene.getState().nodes[node.id] as PolygonTestNode).polygon).toEqual([
|
||||
[0, 0],
|
||||
[4, 4],
|
||||
[0, 4],
|
||||
])
|
||||
|
||||
const triangle = useScene.getState().nodes[node.id] as PolygonTestNode
|
||||
const blocked = startDelete(triangle, { vertexIndex: 1 })
|
||||
expect(blocked.canCommit()).toBe(false)
|
||||
blocked.commit?.()
|
||||
expect((useScene.getState().nodes[node.id] as PolygonTestNode).polygon).toHaveLength(3)
|
||||
})
|
||||
|
||||
test('deletes from the targeted hole without changing the outer ring', () => {
|
||||
const node = polygonNode(
|
||||
[
|
||||
[0, 0],
|
||||
[8, 0],
|
||||
[8, 8],
|
||||
[0, 8],
|
||||
],
|
||||
[
|
||||
[
|
||||
[2, 2],
|
||||
[4, 2],
|
||||
[4, 4],
|
||||
[2, 4],
|
||||
],
|
||||
],
|
||||
)
|
||||
const session = startDelete(node, { holeIndex: 0, vertexIndex: 2 })
|
||||
|
||||
expect(session.canCommit()).toBe(true)
|
||||
session.commit?.()
|
||||
const updated = useScene.getState().nodes[node.id] as PolygonTestNode
|
||||
expect(updated.polygon).toEqual(node.polygon)
|
||||
expect(updated.holes?.[0]).toEqual([
|
||||
[2, 2],
|
||||
[4, 2],
|
||||
[2, 4],
|
||||
])
|
||||
})
|
||||
|
||||
test('registers delete-vertex for every registry-driven polygon surface', () => {
|
||||
expect(zoneDefinition.floorplanAffordances?.['delete-vertex']).toBeDefined()
|
||||
expect(slabDefinition.floorplanAffordances?.['delete-vertex']).toBeDefined()
|
||||
expect(ceilingDefinition.floorplanAffordances?.['delete-vertex']).toBeDefined()
|
||||
})
|
||||
|
||||
test('applies kind-owned detachment data with a manual ring edit', () => {
|
||||
const node = {
|
||||
...polygonNode([
|
||||
[0, 0],
|
||||
[4, 0],
|
||||
[4, 4],
|
||||
[0, 4],
|
||||
]),
|
||||
autoFromWalls: true,
|
||||
}
|
||||
const session = startDelete(node, { vertexIndex: 1 }, { autoFromWalls: false })
|
||||
|
||||
session.commit?.()
|
||||
|
||||
expect(
|
||||
(useScene.getState().nodes[node.id] as PolygonTestNode & { autoFromWalls: boolean })
|
||||
.autoFromWalls,
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -23,13 +23,15 @@ import {
|
||||
* targets the outer `node.polygon`. The same factory wires both
|
||||
* boundary and hole interactions without duplicating the math.
|
||||
*
|
||||
* Three affordances available:
|
||||
* Four affordances available:
|
||||
*
|
||||
* - `move-vertex` — drag an existing vertex.
|
||||
* - `add-vertex` — insert a new vertex at an edge midpoint, then drag
|
||||
* it (click-without-drag reverts to the snapshot).
|
||||
* - `move-edge` — drag a whole edge perpendicular to itself (both
|
||||
* endpoints translate by `normal * projection`).
|
||||
* - `delete-vertex` — remove a double-clicked vertex while preserving
|
||||
* the minimum three-vertex ring.
|
||||
*/
|
||||
|
||||
export type PolygonVertexPayload = {
|
||||
@@ -70,7 +72,7 @@ export type PolygonEdgeSnapContext<N extends PolygonShape & { id: AnyNodeId }> =
|
||||
holeIndex?: number
|
||||
}
|
||||
|
||||
type PolygonAffordanceOptions<N extends PolygonShape & { id: AnyNodeId }> = {
|
||||
export type PolygonAffordanceOptions<N extends PolygonShape & { id: AnyNodeId }> = {
|
||||
/** Data committed only when the outer boundary (not a hole) is edited. */
|
||||
boundaryCommitData?: Partial<N>
|
||||
resolvePlanPoint?: (context: PolygonAffordanceSnapContext<N>) => WallPlanPoint
|
||||
@@ -270,6 +272,54 @@ export function createPolygonAddVertexAffordance<N extends PolygonShape & { id:
|
||||
}
|
||||
}
|
||||
|
||||
export function createPolygonDeleteVertexAffordance<N extends PolygonShape & { id: AnyNodeId }>(
|
||||
kind: string,
|
||||
options?: PolygonAffordanceOptions<N>,
|
||||
): FloorplanAffordance<N> {
|
||||
return {
|
||||
start({ node, payload }): FloorplanAffordanceSession {
|
||||
const { vertexIndex, holeIndex } = payload as PolygonVertexPayload
|
||||
const canDeleteCurrentVertex = () => {
|
||||
const current = useScene.getState().nodes[node.id] as N | undefined
|
||||
if (!current || (current as unknown as { type: string }).type !== kind) return false
|
||||
const ring = getRing(current, holeIndex)
|
||||
return Boolean(
|
||||
ring &&
|
||||
ring.length > 3 &&
|
||||
Number.isInteger(vertexIndex) &&
|
||||
vertexIndex >= 0 &&
|
||||
vertexIndex < ring.length,
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
affectedIds: [node.id],
|
||||
apply() {},
|
||||
canCommit: canDeleteCurrentVertex,
|
||||
commit() {
|
||||
const current = useScene.getState().nodes[node.id] as N | undefined
|
||||
if (!current || (current as unknown as { type: string }).type !== kind) return
|
||||
const ring = getRing(current, holeIndex)
|
||||
if (
|
||||
!ring ||
|
||||
ring.length <= 3 ||
|
||||
!Number.isInteger(vertexIndex) ||
|
||||
vertexIndex < 0 ||
|
||||
vertexIndex >= ring.length
|
||||
) {
|
||||
return
|
||||
}
|
||||
const nextRing = ring.filter((_, index) => index !== vertexIndex)
|
||||
const patch = buildRingPatch(current, holeIndex, nextRing, options?.boundaryCommitData)
|
||||
useScene
|
||||
.getState()
|
||||
.updateNodes([{ id: node.id, data: patch as Partial<unknown> as never }])
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Edge-drag: move a whole edge perpendicular to itself. Both endpoints
|
||||
* translate by `edgeNormal * projectedDelta`. The other vertices of
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import type { SlabNode, WallNode, ZoneNode } from '@pascal-app/core'
|
||||
import { slabQuickMeasurement } from '../slab/quick-measurement'
|
||||
import { wallQuickMeasurement } from '../wall/quick-measurement'
|
||||
import { zoneQuickMeasurement } from '../zone/quick-measurement'
|
||||
|
||||
describe('quick measurement reports', () => {
|
||||
test('reports the requested wall dimensions and gross face surface', () => {
|
||||
const report = wallQuickMeasurement({
|
||||
id: 'wall_a',
|
||||
type: 'wall',
|
||||
parentId: 'level_a',
|
||||
start: [0, 0],
|
||||
end: [4, 0],
|
||||
height: 3,
|
||||
thickness: 0.2,
|
||||
children: [],
|
||||
} as WallNode)
|
||||
|
||||
expect(report.metrics.map((metric) => metric.key)).toEqual([
|
||||
'length',
|
||||
'height',
|
||||
'surface',
|
||||
'thickness',
|
||||
])
|
||||
expect(report.metrics.find((metric) => metric.key === 'surface')?.value).toBeCloseTo(12)
|
||||
})
|
||||
|
||||
test('subtracts slab openings while keeping the outside perimeter', () => {
|
||||
const report = slabQuickMeasurement({
|
||||
id: 'slab_a',
|
||||
type: 'slab',
|
||||
parentId: 'level_a',
|
||||
polygon: [
|
||||
[0, 0],
|
||||
[4, 0],
|
||||
[4, 3],
|
||||
[0, 3],
|
||||
],
|
||||
holes: [
|
||||
[
|
||||
[1, 1],
|
||||
[2, 1],
|
||||
[2, 2],
|
||||
[1, 2],
|
||||
],
|
||||
],
|
||||
elevation: 0.25,
|
||||
} as SlabNode)
|
||||
|
||||
expect(report?.metrics.find((metric) => metric.key === 'area')?.value).toBeCloseTo(11)
|
||||
expect(report?.metrics.find((metric) => metric.key === 'perimeter')?.value).toBeCloseTo(14)
|
||||
})
|
||||
|
||||
test('keeps zone hover quantities explicitly footprint-only', () => {
|
||||
const report = zoneQuickMeasurement({
|
||||
id: 'zone_a',
|
||||
type: 'zone',
|
||||
parentId: 'level_a',
|
||||
name: 'Kitchen',
|
||||
polygon: [
|
||||
[0, 0],
|
||||
[5, 0],
|
||||
[5, 4],
|
||||
[0, 4],
|
||||
],
|
||||
} as ZoneNode)
|
||||
|
||||
expect(report?.title).toBe('Kitchen')
|
||||
expect(report?.metrics.find((metric) => metric.key === 'area')?.value).toBeCloseTo(20)
|
||||
expect(report?.note).toContain('room envelope not proven')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
type MeasurementPoint,
|
||||
measurementArea,
|
||||
measurementCentroid,
|
||||
measurementPerimeter,
|
||||
} from '@pascal-app/core'
|
||||
|
||||
type PolygonPoint = readonly [number, number]
|
||||
|
||||
export function polygonMeasurementPoints(
|
||||
polygon: readonly PolygonPoint[],
|
||||
height: number,
|
||||
): MeasurementPoint[] {
|
||||
return polygon.map(([x, z]) => [x, height, z])
|
||||
}
|
||||
|
||||
export function polygonSurfaceArea(
|
||||
polygon: readonly PolygonPoint[],
|
||||
holes: readonly (readonly PolygonPoint[])[] = [],
|
||||
): number {
|
||||
const outer = measurementArea(polygonMeasurementPoints(polygon, 0))
|
||||
const openings = holes.reduce(
|
||||
(total, hole) => total + measurementArea(polygonMeasurementPoints(hole, 0)),
|
||||
0,
|
||||
)
|
||||
return Math.max(0, outer - openings)
|
||||
}
|
||||
|
||||
export function polygonBoundaryLength(polygon: readonly PolygonPoint[]): number {
|
||||
return measurementPerimeter(polygonMeasurementPoints(polygon, 0))
|
||||
}
|
||||
|
||||
export function polygonReportAnchor(
|
||||
polygon: readonly PolygonPoint[],
|
||||
height: number,
|
||||
): MeasurementPoint {
|
||||
return measurementCentroid(polygonMeasurementPoints(polygon, height)) ?? [0, height, 0]
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type NodeDefinition, SiteNode as SiteNodeSchema } from '@pascal-app/core'
|
||||
import { polygonMeasurementFeatures } from '../shared/polygon-measurement'
|
||||
import { siteParametrics } from './parametrics'
|
||||
import { SiteNode } from './schema'
|
||||
|
||||
@@ -30,6 +31,15 @@ export const siteDefinition: NodeDefinition<typeof SiteNode> = {
|
||||
},
|
||||
|
||||
parametrics: siteParametrics,
|
||||
measurement: {
|
||||
features: (node) =>
|
||||
polygonMeasurementFeatures({
|
||||
featurePrefix: 'site',
|
||||
height: 0,
|
||||
label: 'Property',
|
||||
polygon: node.polygon.points,
|
||||
}),
|
||||
},
|
||||
// No dirty consumer rebuilds this kind — see NodeDefinition.dirtyTracking.
|
||||
dirtyTracking: false,
|
||||
|
||||
|
||||
@@ -4,9 +4,11 @@ import {
|
||||
pointInPolygon2D,
|
||||
type SlabNode as SlabNodeType,
|
||||
} from '@pascal-app/core'
|
||||
import { polygonMeasurementFeatures } from '../shared/polygon-measurement'
|
||||
import { buildSlabFloorplan } from './floorplan'
|
||||
import {
|
||||
slabAddVertexAffordance,
|
||||
slabDeleteVertexAffordance,
|
||||
slabMoveEdgeAffordance,
|
||||
slabMoveVertexAffordance,
|
||||
} from './floorplan-affordances'
|
||||
@@ -14,6 +16,7 @@ import { slabFloorplanMoveTarget } from './floorplan-move'
|
||||
import { buildSlabGeometry } from './geometry'
|
||||
import { slabPaint } from './paint'
|
||||
import { slabParametrics } from './parametrics'
|
||||
import { slabQuickMeasurement } from './quick-measurement'
|
||||
import { SlabNode } from './schema'
|
||||
import { slabSlots } from './slots'
|
||||
|
||||
@@ -171,6 +174,16 @@ export const slabDefinition: NodeDefinition<typeof SlabNode> = {
|
||||
|
||||
parametrics: slabParametrics,
|
||||
handles: slabHandles,
|
||||
measurement: {
|
||||
features: (node) =>
|
||||
polygonMeasurementFeatures({
|
||||
featurePrefix: 'slab',
|
||||
height: node.elevation,
|
||||
label: 'Slab',
|
||||
polygon: node.polygon,
|
||||
}),
|
||||
quickMeasure: (node) => slabQuickMeasurement(node),
|
||||
},
|
||||
|
||||
// Stage D: kind-owned placement tool. Multi-click polygon drawing
|
||||
// with 15° angle snap (Shift to defeat).
|
||||
@@ -212,6 +225,7 @@ export const slabDefinition: NodeDefinition<typeof SlabNode> = {
|
||||
'move-vertex': slabMoveVertexAffordance,
|
||||
'add-vertex': slabAddVertexAffordance,
|
||||
'move-edge': slabMoveEdgeAffordance,
|
||||
'delete-vertex': slabDeleteVertexAffordance,
|
||||
},
|
||||
|
||||
toolHints: [
|
||||
|
||||
@@ -2,6 +2,7 @@ import { type AnyNode, resolveLevelId, type SlabNode } from '@pascal-app/core'
|
||||
import { resolveSlabEdgeBandSnap, resolveSlabPlanPointSnap } from '@pascal-app/editor'
|
||||
import {
|
||||
createPolygonAddVertexAffordance,
|
||||
createPolygonDeleteVertexAffordance,
|
||||
createPolygonMoveEdgeAffordance,
|
||||
createPolygonVertexAffordance,
|
||||
type PolygonAffordanceSnapContext,
|
||||
@@ -9,13 +10,14 @@ import {
|
||||
} from '../shared/polygon-vertex-affordance'
|
||||
|
||||
/**
|
||||
* 2D drag affordances for slab. Three operations, each accepting an
|
||||
* 2D affordances for slab. Four operations, each accepting an
|
||||
* optional `holeIndex` in the payload so they target the boundary
|
||||
* polygon or a specific hole:
|
||||
*
|
||||
* - `move-vertex` — drag an existing vertex.
|
||||
* - `add-vertex` — insert a new vertex at a midpoint then drag.
|
||||
* - `move-edge` — drag a whole edge perpendicular to itself.
|
||||
* - `delete-vertex` — remove a double-clicked vertex down to three.
|
||||
*
|
||||
* Holes are surfaced inline alongside the boundary in `def.floorplan`
|
||||
* (no separate "hole edit mode" state machine like the legacy) — when
|
||||
@@ -72,3 +74,7 @@ export const slabMoveEdgeAffordance = createPolygonMoveEdgeAffordance<SlabNode>(
|
||||
'slab',
|
||||
slabSnapOptions,
|
||||
)
|
||||
export const slabDeleteVertexAffordance = createPolygonDeleteVertexAffordance<SlabNode>(
|
||||
'slab',
|
||||
slabSnapOptions,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { QuickMeasurementReport, SlabNode } from '@pascal-app/core'
|
||||
import {
|
||||
polygonBoundaryLength,
|
||||
polygonReportAnchor,
|
||||
polygonSurfaceArea,
|
||||
} from '../shared/quick-measurement'
|
||||
|
||||
export function slabQuickMeasurement(node: SlabNode): QuickMeasurementReport | null {
|
||||
if (node.polygon.length < 3) return null
|
||||
const thickness = node.elevation ?? 0.05
|
||||
|
||||
return {
|
||||
title: node.name ?? 'Floor slab',
|
||||
kindLabel: 'Floor slab',
|
||||
anchor: polygonReportAnchor(node.polygon, thickness + 0.04),
|
||||
metrics: [
|
||||
{
|
||||
key: 'area',
|
||||
label: 'Surface',
|
||||
abbreviation: 'A',
|
||||
quantity: 'area',
|
||||
value: polygonSurfaceArea(node.polygon, node.holes),
|
||||
},
|
||||
{
|
||||
key: 'perimeter',
|
||||
label: 'Perimeter',
|
||||
abbreviation: 'P',
|
||||
quantity: 'length',
|
||||
value: polygonBoundaryLength(node.polygon),
|
||||
},
|
||||
{
|
||||
key: 'thickness',
|
||||
label: 'Thickness',
|
||||
abbreviation: 'T',
|
||||
quantity: 'length',
|
||||
value: thickness,
|
||||
},
|
||||
],
|
||||
note: node.holes.length > 0 ? 'Surface excludes slab openings.' : undefined,
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,7 @@ describe('spawn definition', () => {
|
||||
parent: null,
|
||||
viewState: {
|
||||
selected: true,
|
||||
unit: 'metric',
|
||||
highlighted: false,
|
||||
hovered: false,
|
||||
moving: false,
|
||||
|
||||
@@ -3,8 +3,14 @@ import { buildWallFloorplan, computeWallFloorplanLevelData } from './floorplan'
|
||||
import { wallCurveAffordance, wallMoveEndpointAffordance } from './floorplan-affordances'
|
||||
import { wallFloorplanMoveTarget } from './floorplan-move'
|
||||
import { wallFloorplanSiblingOverrides } from './floorplan-overrides'
|
||||
import {
|
||||
matchWallMeasurementFeature,
|
||||
resolveWallMeasurementFeature,
|
||||
wallMeasurementFeatures,
|
||||
} from './measurement'
|
||||
import { wallPaint } from './paint'
|
||||
import { wallParametrics } from './parametrics'
|
||||
import { wallQuickMeasurement } from './quick-measurement'
|
||||
import { WallNode } from './schema'
|
||||
import { wallSlots } from './slots'
|
||||
|
||||
@@ -104,6 +110,13 @@ export const wallDefinition: NodeDefinition<typeof WallNode> = {
|
||||
// per render pass, then the builder reads its own junctions by wall id.
|
||||
computeFloorplanLevelData: computeWallFloorplanLevelData,
|
||||
floorplan: buildWallFloorplan,
|
||||
measurement: {
|
||||
features: (node) => wallMeasurementFeatures(node),
|
||||
quickMeasure: (node) => wallQuickMeasurement(node),
|
||||
match: (node, _ctx, point, maxDistance) =>
|
||||
matchWallMeasurementFeature(node, point, maxDistance),
|
||||
resolve: (node, _ctx, reference) => resolveWallMeasurementFeature(node, reference),
|
||||
},
|
||||
floorplanDependsOnSiblings: true,
|
||||
// 2D drag affordances triggered by `endpoint-handle` primitives in
|
||||
// `def.floorplan`'s output. Sister to `affordanceTools` (3D) — the
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { WallNode } from '@pascal-app/core'
|
||||
import { matchWallMeasurementFeature } from './measurement'
|
||||
|
||||
describe('matchWallMeasurementFeature', () => {
|
||||
test('keeps an exact plan corner bound to the wall endpoint instead of its face', () => {
|
||||
const wall = WallNode.parse({ start: [0, 0], end: [4, 0], thickness: 0.2 })
|
||||
|
||||
expect(matchWallMeasurementFeature(wall, [4, 0, 0], 0.2)).toMatchObject({
|
||||
featureId: 'wall:end',
|
||||
point: [4, 0, 0],
|
||||
})
|
||||
})
|
||||
|
||||
test('keeps elevated 3D hits on the wall face matcher', () => {
|
||||
const wall = WallNode.parse({ start: [0, 0], end: [4, 0], thickness: 0.2 })
|
||||
|
||||
expect(matchWallMeasurementFeature(wall, [4, 1, 0.1], 0.2)?.featureId).toBe('wall:face:left')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,216 @@
|
||||
import {
|
||||
DEFAULT_WALL_HEIGHT,
|
||||
getWallCurveFrameAt,
|
||||
getWallThickness,
|
||||
type MeasurementFeature,
|
||||
type MeasurementFeatureBinding,
|
||||
type MeasurementFeatureReference,
|
||||
sampleWallCenterline,
|
||||
type WallNode,
|
||||
} from '@pascal-app/core'
|
||||
|
||||
const point = (x: number, y: number, z: number) => [x, y, z] as [number, number, number]
|
||||
|
||||
export function wallMeasurementFeatures(wall: WallNode): MeasurementFeature[] {
|
||||
const height = wall.height ?? DEFAULT_WALL_HEIGHT
|
||||
const centerline = sampleWallCenterline(wall).map(({ x, y }) => point(x, 0, y))
|
||||
const midpoint = getWallCurveFrameAt(wall, 0.5).point
|
||||
const halfThickness = getWallThickness(wall) / 2
|
||||
const leftFace = centerline.map((_center, index) => {
|
||||
const frame = getWallCurveFrameAt(wall, index / Math.max(1, centerline.length - 1))
|
||||
return point(
|
||||
frame.point.x + frame.normal.x * halfThickness,
|
||||
0,
|
||||
frame.point.y + frame.normal.y * halfThickness,
|
||||
)
|
||||
})
|
||||
const rightFace = centerline.map((_center, index) => {
|
||||
const frame = getWallCurveFrameAt(wall, index / Math.max(1, centerline.length - 1))
|
||||
return point(
|
||||
frame.point.x - frame.normal.x * halfThickness,
|
||||
0,
|
||||
frame.point.y - frame.normal.y * halfThickness,
|
||||
)
|
||||
})
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'wall:start',
|
||||
label: 'Wall start',
|
||||
snapKind: 'endpoint',
|
||||
priority: 100,
|
||||
geometry: { kind: 'point', point: point(wall.start[0], 0, wall.start[1]) },
|
||||
},
|
||||
{
|
||||
id: 'wall:end',
|
||||
label: 'Wall end',
|
||||
snapKind: 'endpoint',
|
||||
priority: 100,
|
||||
geometry: { kind: 'point', point: point(wall.end[0], 0, wall.end[1]) },
|
||||
},
|
||||
{
|
||||
id: 'wall:centerline',
|
||||
label: 'Wall centerline',
|
||||
snapKind: 'edge',
|
||||
priority: 80,
|
||||
geometry: { kind: 'path', points: centerline },
|
||||
},
|
||||
{
|
||||
id: 'wall:midpoint',
|
||||
label: 'Wall midpoint',
|
||||
snapKind: 'midpoint',
|
||||
priority: 90,
|
||||
geometry: { kind: 'point', point: point(midpoint.x, 0, midpoint.y) },
|
||||
},
|
||||
{
|
||||
id: 'wall:face:left',
|
||||
label: 'Wall face',
|
||||
snapKind: 'face',
|
||||
priority: 95,
|
||||
geometry: { kind: 'path', points: leftFace },
|
||||
},
|
||||
{
|
||||
id: 'wall:face:right',
|
||||
label: 'Wall face',
|
||||
snapKind: 'face',
|
||||
priority: 95,
|
||||
geometry: { kind: 'path', points: rightFace },
|
||||
},
|
||||
{
|
||||
id: 'wall:height',
|
||||
label: 'Wall height',
|
||||
snapKind: 'height',
|
||||
priority: 85,
|
||||
geometry: {
|
||||
kind: 'segment',
|
||||
start: point(midpoint.x, 0, midpoint.y),
|
||||
end: point(midpoint.x, height, midpoint.y),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'wall:top-centerline',
|
||||
label: 'Wall top',
|
||||
snapKind: 'edge',
|
||||
priority: 75,
|
||||
geometry: {
|
||||
kind: 'path',
|
||||
points: centerline.map(([x, , z]) => point(x, height, z)),
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export function matchWallMeasurementFeature(
|
||||
wall: WallNode,
|
||||
hit: [number, number, number],
|
||||
maxDistance: number,
|
||||
): MeasurementFeatureBinding | null {
|
||||
// Plan drafting deliberately snaps to structural wall endpoints. Preserve
|
||||
// that semantic corner before the face matcher below expands its threshold
|
||||
// by half the wall thickness and turns an exact endpoint into a face anchor.
|
||||
if (Math.abs(hit[1]) <= maxDistance) {
|
||||
const endpointCandidates = [
|
||||
{ featureId: 'wall:start', point: point(wall.start[0], 0, wall.start[1]) },
|
||||
{ featureId: 'wall:end', point: point(wall.end[0], 0, wall.end[1]) },
|
||||
]
|
||||
.map((candidate) => ({
|
||||
...candidate,
|
||||
distance: Math.hypot(
|
||||
hit[0] - candidate.point[0],
|
||||
hit[1] - candidate.point[1],
|
||||
hit[2] - candidate.point[2],
|
||||
),
|
||||
}))
|
||||
.filter((candidate) => candidate.distance <= maxDistance)
|
||||
.sort((a, b) => a.distance - b.distance)
|
||||
const endpoint = endpointCandidates[0]
|
||||
if (endpoint) {
|
||||
return {
|
||||
featureId: endpoint.featureId,
|
||||
point: endpoint.point,
|
||||
parameters: { t: 0 },
|
||||
distance: endpoint.distance,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const points = sampleWallCenterline(wall)
|
||||
let best: MeasurementFeatureBinding | null = null
|
||||
let before = 0
|
||||
const lengths = points.slice(1).map((end, index) => {
|
||||
const start = points[index]!
|
||||
return Math.hypot(end.x - start.x, end.y - start.y)
|
||||
})
|
||||
const total = lengths.reduce((sum, length) => sum + length, 0)
|
||||
|
||||
for (let index = 0; index < points.length - 1; index++) {
|
||||
const start = points[index]!
|
||||
const end = points[index + 1]!
|
||||
const dx = end.x - start.x
|
||||
const dz = end.y - start.y
|
||||
const lengthSquared = dx * dx + dz * dz
|
||||
const localT =
|
||||
lengthSquared <= 1e-12
|
||||
? 0
|
||||
: Math.max(
|
||||
0,
|
||||
Math.min(1, ((hit[0] - start.x) * dx + (hit[2] - start.y) * dz) / lengthSquared),
|
||||
)
|
||||
const t = total <= 1e-9 ? 0 : (before + localT * lengths[index]!) / total
|
||||
const frame = getWallCurveFrameAt(wall, t)
|
||||
const side =
|
||||
(hit[0] - frame.point.x) * frame.normal.x + (hit[2] - frame.point.y) * frame.normal.y >= 0
|
||||
? 1
|
||||
: -1
|
||||
const halfThickness = getWallThickness(wall) / 2
|
||||
const faceX = frame.point.x + frame.normal.x * halfThickness * side
|
||||
const faceZ = frame.point.y + frame.normal.y * halfThickness * side
|
||||
const faceDistance = Math.hypot(hit[0] - faceX, hit[2] - faceZ)
|
||||
const threshold = Math.max(maxDistance, halfThickness + 0.03)
|
||||
if (faceDistance <= threshold && (!best || faceDistance < best.distance)) {
|
||||
const height = Math.max(0, Math.min(wall.height ?? DEFAULT_WALL_HEIGHT, hit[1]))
|
||||
best = {
|
||||
featureId: side > 0 ? 'wall:face:left' : 'wall:face:right',
|
||||
point: point(faceX, height, faceZ),
|
||||
parameters: { t, height },
|
||||
distance: faceDistance,
|
||||
}
|
||||
}
|
||||
before += lengths[index]!
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
export function resolveWallMeasurementFeature(
|
||||
wall: WallNode,
|
||||
reference: MeasurementFeatureReference,
|
||||
): MeasurementFeature | null {
|
||||
const feature = wallMeasurementFeatures(wall).find(
|
||||
(candidate) => candidate.id === reference.featureId,
|
||||
)
|
||||
if (!feature) return null
|
||||
const tValue = reference.parameters?.t
|
||||
const t = typeof tValue === 'number' ? Math.max(0, Math.min(1, tValue)) : 0.5
|
||||
const frame = getWallCurveFrameAt(wall, t)
|
||||
const normal =
|
||||
feature.id === 'wall:face:left'
|
||||
? point(frame.normal.x, 0, frame.normal.y)
|
||||
: feature.id === 'wall:face:right'
|
||||
? point(-frame.normal.x, 0, -frame.normal.y)
|
||||
: feature.id === 'wall:top-centerline'
|
||||
? point(0, 1, 0)
|
||||
: undefined
|
||||
const heightValue = reference.parameters?.height
|
||||
if (typeof heightValue !== 'number' || feature.geometry.kind !== 'path') {
|
||||
return normal ? { ...feature, normal } : feature
|
||||
}
|
||||
const height = Math.max(0, Math.min(wall.height ?? DEFAULT_WALL_HEIGHT, heightValue))
|
||||
return {
|
||||
...feature,
|
||||
...(normal ? { normal } : {}),
|
||||
geometry: {
|
||||
...feature.geometry,
|
||||
points: feature.geometry.points.map(([x, , z]) => point(x, height, z)),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
DEFAULT_WALL_HEIGHT,
|
||||
getWallCurveFrameAt,
|
||||
getWallCurveLength,
|
||||
getWallThickness,
|
||||
type QuickMeasurementReport,
|
||||
type WallNode,
|
||||
} from '@pascal-app/core'
|
||||
|
||||
export function wallQuickMeasurement(node: WallNode): QuickMeasurementReport {
|
||||
const length = getWallCurveLength(node)
|
||||
const height = node.height ?? DEFAULT_WALL_HEIGHT
|
||||
const frame = getWallCurveFrameAt(node, 0.5)
|
||||
|
||||
return {
|
||||
title: node.name ?? 'Wall',
|
||||
kindLabel: 'Wall',
|
||||
anchor: [frame.point.x, height * 0.55, frame.point.y],
|
||||
metrics: [
|
||||
{ key: 'length', label: 'Length', abbreviation: 'L', quantity: 'length', value: length },
|
||||
{ key: 'height', label: 'Height', abbreviation: 'H', quantity: 'length', value: height },
|
||||
{
|
||||
key: 'surface',
|
||||
label: 'Surface',
|
||||
abbreviation: 'A',
|
||||
quantity: 'area',
|
||||
value: length * height,
|
||||
},
|
||||
{
|
||||
key: 'thickness',
|
||||
label: 'Thickness',
|
||||
abbreviation: 'T',
|
||||
quantity: 'length',
|
||||
value: getWallThickness(node),
|
||||
},
|
||||
],
|
||||
note: 'Gross face area before openings.',
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,19 @@
|
||||
import { type NodeDefinition, ZoneNode as ZoneNodeSchema } from '@pascal-app/core'
|
||||
import {
|
||||
type NodeDefinition,
|
||||
resolveAutoZonePolygon,
|
||||
ZoneNode as ZoneNodeSchema,
|
||||
} from '@pascal-app/core'
|
||||
import { polygonMeasurementFeatures } from '../shared/polygon-measurement'
|
||||
import { buildZoneFloorplan } from './floorplan'
|
||||
import {
|
||||
zoneAddVertexAffordance,
|
||||
zoneDeleteVertexAffordance,
|
||||
zoneMoveEdgeAffordance,
|
||||
zoneMoveVertexAffordance,
|
||||
} from './floorplan-affordances'
|
||||
import { zoneFloorplanMoveTarget } from './floorplan-move'
|
||||
import { zoneParametrics } from './parametrics'
|
||||
import { zoneQuickMeasurement } from './quick-measurement'
|
||||
import { ZoneNode } from './schema'
|
||||
|
||||
/**
|
||||
@@ -38,6 +45,16 @@ export const zoneDefinition: NodeDefinition<typeof ZoneNode> = {
|
||||
},
|
||||
|
||||
parametrics: zoneParametrics,
|
||||
measurement: {
|
||||
features: (node, ctx) =>
|
||||
polygonMeasurementFeatures({
|
||||
featurePrefix: 'zone',
|
||||
height: 0,
|
||||
label: 'Zone',
|
||||
polygon: resolveAutoZonePolygon(node, ctx.resolve),
|
||||
}),
|
||||
quickMeasure: (node, ctx) => zoneQuickMeasurement(node, ctx),
|
||||
},
|
||||
// No dirty consumer rebuilds this kind — see NodeDefinition.dirtyTracking.
|
||||
dirtyTracking: false,
|
||||
|
||||
@@ -50,18 +67,20 @@ export const zoneDefinition: NodeDefinition<typeof ZoneNode> = {
|
||||
priority: 4,
|
||||
},
|
||||
floorplan: buildZoneFloorplan,
|
||||
floorplanDependencies: (node) => (node.autoFromWalls ? node.boundaryWallIds : []),
|
||||
// 2D body move — centroid-pivot polygon mover (same as slab / ceiling).
|
||||
// Without this, zone fell through to the overlay's generic free-translate
|
||||
// path, which committed a `position` field zone has no schema for, so the
|
||||
// polygon never actually moved on drop.
|
||||
floorplanMoveTarget: zoneFloorplanMoveTarget,
|
||||
// Polygon editor when selected — same three operations slabs / ceilings
|
||||
// Polygon editor when selected — same four operations slabs / ceilings
|
||||
// expose. The shared factories key off `node.polygon`, optional
|
||||
// `node.holes` (absent on zones). See `floorplan-affordances.ts`.
|
||||
floorplanAffordances: {
|
||||
'move-vertex': zoneMoveVertexAffordance,
|
||||
'add-vertex': zoneAddVertexAffordance,
|
||||
'move-edge': zoneMoveEdgeAffordance,
|
||||
'delete-vertex': zoneDeleteVertexAffordance,
|
||||
},
|
||||
|
||||
presentation: {
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import type { ZoneNode } from '@pascal-app/core'
|
||||
import {
|
||||
createPolygonAddVertexAffordance,
|
||||
createPolygonDeleteVertexAffordance,
|
||||
createPolygonMoveEdgeAffordance,
|
||||
createPolygonVertexAffordance,
|
||||
} from '../shared/polygon-vertex-affordance'
|
||||
|
||||
/**
|
||||
* 2D drag affordances for zone — same three polygon-editing operations
|
||||
* 2D affordances for zone — same four polygon-editing operations
|
||||
* slabs and ceilings expose. Zones have no `holes` field, but the
|
||||
* shared factory accepts that case (holeIndex stays undefined and the
|
||||
* boundary polygon is the target).
|
||||
@@ -14,7 +15,25 @@ import {
|
||||
* - `move-vertex` — drag an existing polygon vertex.
|
||||
* - `add-vertex` — insert a new vertex at an edge midpoint, then drag.
|
||||
* - `move-edge` — drag an entire edge perpendicular to itself.
|
||||
* - `delete-vertex` — remove a double-clicked vertex down to three.
|
||||
*/
|
||||
export const zoneMoveVertexAffordance = createPolygonVertexAffordance<ZoneNode>('zone')
|
||||
export const zoneAddVertexAffordance = createPolygonAddVertexAffordance<ZoneNode>('zone')
|
||||
export const zoneMoveEdgeAffordance = createPolygonMoveEdgeAffordance<ZoneNode>('zone')
|
||||
const zoneManualEditOptions = {
|
||||
boundaryCommitData: { autoFromWalls: false, boundaryWallIds: [] },
|
||||
}
|
||||
|
||||
export const zoneMoveVertexAffordance = createPolygonVertexAffordance<ZoneNode>(
|
||||
'zone',
|
||||
zoneManualEditOptions,
|
||||
)
|
||||
export const zoneAddVertexAffordance = createPolygonAddVertexAffordance<ZoneNode>(
|
||||
'zone',
|
||||
zoneManualEditOptions,
|
||||
)
|
||||
export const zoneMoveEdgeAffordance = createPolygonMoveEdgeAffordance<ZoneNode>(
|
||||
'zone',
|
||||
zoneManualEditOptions,
|
||||
)
|
||||
export const zoneDeleteVertexAffordance = createPolygonDeleteVertexAffordance<ZoneNode>(
|
||||
'zone',
|
||||
zoneManualEditOptions,
|
||||
)
|
||||
|
||||
@@ -12,4 +12,9 @@ import { createPolygonCentroidMoveTarget } from '../shared/polygon-centroid-move
|
||||
* polygon mover translates the actual vertices. `meshY = 0`.
|
||||
*/
|
||||
export const zoneFloorplanMoveTarget: FloorplanMoveTarget<ZoneNode> = ({ node, nodes }) =>
|
||||
createPolygonCentroidMoveTarget({ node, nodes, meshY: 0 })
|
||||
createPolygonCentroidMoveTarget({
|
||||
node,
|
||||
nodes,
|
||||
meshY: 0,
|
||||
extraCommitData: node.autoFromWalls ? { autoFromWalls: false, boundaryWallIds: [] } : undefined,
|
||||
})
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import type { FloorplanGeometry, FloorplanPoint, GeometryContext, ZoneNode } from '@pascal-app/core'
|
||||
import {
|
||||
type FloorplanGeometry,
|
||||
type FloorplanPoint,
|
||||
type GeometryContext,
|
||||
resolveAutoZonePolygon,
|
||||
type ZoneNode,
|
||||
} from '@pascal-app/core'
|
||||
|
||||
/**
|
||||
* Stage C floor-plan builder for zone. Zones are colored polygons —
|
||||
@@ -11,7 +17,7 @@ import type { FloorplanGeometry, FloorplanPoint, GeometryContext, ZoneNode } fro
|
||||
* furniture in the SVG document order (= z-order).
|
||||
*/
|
||||
export function buildZoneFloorplan(node: ZoneNode, ctx: GeometryContext): FloorplanGeometry | null {
|
||||
const ring = node.polygon
|
||||
const ring = resolveAutoZonePolygon(node, ctx.resolve)
|
||||
if (!ring || ring.length < 3) return null
|
||||
|
||||
const view = ctx.viewState
|
||||
|
||||
@@ -2,4 +2,5 @@ import type { ParametricDescriptor, ZoneNode } from '@pascal-app/core'
|
||||
|
||||
export const zoneParametrics: ParametricDescriptor<ZoneNode> = {
|
||||
groups: [],
|
||||
trailingSection: () => import('./quantities-panel'),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
deriveZoneQuantityReport,
|
||||
resolveAutoZonePolygon,
|
||||
useLiveNodeOverrides,
|
||||
useScene,
|
||||
type ZoneNode,
|
||||
type ZoneQuantityValue,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
formatAreaLabel,
|
||||
formatLinearMeasurement,
|
||||
formatVolumeLabel,
|
||||
PanelSection,
|
||||
} from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useMemo } from 'react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
|
||||
type Point2D = readonly [number, number]
|
||||
|
||||
function ZonePlanSketch({
|
||||
edgeLengths,
|
||||
polygon,
|
||||
unit,
|
||||
}: {
|
||||
edgeLengths: readonly number[]
|
||||
polygon: readonly Point2D[]
|
||||
unit: 'metric' | 'imperial'
|
||||
}) {
|
||||
if (polygon.length < 3) {
|
||||
return (
|
||||
<div className="flex h-28 items-center justify-center rounded-md border border-border/50 text-muted-foreground text-xs">
|
||||
Zone boundary unavailable
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const viewWidth = 276
|
||||
const viewHeight = 176
|
||||
const padding = 34
|
||||
const xs = polygon.map((point) => point[0])
|
||||
const ys = polygon.map((point) => point[1])
|
||||
const minX = Math.min(...xs)
|
||||
const maxX = Math.max(...xs)
|
||||
const minY = Math.min(...ys)
|
||||
const maxY = Math.max(...ys)
|
||||
const width = Math.max(maxX - minX, 1e-6)
|
||||
const height = Math.max(maxY - minY, 1e-6)
|
||||
const scale = Math.min((viewWidth - padding * 2) / width, (viewHeight - padding * 2) / height)
|
||||
const offsetX = (viewWidth - width * scale) / 2
|
||||
const offsetY = (viewHeight - height * scale) / 2
|
||||
const projected = polygon.map(
|
||||
([x, y]) => [offsetX + (x - minX) * scale, offsetY + (maxY - y) * scale] as Point2D,
|
||||
)
|
||||
const center = projected.reduce(
|
||||
(sum, point) => [sum[0] + point[0] / projected.length, sum[1] + point[1] / projected.length],
|
||||
[0, 0] as [number, number],
|
||||
)
|
||||
|
||||
return (
|
||||
<svg
|
||||
aria-label="Top view with zone edge dimensions"
|
||||
className="h-auto w-full rounded-md border border-cyan-950/20 bg-[#f8faf7]"
|
||||
role="img"
|
||||
viewBox={`0 0 ${viewWidth} ${viewHeight}`}
|
||||
>
|
||||
<defs>
|
||||
<pattern height="12" id="zone-quantity-grid" patternUnits="userSpaceOnUse" width="12">
|
||||
<path d="M 12 0 L 0 0 0 12" fill="none" stroke="#0891b2" strokeOpacity="0.08" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect fill="url(#zone-quantity-grid)" height={viewHeight} width={viewWidth} />
|
||||
<polygon
|
||||
fill="#67e8f9"
|
||||
fillOpacity="0.12"
|
||||
points={projected.map((point) => point.join(',')).join(' ')}
|
||||
stroke="#0e7490"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
{projected.map((start, index) => {
|
||||
const end = projected[(index + 1) % projected.length]
|
||||
if (!end) return null
|
||||
const midpoint: Point2D = [(start[0] + end[0]) / 2, (start[1] + end[1]) / 2]
|
||||
const fromCenter = [midpoint[0] - center[0], midpoint[1] - center[1]] as const
|
||||
const directionLength = Math.hypot(fromCenter[0], fromCenter[1]) || 1
|
||||
const labelPoint: Point2D = [
|
||||
midpoint[0] + (fromCenter[0] / directionLength) * 15,
|
||||
midpoint[1] + (fromCenter[1] / directionLength) * 15,
|
||||
]
|
||||
const label = formatLinearMeasurement(edgeLengths[index] ?? 0, unit)
|
||||
const labelWidth = Math.max(24, label.length * 5.5 + 8)
|
||||
|
||||
return (
|
||||
<g key={`${start[0]}-${start[1]}-${index}`}>
|
||||
<circle cx={start[0]} cy={start[1]} fill="#f8faf7" r="2.5" stroke="#0e7490" />
|
||||
<rect
|
||||
fill="#f8faf7"
|
||||
height="13"
|
||||
rx="2"
|
||||
stroke="#0e7490"
|
||||
strokeOpacity="0.25"
|
||||
width={labelWidth}
|
||||
x={labelPoint[0] - labelWidth / 2}
|
||||
y={labelPoint[1] - 7}
|
||||
/>
|
||||
<text
|
||||
dominantBaseline="middle"
|
||||
fill="#164e63"
|
||||
fontFamily="ui-monospace, SFMono-Regular, monospace"
|
||||
fontSize="7.5"
|
||||
textAnchor="middle"
|
||||
x={labelPoint[0]}
|
||||
y={labelPoint[1]}
|
||||
>
|
||||
{label}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function QuantityRow({
|
||||
abbreviation,
|
||||
format,
|
||||
label,
|
||||
quantity,
|
||||
}: {
|
||||
abbreviation: string
|
||||
format: (value: number) => string
|
||||
label: string
|
||||
quantity: ZoneQuantityValue
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-md border border-border/50 bg-background/35 px-2.5 py-2">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="font-mono font-semibold text-cyan-600 text-[10px]">{abbreviation}</span>
|
||||
<span className="text-muted-foreground text-xs">{label}</span>
|
||||
<span className="ml-auto font-mono font-medium text-foreground text-xs tabular-nums">
|
||||
{quantity.status === 'available' ? format(quantity.value) : 'Not proven'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-[10px] text-muted-foreground leading-snug">
|
||||
{quantity.status === 'available' ? quantity.note : quantity.reason}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ZoneQuantitiesPanel() {
|
||||
const selectedZoneId = useViewer((state) => state.selection.zoneId)
|
||||
const unit = useViewer((state) => state.unit)
|
||||
const nodes = useScene((state) => state.nodes)
|
||||
const zone = selectedZoneId ? (nodes[selectedZoneId] as ZoneNode | undefined) : undefined
|
||||
const livePolygon = useLiveNodeOverrides((state) =>
|
||||
selectedZoneId ? state.overrides.get(selectedZoneId)?.polygon : undefined,
|
||||
) as ZoneNode['polygon'] | undefined
|
||||
const boundaryWallIds = zone?.autoFromWalls ? zone.boundaryWallIds : []
|
||||
const boundaryOverrides = useLiveNodeOverrides(
|
||||
useShallow((state) => boundaryWallIds.map((id) => state.overrides.get(id))),
|
||||
)
|
||||
const proceduralPolygon = zone
|
||||
? resolveAutoZonePolygon(zone, (id) => {
|
||||
const dependency = nodes[id]
|
||||
if (!dependency) return undefined
|
||||
const override =
|
||||
boundaryOverrides[boundaryWallIds.indexOf(id as (typeof boundaryWallIds)[number])]
|
||||
return override ? { ...dependency, ...override } : dependency
|
||||
})
|
||||
: undefined
|
||||
const effectiveZone = zone
|
||||
? { ...zone, polygon: livePolygon ?? proceduralPolygon ?? zone.polygon }
|
||||
: undefined
|
||||
const effectiveNodes = useMemo(() => {
|
||||
if (boundaryOverrides.every((override) => !override)) return nodes
|
||||
const merged = { ...nodes }
|
||||
boundaryWallIds.forEach((id, index) => {
|
||||
const dependency = nodes[id]
|
||||
const override = boundaryOverrides[index]
|
||||
if (dependency && override) merged[id] = { ...dependency, ...override }
|
||||
})
|
||||
return merged
|
||||
}, [boundaryOverrides, boundaryWallIds, nodes])
|
||||
const report = useMemo(
|
||||
() => (effectiveZone ? deriveZoneQuantityReport(effectiveZone, effectiveNodes) : null),
|
||||
[effectiveNodes, effectiveZone],
|
||||
)
|
||||
|
||||
if (!effectiveZone || !report) return null
|
||||
|
||||
return (
|
||||
<PanelSection title="Zone quantities">
|
||||
<div className="overflow-hidden rounded-md border border-cyan-950/20 bg-[#f8faf7] text-slate-950">
|
||||
<div className="flex items-center border-cyan-950/15 border-b px-2.5 py-2">
|
||||
<span className="font-semibold text-[11px]">{effectiveZone.name}</span>
|
||||
<span className="ml-auto rounded-full border border-cyan-800/25 bg-cyan-50 px-2 py-0.5 text-cyan-900 text-[9px]">
|
||||
{report.classification === 'enclosed-room' ? 'Enclosed room' : 'Footprint only'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-2 px-2.5 py-2 font-mono text-[10px]">
|
||||
<span className="text-cyan-800">A</span>
|
||||
<span>{formatAreaLabel(report.footprintArea, unit, 2)}</span>
|
||||
<span className="ml-auto text-slate-600">P</span>
|
||||
<span>{formatLinearMeasurement(report.perimeter, unit)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ZonePlanSketch
|
||||
edgeLengths={report.edgeLengths}
|
||||
polygon={effectiveZone.polygon}
|
||||
unit={unit}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<QuantityRow
|
||||
abbreviation="Aw"
|
||||
format={(value) => formatAreaLabel(value, unit, 2)}
|
||||
label="Wall surface"
|
||||
quantity={report.wallSurface}
|
||||
/>
|
||||
<QuantityRow
|
||||
abbreviation="Af"
|
||||
format={(value) => formatAreaLabel(value, unit, 2)}
|
||||
label="Floor surface"
|
||||
quantity={report.floorSurface}
|
||||
/>
|
||||
<QuantityRow
|
||||
abbreviation="V"
|
||||
format={(value) => formatVolumeLabel(value, unit, 2)}
|
||||
label="Volume"
|
||||
quantity={report.volume}
|
||||
/>
|
||||
</div>
|
||||
</PanelSection>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import {
|
||||
type GeometryContext,
|
||||
type QuickMeasurementReport,
|
||||
resolveAutoZonePolygon,
|
||||
type ZoneNode,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
polygonBoundaryLength,
|
||||
polygonReportAnchor,
|
||||
polygonSurfaceArea,
|
||||
} from '../shared/quick-measurement'
|
||||
|
||||
export function zoneQuickMeasurement(
|
||||
node: ZoneNode,
|
||||
context?: GeometryContext,
|
||||
): QuickMeasurementReport | null {
|
||||
const polygon = context ? resolveAutoZonePolygon(node, context.resolve) : node.polygon
|
||||
if (polygon.length < 3) return null
|
||||
|
||||
return {
|
||||
title: node.name,
|
||||
kindLabel: 'Zone',
|
||||
anchor: polygonReportAnchor(polygon, 0.08),
|
||||
metrics: [
|
||||
{
|
||||
key: 'area',
|
||||
label: 'Footprint',
|
||||
abbreviation: 'A',
|
||||
quantity: 'area',
|
||||
value: polygonSurfaceArea(polygon),
|
||||
},
|
||||
{
|
||||
key: 'perimeter',
|
||||
label: 'Perimeter',
|
||||
abbreviation: 'P',
|
||||
quantity: 'length',
|
||||
value: polygonBoundaryLength(polygon),
|
||||
},
|
||||
],
|
||||
note: 'Footprint only — room envelope not proven.',
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,20 @@
|
||||
'use client'
|
||||
|
||||
import { useLiveNodeOverrides, useRegistry, type ZoneNode } from '@pascal-app/core'
|
||||
import {
|
||||
type AnyNode,
|
||||
resolveAutoZonePolygon,
|
||||
useLiveNodeOverrides,
|
||||
useRegistry,
|
||||
useScene,
|
||||
type ZoneNode,
|
||||
} from '@pascal-app/core'
|
||||
import { useNodeEvents, useViewer, ZONE_LAYER } from '@pascal-app/viewer'
|
||||
import { Html } from '@react-three/drei'
|
||||
import { useMemo, useRef } from 'react'
|
||||
import { BufferGeometry, Color, DoubleSide, Float32BufferAttribute, type Group, Shape } from 'three'
|
||||
import { color, float, uniform, uv } from 'three/tsl'
|
||||
import { MeshBasicNodeMaterial } from 'three/webgpu'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
|
||||
const Y_OFFSET = 0.01
|
||||
const WALL_HEIGHT = 2.3
|
||||
@@ -123,7 +131,25 @@ export const ZoneRenderer = ({ node }: { node: ZoneNode }) => {
|
||||
const livePolygon = useLiveNodeOverrides((s) => s.overrides.get(node.id)?.polygon) as
|
||||
| Array<[number, number]>
|
||||
| undefined
|
||||
const polygon = livePolygon ?? node?.polygon
|
||||
const dependencyIds = node.autoFromWalls ? node.boundaryWallIds : []
|
||||
const dependencyNodes = useScene(
|
||||
useShallow((state) => dependencyIds.map((id) => state.nodes[id])),
|
||||
)
|
||||
const dependencyOverrides = useLiveNodeOverrides(
|
||||
useShallow((state) => dependencyIds.map((id) => state.overrides.get(id))),
|
||||
)
|
||||
const proceduralPolygon = useMemo(
|
||||
() =>
|
||||
resolveAutoZonePolygon(node, (id) => {
|
||||
const index = dependencyIds.indexOf(id as (typeof dependencyIds)[number])
|
||||
const dependency = dependencyNodes[index]
|
||||
if (!dependency) return undefined
|
||||
const override = dependencyOverrides[index]
|
||||
return override ? ({ ...dependency, ...override } as AnyNode) : dependency
|
||||
}),
|
||||
[dependencyIds, dependencyNodes, dependencyOverrides, node],
|
||||
)
|
||||
const polygon = livePolygon ?? proceduralPolygon
|
||||
|
||||
// Create floor shape from polygon
|
||||
const floorShape = useMemo(() => {
|
||||
|
||||
Reference in New Issue
Block a user