Skip to content
Open
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
2,638 changes: 1,319 additions & 1,319 deletions packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts

Large diffs are not rendered by default.

962 changes: 483 additions & 479 deletions packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts

Large diffs are not rendered by default.

5,143 changes: 2,574 additions & 2,569 deletions packages/core/src/lib/space-detection.ts

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions packages/core/src/schema/nodes/wall.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,11 @@ export const WallNode = BaseNode.extend({
slots: z.record(z.string(), z.string()).optional(),
thickness: z.number().optional(),
height: z.number().optional(),
// Added to the wall's top only at its `end` point (`start` is unaffected),
// tilting the top edge along the wall's length so one side is taller than
// the other — e.g. a knee wall following a single-pitch roof slope.
/** Height offset at the end point (default 0). */
endHeightOffset: z.number().optional(),
curveOffset: z.number().optional(),
// Persisted slab-support host — see ItemNode.supportSlabId for the rules.
supportSlabId: z.string().optional(),
Expand All @@ -174,6 +179,7 @@ export const WallNode = BaseNode.extend({
Wall node - used to represent a wall in the building
- thickness: thickness in meters
- height: height in meters
- endHeightOffset: added to the top only at the wall's end point, tilting the top edge so one side is taller than the other
- fillToTerrain: extends the wall downward to the terrain without changing its authored height
- curveOffset: midpoint sagitta offset used to bend the wall into an arc
- start: start point of the wall in level coordinate system
Expand Down
238 changes: 120 additions & 118 deletions packages/core/src/services/level-height.ts
Original file line number Diff line number Diff line change
@@ -1,118 +1,120 @@
import type { CeilingNode, LevelNode, SlabNode, WallNode } from '../schema'
import type { AnyNode, AnyNodeId } from '../schema/types'
import { computeWallSlabSupport, pointInPolygon } from '../systems/slab/slab-support'
import { resolveWallTop } from '../systems/wall/wall-top'
// Cycle with ./storey (it imports DEFAULT_LEVEL_HEIGHT from here) is safe:
// both sides only reference the other inside function bodies.
import { CEILING_CLAMP_MARGIN, getCeilingClampBound } from './storey'

export const DEFAULT_LEVEL_HEIGHT = 2.5

/**
* Effective ceiling height in level-local meters. An explicit stored
* `height` wins; absent height means the ceiling follows the level top —
* the same bound its write-clamp uses: min(storey plane, lowest
* covering-slab underside over its polygon) − CEILING_CLAMP_MARGIN (see
* {@link getCeilingClampBound}). Falls back to the default plane minus
* the same margin when the owning level is unresolvable.
*/
export function resolveCeilingHeight(
ceiling: Pick<CeilingNode, 'height' | 'parentId' | 'polygon'>,
nodes: Record<AnyNodeId, AnyNode>,
): number {
if (ceiling.height != null) return ceiling.height
const bound =
typeof ceiling.parentId === 'string'
? getCeilingClampBound(ceiling.parentId, nodes, ceiling.polygon)
: Number.POSITIVE_INFINITY
return Number.isFinite(bound) ? bound : DEFAULT_LEVEL_HEIGHT - CEILING_CLAMP_MARGIN
}

export function deriveLegacyLevelHeight(
levelId: string,
nodes: Record<AnyNodeId, AnyNode>,
): number {
const level = nodes[levelId as LevelNode['id']] as LevelNode | undefined
if (!level) return DEFAULT_LEVEL_HEIGHT

const levelChildren = level.children
.map((childId) => nodes[childId as keyof typeof nodes])
.filter((child): child is AnyNode => child !== undefined)
const slabs = levelChildren.filter((child): child is SlabNode => child.type === 'slab')
const walls = levelChildren.filter((child): child is WallNode => child.type === 'wall')

let maxTop = 0

for (const child of levelChildren) {
if (child.type === 'ceiling') {
// Absence here is the PRE-migration legacy schema default (2.5), not
// follows-mode — this derivation runs before the level has a height
// for a follows-mode bound to track.
const height = (child as CeilingNode).height ?? DEFAULT_LEVEL_HEIGHT
if (height > maxTop) maxTop = height
} else if (child.type === 'wall') {
const wall = child as WallNode
const electedElevation = computeWallSlabSupport(
{
start: wall.start,
end: wall.end,
curveOffset: wall.curveOffset,
thickness: wall.thickness,
},
slabs,
walls,
).elevation
const top = resolveWallTop(wall, level.height ?? DEFAULT_LEVEL_HEIGHT, electedElevation)
if (top > maxTop) maxTop = top
}
}

return maxTop > 0 ? maxTop : DEFAULT_LEVEL_HEIGHT
}

/**
* The ceiling covering level-local point `[x, z]`, or `null` when none
* sits over it. Points inside a ceiling's hole are treated as uncovered.
* When ceilings overlap, the lowest one wins — that's the surface a duct
* would actually hang from.
*/
export function getCeilingAt(
levelId: string,
nodes: Record<AnyNodeId, AnyNode>,
x: number,
z: number,
): CeilingNode | null {
const level = nodes[levelId as LevelNode['id']] as LevelNode | undefined
if (!level) return null

let best: CeilingNode | null = null
let bestHeight = Number.POSITIVE_INFINITY
for (const childId of level.children) {
const child = nodes[childId as keyof typeof nodes]
if (child?.type !== 'ceiling') continue
const ceiling = child as CeilingNode
if (ceiling.polygon.length < 3 || !pointInPolygon(x, z, ceiling.polygon)) continue
if (ceiling.holes.some((hole) => hole.length >= 3 && pointInPolygon(x, z, hole))) continue
const h = resolveCeilingHeight(ceiling, nodes)
if (best === null || h < bestHeight) {
best = ceiling
bestHeight = h
}
}
return best
}

/**
* Underside elevation (meters above the level floor) of the ceiling
* covering level-local point `[x, z]`, or `null` when no ceiling sits
* over that point. See {@link getCeilingAt}.
*/
export function getCeilingHeightAt(
levelId: string,
nodes: Record<AnyNodeId, AnyNode>,
x: number,
z: number,
): number | null {
const ceiling = getCeilingAt(levelId, nodes, x, z)
return ceiling ? resolveCeilingHeight(ceiling, nodes) : null
}
import type { CeilingNode, LevelNode, SlabNode, WallNode } from '../schema'
import type { AnyNode, AnyNodeId } from '../schema/types'
import { computeWallSlabSupport, pointInPolygon } from '../systems/slab/slab-support'
import { resolveWallTop } from '../systems/wall/wall-top'
// Cycle with ./storey (it imports DEFAULT_LEVEL_HEIGHT from here) is safe:
// both sides only reference the other inside function bodies.
import { CEILING_CLAMP_MARGIN, getCeilingClampBound } from './storey'

export const DEFAULT_LEVEL_HEIGHT = 2.5

/**
* Effective ceiling height in level-local meters. An explicit stored
* `height` wins; absent height means the ceiling follows the level top —
* the same bound its write-clamp uses: min(storey plane, lowest
* covering-slab underside over its polygon) − CEILING_CLAMP_MARGIN (see
* {@link getCeilingClampBound}). Falls back to the default plane minus
* the same margin when the owning level is unresolvable.
*/
export function resolveCeilingHeight(
ceiling: Pick<CeilingNode, 'height' | 'parentId' | 'polygon'>,
nodes: Record<AnyNodeId, AnyNode>,
): number {
if (ceiling.height != null) return ceiling.height
const bound =
typeof ceiling.parentId === 'string'
? getCeilingClampBound(ceiling.parentId, nodes, ceiling.polygon)
: Number.POSITIVE_INFINITY
return Number.isFinite(bound) ? bound : DEFAULT_LEVEL_HEIGHT - CEILING_CLAMP_MARGIN
}

export function deriveLegacyLevelHeight(
levelId: string,
nodes: Record<AnyNodeId, AnyNode>,
): number {
const level = nodes[levelId as LevelNode['id']] as LevelNode | undefined
if (!level) return DEFAULT_LEVEL_HEIGHT

const levelChildren = level.children
.map((childId) => nodes[childId as keyof typeof nodes])
.filter((child): child is AnyNode => child !== undefined)
const slabs = levelChildren.filter((child): child is SlabNode => child.type === 'slab')
const walls = levelChildren.filter((child): child is WallNode => child.type === 'wall')

let maxTop = 0

for (const child of levelChildren) {
if (child.type === 'ceiling') {
// Absence here is the PRE-migration legacy schema default (2.5), not
// follows-mode — this derivation runs before the level has a height
// for a follows-mode bound to track.
const height = (child as CeilingNode).height ?? DEFAULT_LEVEL_HEIGHT
if (height > maxTop) maxTop = height
} else if (child.type === 'wall') {
const wall = child as WallNode
const electedElevation = computeWallSlabSupport(
{
start: wall.start,
end: wall.end,
curveOffset: wall.curveOffset,
thickness: wall.thickness,
},
slabs,
walls,
).elevation
const topStart = resolveWallTop(wall, level.height ?? DEFAULT_LEVEL_HEIGHT, electedElevation, 0)
const topEnd = resolveWallTop(wall, level.height ?? DEFAULT_LEVEL_HEIGHT, electedElevation, 1)
const top = Math.max(topStart, topEnd)
if (top > maxTop) maxTop = top
}
}

return maxTop > 0 ? maxTop : DEFAULT_LEVEL_HEIGHT
}

/**
* The ceiling covering level-local point `[x, z]`, or `null` when none
* sits over it. Points inside a ceiling's hole are treated as uncovered.
* When ceilings overlap, the lowest one wins — that's the surface a duct
* would actually hang from.
*/
export function getCeilingAt(
levelId: string,
nodes: Record<AnyNodeId, AnyNode>,
x: number,
z: number,
): CeilingNode | null {
const level = nodes[levelId as LevelNode['id']] as LevelNode | undefined
if (!level) return null

let best: CeilingNode | null = null
let bestHeight = Number.POSITIVE_INFINITY
for (const childId of level.children) {
const child = nodes[childId as keyof typeof nodes]
if (child?.type !== 'ceiling') continue
const ceiling = child as CeilingNode
if (ceiling.polygon.length < 3 || !pointInPolygon(x, z, ceiling.polygon)) continue
if (ceiling.holes.some((hole) => hole.length >= 3 && pointInPolygon(x, z, hole))) continue
const h = resolveCeilingHeight(ceiling, nodes)
if (best === null || h < bestHeight) {
best = ceiling
bestHeight = h
}
}
return best
}

/**
* Underside elevation (meters above the level floor) of the ceiling
* covering level-local point `[x, z]`, or `null` when no ceiling sits
* over that point. See {@link getCeilingAt}.
*/
export function getCeilingHeightAt(
levelId: string,
nodes: Record<AnyNodeId, AnyNode>,
x: number,
z: number,
): number | null {
const ceiling = getCeilingAt(levelId, nodes, x, z)
return ceiling ? resolveCeilingHeight(ceiling, nodes) : null
}
126 changes: 70 additions & 56 deletions packages/core/src/systems/wall/wall-top.ts
Original file line number Diff line number Diff line change
@@ -1,56 +1,70 @@
import type { WallNode } from '../../schema/nodes/wall'

/**
* Minimum wall body height in meters. Governs both the wall height
* arrow's lower drag bound and the slab-elevation clamp: a slab may not
* rise past `storeyHeight - MIN_WALL_HEIGHT` while a plane-bound wall
* elects it as its base, or the wall's extrusion (plane minus base)
* would collapse below this minimum.
*/
export const MIN_WALL_HEIGHT = 0.5

/**
* Wall-top inversion (vertical building model): a wall with no stored
* `height` is plane-bound — its top sits at the storey plane (level-local
* Y = the level's stored height), so a slab lifting the wall's base makes
* the wall shorter, never taller, and no gap can open at the top of a
* level. A wall WITH `height` is an explicit exception (half wall,
* parapet) and keeps the legacy semantics: the top rides a raised elected
* base (`electedBase + height`), while a zero or sunken slab base leaves
* the top at `height` (the legacy negative-slab constraint). Explicit
* ground-hosted walls are the terrain exception: `height` is always body
* height, including below datum, so sculpting cannot stretch the wall.
*
* Returns the top in level-local Y (same frame as `electedBase`).
*/
export function resolveWallTop(
wall: Pick<WallNode, 'height' | 'supportSlabId'>,
storeyHeight: number,
electedBase: number,
): number {
if (wall.height == null) return storeyHeight
if (wall.supportSlabId === 'ground') return electedBase + wall.height
return electedBase > 0 ? electedBase + wall.height : wall.height
}

/**
* Extruded height of the wall body: {@link resolveWallTop} minus the
* elected base. Base convention: the elected slab-support elevation itself
* — the viewer computes `effectiveBaseElevation = min(baseElevation,
* slabElevation)` and defaults `baseElevation` to the elected elevation,
* so with only the election in hand the two coincide. Fill-down below the
* elected base (`baseSegments`) is a geometry detail the extruder handles
* separately and never changes where the top sits.
*
* Equivalently: the wall-local Y of the wall's top, measured from the wall
* mesh origin (which sits at `electedBase`). May be non-positive when a
* slab reaches the storey plane; callers own the degenerate-geometry
* policy.
*/
export function resolveWallEffectiveHeight(
wall: Pick<WallNode, 'height' | 'supportSlabId'>,
storeyHeight: number,
electedBase: number,
): number {
return resolveWallTop(wall, storeyHeight, electedBase) - electedBase
}
import type { WallNode } from '../../schema/nodes/wall'

/**
* Minimum wall body height in meters. Governs both the wall height
* arrow's lower drag bound and the slab-elevation clamp: a slab may not
* rise past `storeyHeight - MIN_WALL_HEIGHT` while a plane-bound wall
* elects it as its base, or the wall's extrusion (plane minus base)
* would collapse below this minimum.
*/
export const MIN_WALL_HEIGHT = 0.5

/**
* Wall-top inversion (vertical building model): a wall with no stored
* `height` is plane-bound — its top sits at the storey plane (level-local
* Y = the level's stored height), so a slab lifting the wall's base makes
* the wall shorter, never taller, and no gap can open at the top of a
* level. A wall WITH `height` is an explicit exception (half wall,
* parapet) and keeps the legacy semantics: the top rides a raised elected
* base (`electedBase + height`), while a zero or sunken slab base leaves
* the top at `height` (the legacy negative-slab constraint). Explicit
* ground-hosted walls are the terrain exception: `height` is always body
* height, including below datum, so sculpting cannot stretch the wall.
*
* Returns the top in level-local Y (same frame as `electedBase`).
*/
export function resolveWallTop(
wall: Pick<WallNode, 'height' | 'supportSlabId' | 'endHeightOffset'>,
storeyHeight: number,
electedBase: number,
t?: number,
): number {
let top: number
if (wall.height == null) {
top = storeyHeight
} else if (wall.supportSlabId === 'ground') {
top = electedBase + wall.height
} else {
top = electedBase > 0 ? electedBase + wall.height : wall.height
}
if (wall.endHeightOffset && t !== undefined) {
const bodyHeight = Math.max(0.01, top - electedBase)
const minEndHeight = 0.01
const clampedOffset = Math.max(wall.endHeightOffset, -(bodyHeight - minEndHeight))
top += clampedOffset * t
}
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.
return top
}

/**
* Extruded height of the wall body: {@link resolveWallTop} minus the
* elected base. Base convention: the elected slab-support elevation itself
* — the viewer computes `effectiveBaseElevation = min(baseElevation,
* slabElevation)` and defaults `baseElevation` to the elected elevation,
* so with only the election in hand the two coincide. Fill-down below the
* elected base (`baseSegments`) is a geometry detail the extruder handles
* separately and never changes where the top sits.
*
* Equivalently: the wall-local Y of the wall's top, measured from the wall
* mesh origin (which sits at `electedBase`). May be non-positive when a
* slab reaches the storey plane; callers own the degenerate-geometry
* policy.
*/
export function resolveWallEffectiveHeight(
wall: Pick<WallNode, 'height' | 'supportSlabId' | 'endHeightOffset'>,
storeyHeight: number,
electedBase: number,
t?: number,
): number {
return resolveWallTop(wall, storeyHeight, electedBase, t) - electedBase
}
Loading