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:
Aymeric Rabot
2026-07-17 19:01:01 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 22c9472066
commit ae87ca5475
137 changed files with 15492 additions and 330 deletions
+12
View File
@@ -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,
)
+6 -1
View File
@@ -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,
})
+3
View File
@@ -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)
})
})
+156
View File
@@ -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()
}
}
+129 -45
View File
@@ -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.',
},
}
+124
View File
@@ -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)
})
})
+139
View File
@@ -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',
})
})
})
+236
View File
@@ -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 }
}
+15
View File
@@ -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)
})
})
+458
View File
@@ -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')
}
})
})
+289
View File
@@ -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 }
+1
View File
@@ -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 />
}
+567
View File
@@ -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]
}
+10
View File
@@ -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,
+14
View File
@@ -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,
+13
View File
@@ -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')
})
})
+216
View File
@@ -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.',
}
}
+21 -2
View File
@@ -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,
)
+6 -1
View File
@@ -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,
})
+8 -2
View File
@@ -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
+1
View File
@@ -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.',
}
}
+28 -2
View File
@@ -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(() => {