fix: bug pass — legacy window crash, plant exports, zone snapping/visibility, HDR (#473)
* fix(core): apply window schema defaults on scene load Windows saved before a schema field existed (columnRatios/rowRatios/ frameThickness/…) loaded with those fields missing; the window mesh builder reads them unconditionally and threw every frame, crashing the viewer on legacy scenes. Zod-parse windows on load like doors so the schema defaults land. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(editor): mount plant geometry during client GLB/STL/OBJ export The client export never set isExporting, so instanced kinds (trees/ flowers/grass) kept their colorWrite:false raycast collider mounted and exported it as an opaque white box, while the real geometry (which only mounts while exporting) was never captured. Reuse BakeExporter's flag + frame-wait dance in ExportManager, and harden isRenderableMesh to drop colorWrite:false materials from exports entirely. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(editor): route zone drawing through the shared surface snap The zone tool quantized later vertices by distance along a free ray instead of snapping to the grid, and never joined the magnetic wall-corner/midpoint/crossing + alignment-guide pipeline that walls, slabs and ceilings use. Give zone the slab treatment in the 3D tool and both 2D floorplan branches (move + click + double-click commit). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(viewer): self-host the scene environment HDR drei's preset="sunset" fetches venice_sunset_1k.hdr from raw.githack.com, which intermittently fails ("Could not load venice_sunset_1k.hdr: Failed to fetch"). Point Environment at /hdri/venice_sunset_1k.hdr and ship the file in the app's public/ — same mirroring convention as /audios/sfx; consuming apps must carry the file too. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(editor): unmount zones entirely outside the zones layer Zone labels showed in every editing mode (visual noise), and each zone kept a drei <Html> mounted at opacity 0 — an <Html> costs per-frame matrix work and live DOM even when invisible. A new viewer presentation flag (showZones, default true) lets the editor unmount zone meshes and labels whenever the structure layer isn't 'zones' (and during snapshot capture); the registered group stays so zones keep their scene identity for selection and GLB export. Preview / first-person / viewer surfaces are untouched. Raycast-disable moved from a one-shot group flag to per-frame on the meshes, which now remount on layer toggles. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
51fddc2d9c
commit
717c2c5c0a
Binary file not shown.
@@ -0,0 +1,92 @@
|
|||||||
|
import { beforeEach, describe, expect, test } from 'bun:test'
|
||||||
|
import type { AnyNode } from '../schema'
|
||||||
|
import useScene from './use-scene'
|
||||||
|
|
||||||
|
describe('scene window migrations', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useScene.setState({
|
||||||
|
nodes: {},
|
||||||
|
rootNodeIds: [],
|
||||||
|
dirtyNodes: new Set(),
|
||||||
|
collections: {},
|
||||||
|
} as never)
|
||||||
|
useScene.temporal.getState().clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('fills schema defaults on windows saved before a field existed', () => {
|
||||||
|
// Mirrors real legacy scenes (e.g. windows persisted without
|
||||||
|
// columnRatios/rowRatios/frameThickness): the mesh builder reads those
|
||||||
|
// unconditionally, so a missing array crashed the viewer every frame.
|
||||||
|
useScene.getState().setScene(
|
||||||
|
{
|
||||||
|
site_test: {
|
||||||
|
object: 'node',
|
||||||
|
id: 'site_test',
|
||||||
|
type: 'site',
|
||||||
|
parentId: null,
|
||||||
|
visible: true,
|
||||||
|
metadata: {},
|
||||||
|
children: ['building_test'],
|
||||||
|
},
|
||||||
|
building_test: {
|
||||||
|
object: 'node',
|
||||||
|
id: 'building_test',
|
||||||
|
type: 'building',
|
||||||
|
parentId: 'site_test',
|
||||||
|
visible: true,
|
||||||
|
metadata: {},
|
||||||
|
children: ['level_test'],
|
||||||
|
},
|
||||||
|
level_test: {
|
||||||
|
object: 'node',
|
||||||
|
id: 'level_test',
|
||||||
|
type: 'level',
|
||||||
|
parentId: 'building_test',
|
||||||
|
visible: true,
|
||||||
|
metadata: {},
|
||||||
|
children: ['wall_test'],
|
||||||
|
level: 0,
|
||||||
|
},
|
||||||
|
wall_test: {
|
||||||
|
object: 'node',
|
||||||
|
id: 'wall_test',
|
||||||
|
type: 'wall',
|
||||||
|
parentId: 'level_test',
|
||||||
|
visible: true,
|
||||||
|
metadata: {},
|
||||||
|
children: ['window_test'],
|
||||||
|
start: [0, 0],
|
||||||
|
end: [4, 0],
|
||||||
|
height: 2.5,
|
||||||
|
thickness: 0.2,
|
||||||
|
},
|
||||||
|
window_test: {
|
||||||
|
object: 'node',
|
||||||
|
id: 'window_test',
|
||||||
|
type: 'window',
|
||||||
|
parentId: 'wall_test',
|
||||||
|
visible: true,
|
||||||
|
metadata: {},
|
||||||
|
wallId: 'wall_test',
|
||||||
|
position: [1, 1, 0],
|
||||||
|
width: 1.2,
|
||||||
|
height: 1.5,
|
||||||
|
windowType: 'fixed',
|
||||||
|
},
|
||||||
|
} as unknown as Record<string, AnyNode>,
|
||||||
|
['site_test'] as never,
|
||||||
|
)
|
||||||
|
|
||||||
|
const window = useScene.getState().nodes.window_test as Extract<AnyNode, { type: 'window' }>
|
||||||
|
expect(window).toBeDefined()
|
||||||
|
// Schema defaults land on load…
|
||||||
|
expect(window.columnRatios).toEqual([1])
|
||||||
|
expect(window.rowRatios).toEqual([1])
|
||||||
|
expect(window.frameThickness).toBe(0.05)
|
||||||
|
expect(window.sill).toBe(true)
|
||||||
|
// …and authored fields survive.
|
||||||
|
expect(window.width).toBe(1.2)
|
||||||
|
expect(window.height).toBe(1.5)
|
||||||
|
expect(window.wallId).toBe('wall_test')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
} from '../schema/nodes/stair'
|
} from '../schema/nodes/stair'
|
||||||
import { StairSegmentNode as StairSegmentNodeSchema } from '../schema/nodes/stair-segment'
|
import { StairSegmentNode as StairSegmentNodeSchema } from '../schema/nodes/stair-segment'
|
||||||
import { getEffectiveWallSurfaceMaterial, type WallSurfaceSide } from '../schema/nodes/wall'
|
import { getEffectiveWallSurfaceMaterial, type WallSurfaceSide } from '../schema/nodes/wall'
|
||||||
|
import { WindowNode as WindowNodeSchema } from '../schema/nodes/window'
|
||||||
import {
|
import {
|
||||||
generateSceneMaterialId,
|
generateSceneMaterialId,
|
||||||
type SceneMaterial,
|
type SceneMaterial,
|
||||||
@@ -132,6 +133,14 @@ function normalizeDoorNode(node: Record<string, unknown>) {
|
|||||||
return parsed.success ? { ...node, ...parsed.data } : null
|
return parsed.success ? { ...node, ...parsed.data } : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Windows saved before a schema field existed (e.g. `columnRatios`/`rowRatios`/
|
||||||
|
// `frameThickness`) load without it; the mesh builder then reads undefined and
|
||||||
|
// throws every frame. Zod-parse on load so schema defaults land, like doors.
|
||||||
|
function normalizeWindowNode(node: Record<string, unknown>) {
|
||||||
|
const parsed = WindowNodeSchema.safeParse(node)
|
||||||
|
return parsed.success ? { ...node, ...parsed.data } : null
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeShelfNode(node: Record<string, unknown>) {
|
function normalizeShelfNode(node: Record<string, unknown>) {
|
||||||
const sanitized = {
|
const sanitized = {
|
||||||
...node,
|
...node,
|
||||||
@@ -640,6 +649,13 @@ function migrateNodes(nodes: Record<string, any>): {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (node.type === 'window') {
|
||||||
|
const normalized = normalizeWindowNode(node)
|
||||||
|
if (normalized) {
|
||||||
|
patchedNodes[id] = normalized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (node.type === 'stair') {
|
if (node.type === 'stair') {
|
||||||
const normalized = normalizeStairNode(migrateStairSurfaceMaterials(node))
|
const normalized = normalizeStairNode(migrateStairSurfaceMaterials(node))
|
||||||
if (normalized) {
|
if (normalized) {
|
||||||
|
|||||||
@@ -4,16 +4,7 @@ import { useScene } from '@pascal-app/core'
|
|||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { useThree } from '@react-three/fiber'
|
import { useThree } from '@react-three/fiber'
|
||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import { exportSceneToGlb } from '../../lib/glb-export'
|
import { exportSceneToGlb, nextFrames } from '../../lib/glb-export'
|
||||||
|
|
||||||
/** Resolve after the next couple of animation frames, giving React/R3F time to
|
|
||||||
* commit and mount export-only geometry (e.g. instanced kinds' real meshes)
|
|
||||||
* before the exporter clones the scene graph. */
|
|
||||||
function nextFrames(): Promise<void> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BakeExporter({
|
export function BakeExporter({
|
||||||
active,
|
active,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { useEffect } from 'react'
|
|||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
import { OBJExporter } from 'three/examples/jsm/exporters/OBJExporter.js'
|
import { OBJExporter } from 'three/examples/jsm/exporters/OBJExporter.js'
|
||||||
import { STLExporter } from 'three/examples/jsm/exporters/STLExporter.js'
|
import { STLExporter } from 'three/examples/jsm/exporters/STLExporter.js'
|
||||||
import { exportSceneToGlb, prepareSceneForExport } from '../../lib/glb-export'
|
import { exportSceneToGlb, nextFrames, prepareSceneForExport } from '../../lib/glb-export'
|
||||||
|
|
||||||
// prepareSceneForExport neutralises container meshes (door/window hitbox roots,
|
// prepareSceneForExport neutralises container meshes (door/window hitbox roots,
|
||||||
// material-less renderables) with an attribute-less geometry — GLTFExporter
|
// material-less renderables) with an attribute-less geometry — GLTFExporter
|
||||||
@@ -46,41 +46,52 @@ export function ExportManager() {
|
|||||||
|
|
||||||
const date = new Date().toISOString().split('T')[0]
|
const date = new Date().toISOString().split('T')[0]
|
||||||
|
|
||||||
if (format === 'glb') {
|
// Signal export so instanced kinds (trees/flowers/grass) swap their
|
||||||
const buffer = await exportSceneToGlb(sceneGroup, useScene.getState().nodes)
|
// invisible proxy for real, exportable geometry, then wait for the
|
||||||
const blob = new Blob([buffer], { type: 'model/gltf-binary' })
|
// commit before cloning the scene graph (same dance as BakeExporter —
|
||||||
downloadBlob(blob, `model_${date}.glb`)
|
// without it every plant exports as its raycast collider, a white box).
|
||||||
return
|
useViewer.getState().setExporting(true)
|
||||||
}
|
|
||||||
|
|
||||||
// Hide editor affordances that live on the scene layer (selection handles,
|
|
||||||
// ceiling/site brackets) and let wall-cutout reveal all walls — the same
|
|
||||||
// synchronous capture path thumbnails use. We clone the scene inside the
|
|
||||||
// window, so the export snapshots the clean building, then restore.
|
|
||||||
emitter.emit('thumbnail:before-capture', undefined)
|
|
||||||
let prepared: ReturnType<typeof prepareSceneForExport>
|
|
||||||
try {
|
try {
|
||||||
prepared = prepareSceneForExport(sceneGroup, useScene.getState().nodes)
|
await nextFrames()
|
||||||
|
|
||||||
|
if (format === 'glb') {
|
||||||
|
const buffer = await exportSceneToGlb(sceneGroup, useScene.getState().nodes)
|
||||||
|
const blob = new Blob([buffer], { type: 'model/gltf-binary' })
|
||||||
|
downloadBlob(blob, `model_${date}.glb`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide editor affordances that live on the scene layer (selection handles,
|
||||||
|
// ceiling/site brackets) and let wall-cutout reveal all walls — the same
|
||||||
|
// synchronous capture path thumbnails use. We clone the scene inside the
|
||||||
|
// window, so the export snapshots the clean building, then restore.
|
||||||
|
emitter.emit('thumbnail:before-capture', undefined)
|
||||||
|
let prepared: ReturnType<typeof prepareSceneForExport>
|
||||||
|
try {
|
||||||
|
prepared = prepareSceneForExport(sceneGroup, useScene.getState().nodes)
|
||||||
|
} finally {
|
||||||
|
emitter.emit('thumbnail:after-capture', undefined)
|
||||||
|
}
|
||||||
|
const { scene: exportScene } = prepared
|
||||||
|
ensurePositionAttributes(exportScene)
|
||||||
|
|
||||||
|
if (format === 'stl') {
|
||||||
|
const exporter = new STLExporter()
|
||||||
|
const result = exporter.parse(exportScene, { binary: true })
|
||||||
|
const blob = new Blob([result], { type: 'model/stl' })
|
||||||
|
downloadBlob(blob, `model_${date}.stl`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (format === 'obj') {
|
||||||
|
const exporter = new OBJExporter()
|
||||||
|
const result = exporter.parse(exportScene)
|
||||||
|
const blob = new Blob([result], { type: 'model/obj' })
|
||||||
|
downloadBlob(blob, `model_${date}.obj`)
|
||||||
|
return
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
emitter.emit('thumbnail:after-capture', undefined)
|
useViewer.getState().setExporting(false)
|
||||||
}
|
|
||||||
const { scene: exportScene } = prepared
|
|
||||||
ensurePositionAttributes(exportScene)
|
|
||||||
|
|
||||||
if (format === 'stl') {
|
|
||||||
const exporter = new STLExporter()
|
|
||||||
const result = exporter.parse(exportScene, { binary: true })
|
|
||||||
const blob = new Blob([result], { type: 'model/stl' })
|
|
||||||
downloadBlob(blob, `model_${date}.stl`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (format === 'obj') {
|
|
||||||
const exporter = new OBJExporter()
|
|
||||||
const result = exporter.parse(exportScene)
|
|
||||||
const blob = new Blob([result], { type: 'model/obj' })
|
|
||||||
downloadBlob(blob, `model_${date}.obj`)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8935,21 +8935,14 @@ export function FloorplanPanel({
|
|||||||
start: activePolygonDraftPoints[activePolygonDraftPoints.length - 1],
|
start: activePolygonDraftPoints[activePolygonDraftPoints.length - 1],
|
||||||
angleSnap,
|
angleSnap,
|
||||||
})
|
})
|
||||||
let snappedPoint = fallbackPoint
|
// Zone shares the slab surface snap (wall corners / midpoints /
|
||||||
if (isSlabBuildActive) {
|
// crossings + alignment) — it's the same polygon-on-a-level draw.
|
||||||
snappedPoint = resolveSlabPlanPointSnap({
|
const snappedPoint = resolveSlabPlanPointSnap({
|
||||||
rawPoint: planPoint,
|
rawPoint: planPoint,
|
||||||
fallbackPoint,
|
fallbackPoint,
|
||||||
levelId,
|
levelId,
|
||||||
align: !angleSnap,
|
align: !angleSnap,
|
||||||
}).point
|
}).point
|
||||||
} else if (angleSnap) {
|
|
||||||
useAlignmentGuides.getState().clear()
|
|
||||||
} else {
|
|
||||||
snappedPoint = alignFloorplanDraftPoint(fallbackPoint, {
|
|
||||||
applySnap: isMagneticSnapActive(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Emit `grid:move` so the registry-driven slab tool also tracks
|
// Emit `grid:move` so the registry-driven slab tool also tracks
|
||||||
// the cursor (its 3D preview needs it).
|
// the cursor (its 3D preview needs it).
|
||||||
@@ -9647,15 +9640,15 @@ export function FloorplanPanel({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const snappedPoint = resolveSlabPlanPointSnap({
|
||||||
|
rawPoint: planPoint,
|
||||||
|
fallbackPoint,
|
||||||
|
levelId,
|
||||||
|
align: !angleSnap,
|
||||||
|
}).point
|
||||||
if (isZoneBuildActive) {
|
if (isZoneBuildActive) {
|
||||||
handleZonePlacementConfirm(fallbackPoint)
|
handleZonePlacementConfirm(snappedPoint)
|
||||||
} else {
|
} else {
|
||||||
const snappedPoint = resolveSlabPlanPointSnap({
|
|
||||||
rawPoint: planPoint,
|
|
||||||
fallbackPoint,
|
|
||||||
levelId,
|
|
||||||
align: !angleSnap,
|
|
||||||
}).point
|
|
||||||
// Slab is registry-driven: forward the double-click so the 3D tool
|
// Slab is registry-driven: forward the double-click so the 3D tool
|
||||||
// commits the node (zone has no registry tool, so it commits locally).
|
// commits the node (zone has no registry tool, so it commits locally).
|
||||||
emitFloorplanGridEvent('double-click', snappedPoint, event)
|
emitFloorplanGridEvent('double-click', snappedPoint, event)
|
||||||
|
|||||||
@@ -275,21 +275,15 @@ export function useFloorplanBackgroundPlacement({
|
|||||||
start: activePolygonDraftPoints[activePolygonDraftPoints.length - 1],
|
start: activePolygonDraftPoints[activePolygonDraftPoints.length - 1],
|
||||||
angleSnap,
|
angleSnap,
|
||||||
})
|
})
|
||||||
let snappedPoint = fallbackPoint
|
// Zone shares the slab surface snap (wall corners / midpoints /
|
||||||
if (isSlabBuildActive) {
|
// crossings + alignment) — it's the same polygon-on-a-level draw.
|
||||||
snappedPoint = resolveSlabPlanPointSnap({
|
const snappedPoint = resolveSlabPlanPointSnap({
|
||||||
rawPoint: planPoint,
|
rawPoint: planPoint,
|
||||||
fallbackPoint,
|
fallbackPoint,
|
||||||
levelId,
|
levelId,
|
||||||
altKey: event.altKey,
|
altKey: event.altKey,
|
||||||
align: !angleSnap,
|
align: !angleSnap,
|
||||||
}).point
|
}).point
|
||||||
} else if (!angleSnap) {
|
|
||||||
snappedPoint = alignFloorplanDraftPoint(fallbackPoint, {
|
|
||||||
applySnap: isMagneticSnapActive(),
|
|
||||||
bypass: event.altKey,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Emit the grid event so the registry-driven slab tool also
|
// Emit the grid event so the registry-driven slab tool also
|
||||||
// sees the click (parity with ceiling / fence / roof branches
|
// sees the click (parity with ceiling / fence / roof branches
|
||||||
|
|||||||
@@ -316,6 +316,12 @@ export function ZoneLabelEditorSystem() {
|
|||||||
.map((n) => n.id as ZoneNode['id']),
|
.map((n) => n.id as ZoneNode['id']),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
// The zone renderer unmounts its <Html> label when zones are hidden —
|
||||||
|
// unmount the portals with it, or each editor would hold (and rAF-poll for)
|
||||||
|
// a detached label element.
|
||||||
|
const showZones = useViewer((s) => s.showZones)
|
||||||
|
|
||||||
|
if (!showZones) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { sceneRegistry, useScene, type ZoneNode } from '@pascal-app/core'
|
import { sceneRegistry, useScene, type ZoneNode } from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { useFrame } from '@react-three/fiber'
|
import { useFrame } from '@react-three/fiber'
|
||||||
|
import { useEffect } from 'react'
|
||||||
import { type Group, MathUtils, type Mesh } from 'three'
|
import { type Group, MathUtils, type Mesh } from 'three'
|
||||||
import type { MeshBasicNodeMaterial } from 'three/webgpu'
|
import type { MeshBasicNodeMaterial } from 'three/webgpu'
|
||||||
import { resolveOverlayPolicy } from '../../../lib/interaction/overlay-policy'
|
import { resolveOverlayPolicy } from '../../../lib/interaction/overlay-policy'
|
||||||
@@ -12,7 +13,21 @@ import useInteractionScope from '../../../store/use-interaction-scope'
|
|||||||
const noopRaycast = () => {}
|
const noopRaycast = () => {}
|
||||||
|
|
||||||
export const ZoneSystem = () => {
|
export const ZoneSystem = () => {
|
||||||
|
// Outside the zones layer (or during snapshot capture) zones unmount
|
||||||
|
// entirely — meshes AND drei <Html> labels, which cost per-frame matrix work
|
||||||
|
// + live DOM even at opacity 0. The renderer reads this viewer flag; the
|
||||||
|
// unmount cleanup restores the default so preview / first-person surfaces
|
||||||
|
// (which swap this system for ViewerZoneSystem) keep their labels.
|
||||||
|
const structureLayerState = useEditor((s) => s.structureLayer)
|
||||||
|
const isCaptureModeState = useEditor((s) => s.isCaptureMode)
|
||||||
|
useEffect(() => {
|
||||||
|
useViewer.getState().setShowZones(structureLayerState === 'zones' && !isCaptureModeState)
|
||||||
|
return () => useViewer.getState().setShowZones(true)
|
||||||
|
}, [structureLayerState, isCaptureModeState])
|
||||||
|
|
||||||
useFrame((_, delta) => {
|
useFrame((_, delta) => {
|
||||||
|
if (!useViewer.getState().showZones) return
|
||||||
|
|
||||||
const structureLayer = useEditor.getState().structureLayer
|
const structureLayer = useEditor.getState().structureLayer
|
||||||
const editorMode = useEditor.getState().mode
|
const editorMode = useEditor.getState().mode
|
||||||
const selectedLevelId = useViewer.getState().selection.levelId
|
const selectedLevelId = useViewer.getState().selection.levelId
|
||||||
@@ -54,9 +69,13 @@ export const ZoneSystem = () => {
|
|||||||
? 1
|
? 1
|
||||||
: 0
|
: 0
|
||||||
|
|
||||||
|
// Raycast is re-disabled per frame (not once per group): the meshes
|
||||||
|
// remount whenever the zones layer toggles, so a one-shot flag on the
|
||||||
|
// persistent group would leave fresh meshes clickable.
|
||||||
const walls = (obj as Group).getObjectByName('walls') as Mesh | undefined
|
const walls = (obj as Group).getObjectByName('walls') as Mesh | undefined
|
||||||
if (walls) {
|
if (walls) {
|
||||||
walls.visible = meshVisible
|
walls.visible = meshVisible
|
||||||
|
walls.raycast = noopRaycast
|
||||||
const material = walls.material as MeshBasicNodeMaterial
|
const material = walls.material as MeshBasicNodeMaterial
|
||||||
if (material?.userData?.uOpacity) {
|
if (material?.userData?.uOpacity) {
|
||||||
material.userData.uOpacity.value = MathUtils.lerp(
|
material.userData.uOpacity.value = MathUtils.lerp(
|
||||||
@@ -70,6 +89,7 @@ export const ZoneSystem = () => {
|
|||||||
const floor = (obj as Group).getObjectByName('floor') as Mesh | undefined
|
const floor = (obj as Group).getObjectByName('floor') as Mesh | undefined
|
||||||
if (floor) {
|
if (floor) {
|
||||||
floor.visible = meshVisible
|
floor.visible = meshVisible
|
||||||
|
floor.raycast = noopRaycast
|
||||||
const material = floor.material as MeshBasicNodeMaterial
|
const material = floor.material as MeshBasicNodeMaterial
|
||||||
if (material?.userData?.uOpacity) {
|
if (material?.userData?.uOpacity) {
|
||||||
material.userData.uOpacity.value = MathUtils.lerp(
|
material.userData.uOpacity.value = MathUtils.lerp(
|
||||||
@@ -80,15 +100,6 @@ export const ZoneSystem = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Disable raycasting once per zone object so geometry never intercepts clicks
|
|
||||||
if (!obj.userData.__raycastDisabled) {
|
|
||||||
obj.raycast = noopRaycast
|
|
||||||
obj.traverse((child) => {
|
|
||||||
child.raycast = noopRaycast
|
|
||||||
})
|
|
||||||
obj.userData.__raycastDisabled = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Labels: visible on the current level (regardless of mode), but never
|
// Labels: visible on the current level (regardless of mode), but never
|
||||||
// during snapshot capture.
|
// during snapshot capture.
|
||||||
const showLabel =
|
const showLabel =
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ import { useEffect, useMemo, useRef, useState } from 'react'
|
|||||||
import { BufferGeometry, DoubleSide, type Group, type Line, Shape, Vector3 } from 'three'
|
import { BufferGeometry, DoubleSide, type Group, type Line, Shape, Vector3 } from 'three'
|
||||||
import { EDITOR_LAYER } from './../../../lib/constants'
|
import { EDITOR_LAYER } from './../../../lib/constants'
|
||||||
import { sfxEmitter } from './../../../lib/sfx-bus'
|
import { sfxEmitter } from './../../../lib/sfx-bus'
|
||||||
|
import {
|
||||||
|
clearSurfacePlanSnapFeedback,
|
||||||
|
resolveSurfacePlanPointSnap,
|
||||||
|
} from './../../../lib/surface-plan-snap'
|
||||||
import { snapWorldXZForActiveBuilding } from './../../../lib/world-grid-snap'
|
import { snapWorldXZForActiveBuilding } from './../../../lib/world-grid-snap'
|
||||||
import useEditor, { isAngleSnapActive, isGridSnapActive } from './../../../store/use-editor'
|
import useEditor, { isAngleSnapActive, isGridSnapActive } from './../../../store/use-editor'
|
||||||
import { CursorSphere } from '../shared/cursor-sphere'
|
import { CursorSphere } from '../shared/cursor-sphere'
|
||||||
@@ -79,26 +83,35 @@ export const ZoneTool: React.FC = () => {
|
|||||||
if (!currentLevelId) return
|
if (!currentLevelId) return
|
||||||
|
|
||||||
let cursorPosition: [number, number] = [0, 0]
|
let cursorPosition: [number, number] = [0, 0]
|
||||||
let rawCursorPosition: [number, number] = [0, 0]
|
let snappedCursorPosition: [number, number] | null = null
|
||||||
|
|
||||||
// Initialize line geometries
|
// Initialize line geometries
|
||||||
mainLineRef.current.geometry = new BufferGeometry()
|
mainLineRef.current.geometry = new BufferGeometry()
|
||||||
closingLineRef.current.geometry = new BufferGeometry()
|
closingLineRef.current.geometry = new BufferGeometry()
|
||||||
|
|
||||||
// Snapping follows the active mode (zone resolves to the 'wall' context):
|
// Snapping follows the active mode: `angles` locks the ray to 15° from the
|
||||||
// `angles` locks the ray to 15° from the last vertex, `grid` quantizes the
|
// last vertex (grid quantizes the distance along it), otherwise `grid`
|
||||||
// distance along it, `lines` / `off` leave the raw cursor. No held-Shift
|
// quantizes to the world-aligned grid; the shared surface snap then layers
|
||||||
// bypass — Shift cycles the mode (see interaction-scope.md).
|
// wall-corner/midpoint/crossing magnetics and alignment guides on top —
|
||||||
|
// the same pipeline slab/ceiling drafting uses. No held-Shift bypass —
|
||||||
|
// Shift cycles the mode (see interaction-scope.md).
|
||||||
const snapDraftPoint = (
|
const snapDraftPoint = (
|
||||||
lastPoint: [number, number],
|
lastPoint: [number, number] | undefined,
|
||||||
_gridPoint: [number, number],
|
gridPoint: [number, number],
|
||||||
rawPoint: [number, number],
|
rawPoint: [number, number],
|
||||||
|
altKey: boolean,
|
||||||
): [number, number] => {
|
): [number, number] => {
|
||||||
const angleStep = isAngleSnapActive() ? DEFAULT_ANGLE_STEP : 0
|
|
||||||
const gridStep = isGridSnapActive() ? useEditor.getState().gridSnapStep : 0
|
const gridStep = isGridSnapActive() ? useEditor.getState().gridSnapStep : 0
|
||||||
if (angleStep === 0 && gridStep === 0) return rawPoint
|
const orthoPoint: [number, number] =
|
||||||
const [x, z] = snapPointAlongAngleRay(lastPoint, rawPoint, angleStep, gridStep)
|
isAngleSnapActive() && lastPoint
|
||||||
return [x, z]
|
? [...snapPointAlongAngleRay(lastPoint, rawPoint, DEFAULT_ANGLE_STEP, gridStep)]
|
||||||
|
: gridPoint
|
||||||
|
return resolveSurfacePlanPointSnap({
|
||||||
|
rawPoint,
|
||||||
|
fallbackPoint: orthoPoint,
|
||||||
|
levelId: currentLevelId,
|
||||||
|
altKey,
|
||||||
|
}).point
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateLines = () => {
|
const updateLines = () => {
|
||||||
@@ -116,11 +129,9 @@ export const ZoneTool: React.FC = () => {
|
|||||||
|
|
||||||
// Add cursor point
|
// Add cursor point
|
||||||
const lastPoint = points[points.length - 1]
|
const lastPoint = points[points.length - 1]
|
||||||
if (lastPoint) {
|
const snapped = snappedCursorPosition ?? cursorPosition
|
||||||
const snapped = snapDraftPoint(lastPoint, cursorPosition, rawCursorPosition)
|
if (lastPoint && isValidPoint(snapped)) {
|
||||||
if (isValidPoint(snapped)) {
|
linePoints.push(new Vector3(snapped[0], y, snapped[1]))
|
||||||
linePoints.push(new Vector3(snapped[0], y, snapped[1]))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update main line geometry
|
// Update main line geometry
|
||||||
@@ -134,17 +145,14 @@ export const ZoneTool: React.FC = () => {
|
|||||||
|
|
||||||
// Update closing line (from cursor back to first point)
|
// Update closing line (from cursor back to first point)
|
||||||
const firstPoint = points[0]
|
const firstPoint = points[0]
|
||||||
if (points.length >= 2 && lastPoint && isValidPoint(firstPoint)) {
|
if (points.length >= 2 && lastPoint && isValidPoint(firstPoint) && isValidPoint(snapped)) {
|
||||||
const snapped = snapDraftPoint(lastPoint, cursorPosition, rawCursorPosition)
|
const closingPoints = [
|
||||||
if (isValidPoint(snapped)) {
|
new Vector3(snapped[0], y, snapped[1]),
|
||||||
const closingPoints = [
|
new Vector3(firstPoint[0], y, firstPoint[1]),
|
||||||
new Vector3(snapped[0], y, snapped[1]),
|
]
|
||||||
new Vector3(firstPoint[0], y, firstPoint[1]),
|
closingLineRef.current.geometry.dispose()
|
||||||
]
|
closingLineRef.current.geometry = new BufferGeometry().setFromPoints(closingPoints)
|
||||||
closingLineRef.current.geometry.dispose()
|
closingLineRef.current.visible = true
|
||||||
closingLineRef.current.geometry = new BufferGeometry().setFromPoints(closingPoints)
|
|
||||||
closingLineRef.current.visible = true
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
closingLineRef.current.visible = false
|
closingLineRef.current.visible = false
|
||||||
}
|
}
|
||||||
@@ -152,42 +160,42 @@ export const ZoneTool: React.FC = () => {
|
|||||||
|
|
||||||
const updatePreview = () => {
|
const updatePreview = () => {
|
||||||
const points = pointsRef.current
|
const points = pointsRef.current
|
||||||
const lastPoint = points[points.length - 1]
|
const cursorPt = snappedCursorPosition ?? cursorPosition
|
||||||
|
|
||||||
let cursorPt: [number, number] | null = null
|
setPreview({
|
||||||
if (lastPoint) {
|
points: [...points],
|
||||||
cursorPt = snapDraftPoint(lastPoint, cursorPosition, rawCursorPosition)
|
cursorPoint: isValidPoint(cursorPt) ? cursorPt : null,
|
||||||
} else if (points.length === 0) {
|
levelY: levelYRef.current,
|
||||||
cursorPt = cursorPosition
|
})
|
||||||
}
|
|
||||||
|
|
||||||
setPreview({ points: [...points], cursorPoint: cursorPt, levelY: levelYRef.current })
|
|
||||||
updateLines()
|
updateLines()
|
||||||
}
|
}
|
||||||
|
|
||||||
const onGridMove = (event: GridEvent) => {
|
// World-grid snap projected into building-local; rotated buildings
|
||||||
if (!cursorRef.current) return
|
// used to pull the snap off the visible grid lines. Grid quantize only
|
||||||
|
// in grid mode; off / lines / angles leave the raw cursor.
|
||||||
// World-grid snap projected into building-local; rotated buildings
|
const gridPointOf = (event: GridEvent): [number, number] =>
|
||||||
// used to pull the snap off the visible grid lines. Grid quantize only
|
isGridSnapActive()
|
||||||
// in grid mode; off / lines / angles leave the raw cursor for the first
|
|
||||||
// vertex (later vertices snap along the ray in `snapDraftPoint`).
|
|
||||||
const [gridX, gridZ] = isGridSnapActive()
|
|
||||||
? snapWorldXZForActiveBuilding(
|
? snapWorldXZForActiveBuilding(
|
||||||
event.position[0],
|
event.position[0],
|
||||||
event.position[2],
|
event.position[2],
|
||||||
useEditor.getState().gridSnapStep,
|
useEditor.getState().gridSnapStep,
|
||||||
).local
|
).local
|
||||||
: [event.localPosition[0], event.localPosition[2]]
|
: [event.localPosition[0], event.localPosition[2]]
|
||||||
cursorPosition = [gridX, gridZ]
|
|
||||||
rawCursorPosition = [event.localPosition[0], event.localPosition[2]]
|
const onGridMove = (event: GridEvent) => {
|
||||||
|
if (!cursorRef.current) return
|
||||||
|
|
||||||
|
cursorPosition = gridPointOf(event)
|
||||||
levelYRef.current = event.localPosition[1]
|
levelYRef.current = event.localPosition[1]
|
||||||
|
|
||||||
// If we have points, snap to the 15° ray from the last point
|
|
||||||
const lastPoint = pointsRef.current[pointsRef.current.length - 1]
|
const lastPoint = pointsRef.current[pointsRef.current.length - 1]
|
||||||
const displayPoint = lastPoint
|
const displayPoint = snapDraftPoint(
|
||||||
? snapDraftPoint(lastPoint, cursorPosition, rawCursorPosition)
|
lastPoint,
|
||||||
: cursorPosition
|
cursorPosition,
|
||||||
|
[event.localPosition[0], event.localPosition[2]],
|
||||||
|
event.nativeEvent?.altKey === true,
|
||||||
|
)
|
||||||
|
snappedCursorPosition = displayPoint
|
||||||
|
|
||||||
// Play snap sound when the snapped position changes during drawing — only
|
// Play snap sound when the snapped position changes during drawing — only
|
||||||
// when a quantizing mode is active (off / lines move continuously).
|
// when a quantizing mode is active (off / lines move continuously).
|
||||||
@@ -210,23 +218,13 @@ export const ZoneTool: React.FC = () => {
|
|||||||
const onGridClick = (event: GridEvent) => {
|
const onGridClick = (event: GridEvent) => {
|
||||||
if (!currentLevelId) return
|
if (!currentLevelId) return
|
||||||
|
|
||||||
const [gridX, gridZ] = isGridSnapActive()
|
|
||||||
? snapWorldXZForActiveBuilding(
|
|
||||||
event.position[0],
|
|
||||||
event.position[2],
|
|
||||||
useEditor.getState().gridSnapStep,
|
|
||||||
).local
|
|
||||||
: [event.localPosition[0], event.localPosition[2]]
|
|
||||||
let clickPoint: [number, number] = [gridX, gridZ]
|
|
||||||
|
|
||||||
// Snap to the 15° ray from the last point
|
|
||||||
const lastPoint = pointsRef.current[pointsRef.current.length - 1]
|
const lastPoint = pointsRef.current[pointsRef.current.length - 1]
|
||||||
if (lastPoint) {
|
const clickPoint = snapDraftPoint(
|
||||||
clickPoint = snapDraftPoint(lastPoint, clickPoint, [
|
lastPoint,
|
||||||
event.localPosition[0],
|
gridPointOf(event),
|
||||||
event.localPosition[2],
|
[event.localPosition[0], event.localPosition[2]],
|
||||||
])
|
event.nativeEvent?.altKey === true,
|
||||||
}
|
)
|
||||||
|
|
||||||
// Check if clicking on the first point to close the shape
|
// Check if clicking on the first point to close the shape
|
||||||
const firstPoint = pointsRef.current[0]
|
const firstPoint = pointsRef.current[0]
|
||||||
@@ -280,6 +278,7 @@ export const ZoneTool: React.FC = () => {
|
|||||||
|
|
||||||
// Reset state on unmount
|
// Reset state on unmount
|
||||||
pointsRef.current = []
|
pointsRef.current = []
|
||||||
|
clearSurfacePlanSnapFeedback()
|
||||||
}
|
}
|
||||||
}, [currentLevelId])
|
}, [currentLevelId])
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,16 @@ export type GlbExport = {
|
|||||||
animations: THREE.AnimationClip[]
|
animations: THREE.AnimationClip[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Resolve after the next couple of animation frames, giving React/R3F time to
|
||||||
|
* commit and mount export-only geometry (e.g. instanced kinds' real meshes)
|
||||||
|
* before the exporter clones the scene graph. Callers must set
|
||||||
|
* `useViewer.setExporting(true)` first and reset it after the export. */
|
||||||
|
export function nextFrames(): Promise<void> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export async function exportSceneToGlb(
|
export async function exportSceneToGlb(
|
||||||
sceneGroup: Object3D,
|
sceneGroup: Object3D,
|
||||||
nodes: Record<string, AnyNode>,
|
nodes: Record<string, AnyNode>,
|
||||||
@@ -296,9 +306,12 @@ function isRenderableMesh(mesh: THREE.Mesh): boolean {
|
|||||||
const position = mesh.geometry?.getAttribute('position')
|
const position = mesh.geometry?.getAttribute('position')
|
||||||
if (!position || position.count === 0) return false
|
if (!position || position.count === 0) return false
|
||||||
const material = mesh.material
|
const material = mesh.material
|
||||||
return Array.isArray(material)
|
// `colorWrite: false` is how raycast-only colliders (e.g. instanced plants'
|
||||||
? material.some((m) => m?.visible !== false)
|
// proxy boxes) hide on the GPU — glTF has no equivalent, so exporting one
|
||||||
: material?.visible !== false
|
// yields an opaque white box. Treat it as non-renderable.
|
||||||
|
const renders = (m: THREE.Material | null | undefined) =>
|
||||||
|
m?.visible !== false && m?.colorWrite !== false
|
||||||
|
return Array.isArray(material) ? material.some(renders) : renders(material)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Material conversion -------------------------------------------------
|
// --- Material conversion -------------------------------------------------
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useRegistry, type ZoneNode } from '@pascal-app/core'
|
import { useRegistry, type ZoneNode } from '@pascal-app/core'
|
||||||
import { useNodeEvents, ZONE_LAYER } from '@pascal-app/viewer'
|
import { useNodeEvents, useViewer, ZONE_LAYER } from '@pascal-app/viewer'
|
||||||
import { Html } from '@react-three/drei'
|
import { Html } from '@react-three/drei'
|
||||||
import { useMemo, useRef } from 'react'
|
import { useMemo, useRef } from 'react'
|
||||||
import { BufferGeometry, Color, DoubleSide, Float32BufferAttribute, type Group, Shape } from 'three'
|
import { BufferGeometry, Color, DoubleSide, Float32BufferAttribute, type Group, Shape } from 'three'
|
||||||
@@ -109,6 +109,12 @@ export const ZoneRenderer = ({ node }: { node: ZoneNode }) => {
|
|||||||
|
|
||||||
useRegistry(node.id, 'zone', ref)
|
useRegistry(node.id, 'zone', ref)
|
||||||
|
|
||||||
|
// When zones are toggled off (editor outside the zones layer) the visuals
|
||||||
|
// unmount entirely — a drei <Html> keeps costing per-frame matrix work and
|
||||||
|
// live DOM even at opacity 0. The registered group stays so the zone keeps
|
||||||
|
// its scene identity (selection registry, GLB export polygon stamping).
|
||||||
|
const showZones = useViewer((s) => s.showZones)
|
||||||
|
|
||||||
// Create floor shape from polygon
|
// Create floor shape from polygon
|
||||||
const floorShape = useMemo(() => {
|
const floorShape = useMemo(() => {
|
||||||
if (!node?.polygon || node.polygon.length < 3) return null
|
if (!node?.polygon || node.polygon.length < 3) return null
|
||||||
@@ -180,77 +186,81 @@ export const ZoneRenderer = ({ node }: { node: ZoneNode }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<group ref={ref} {...handlers} userData={{ labelPosition: [centroid[0], 1, centroid[1]] }}>
|
<group ref={ref} {...handlers} userData={{ labelPosition: [centroid[0], 1, centroid[1]] }}>
|
||||||
<Html
|
{showZones && (
|
||||||
name="label"
|
<>
|
||||||
position={[centroid[0], 1, centroid[1]]}
|
<Html
|
||||||
style={{ pointerEvents: 'none' }}
|
name="label"
|
||||||
zIndexRange={[10, 0]}
|
position={[centroid[0], 1, centroid[1]]}
|
||||||
>
|
style={{ pointerEvents: 'none' }}
|
||||||
<div
|
zIndexRange={[10, 0]}
|
||||||
id={`${node.id}-label`}
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'center',
|
|
||||||
transform: 'translate3d(-50%, -50%, 0)',
|
|
||||||
opacity: 0,
|
|
||||||
transition: 'opacity 0.3s ease-in-out',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 'max-content',
|
|
||||||
color: 'white',
|
|
||||||
textShadow: `-1px -1px 0 ${node.color}, 1px -1px 0 ${node.color}, -1px 1px 0 ${node.color}, 1px 1px 0 ${node.color}`,
|
|
||||||
textAlign: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span>{node.name}</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="label-pin"
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'center',
|
|
||||||
marginTop: '2px',
|
|
||||||
opacity: 0,
|
|
||||||
transition: 'opacity 0.5s ease-in-out',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
id={`${node.id}-label`}
|
||||||
style={{
|
style={{
|
||||||
width: '2px',
|
display: 'flex',
|
||||||
height: '40px',
|
flexDirection: 'column',
|
||||||
backgroundColor: node.color,
|
alignItems: 'center',
|
||||||
|
transform: 'translate3d(-50%, -50%, 0)',
|
||||||
|
opacity: 0,
|
||||||
|
transition: 'opacity 0.3s ease-in-out',
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
width: '10px',
|
width: 'max-content',
|
||||||
height: '10px',
|
color: 'white',
|
||||||
borderRadius: '50%',
|
textShadow: `-1px -1px 0 ${node.color}, 1px -1px 0 ${node.color}, -1px 1px 0 ${node.color}, 1px 1px 0 ${node.color}`,
|
||||||
backgroundColor: node.color,
|
textAlign: 'center',
|
||||||
border: '1px solid white',
|
}}
|
||||||
}}
|
>
|
||||||
/>
|
<span>{node.name}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div
|
||||||
</Html>
|
className="label-pin"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: '2px',
|
||||||
|
opacity: 0,
|
||||||
|
transition: 'opacity 0.5s ease-in-out',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '2px',
|
||||||
|
height: '40px',
|
||||||
|
backgroundColor: node.color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '10px',
|
||||||
|
height: '10px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: node.color,
|
||||||
|
border: '1px solid white',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Html>
|
||||||
|
|
||||||
{/* Floor fill */}
|
{/* Floor fill */}
|
||||||
<mesh
|
<mesh
|
||||||
layers={ZONE_LAYER}
|
layers={ZONE_LAYER}
|
||||||
material={floorMaterial}
|
material={floorMaterial}
|
||||||
name="floor"
|
name="floor"
|
||||||
position={[0, Y_OFFSET, 0]}
|
position={[0, Y_OFFSET, 0]}
|
||||||
rotation={[-Math.PI / 2, 0, 0]}
|
rotation={[-Math.PI / 2, 0, 0]}
|
||||||
>
|
>
|
||||||
<shapeGeometry args={[floorShape]} />
|
<shapeGeometry args={[floorShape]} />
|
||||||
</mesh>
|
</mesh>
|
||||||
|
|
||||||
{/* Wall borders with gradient */}
|
{/* Wall borders with gradient */}
|
||||||
<mesh geometry={wallGeometry} layers={ZONE_LAYER} material={wallMaterial} name="walls" />
|
<mesh geometry={wallGeometry} layers={ZONE_LAYER} material={wallMaterial} name="walls" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</group>
|
</group>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,11 +12,16 @@ import { Suspense } from 'react'
|
|||||||
* do alone. Intensity is dialled below the preset default so it complements
|
* do alone. Intensity is dialled below the preset default so it complements
|
||||||
* the scene lights rather than washing them out. Only visible in `rendered`
|
* the scene lights rather than washing them out. Only visible in `rendered`
|
||||||
* shading.
|
* shading.
|
||||||
|
*
|
||||||
|
* The HDR is self-hosted (drei's `preset="sunset"` resolves to the same
|
||||||
|
* `venice_sunset_1k.hdr` on raw.githack.com, which intermittently fails).
|
||||||
|
* Every app that mounts this — like `/audios/sfx` — must ship the file in its
|
||||||
|
* own `public/hdri/`.
|
||||||
*/
|
*/
|
||||||
export function SceneEnvironment() {
|
export function SceneEnvironment() {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<Environment preset="sunset" environmentIntensity={0.6} />
|
<Environment environmentIntensity={0.6} files="/hdri/venice_sunset_1k.hdr" />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,6 +87,14 @@ type ViewerState = {
|
|||||||
showGrid: boolean
|
showGrid: boolean
|
||||||
setShowGrid: (show: boolean) => void
|
setShowGrid: (show: boolean) => void
|
||||||
|
|
||||||
|
// Presentation flag for parametric zones. When false the zone renderer
|
||||||
|
// unmounts its meshes AND its drei <Html> label (an <Html> costs per-frame
|
||||||
|
// matrix work + live DOM even at opacity 0, so hiding is not enough). The
|
||||||
|
// editor drives this from its structure layer; viewer surfaces keep the
|
||||||
|
// default. Not persisted — derived state, not a user preference.
|
||||||
|
showZones: boolean
|
||||||
|
setShowZones: (show: boolean) => void
|
||||||
|
|
||||||
transparentBackground: boolean
|
transparentBackground: boolean
|
||||||
setTransparentBackground: (transparent: boolean) => void
|
setTransparentBackground: (transparent: boolean) => void
|
||||||
|
|
||||||
@@ -308,6 +316,9 @@ const useViewer = create<ViewerState>()(
|
|||||||
return { showGrid: show, projectPreferences }
|
return { showGrid: show, projectPreferences }
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
showZones: true,
|
||||||
|
setShowZones: (show) => set({ showZones: show }),
|
||||||
|
|
||||||
transparentBackground: false,
|
transparentBackground: false,
|
||||||
setTransparentBackground: (transparent) => set({ transparentBackground: transparent }),
|
setTransparentBackground: (transparent) => set({ transparentBackground: transparent }),
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user