Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 18 additions & 3 deletions packages/core/src/agent-operations/usage-checks.ts
Original file line number Diff line number Diff line change
@@ -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<string> | undefined {
if (typeof type !== 'string' || type.length === 0) return undefined
if (type === ROOM_ENTRY_TYPE) return new Set<string>(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[]) : [],
})
141 changes: 141 additions & 0 deletions packages/core/src/agent-operations/verify-scene-typed.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) => verifyScene(nodes, input, context).result
const answer = (from: Record<string, unknown>, input: Record<string, unknown>) =>
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,
)
})
})
78 changes: 59 additions & 19 deletions packages/core/src/spatial/usage-field/findings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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<string> = 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<string>
}

/**
* 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))
}
49 changes: 30 additions & 19 deletions packages/core/src/spatial/usage-field/path.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
}
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -154,7 +164,8 @@ export function travel(g: Grid, pass: Uint8Array, sources: Iterable<number>): 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
Expand Down Expand Up @@ -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
Expand Down
Loading