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
12 changes: 8 additions & 4 deletions packages/editor/src/components/systems/zone/zone-system.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,13 @@ export const ZoneSystem = () => {
const isDeleteHovered = editorMode === 'delete' && hoveredId === zoneId

// Keep group visible (so <Html> labels stay active), hide/show meshes only.
// Show meshes when: in zone mode, selected, or delete-hovered.
if (!obj.visible) obj.visible = true
const meshVisible = !isCaptureMode && (zoneGeometryVisible || isSelected || isDeleteHovered)
// Show meshes when: in zone mode, selected, or delete-hovered. A zone the
// author hid (sidebar eye) is the one case where the group itself goes:
// otherwise this per-frame write would undo the renderer's `visible` prop.
const nodeVisible = zone?.visible !== false
if (obj.visible !== nodeVisible) obj.visible = nodeVisible
const meshVisible =
nodeVisible && !isCaptureMode && (zoneGeometryVisible || isSelected || isDeleteHovered)
const targetOpacity = isCaptureMode
? 0
: isSelected || isDeleteHovered
Expand Down Expand Up @@ -103,7 +107,7 @@ export const ZoneSystem = () => {
// Labels: visible on the current level (regardless of mode), but never
// during snapshot capture.
const showLabel =
!isCaptureMode && !zoneLabelsHidden && !!selectedLevelId && isOnSelectedLevel
nodeVisible && !isCaptureMode && !zoneLabelsHidden && !!selectedLevelId && isOnSelectedLevel
const labelOpacity = showLabel ? '1' : '0'
const labelEl = document.getElementById(`${zoneId}-label`)
if (labelEl && labelEl.style.opacity !== labelOpacity) {
Expand Down
9 changes: 7 additions & 2 deletions packages/editor/src/components/viewer-zone-system.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,18 +33,23 @@ export const ViewerZoneSystem = () => {
// Zone geometry: visible in zone mode on the right level, OR when this zone is selected.
// The editor ZoneSystem handles the selected zone's opacity animation.
const isSelected = id === zoneId
// A zone the author hid (sidebar eye) takes the group with it — this
// per-frame write would otherwise undo the renderer's `visible` prop.
const nodeVisible = zone.visible !== false
const shouldShowGeometry =
nodeVisible &&
!isCaptureMode &&
((structureLayer === 'zones' && !!levelId && isOnSelectedLevel) || isSelected)
if (!obj.visible) obj.visible = true
if (obj.visible !== nodeVisible) obj.visible = nodeVisible
obj.traverse((child) => {
if ((child as Mesh).isMesh) {
child.visible = shouldShowGeometry
}
})

// Labels: always visible on the current level (regardless of mode or zone selection)
const showLabel = !isCaptureMode && !zoneLabelsHidden && !!levelId && isOnSelectedLevel
const showLabel =
nodeVisible && !isCaptureMode && !zoneLabelsHidden && !!levelId && isOnSelectedLevel
const targetOpacity = showLabel ? '1' : '0'
const labelEl = document.getElementById(`${id}-label`)
if (labelEl && labelEl.style.opacity !== targetOpacity) {
Expand Down
1 change: 1 addition & 0 deletions packages/nodes/src/building/renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export const BuildingRenderer = ({ node }: { node: BuildingNode }) => {
position={node.position}
ref={ref}
rotation={[node.rotation[0], node.rotation[1], node.rotation[2]]}
visible={node.visible !== false}
{...handlers}
>
{(node.children ?? []).map((childId) => (
Expand Down
1 change: 1 addition & 0 deletions packages/nodes/src/ceiling/renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ export const CeilingRenderer = ({ node }: { node: CeilingNode }) => {
material={materials.bottomMaterial}
position={position}
ref={ref}
visible={node.visible !== false}
{...handlers}
>
<mesh
Expand Down
2 changes: 1 addition & 1 deletion packages/nodes/src/level/renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export const LevelRenderer = ({ node }: { node: LevelNode }) => {
const handlers = useNodeEvents(node, 'level')

return (
<group ref={ref} {...handlers}>
<group ref={ref} visible={node.visible !== false} {...handlers}>
{node.children.map((childId) => (
<NodeRenderer key={childId} nodeId={childId} />
))}
Expand Down
21 changes: 15 additions & 6 deletions packages/nodes/src/site/renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,13 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => {
// terrain still mounts the mesh.
const showTerrain = terrainGrid !== null

// The Site is the one kind whose `visible` flag stops at itself: hiding it
// drops the parcel's own presentation — ground fill, sculpted ground, lot
// line — while everything standing on the site keeps its own flag (the
// exporter and the 2D plan draw the same line). The horizon disc is a world
// backdrop rather than part of the parcel, so it stays either way.
const showSiteSurfaces = node.visible !== false

if (!(node && lineGeometry)) {
return null
}
Expand All @@ -395,10 +402,10 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => {
))}

{/* Sculpted ground, when the site has terrain */}
{showTerrain && <TerrainRenderer material={groundMaterial} site={node} />}
{showSiteSurfaces && showTerrain && <TerrainRenderer material={groundMaterial} site={node} />}

{/* Ground fill: site polygon with slab holes, occludes below-grade geometry */}
{groundGeometry && !showTerrain && (
{showSiteSurfaces && groundGeometry && !showTerrain && (
<mesh
geometry={groundGeometry}
material={groundMaterial}
Expand All @@ -422,10 +429,12 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => {
)}

{/* Simple boundary line */}
{/* @ts-ignore */}
<line frustumCulled={false} geometry={lineGeometry} renderOrder={9}>
<lineBasicMaterial color="#f59e0b" linewidth={2} opacity={0.6} transparent />
</line>
{showSiteSurfaces && (
// @ts-expect-error
<line frustumCulled={false} geometry={lineGeometry} renderOrder={9}>
<lineBasicMaterial color="#f59e0b" linewidth={2} opacity={0.6} transparent />
</line>
)}
</group>
)
}
Expand Down
7 changes: 6 additions & 1 deletion packages/nodes/src/zone/renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,12 @@ export const ZoneRenderer = ({ node }: { node: ZoneNode }) => {
}

return (
<group ref={ref} {...handlers} userData={{ labelPosition: [centroid[0], 1, centroid[1]] }}>
<group
ref={ref}
visible={node.visible !== false}
{...handlers}
userData={{ labelPosition: [centroid[0], 1, centroid[1]] }}
>
{showZones && (
<>
<Html
Expand Down
42 changes: 42 additions & 0 deletions packages/viewer/src/systems/level/level-system.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,12 @@ function setupLevels(baseElevations: number[]) {
return { building, levels, objects }
}

function hideLevel(levelId: string) {
const nodes = useScene.getState().nodes
const level = nodes[levelId as AnyNodeId]!
useScene.setState({ nodes: { ...nodes, [levelId]: { ...level, visible: false } } })
}

function setLevelMode(
mode: 'stacked' | 'exploded' | 'solo',
selectedLevelId: string | null = null,
Expand Down Expand Up @@ -108,6 +114,28 @@ describe('updateLevelPresentation', () => {
expect(objects[0]!.visible).toBe(false)
expect(objects[1]!.visible).toBe(true)
})

test('hides a level the author hid, outside solo too', async () => {
const { levels, objects } = setupLevels([0, 1])
hideLevel(levels[0]!.id)
setLevelMode('stacked')

await updateLevelPresentation(1 / 12)

expect(objects[0]!.visible).toBe(false)
expect(objects[1]!.visible).toBe(true)
})

test('keeps a level the author hid out of the solo shadow-caster branch', async () => {
const { levels, objects } = setupLevels([0, 1])
hideLevel(levels[1]!.id)
setLevelMode('solo', levels[0]!.id)

await updateLevelPresentation(1 / 12)

expect(objects[0]!.visible).toBe(true)
expect(objects[1]!.visible).toBe(false)
})
})

describe('snapLevelsToTruePositions', () => {
Expand All @@ -127,4 +155,18 @@ describe('snapLevelsToTruePositions', () => {
expect(objects.map((object) => object.position.y)).toEqual([10, 20])
expect(objects.map((object) => object.visible)).toEqual([false, true])
})

test('leaves a level the author hid hidden, matching the export', () => {
const { levels, objects } = setupLevels([0.5, 1.25])
hideLevel(levels[0]!.id)
objects[0]!.visible = true

const restore = snapLevelsToTruePositions()

expect(objects.map((object) => object.visible)).toEqual([false, true])

restore()

expect(objects.map((object) => object.visible)).toEqual([true, true])
})
})
28 changes: 14 additions & 14 deletions packages/viewer/src/systems/level/level-system.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { Object3D } from 'three'
import { lerp } from 'three/src/math/MathUtils.js'
import { applyShadowOnly, clearShadowOnly } from '../../lib/shadow-only'
import useViewer from '../../store/use-viewer'
import { EXPLODED_GAP } from './level-utils'
import { EXPLODED_GAP, resolveLevelVisibility } from './level-utils'

// Levels currently in shadow-caster-only mode (solo hides them from the color
// passes but keeps their sun shadows). Tracked so we can restore layer masks
Expand Down Expand Up @@ -54,22 +54,22 @@ export const LevelSystem = () => {
// feel at 60 fps, exact snap instead of overshoot on slow frames.
obj.position.y = lerp(obj.position.y, targetY, Math.min(1, delta * 12))

// Solo: hidden levels ABOVE the soloed one stay in the shadow map
// (shadow-caster-only) so the sun still shadows the soloed floor through
// them; levels below can't block the sun, so they plain-hide.
const hidden = levelMode === 'solo' && Boolean(selectedLevel) && level?.id !== selectedLevel
const castsWhileHidden = hidden && selectedIndex !== undefined && index > selectedIndex
if (castsWhileHidden) {
const { visible, shadowOnly } = resolveLevelVisibility({
levelMode,
hasSelectedLevel: Boolean(selectedLevel),
isSelected: level?.id === selectedLevel,
index,
selectedIndex,
nodeVisible: level?.visible !== false,
})
if (shadowOnly) {
applyShadowOnly(obj)
shadowOnlyLevels.add(obj)
obj.visible = true
} else {
if (shadowOnlyLevels.has(obj)) {
clearShadowOnly(obj)
shadowOnlyLevels.delete(obj)
}
obj.visible = !hidden
} else if (shadowOnlyLevels.has(obj)) {
clearShadowOnly(obj)
shadowOnlyLevels.delete(obj)
}
obj.visible = visible
}
}, 5) // Using a lower priority so it runs after transforms from other systems have settled
return null
Expand Down
69 changes: 69 additions & 0 deletions packages/viewer/src/systems/level/level-utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { describe, expect, test } from 'bun:test'
import { resolveLevelVisibility } from './level-utils'

const decide = (
overrides: Partial<Parameters<typeof resolveLevelVisibility>[0]> = {},
): ReturnType<typeof resolveLevelVisibility> =>
resolveLevelVisibility({
levelMode: 'stacked',
hasSelectedLevel: false,
isSelected: false,
index: 0,
selectedIndex: undefined,
nodeVisible: true,
...overrides,
})

describe('resolveLevelVisibility', () => {
test('shows every level outside solo mode', () => {
expect(decide()).toEqual({ visible: true, shadowOnly: false })
expect(decide({ levelMode: 'exploded', index: 2 })).toEqual({
visible: true,
shadowOnly: false,
})
})

test('solo keeps the soloed level, plain-hides the ones below it', () => {
expect(
decide({ levelMode: 'solo', hasSelectedLevel: true, isSelected: true, index: 1 }),
).toEqual({ visible: true, shadowOnly: false })
expect(
decide({ levelMode: 'solo', hasSelectedLevel: true, index: 0, selectedIndex: 1 }),
).toEqual({ visible: false, shadowOnly: false })
})

test('solo keeps the levels above the soloed one as shadow casters', () => {
expect(
decide({ levelMode: 'solo', hasSelectedLevel: true, index: 2, selectedIndex: 1 }),
).toEqual({ visible: true, shadowOnly: true })
})

test('solo plain-hides everything when the selected level is not registered', () => {
expect(
decide({ levelMode: 'solo', hasSelectedLevel: true, index: 2, selectedIndex: undefined }),
).toEqual({ visible: false, shadowOnly: false })
})

test('a level the author hid is hidden outright, never a shadow caster', () => {
expect(decide({ nodeVisible: false })).toEqual({ visible: false, shadowOnly: false })
expect(
decide({
levelMode: 'solo',
hasSelectedLevel: true,
index: 2,
selectedIndex: 1,
nodeVisible: false,
}),
).toEqual({ visible: false, shadowOnly: false })
expect(
decide({
levelMode: 'solo',
hasSelectedLevel: true,
isSelected: true,
index: 1,
selectedIndex: 1,
nodeVisible: false,
}),
).toEqual({ visible: false, shadowOnly: false })
})
})
45 changes: 41 additions & 4 deletions packages/viewer/src/systems/level/level-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,44 @@ export function getLevelPresentationY(
return baseY + explodedExtra
}

/**
* Whether a level renders this frame, and whether it renders as a
* shadow-caster only.
*
* Two unrelated things hide a level and they do not compose: the author's own
* `visible` flag (the sidebar eye), which hides the floor outright, and solo
* mode, which hides every level but the soloed one — keeping the levels ABOVE
* it in the shadow map so the sun still shadows the soloed floor through them.
* A level the author hid never enters that shadow-caster branch: its shadows
* on the floor below would be exactly what hiding it was meant to remove.
*/
export function resolveLevelVisibility({
levelMode,
hasSelectedLevel,
isSelected,
index,
selectedIndex,
nodeVisible,
}: {
levelMode: 'stacked' | 'exploded' | 'solo' | 'manual'
hasSelectedLevel: boolean
isSelected: boolean
index: number
selectedIndex: number | undefined
nodeVisible: boolean
}): { visible: boolean; shadowOnly: boolean } {
if (!nodeVisible) return { visible: false, shadowOnly: false }

const hidden = levelMode === 'solo' && hasSelectedLevel && !isSelected
const shadowOnly = hidden && selectedIndex !== undefined && index > selectedIndex
return { visible: shadowOnly || !hidden, shadowOnly }
}

/**
* Instantly snaps all level Objects3D to their true stacked Y positions
* (ignores levelMode — always uses stacked, no exploded gap).
* (ignores levelMode — always uses stacked, no exploded gap). Presentation
* hiding is undone with it, but a level the author hid stays hidden: the
* capture has to match the export, which prunes it.
*
* Returns a restore function that reverts each level's Y to what it was
* before the snap, so lerp animations in LevelSystem can continue undisturbed.
Expand All @@ -38,6 +73,7 @@ export function snapLevelsToTruePositions(): () => void {
type LevelEntry = {
obj: NonNullable<ReturnType<typeof sceneRegistry.nodes.get>>
levelId: string
nodeVisible: boolean
}

const entries: LevelEntry[] = []
Expand All @@ -48,6 +84,7 @@ export function snapLevelsToTruePositions(): () => void {
entries.push({
levelId,
obj,
nodeVisible: level.visible !== false,
})
}
})
Expand All @@ -58,10 +95,10 @@ export function snapLevelsToTruePositions(): () => void {
entries.map(({ levelId, obj }) => [levelId, { y: obj.position.y, visible: obj.visible }]),
)

// Snap to true stacked positions and make all levels visible
for (const { levelId, obj } of entries) {
// Snap to true stacked positions and undo presentation hiding
for (const { levelId, obj, nodeVisible } of entries) {
obj.position.y = levelElevations.get(levelId)?.baseY ?? 0
obj.visible = true
obj.visible = nodeVisible
}

return () => {
Expand Down
Loading
Loading