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
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ export {
remapMeasurementAnchors,
remapMeasurementReferences,
} from './lib/measurement-geometry'
export { HIDDEN_SITE_NOTE, hidesDescendants } from './lib/node-visibility'
export {
type Point2D as PolygonPoint2D,
pointInPolygon as pointInPolygon2D,
Expand Down
16 changes: 16 additions & 0 deletions packages/core/src/lib/node-visibility.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { AnyNode } from '../schema'

/**
* Whether a node's `visible: false` also hides the nodes beneath it.
*
* A Site is the parcel reference, not a container: hiding it hides its own
* ground fill and boundary only, and the buildings on it keep their own flag.
* Every other kind hides its whole subtree. Exports, the 2D plan and the
* validators all read this one rule so a hidden Site can never empty a scene.
*/
export function hidesDescendants(node: Pick<AnyNode, 'type'>): boolean {
return node.type !== 'site'
}

export const HIDDEN_SITE_NOTE =
'A hidden Site hides only its own ground fill and boundary; the buildings on it stay visible in the viewport, the 2D plan and every export.'
16 changes: 15 additions & 1 deletion packages/core/src/validation/validate-build-json.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import { z } from 'zod'
import { nodeRegistry, registerNode } from '../registry'
import type { AnyNodeDefinition } from '../registry/types'
import { LevelNode, WallNode } from '../schema'
import { LevelNode, SiteNode, WallNode } from '../schema'
import { validateBuildJson } from './validate-build-json'

function makeScene() {
Expand Down Expand Up @@ -31,6 +31,20 @@ describe('validateBuildJson', () => {
expect(result.schemaIssueCount).toBe(0)
})

test('warns that a hidden site keeps the buildings on it visible', () => {
const scene = makeScene()
const site = SiteNode.parse({ id: 'site_test', visible: false })
const result = validateBuildJson({
...scene,
nodes: { ...scene.nodes, [site.id]: site },
rootNodeIds: [site.id, ...scene.rootNodeIds],
})
expect(result.ok).toBe(true)
const warning = result.warnings.find((w) => w.code === 'site_hidden')
expect(warning?.message).toContain('site_test')
expect(warning?.message).toContain('stay visible')
})

test('plugin-typed children do not hard-fail their parent level', () => {
// Exports from projects with plugins carry nodes like `trees:tree`
// whose ids sit in level.children. The static children id union would
Expand Down
12 changes: 12 additions & 0 deletions packages/core/src/validation/validate-build-json.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { HIDDEN_SITE_NOTE } from '../lib/node-visibility'
import { nodeRegistry } from '../registry'
import type { Collection } from '../schema/collections'
import { SceneMaterial } from '../schema/scene-material'
Expand Down Expand Up @@ -267,6 +268,17 @@ export function validateBuildJson(input: unknown): ValidateBuildJsonResult {
})
}

// Hand-authored files hide the Site expecting the parcel to disappear; the
// flag is accepted but reaches nothing beneath it, so say so at import.
for (const [key, value] of Object.entries(nodes)) {
if (!isPlainObject(value) || value.type !== 'site' || value.visible !== false) continue
warnings.push({
severity: 'warning',
code: 'site_hidden',
message: `Site "${typeof value.id === 'string' ? value.id : key}" is hidden. ${HIDDEN_SITE_NOTE}`,
})
}

// Ids of nodes whose type falls outside the static schema union — plugin
// kinds (`trees:tree`) or genuinely unknown types. The scene store accepts
// them on load (they already round-trip through the DB fine) and they're
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
floorplanEntryYieldsToTool,
floorplanHandleDoubleClickAffordance,
InteractiveGeometry,
isFloorplanHierarchyVisible,
isFloorplanOpeningPlacementState,
resolveFloorplanHandleUnitsPerPixel,
siteToFloorplanTransform,
Expand Down Expand Up @@ -790,3 +791,41 @@ describe('floorplan entry routing while a tool is active', () => {
expect(floorplanEntryYieldsToTool({ mode: 'delete', openingPlacement: false })).toBe(false)
})
})

