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
@@ -4,16 +4,7 @@ import { useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useThree } from '@react-three/fiber'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { exportSceneToGlb } 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()))
|
||||
})
|
||||
}
|
||||
import { exportSceneToGlb, nextFrames } from '../../lib/glb-export'
|
||||
|
||||
export function BakeExporter({
|
||||
active,
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useEffect } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { OBJExporter } from 'three/examples/jsm/exporters/OBJExporter.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,
|
||||
// material-less renderables) with an attribute-less geometry — GLTFExporter
|
||||
@@ -46,41 +46,52 @@ export function ExportManager() {
|
||||
|
||||
const date = new Date().toISOString().split('T')[0]
|
||||
|
||||
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>
|
||||
// Signal export so instanced kinds (trees/flowers/grass) swap their
|
||||
// invisible proxy for real, exportable geometry, then wait for the
|
||||
// commit before cloning the scene graph (same dance as BakeExporter —
|
||||
// without it every plant exports as its raycast collider, a white box).
|
||||
useViewer.getState().setExporting(true)
|
||||
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 {
|
||||
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
|
||||
useViewer.getState().setExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8935,21 +8935,14 @@ export function FloorplanPanel({
|
||||
start: activePolygonDraftPoints[activePolygonDraftPoints.length - 1],
|
||||
angleSnap,
|
||||
})
|
||||
let snappedPoint = fallbackPoint
|
||||
if (isSlabBuildActive) {
|
||||
snappedPoint = resolveSlabPlanPointSnap({
|
||||
rawPoint: planPoint,
|
||||
fallbackPoint,
|
||||
levelId,
|
||||
align: !angleSnap,
|
||||
}).point
|
||||
} else if (angleSnap) {
|
||||
useAlignmentGuides.getState().clear()
|
||||
} else {
|
||||
snappedPoint = alignFloorplanDraftPoint(fallbackPoint, {
|
||||
applySnap: isMagneticSnapActive(),
|
||||
})
|
||||
}
|
||||
// Zone shares the slab surface snap (wall corners / midpoints /
|
||||
// crossings + alignment) — it's the same polygon-on-a-level draw.
|
||||
const snappedPoint = resolveSlabPlanPointSnap({
|
||||
rawPoint: planPoint,
|
||||
fallbackPoint,
|
||||
levelId,
|
||||
align: !angleSnap,
|
||||
}).point
|
||||
|
||||
// Emit `grid:move` so the registry-driven slab tool also tracks
|
||||
// the cursor (its 3D preview needs it).
|
||||
@@ -9647,15 +9640,15 @@ export function FloorplanPanel({
|
||||
return
|
||||
}
|
||||
|
||||
const snappedPoint = resolveSlabPlanPointSnap({
|
||||
rawPoint: planPoint,
|
||||
fallbackPoint,
|
||||
levelId,
|
||||
align: !angleSnap,
|
||||
}).point
|
||||
if (isZoneBuildActive) {
|
||||
handleZonePlacementConfirm(fallbackPoint)
|
||||
handleZonePlacementConfirm(snappedPoint)
|
||||
} else {
|
||||
const snappedPoint = resolveSlabPlanPointSnap({
|
||||
rawPoint: planPoint,
|
||||
fallbackPoint,
|
||||
levelId,
|
||||
align: !angleSnap,
|
||||
}).point
|
||||
// Slab is registry-driven: forward the double-click so the 3D tool
|
||||
// commits the node (zone has no registry tool, so it commits locally).
|
||||
emitFloorplanGridEvent('double-click', snappedPoint, event)
|
||||
|
||||
@@ -275,21 +275,15 @@ export function useFloorplanBackgroundPlacement({
|
||||
start: activePolygonDraftPoints[activePolygonDraftPoints.length - 1],
|
||||
angleSnap,
|
||||
})
|
||||
let snappedPoint = fallbackPoint
|
||||
if (isSlabBuildActive) {
|
||||
snappedPoint = resolveSlabPlanPointSnap({
|
||||
rawPoint: planPoint,
|
||||
fallbackPoint,
|
||||
levelId,
|
||||
altKey: event.altKey,
|
||||
align: !angleSnap,
|
||||
}).point
|
||||
} else if (!angleSnap) {
|
||||
snappedPoint = alignFloorplanDraftPoint(fallbackPoint, {
|
||||
applySnap: isMagneticSnapActive(),
|
||||
bypass: event.altKey,
|
||||
})
|
||||
}
|
||||
// Zone shares the slab surface snap (wall corners / midpoints /
|
||||
// crossings + alignment) — it's the same polygon-on-a-level draw.
|
||||
const snappedPoint = resolveSlabPlanPointSnap({
|
||||
rawPoint: planPoint,
|
||||
fallbackPoint,
|
||||
levelId,
|
||||
altKey: event.altKey,
|
||||
align: !angleSnap,
|
||||
}).point
|
||||
|
||||
// Emit the grid event so the registry-driven slab tool also
|
||||
// sees the click (parity with ceiling / fence / roof branches
|
||||
|
||||
@@ -316,6 +316,12 @@ export function ZoneLabelEditorSystem() {
|
||||
.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 (
|
||||
<>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { sceneRegistry, useScene, type ZoneNode } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useFrame } from '@react-three/fiber'
|
||||
import { useEffect } from 'react'
|
||||
import { type Group, MathUtils, type Mesh } from 'three'
|
||||
import type { MeshBasicNodeMaterial } from 'three/webgpu'
|
||||
import { resolveOverlayPolicy } from '../../../lib/interaction/overlay-policy'
|
||||
@@ -12,7 +13,21 @@ import useInteractionScope from '../../../store/use-interaction-scope'
|
||||
const noopRaycast = () => {}
|
||||
|
||||
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) => {
|
||||
if (!useViewer.getState().showZones) return
|
||||
|
||||
const structureLayer = useEditor.getState().structureLayer
|
||||
const editorMode = useEditor.getState().mode
|
||||
const selectedLevelId = useViewer.getState().selection.levelId
|
||||
@@ -54,9 +69,13 @@ export const ZoneSystem = () => {
|
||||
? 1
|
||||
: 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
|
||||
if (walls) {
|
||||
walls.visible = meshVisible
|
||||
walls.raycast = noopRaycast
|
||||
const material = walls.material as MeshBasicNodeMaterial
|
||||
if (material?.userData?.uOpacity) {
|
||||
material.userData.uOpacity.value = MathUtils.lerp(
|
||||
@@ -70,6 +89,7 @@ export const ZoneSystem = () => {
|
||||
const floor = (obj as Group).getObjectByName('floor') as Mesh | undefined
|
||||
if (floor) {
|
||||
floor.visible = meshVisible
|
||||
floor.raycast = noopRaycast
|
||||
const material = floor.material as MeshBasicNodeMaterial
|
||||
if (material?.userData?.uOpacity) {
|
||||
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
|
||||
// during snapshot capture.
|
||||
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 { EDITOR_LAYER } from './../../../lib/constants'
|
||||
import { sfxEmitter } from './../../../lib/sfx-bus'
|
||||
import {
|
||||
clearSurfacePlanSnapFeedback,
|
||||
resolveSurfacePlanPointSnap,
|
||||
} from './../../../lib/surface-plan-snap'
|
||||
import { snapWorldXZForActiveBuilding } from './../../../lib/world-grid-snap'
|
||||
import useEditor, { isAngleSnapActive, isGridSnapActive } from './../../../store/use-editor'
|
||||
import { CursorSphere } from '../shared/cursor-sphere'
|
||||
@@ -79,26 +83,35 @@ export const ZoneTool: React.FC = () => {
|
||||
if (!currentLevelId) return
|
||||
|
||||
let cursorPosition: [number, number] = [0, 0]
|
||||
let rawCursorPosition: [number, number] = [0, 0]
|
||||
let snappedCursorPosition: [number, number] | null = null
|
||||
|
||||
// Initialize line geometries
|
||||
mainLineRef.current.geometry = new BufferGeometry()
|
||||
closingLineRef.current.geometry = new BufferGeometry()
|
||||
|
||||
// Snapping follows the active mode (zone resolves to the 'wall' context):
|
||||
// `angles` locks the ray to 15° from the last vertex, `grid` quantizes the
|
||||
// distance along it, `lines` / `off` leave the raw cursor. No held-Shift
|
||||
// bypass — Shift cycles the mode (see interaction-scope.md).
|
||||
// Snapping follows the active mode: `angles` locks the ray to 15° from the
|
||||
// last vertex (grid quantizes the distance along it), otherwise `grid`
|
||||
// quantizes to the world-aligned grid; the shared surface snap then layers
|
||||
// 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 = (
|
||||
lastPoint: [number, number],
|
||||
_gridPoint: [number, number],
|
||||
lastPoint: [number, number] | undefined,
|
||||
gridPoint: [number, number],
|
||||
rawPoint: [number, number],
|
||||
altKey: boolean,
|
||||
): [number, number] => {
|
||||
const angleStep = isAngleSnapActive() ? DEFAULT_ANGLE_STEP : 0
|
||||
const gridStep = isGridSnapActive() ? useEditor.getState().gridSnapStep : 0
|
||||
if (angleStep === 0 && gridStep === 0) return rawPoint
|
||||
const [x, z] = snapPointAlongAngleRay(lastPoint, rawPoint, angleStep, gridStep)
|
||||
return [x, z]
|
||||
const orthoPoint: [number, number] =
|
||||
isAngleSnapActive() && lastPoint
|
||||
? [...snapPointAlongAngleRay(lastPoint, rawPoint, DEFAULT_ANGLE_STEP, gridStep)]
|
||||
: gridPoint
|
||||
return resolveSurfacePlanPointSnap({
|
||||
rawPoint,
|
||||
fallbackPoint: orthoPoint,
|
||||
levelId: currentLevelId,
|
||||
altKey,
|
||||
}).point
|
||||
}
|
||||
|
||||
const updateLines = () => {
|
||||
@@ -116,11 +129,9 @@ export const ZoneTool: React.FC = () => {
|
||||
|
||||
// Add cursor point
|
||||
const lastPoint = points[points.length - 1]
|
||||
if (lastPoint) {
|
||||
const snapped = snapDraftPoint(lastPoint, cursorPosition, rawCursorPosition)
|
||||
if (isValidPoint(snapped)) {
|
||||
linePoints.push(new Vector3(snapped[0], y, snapped[1]))
|
||||
}
|
||||
const snapped = snappedCursorPosition ?? cursorPosition
|
||||
if (lastPoint && isValidPoint(snapped)) {
|
||||
linePoints.push(new Vector3(snapped[0], y, snapped[1]))
|
||||
}
|
||||
|
||||
// Update main line geometry
|
||||
@@ -134,17 +145,14 @@ export const ZoneTool: React.FC = () => {
|
||||
|
||||
// Update closing line (from cursor back to first point)
|
||||
const firstPoint = points[0]
|
||||
if (points.length >= 2 && lastPoint && isValidPoint(firstPoint)) {
|
||||
const snapped = snapDraftPoint(lastPoint, cursorPosition, rawCursorPosition)
|
||||
if (isValidPoint(snapped)) {
|
||||
const closingPoints = [
|
||||
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.visible = true
|
||||
}
|
||||
if (points.length >= 2 && lastPoint && isValidPoint(firstPoint) && isValidPoint(snapped)) {
|
||||
const closingPoints = [
|
||||
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.visible = true
|
||||
} else {
|
||||
closingLineRef.current.visible = false
|
||||
}
|
||||
@@ -152,42 +160,42 @@ export const ZoneTool: React.FC = () => {
|
||||
|
||||
const updatePreview = () => {
|
||||
const points = pointsRef.current
|
||||
const lastPoint = points[points.length - 1]
|
||||
const cursorPt = snappedCursorPosition ?? cursorPosition
|
||||
|
||||
let cursorPt: [number, number] | null = null
|
||||
if (lastPoint) {
|
||||
cursorPt = snapDraftPoint(lastPoint, cursorPosition, rawCursorPosition)
|
||||
} else if (points.length === 0) {
|
||||
cursorPt = cursorPosition
|
||||
}
|
||||
|
||||
setPreview({ points: [...points], cursorPoint: cursorPt, levelY: levelYRef.current })
|
||||
setPreview({
|
||||
points: [...points],
|
||||
cursorPoint: isValidPoint(cursorPt) ? cursorPt : null,
|
||||
levelY: levelYRef.current,
|
||||
})
|
||||
updateLines()
|
||||
}
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
if (!cursorRef.current) return
|
||||
|
||||
// World-grid snap projected into building-local; rotated buildings
|
||||
// used to pull the snap off the visible grid lines. Grid quantize only
|
||||
// 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()
|
||||
// World-grid snap projected into building-local; rotated buildings
|
||||
// used to pull the snap off the visible grid lines. Grid quantize only
|
||||
// in grid mode; off / lines / angles leave the raw cursor.
|
||||
const gridPointOf = (event: GridEvent): [number, number] =>
|
||||
isGridSnapActive()
|
||||
? snapWorldXZForActiveBuilding(
|
||||
event.position[0],
|
||||
event.position[2],
|
||||
useEditor.getState().gridSnapStep,
|
||||
).local
|
||||
: [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]
|
||||
|
||||
// If we have points, snap to the 15° ray from the last point
|
||||
const lastPoint = pointsRef.current[pointsRef.current.length - 1]
|
||||
const displayPoint = lastPoint
|
||||
? snapDraftPoint(lastPoint, cursorPosition, rawCursorPosition)
|
||||
: cursorPosition
|
||||
const displayPoint = snapDraftPoint(
|
||||
lastPoint,
|
||||
cursorPosition,
|
||||
[event.localPosition[0], event.localPosition[2]],
|
||||
event.nativeEvent?.altKey === true,
|
||||
)
|
||||
snappedCursorPosition = displayPoint
|
||||
|
||||
// Play snap sound when the snapped position changes during drawing — only
|
||||
// when a quantizing mode is active (off / lines move continuously).
|
||||
@@ -210,23 +218,13 @@ export const ZoneTool: React.FC = () => {
|
||||
const onGridClick = (event: GridEvent) => {
|
||||
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]
|
||||
if (lastPoint) {
|
||||
clickPoint = snapDraftPoint(lastPoint, clickPoint, [
|
||||
event.localPosition[0],
|
||||
event.localPosition[2],
|
||||
])
|
||||
}
|
||||
const clickPoint = snapDraftPoint(
|
||||
lastPoint,
|
||||
gridPointOf(event),
|
||||
[event.localPosition[0], event.localPosition[2]],
|
||||
event.nativeEvent?.altKey === true,
|
||||
)
|
||||
|
||||
// Check if clicking on the first point to close the shape
|
||||
const firstPoint = pointsRef.current[0]
|
||||
@@ -280,6 +278,7 @@ export const ZoneTool: React.FC = () => {
|
||||
|
||||
// Reset state on unmount
|
||||
pointsRef.current = []
|
||||
clearSurfacePlanSnapFeedback()
|
||||
}
|
||||
}, [currentLevelId])
|
||||
|
||||
|
||||
@@ -40,6 +40,16 @@ export type GlbExport = {
|
||||
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(
|
||||
sceneGroup: Object3D,
|
||||
nodes: Record<string, AnyNode>,
|
||||
@@ -296,9 +306,12 @@ function isRenderableMesh(mesh: THREE.Mesh): boolean {
|
||||
const position = mesh.geometry?.getAttribute('position')
|
||||
if (!position || position.count === 0) return false
|
||||
const material = mesh.material
|
||||
return Array.isArray(material)
|
||||
? material.some((m) => m?.visible !== false)
|
||||
: material?.visible !== false
|
||||
// `colorWrite: false` is how raycast-only colliders (e.g. instanced plants'
|
||||
// proxy boxes) hide on the GPU — glTF has no equivalent, so exporting one
|
||||
// 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 -------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user