diff --git a/packages/core/src/agent-operations/usage-checks.ts b/packages/core/src/agent-operations/usage-checks.ts index 5410bc3a8d..b72239ad92 100644 --- a/packages/core/src/agent-operations/usage-checks.ts +++ b/packages/core/src/agent-operations/usage-checks.ts @@ -1,13 +1,28 @@ -import { type UsageIssue, usageIssues } from '../spatial/usage-field/findings' +import { isUsageType, ROOM_ENTRY_TYPE, ROOM_FOLDED_TYPES } from '../agent-tools/verify-scene-usage' +import { USAGE_TYPES, type UsageIssue, usageIssues } from '../spatial/usage-field/findings' import { sdfChecksOn } from '../spatial/usage-field/flag' import { registerSceneCheck } from './verify-scene' +/** + * The finding types a `type` question is about: itself, or what a room's one entry holds. A usage + * type no measurement gives is a misspelt one, and its answer names the types there are: every + * measurement is taken, as for a question with no type. + */ +function askedTypes(type: unknown): ReadonlySet | undefined { + if (typeof type !== 'string' || type.length === 0) return undefined + if (type === ROOM_ENTRY_TYPE) return new Set(ROOM_FOLDED_TYPES) + return isUsageType(type) && !USAGE_TYPES.has(type) ? undefined : new Set([type]) +} + /** * Passages, turning circles, pockets and wall objects, measured on the real shapes of the scene - * (`spatial/usage-field`). Off unless a host turns usage checks on; off, it adds nothing. + * (`spatial/usage-field`). Off unless a host turns usage checks on; off, it adds nothing. A + * question about one finding type takes the measurements that give it, and none for a type that + * is not a usage finding. */ registerSceneCheck({ name: 'usage', order: 60, - run: (nodes) => (sdfChecksOn() ? (usageIssues(nodes) as UsageIssue[]) : []), + run: (nodes, input) => + sdfChecksOn() ? (usageIssues(nodes, { types: askedTypes(input.type) }) as UsageIssue[]) : [], }) diff --git a/packages/core/src/agent-operations/verify-scene-typed.test.ts b/packages/core/src/agent-operations/verify-scene-typed.test.ts new file mode 100644 index 0000000000..1f4ba0ec94 --- /dev/null +++ b/packages/core/src/agent-operations/verify-scene-typed.test.ts @@ -0,0 +1,141 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test' +import { verifySceneModelResult } from '../agent-tools/verify-scene' +import { + type AnyNode, + BuildingNode, + DoorNode, + ItemNode, + LevelNode, + SlabNode, + WallNode, + ZoneNode, +} from '../schema' +import { setSdfChecks } from '../spatial/usage-field/flag' +import './index' +import type { SceneNodes } from './types' +import { verifyScene } from './verify-scene' + +type Pt = [number, number] +type Report = { issues: { type: string }[] } +type Answer = { issueCount: number; issues: unknown[] } + +const wall = (id: string, start: Pt, end: Pt, children: string[] = []) => + WallNode.parse({ id, parentId: 'level_1', start, end, thickness: 0.1, children }) + +const door = (id: string, wallId: string, x: number) => + DoorNode.parse({ + id, + parentId: wallId, + wallId, + position: [x, 1.05, 0], + width: 0.9, + height: 2.1, + frameDepth: 0.07, + }) + +const room = (id: string, name: string, x0: number, x1: number) => + ZoneNode.parse({ + id, + parentId: 'level_1', + name, + polygon: [ + [x0, 0], + [x1, 0], + [x1, 4], + [x0, 4], + ], + }) + +const piece = (id: string, x: number, z: number, width: number, depth: number) => + ItemNode.parse({ + id, + parentId: 'level_1', + position: [x, 0, z], + name: id, + asset: { + id, + name: id, + category: 'storage', + thumbnail: '/t.webp', + src: `/items/${id}/model.glb`, + dimensions: [width, 1.0, depth], + }, + }) + +// A 6 x 4 m shell of two rooms, entered from the street into the west one. Its pieces leave a +// narrow way to the inner door, stand in that door's swing, overlap each other and fence off a +// corner, so the report holds findings of several usage types beside the ordinary ones. +const nodes = Object.fromEntries( + ( + [ + BuildingNode.parse({ id: 'building_1', children: ['level_1'] }), + LevelNode.parse({ id: 'level_1', parentId: 'building_1', level: 0, children: [] }), + SlabNode.parse({ + id: 'slab_1', + parentId: 'level_1', + polygon: [ + [0, 0], + [6, 0], + [6, 4], + [0, 4], + ], + }), + wall('wall_s', [0, 0], [6, 0], ['door_front']), + wall('wall_e', [6, 0], [6, 4]), + wall('wall_n', [6, 4], [0, 4]), + wall('wall_w', [0, 4], [0, 0]), + wall('wall_p', [3, 0], [3, 4], ['door_inner']), + door('door_front', 'wall_s', 1.5), + door('door_inner', 'wall_p', 2), + room('zone_west', 'West room', 0, 3), + room('zone_east', 'East room', 3, 6), + piece('item_a', 1.2, 1.0, 0.4, 1.4), + piece('item_b', 2.0, 1.4, 0.6, 0.4), + piece('item_c', 2.2, 1.5, 0.6, 0.6), + piece('item_d', 3.6, 2.0, 0.5, 0.9), + piece('item_e', 4.6, 2.0, 0.4, 3.6), + ] as AnyNode[] + ).map((node) => [node.id, node]), +) as SceneNodes + +const context = { activeLevelId: 'level_1' } +const report = (input: Record) => verifyScene(nodes, input, context).result +const answer = (from: Record, input: Record) => + verifySceneModelResult(from, input) as Answer + +describe('verify_scene asked for one finding type, with the usage checks on', () => { + beforeAll(() => setSdfChecks(true)) + afterAll(() => setSdfChecks(false)) + + test('answers the findings of that type the whole report holds, for every type', () => { + const whole = report({}) + const found = [...new Set((whole as Report).issues.map((issue) => issue.type))] + expect(found.filter((type) => type.startsWith('usage_')).length).toBeGreaterThan(2) + expect(found.some((type) => !type.startsWith('usage_'))).toBe(true) + const asked = [...found, 'usage_room', 'usage_bed_clearance', 'room_unnamed', 'no_such_type'] + for (const type of asked) { + const typed = answer(report({ type }), { type }) + const before = answer(whole, { type }) + expect({ type, issues: typed.issues }).toEqual({ type, issues: before.issues }) + expect(typed.issueCount).toBe(before.issueCount) + } + }) + + test('a room and a type together answer as the whole report does', () => { + const input = { room: 'West room', type: 'usage_room' } + expect(answer(report(input), input)).toEqual(answer(report({ room: 'West room' }), input)) + }) + + test('a misspelt usage type answers with every type the whole report holds', () => { + const input = { type: 'usage_passage_narow' } + expect(answer(report(input), input)).toEqual(answer(report({}), input)) + }) + + test('the report for a type that is not a usage finding holds no usage findings', () => { + const typed = report({ type: 'door_blocked' }) as Report + expect(typed.issues.filter((issue) => issue.type.startsWith('usage_'))).toEqual([]) + expect((report({}) as Report).issues.some((issue) => issue.type.startsWith('usage_'))).toBe( + true, + ) + }) +}) diff --git a/packages/core/src/spatial/usage-field/findings.ts b/packages/core/src/spatial/usage-field/findings.ts index ded10c145e..3ce0431b3a 100644 --- a/packages/core/src/spatial/usage-field/findings.ts +++ b/packages/core/src/spatial/usage-field/findings.ts @@ -18,7 +18,7 @@ import { type Passage, type RoomMeasure, } from './measure' -import { usageShapes } from './shapes' +import { type UsageShapes, usageShapes } from './shapes' import { doorSwingIssues } from './swings' import type { Nodes } from './walls' @@ -297,15 +297,12 @@ function pocketIssue(nodes: Nodes, room: RoomMeasure, from: string): UsageIssue } } -/** - * The usage findings of a scene: narrow passages (measured from the chosen entrance, one for each - * place that is narrow with the rooms it leads to, and the best other door as a fact), missing turning circles, floor a 0.60 m unit cannot get out of, wall - * objects that stand out into circulation, pieces inside something solid, what stands in front of a - * window or a door, a bed's room, a room's headroom and its ceiling. Every threshold is an indicator, - * not a compliance check. - */ -export function usageIssues(nodes: Nodes, options: MeasureOptions = {}): UsageIssue[] { - const shapes = usageShapes(nodes) +/** The passages, turning circles and pockets of every group of rooms, measured on one clearance field. */ +function circulationIssues( + nodes: Nodes, + shapes: UsageShapes, + options: MeasureOptions, +): UsageIssue[] { const measure = measureUsage(nodes, shapes, options) const issues: UsageIssue[] = [] for (const group of measure.groups) { @@ -343,14 +340,57 @@ export function usageIssues(nodes: Nodes, options: MeasureOptions = {}): UsageIs } } } - issues.push( - ...clashIssues(nodes, shapes), - ...blockedOpeningIssues(nodes, shapes), - ...doorSwingIssues(nodes, shapes), - ...entryIssues(nodes, shapes), - ...doorOverStairIssues(nodes, shapes), - ...fixtureClearanceIssues(nodes, shapes), - ...bedIssues(nodes, shapes), - ) return issues } + +/** + * Each measurement with the finding types it gives, in the order their findings are listed. On a + * building of a few hundred rooms the circulation measurement takes seconds and the others a + * fraction of one, so a question about some types takes the measurements that give them alone. + */ +const MEASUREMENTS: { + types: readonly UsageIssue['type'][] + run: (nodes: Nodes, shapes: UsageShapes, options: MeasureOptions) => UsageIssue[] +}[] = [ + { + types: ['usage_pocket', 'usage_passage_narrow', 'usage_turning_narrow'], + run: circulationIssues, + }, + { types: ['usage_clash'], run: clashIssues }, + { + types: ['usage_opening_blocked'], + run: (nodes, shapes) => blockedOpeningIssues(nodes, shapes), + }, + { types: ['usage_door_swing'], run: doorSwingIssues }, + { types: ['usage_entry_narrow'], run: entryIssues }, + { types: ['usage_door_over_stair'], run: doorOverStairIssues }, + { types: ['usage_fixture_clearance'], run: fixtureClearanceIssues }, + { types: ['usage_bed_clearance'], run: bedIssues }, +] + +/** Every finding type a measurement gives. */ +export const USAGE_TYPES: ReadonlySet = new Set( + MEASUREMENTS.flatMap((measurement) => measurement.types), +) + +export type UsageOptions = MeasureOptions & { + /** The finding types asked for; left out, every type. A measurement's findings come whole. */ + types?: ReadonlySet +} + +/** + * The usage findings of a scene: narrow passages (measured from the chosen entrance, one for each + * place that is narrow with the rooms it leads to, and the best other door as a fact), missing turning circles, floor a 0.60 m unit cannot get out of, wall + * objects that stand out into circulation, pieces inside something solid, what stands in front of a + * window or a door, a bed's room, a room's headroom and its ceiling. Every threshold is an indicator, + * not a compliance check. + */ +export function usageIssues(nodes: Nodes, options: UsageOptions = {}): UsageIssue[] { + const { types, ...measure } = options + const asked = types + ? MEASUREMENTS.filter((measurement) => measurement.types.some((type) => types.has(type))) + : MEASUREMENTS + if (asked.length === 0) return [] + const shapes = usageShapes(nodes) + return asked.flatMap((measurement) => measurement.run(nodes, shapes, measure)) +} diff --git a/packages/core/src/spatial/usage-field/path.ts b/packages/core/src/spatial/usage-field/path.ts index d84bdc828f..0575a460f2 100644 --- a/packages/core/src/spatial/usage-field/path.ts +++ b/packages/core/src/spatial/usage-field/path.ts @@ -1,9 +1,10 @@ import { type BucketIndex, cellOf, cellX, cellZ, type Grid } from './field' -/** A binary min-heap on a numeric key. */ +/** A binary min-heap on a numeric key. `pop` returns the value and leaves its key in `key`. */ class Heap { private keys: number[] = [] private values: number[] = [] + key = 0 get size() { return this.keys.length @@ -17,34 +18,43 @@ class Heap { values.push(value) while (i > 0) { const parent = (i - 1) >> 1 - if (keys[i]! >= keys[parent]!) break - ;[keys[i], keys[parent]] = [keys[parent]!, keys[i]!] - ;[values[i], values[parent]] = [values[parent]!, values[i]!] + const above = keys[parent]! + if (key >= above) break + keys[i] = above + values[i] = values[parent]! i = parent } + keys[i] = key + values[i] = value } - pop(): [number, number] { + pop(): number { const keys = this.keys const values = this.values - const top: [number, number] = [keys[0]!, values[0]!] - const lastKey = keys.pop()! - const lastValue = values.pop()! - if (keys.length) { - keys[0] = lastKey - values[0] = lastValue + this.key = keys[0]! + const top = values[0]! + const key = keys.pop()! + const value = values.pop()! + const size = keys.length + if (size) { let i = 0 for (;;) { const l = 2 * i + 1 const r = l + 1 let m = i - if (l < keys.length && keys[l]! < keys[m]!) m = l - if (r < keys.length && keys[r]! < keys[m]!) m = r + let least = key + if (l < size && keys[l]! < least) { + m = l + least = keys[l]! + } + if (r < size && keys[r]! < least) m = r if (m === i) break - ;[keys[i], keys[m]] = [keys[m]!, keys[i]!] - ;[values[i], values[m]] = [values[m]!, values[i]!] + keys[i] = keys[m]! + values[i] = values[m]! i = m } + keys[i] = key + values[i] = value } return top } @@ -79,10 +89,10 @@ export function widestPath( heap.push(-F[s]!, s) } while (heap.size) { - const [negative, c] = heap.pop() + const c = heap.pop() if (done[c]) continue done[c] = 1 - const b = -negative + const b = -heap.key const i = c % g.W const j = (c / g.W) | 0 for (const [di, dj] of FOUR) { @@ -154,7 +164,8 @@ export function travel(g: Grid, pass: Uint8Array, sources: Iterable): Fl heap.push(0, s) } while (heap.size) { - const [d, c] = heap.pop() + const c = heap.pop() + const d = heap.key if (d > D[c]!) continue const i = c % g.W const j = (c / g.W) | 0 @@ -370,7 +381,7 @@ export function shortestRoute( cost[from] = 0 heap.push(heuristic(from), from) while (heap.size) { - const [, c] = heap.pop() + const c = heap.pop() if (c === to) break const i = c % g.W const j = (c / g.W) | 0