describe('isFloorplanHierarchyVisible', () => {
const node = (id: string, type: string, parentId: string | null, visible = true) =>
({ object: 'node', id, type, parentId, visible, metadata: {} }) as unknown as AnyNode
const noOverrides = new Map<string, LiveNodeOverrides>()
const visibleUnder = (nodes: Record<string, AnyNode>, rootId: string, id: string) =>
isFloorplanHierarchyVisible(nodes[id]!, nodes, noOverrides, rootId as AnyNodeId)

test('a hidden Site root keeps the nodes on it, linked or detached', () => {
const nodes: Record<string, AnyNode> = {
site_a: node('site_a', 'site', null, false),
building_a: node('building_a', 'building', 'site_a'),
level_a: node('level_a', 'level', 'building_a'),
wall_a: node('wall_a', 'wall', 'level_a'),
wall_b: node('wall_b', 'wall', 'level_a', false),
tree_a: node('tree_a', 'trees:tree', null),
}
expect(visibleUnder(nodes, 'site_a', 'wall_a')).toBe(true)
expect(visibleUnder(nodes, 'site_a', 'tree_a')).toBe(true)
expect(visibleUnder(nodes, 'site_a', 'wall_b')).toBe(false)
expect(visibleUnder(nodes, 'site_a', 'site_a')).toBe(false)
})

test('a hidden building or level root still hides what it hosts', () => {
const nodes: Record<string, AnyNode> = {
site_a: node('site_a', 'site', null),
building_a: node('building_a', 'building', 'site_a', false),
level_a: node('level_a', 'level', 'building_a'),
wall_a: node('wall_a', 'wall', 'level_a'),
elevator_a: node('elevator_a', 'elevator', null),
}
expect(visibleUnder(nodes, 'site_a', 'wall_a')).toBe(false)
expect(visibleUnder(nodes, 'building_a', 'elevator_a')).toBe(false)
// A level plan is scoped to its level: the walk stops at the root and
// never consults the building above it.
expect(visibleUnder(nodes, 'level_a', 'wall_a')).toBe(true)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
type FloorplanPoint,
type FloorplanScope,
type GeometryContext,
hidesDescendants,
isNodeKindEnabled,
isRegistryMovable,
kindsWithFloorplanScope,
Expand Down Expand Up @@ -3368,15 +3369,26 @@ export function isFloorplanHierarchyVisible(
liveOverrides: Map<string, LiveNodeOverrides>,
rootId: AnyNodeId,
): boolean {
// The root is checked on its own because a site-scoped node can be declared
// on the Site without a `parentId` link. A Site's flag never reaches the
// nodes on it; see `hidesDescendants`.
const root = nodes[rootId]
if (root && !isFloorplanNodeVisible(root, liveOverrides.get(root.id))) return false
if (
root &&
root.id !== node.id &&
hidesDescendants(root) &&
!isFloorplanNodeVisible(root, liveOverrides.get(root.id))
) {
return false
}

let current: AnyNode | undefined = node
const seen = new Set<AnyNodeId>()
while (current) {
if (seen.has(current.id)) return true
seen.add(current.id)
if (!isFloorplanNodeVisible(current, liveOverrides.get(current.id))) return false
const reaches = current.id === node.id || hidesDescendants(current)
if (reaches && !isFloorplanNodeVisible(current, liveOverrides.get(current.id))) return false
if (current.id === rootId) return true
const parentId = current.parentId as AnyNodeId | null
if (!parentId) return true
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { describe, expect, test } from 'bun:test'
import type { AnyNode } from '@pascal-app/core'
import { isVisibleInFloorplan } from './floorplan-preview-visibility'

const node = (id: string, type: string, parentId: string | null, visible = true) =>
({ object: 'node', id, type, parentId, visible, metadata: {} }) as unknown as AnyNode

describe('isVisibleInFloorplan', () => {
test('a hidden Site keeps the buildings on it in the plan', () => {
const nodes: Record<string, AnyNode> = {
site_a: node('site_a', 'site', null, false),
building_a: node('building_a', 'building', 'site_a'),
level_a: node('level_a', 'level', 'building_a'),
wall_a: node('wall_a', 'wall', 'level_a'),
wall_b: node('wall_b', 'wall', 'level_a', false),
}
expect(isVisibleInFloorplan(nodes.wall_a!, nodes)).toBe(true)
expect(isVisibleInFloorplan(nodes.wall_b!, nodes)).toBe(false)
expect(isVisibleInFloorplan(nodes.site_a!, nodes)).toBe(false)
})

test('a hidden building or level still hides its subtree', () => {
const nodes: Record<string, AnyNode> = {
site_a: node('site_a', 'site', null),
building_a: node('building_a', 'building', 'site_a', false),
level_a: node('level_a', 'level', 'building_a'),
wall_a: node('wall_a', 'wall', 'level_a'),
building_b: node('building_b', 'building', 'site_a'),
level_b: node('level_b', 'level', 'building_b', false),
wall_b: node('wall_b', 'wall', 'level_b'),
}
expect(isVisibleInFloorplan(nodes.wall_a!, nodes)).toBe(false)
expect(isVisibleInFloorplan(nodes.wall_b!, nodes)).toBe(false)
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { type AnyNode, hidesDescendants } from '@pascal-app/core'

/**
* A node draws in the plan unless it, or an ancestor whose flag reaches its
* descendants, is hidden. A hidden Site keeps its buildings on the plan; see
* `hidesDescendants`.
*/
export function isVisibleInFloorplan(node: AnyNode, nodes: Record<string, AnyNode>): boolean {
if (node.visible === false) return false
const seen = new Set<string>([node.id])
let current: AnyNode | undefined = node.parentId ? nodes[node.parentId] : undefined
while (current) {
if (seen.has(current.id)) return true
seen.add(current.id)
if (current.visible === false && hidesDescendants(current)) return false
current = current.parentId ? nodes[current.parentId] : undefined
}
return true
}
13 changes: 1 addition & 12 deletions packages/editor/src/components/viewer/floorplan-preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ import {
rotateFloorplanPoint,
visibleFloorplanViewWidth,
} from './floorplan-preview-navigation'
import { isVisibleInFloorplan } from './floorplan-preview-visibility'

const READ_ONLY_PALETTE: FloorplanPalette = {
selectedStroke: '#4f46e5',
Expand Down Expand Up @@ -193,18 +194,6 @@ export function normalizeFloorplanPreviewNodes(
return normalized
}

function isVisibleInFloorplan(node: AnyNode, nodes: Record<string, AnyNode>): boolean {
const seen = new Set<string>()
let current: AnyNode | undefined = node
while (current) {
if (seen.has(current.id)) return true
seen.add(current.id)
if (current.visible === false) return false
current = current.parentId ? nodes[current.parentId] : undefined
}
return true
}

function buildFloorplanGeometries(
nodes: Record<string, AnyNode>,
installedPlugins: readonly string[] | undefined,
Expand Down
4 changes: 3 additions & 1 deletion packages/editor/src/lib/floorplan/floorplan-export.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -774,7 +774,9 @@ describe('collectFloorplanGeometry', () => {
'finished-faces',
[enabledPluginId],
)
expect(hiddenSite.map(({ id }) => id)).toEqual(['level_architecture'])
// A hidden Site hides only its own ground and boundary; the site-scoped
// nodes on it keep their own flag (see `hidesDescendants`).
expect(hiddenSite.map(({ id }) => id)).toEqual(['site_overlay', 'level_architecture'])

const structure = collectFloorplanGeometry(
nodes,
Expand Down
75 changes: 59 additions & 16 deletions packages/editor/src/lib/glb-export.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -590,19 +590,64 @@ describe('prepareSceneForExport', () => {
expect(animations).toHaveLength(0)
})

test('inherits hidden Site visibility for detached declared children and their descendants', async () => {
test('keeps the buildings on a hidden Site and drops only the Site ground', () => {
// A layout authored outside the editor can hide the root Site (the
// renderer ignores that flag) while every node on it stays visible. The
// Site's own flag must stop at the Site: it is the parcel reference, not
// a container the building inherits visibility from.
const root = new THREE.Group()
const siteGroup = new THREE.Group()
const siteGround = meshWithNodeMaterial(nodeMaterial())
const buildingGroup = new THREE.Group()
const levelGroup = new THREE.Group()
const itemGroup = new THREE.Group()
itemGroup.add(meshWithNodeMaterial(nodeMaterial()))
levelGroup.add(itemGroup)
buildingGroup.add(levelGroup)
siteGroup.add(buildingGroup, siteGround)
root.add(siteGroup)

const siteId = 'site_hidden'
const buildingId = 'building_on_hidden_site'
const levelId = 'level_on_hidden_site'
const itemId = 'item_on_hidden_site'
sceneRegistry.nodes.set(siteId, siteGroup)
sceneRegistry.nodes.set(buildingId, buildingGroup)
sceneRegistry.nodes.set(levelId, levelGroup)
sceneRegistry.nodes.set(itemId, itemGroup)
const node = (id: string, type: string, parentId: string | null, visible: boolean) =>
({ object: 'node', id, type, parentId, visible }) as unknown as AnyNode
const nodes: Record<string, AnyNode> = {
[siteId]: node(siteId, 'site', null, false),
[buildingId]: node(buildingId, 'building', siteId, true),
[levelId]: node(levelId, 'level', buildingId, true),
[itemId]: node(itemId, 'item', levelId, true),
}

const { scene } = prepareSceneForExport(root, nodes)

const meshes: THREE.Mesh[] = []
scene.traverse((object) => {
if ((object as THREE.Mesh).isMesh) meshes.push(object as THREE.Mesh)
})
expect(scene.getObjectByName(buildingId)).toBeDefined()
expect(scene.getObjectByName(itemId)).toBeDefined()
expect(meshes).toHaveLength(1)
expect(meshes[0]?.parent?.name).toBe(itemId)
})

test('keeps the nodes a hidden Site hosts, declared or detached, unless hidden themselves', async () => {
const restoreRegistry = nodeRegistry._snapshot()
try {
const kind = 'test:detached-site-visibility'
const kind = 'test:hidden-site-host'
const childId = 'detached_site_child'
const descendantId = 'detached_site_descendant'
const explicitId = 'explicit_site_child'
const unownedId = 'unowned_site_child'
const hiddenChildId = 'hidden_site_child'
const hiddenSite = SiteNode.parse({
visible: false,
children: [childId, explicitId],
children: [childId, explicitId, hiddenChildId],
})
const visibleSite = SiteNode.parse({ visible: true })
registerNode({
kind,
schemaVersion: 1,
Expand All @@ -615,17 +660,19 @@ describe('prepareSceneForExport', () => {
} as AnyNodeDefinition)
const nodes = {
[hiddenSite.id]: hiddenSite,
[visibleSite.id]: visibleSite,
[childId]: { id: childId, type: kind, parentId: null, visible: true },
[descendantId]: { id: descendantId, type: kind, parentId: childId, visible: true },
[explicitId]: { id: explicitId, type: kind, parentId: visibleSite.id, visible: true },
[unownedId]: { id: unownedId, type: kind, parentId: null, visible: true },
[explicitId]: { id: explicitId, type: kind, parentId: hiddenSite.id, visible: true },
[hiddenChildId]: { id: hiddenChildId, type: kind, parentId: hiddenSite.id, visible: false },
} as unknown as Record<string, AnyNode>
const allIds = [childId, descendantId, explicitId, unownedId]
const allIds = [childId, descendantId, explicitId, hiddenChildId]
const root = new THREE.Group()
for (const id of [hiddenSite.id, visibleSite.id, ...allIds]) {
const siteObject = new THREE.Group()
root.add(siteObject)
sceneRegistry.nodes.set(hiddenSite.id, siteObject)
for (const id of allIds) {
const object = new THREE.Group()
root.add(object)
siteObject.add(object)
sceneRegistry.nodes.set(id, object)
}
const exportedIds = async (onlyVisible?: boolean) => {
Expand All @@ -637,12 +684,8 @@ describe('prepareSceneForExport', () => {
}
}

expect(await exportedIds()).toEqual([explicitId, unownedId])
expect(await exportedIds()).toEqual([childId, descendantId, explicitId])
expect(await exportedIds(false)).toEqual(allIds)
nodes[hiddenSite.id] = { ...hiddenSite, visible: true }
expect(await exportedIds()).toEqual(allIds)
nodes[hiddenSite.id] = hiddenSite
expect(await exportedIds()).toEqual([explicitId, unownedId])
} finally {
restoreRegistry()
}
Expand Down
Loading
Loading