Reset core/viewer/editor/mcp packages and apps/editor from private-editor

Wholesale swap of packages/{core,viewer,editor,mcp} and apps/editor with the
versions from the private editor repo, which is the production source of truth.

Setup changes:
- packages/{core,viewer,editor} versions held at 0.7.0 baseline (matching
  the most recent published release) so a bump=minor publishes 0.8.0
- packages/mcp held at 0.1.1 (never published; first publish will go through
  the new release.yml flow)
- peerDependencies and devDependencies for inter-package @pascal-app/*
  references pinned to ^0.7.0 instead of '*' / 'workspace:*' so they are
  valid for npm consumers
- Root package.json: TypeScript bumped to 6.0.2, added overrides for
  @types/react, @types/react-dom, @types/three to prevent JSX namespace
  fragmentation across the workspace
- release.yml extended to also publish editor and mcp; 'both' option renamed
  to 'all'; added a sync step that updates inter-package peerDeps/devDeps to
  match the new versions on every bump (so viewer/editor/mcp tarballs always
  reference the version of core they were built against)
- Root scripts gained release:editor and release:mcp shortcuts

Verification:
- bun install --frozen-lockfile is consistent
- packages/{core,viewer,mcp} build cleanly, dist/index.d.ts emitted
- packages/editor check-types reports 21 pre-existing errors, identical to
  what private-editor currently reports

Open PRs against editor-v2 will need rebasing/conflict resolution.
This commit is contained in:
Wawa
2026-05-09 20:52:26 +00:00
parent 146f0a846f
commit e618175bba
196 changed files with 5899 additions and 3966 deletions
+2 -2
View File
@@ -36,10 +36,10 @@
},
"devDependencies": {
"@pascal/typescript-config": "*",
"@types/node": "^25.5.0",
"@types/node": "^22",
"@types/react": "^19.2.2",
"@types/three": "^0.184.0",
"typescript": "5.9.3"
"typescript": "6.0.2"
},
"keywords": [
"3d",
@@ -834,37 +834,24 @@ function Flutes({
)
}
function DravidianShaftPanels({
node,
shaftY,
shaftHeight,
function DravidianPanelFace({
position,
rotation = 0,
panelHeight,
panelWidth,
rail,
reliefDepth,
panelShape,
}: {
node: ColumnNode
shaftY: number
shaftHeight: number
position: [number, number, number]
rotation?: number
panelHeight: number
panelWidth: number
rail: number
reliefDepth: number
panelShape: NonNullable<ColumnNode['panelShape']>
}) {
const panelCount = Math.max(
node.panelCount ?? 0,
node.style === 'dravidian-carved' || node.shaftDetail === 'panelled' ? 3 : 0,
)
if (panelCount <= 0 || shaftHeight <= 0) return null
const shaftWidth = node.width * 0.72
const shaftDepth = node.depth * 0.72
const panelHeight = Math.min(0.42, shaftHeight / Math.max(4, panelCount + 2))
const panelWidth = node.width * 0.26
const rail = Math.max(0.012, node.width * 0.028)
const reliefDepth = Math.max(0.012, node.panelInsetDepth ?? node.width * 0.025)
const rows = Array.from({ length: panelCount }, (_, index) => (index + 1) / (panelCount + 1))
const panelShape = node.panelShape ?? 'rectangle'
const PanelFace = ({
position,
rotation = 0,
}: {
position: [number, number, number]
rotation?: number
}) => (
return (
<group position={position} rotation={[0, rotation, 0]}>
<MappedBox
depth={reliefDepth}
@@ -917,6 +904,33 @@ function DravidianShaftPanels({
)}
</group>
)
}
function DravidianShaftPanels({
node,
shaftY,
shaftHeight,
}: {
node: ColumnNode
shaftY: number
shaftHeight: number
}) {
const panelCount = Math.max(
node.panelCount ?? 0,
node.style === 'dravidian-carved' || node.shaftDetail === 'panelled' ? 3 : 0,
)
if (panelCount <= 0 || shaftHeight <= 0) return null
const shaftWidth = node.width * 0.72
const shaftDepth = node.depth * 0.72
const panelHeight = Math.min(0.42, shaftHeight / Math.max(4, panelCount + 2))
const panelWidth = node.width * 0.26
const rail = Math.max(0.012, node.width * 0.028)
const reliefDepth = Math.max(0.012, node.panelInsetDepth ?? node.width * 0.025)
const rows = Array.from({ length: panelCount }, (_, index) => (index + 1) / (panelCount + 1))
const panelShape = node.panelShape ?? 'rectangle'
const faceProps = { panelHeight, panelWidth, rail, reliefDepth, panelShape }
return (
<group>
@@ -924,12 +938,23 @@ function DravidianShaftPanels({
const y = shaftY + shaftHeight * t
return (
<group key={rowIndex}>
<PanelFace position={[0, y, shaftDepth / 2 + reliefDepth / 2]} />
<PanelFace position={[0, y, -shaftDepth / 2 - reliefDepth / 2]} />
<PanelFace position={[shaftWidth / 2 + reliefDepth / 2, y, 0]} rotation={Math.PI / 2} />
<PanelFace
<DravidianPanelFace
position={[0, y, shaftDepth / 2 + reliefDepth / 2]}
{...faceProps}
/>
<DravidianPanelFace
position={[0, y, -shaftDepth / 2 - reliefDepth / 2]}
{...faceProps}
/>
<DravidianPanelFace
position={[shaftWidth / 2 + reliefDepth / 2, y, 0]}
rotation={Math.PI / 2}
{...faceProps}
/>
<DravidianPanelFace
position={[-shaftWidth / 2 - reliefDepth / 2, y, 0]}
rotation={Math.PI / 2}
{...faceProps}
/>
</group>
)
@@ -1,6 +1,6 @@
import { type DoorNode, useRegistry, useScene } from '@pascal-app/core'
import { useLayoutEffect, useRef } from 'react'
import { MeshBasicMaterial, type Mesh } from 'three'
import { type Mesh, MeshBasicMaterial } from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events'
const doorHitboxMaterial = new MeshBasicMaterial({ visible: false })
@@ -2,28 +2,12 @@ import { type FenceNode, useRegistry, useScene } from '@pascal-app/core'
import { useLayoutEffect, useMemo, useRef } from 'react'
import type { Mesh } from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events'
import {
createMaterial,
createMaterialFromPresetRef,
DEFAULT_STAIR_MATERIAL,
} from '../../../lib/materials'
import { DEFAULT_STAIR_MATERIAL } from '../../../lib/materials'
export const FenceRenderer = ({ node }: { node: FenceNode }) => {
const ref = useRef<Mesh>(null!)
const handlers = useNodeEvents(node, 'fence')
const material = useMemo(() => {
const presetMaterial = createMaterialFromPresetRef(node.materialPreset)
if (presetMaterial) return presetMaterial
const mat = node.material
if (!mat) return DEFAULT_STAIR_MATERIAL
return createMaterial(mat)
}, [
node.materialPreset,
node.material,
node.material?.preset,
node.material?.properties,
node.material?.texture,
])
const material = useMemo(() => DEFAULT_STAIR_MATERIAL, [])
useRegistry(node.id, 'fence', ref)
useLayoutEffect(() => {
@@ -1,5 +1,5 @@
import { getMaterialPresetByRef, type SlabNode, useRegistry } from '@pascal-app/core'
import { useMemo, useRef } from 'react'
import { useEffect, useMemo, useRef } from 'react'
import type { Mesh } from 'three'
import * as THREE from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events'
@@ -24,10 +24,27 @@ export const StairSegmentRenderer = ({ node }: { node: StairSegmentNode }) => {
const parentNode = node.parentId
? (nodes[node.parentId as AnyNodeId] as StairNode | undefined)
: undefined
const material = useMemo(
() => getStraightStairSegmentBodyMaterials(node, parentNode),
[node, parentNode],
)
const material = useMemo(() => {
return getStraightStairSegmentBodyMaterials(node, parentNode)
}, [
node.materialPreset,
node.material,
node.material?.preset,
node.material?.properties,
node.material?.texture,
parentNode?.materialPreset,
parentNode?.material,
parentNode?.material?.preset,
parentNode?.material?.properties,
parentNode?.material?.texture,
parentNode?.railingMaterialPreset,
parentNode?.railingMaterial,
parentNode?.sideMaterialPreset,
parentNode?.sideMaterial,
parentNode?.treadMaterialPreset,
parentNode?.treadMaterial,
])
const placeholderGeometry = useMemo(() => {
const geometry = new THREE.BufferGeometry()
@@ -112,9 +112,9 @@ export const StairRenderer = ({ node }: { node: StairNode }) => {
receiveShadow
/>
) : null}
{!isSegmentBasedStair ? (
{isSegmentBasedStair ? null : (
<CurvedStairBody bodyMaterials={straightBodyMaterials} stair={node} />
) : null}
)}
<StairRailings material={railingMaterial} stair={node} />
{isSegmentBasedStair ? (
<group name="segments-wrapper" visible={false}>
@@ -292,7 +292,7 @@ function StairRailings({ stair, material }: { stair: StairNode; material: THREE.
))}
{railPaths.slice(1).map((segmentPath, index) => {
const previousPath = railPaths[index]
if (!previousPath || !segmentPath.connectFromPrevious) return null
if (!(previousPath && segmentPath.connectFromPrevious)) return null
if (previousPath.layout.segment.segmentType === 'landing') return null
if (segmentPath.layout.segment.segmentType === 'landing') return null
@@ -451,10 +451,10 @@ function CurvedStairBody({
{isSpiral && (stair.showCenterColumn ?? true) ? (
<mesh
castShadow
receiveShadow
material={sideMaterial}
name="stair-side"
position={[0, spiralColumnHeight / 2, 0]}
receiveShadow
>
<cylinderGeometry
args={[spiralColumnRadius, spiralColumnRadius, spiralColumnHeight, 10]}
@@ -6,7 +6,8 @@ type FrameLimiterProps = {
}
const FrameLimiter: React.FC<FrameLimiterProps> = ({ fps = 50 }) => {
const { advance, set, frameloop: initFrameloop } = useThree()
const { advance, set, frameloop: initFrameloop, scene, clock } = useThree()
const renderer = useThree((state) => state.gl)
useLayoutEffect(() => {
let elapsed = 0
@@ -14,7 +15,6 @@ const FrameLimiter: React.FC<FrameLimiterProps> = ({ fps = 50 }) => {
let i = 0
let raf: number | null = null
const interval = 1000 / fps
function tick(t: DOMHighResTimeStamp) {
raf = requestAnimationFrame(tick)
elapsed = t - then
@@ -24,10 +24,11 @@ const FrameLimiter: React.FC<FrameLimiterProps> = ({ fps = 50 }) => {
then = t - (elapsed % interval)
}
}
// Set frameloop to never, it will shut down the default render loop
set({ frameloop: 'never' })
// Kick off custom render loop
raf = requestAnimationFrame(tick)
// Restore initial setting
return () => {
if (raf) {
cancelAnimationFrame(raf)
+17 -11
View File
@@ -81,7 +81,7 @@ extend(THREE as any)
const WEBGPU_RENDERER_CACHE = new WeakMap<HTMLCanvasElement, Promise<THREE.WebGPURenderer>>()
/**
* Monitors the WebGPU device for loss events and logs them.
* Monitors the WebGPU device for loss / uncaptured errors and logs them.
* WebGPU device loss can happen when:
* - Tab is backgrounded and OS reclaims GPU
* - Driver crash or GPU reset
@@ -105,7 +105,7 @@ function GPUDeviceWatcher() {
useEffect(() => {
const backend = (gl as any).backend
const device = backend?.device as WebGPUDeviceLike | undefined
const device: GPUDevice | undefined = backend?.device
if (!device) {
console.warn('[viewer] No WebGPU device on backend — running on a fallback renderer.', {
@@ -117,25 +117,25 @@ function GPUDeviceWatcher() {
console.log('[viewer] WebGPU device ready', {
label: device.label,
features: device.features ? Array.from(device.features) : [],
features: Array.from(device.features ?? []),
})
device.lost.then((info: WebGPUDeviceLossInfo) => {
device.lost.then((info) => {
console.error(
`[viewer] WebGPU device lost: reason="${info.reason ?? 'unknown'}", message="${info.message ?? ''}". ` +
`[viewer] WebGPU device lost: reason="${info.reason}", message="${info.message}". ` +
'The page must be reloaded to recover the GPU context.',
)
})
// Uncaptured errors are normally silent (only console-warned by Chrome at
// best). Pipe them to console.error so silent mobile crashes show up.
const onUncapturedError = (event: any) => {
console.error('[viewer] WebGPU uncaptured error:', event?.error?.message, event?.error)
const onUncapturedError = (event: GPUUncapturedErrorEvent) => {
console.error('[viewer] WebGPU uncaptured error:', event.error.message, event.error)
}
device.addEventListener?.('uncapturederror', onUncapturedError)
device.addEventListener('uncapturederror', onUncapturedError as EventListener)
return () => {
device.removeEventListener?.('uncapturederror', onUncapturedError)
device.removeEventListener('uncapturederror', onUncapturedError as EventListener)
}
}, [gl])
@@ -147,6 +147,7 @@ interface ViewerProps {
hoverStyles?: HoverStyles
selectionManager?: 'default' | 'custom'
perf?: boolean
useBvh?: boolean
}
const Viewer: React.FC<ViewerProps> = ({
@@ -154,6 +155,7 @@ const Viewer: React.FC<ViewerProps> = ({
hoverStyles = DEFAULT_HOVER_STYLES,
selectionManager = 'default',
perf = false,
useBvh = true,
}) => {
const theme = useViewer((state) => state.theme)
return (
@@ -216,9 +218,13 @@ const Viewer: React.FC<ViewerProps> = ({
{/* <directionalLight position={[10, 10, 5]} intensity={0.5} castShadow
/> */}
<Lights />
<Bvh>
{useBvh ? (
<Bvh>
<SceneRenderer />
</Bvh>
) : (
<SceneRenderer />
</Bvh>
)}
{/* Default Systems */}
<LevelSystem />
@@ -131,8 +131,6 @@ const PostProcessingPasses = ({
// Reset retry state when project changes
useEffect(() => {
// Intentionally touch projectId so the effect reruns on project switches.
void projectId
retryCountRef.current = 0
if (rebuildTimeoutRef.current !== null) {
clearTimeout(rebuildTimeoutRef.current)
@@ -168,11 +166,6 @@ const PostProcessingPasses = ({
// Build / rebuild the post-processing pipeline
useEffect(() => {
// Intentionally touch these so React/biome treat project switches and retry bumps
// as explicit rebuild triggers instead of accidental extra dependencies.
void projectId
void pipelineVersion
if (!(renderer && scene && camera)) {
console.warn('[viewer/post-processing] Skipping pipeline build — missing dependency.', {
hasRenderer: !!renderer,
@@ -192,24 +185,6 @@ const PostProcessingPasses = ({
hasPipelineErrorRef.current = false
// WebGPU availability check: SSGI, denoise, and RenderPipeline are all
// WebGPU-only APIs. When the browser falls back to WebGL2 (no
// `navigator.gpu`, or the device couldn't be created), building the
// pipeline either throws silently or produces a broken output where
// the scene renders for a few frames and then goes black as the retry
// loop fights the direct-render fallback path. Short-circuit here so
// `useFrame` uses the direct `renderer.render(scene, camera)` path
// exclusively and never attempts the TSL pipeline.
const hasWebGPU = typeof navigator !== 'undefined' && 'gpu' in navigator
if (!hasWebGPU) {
console.warn(
'[viewer] WebGPU unavailable — rendering without post-processing (SSGI, outlines, denoise).',
)
hasPipelineErrorRef.current = true
renderPipelineRef.current = null
return
}
// Clear outliner arrays synchronously to prevent stale Object3D refs
// from the previous project leaking into the new pipeline's outline passes.
const outliner = useViewer.getState().outliner
+1
View File
@@ -20,6 +20,7 @@ export {
DEFAULT_WALL_MATERIAL,
DEFAULT_WINDOW_MATERIAL,
disposeMaterial,
glassMaterial,
} from './lib/materials'
export { mergedOutline } from './lib/merged-outline-node'
export { default as useViewer } from './store/use-viewer'
+24 -13
View File
@@ -1,9 +1,9 @@
import {
getMaterialPresetByRef,
type MaterialMapProperties,
type MaterialPresetPayload,
type MaterialProperties,
type MaterialSchema,
getMaterialPresetByRef,
resolveMaterial,
} from '@pascal-app/core'
import * as THREE from 'three'
@@ -90,8 +90,7 @@ function getTexture(material?: MaterialSchema): THREE.Texture | undefined {
function isStandardMaterial(material: THREE.Material): material is StandardMaterial {
return (
material instanceof THREE.MeshStandardMaterial ||
material instanceof THREE.MeshPhysicalMaterial
material instanceof THREE.MeshStandardMaterial || material instanceof THREE.MeshPhysicalMaterial
)
}
@@ -112,11 +111,19 @@ function applyTextureProperties(
return texture
}
function getPresetTextureCacheKey(path: string, props: MaterialMapProperties, slot?: TextureSlot): string {
function getPresetTextureCacheKey(
path: string,
props: MaterialMapProperties,
slot?: TextureSlot,
): string {
return `${path}-${props.repeatX}-${props.repeatY}-${props.rotation}-${props.wrapS}-${props.wrapT}-${props.flipY}-${slot ?? 'map'}`
}
function getPresetTexture(path: string, props: MaterialMapProperties, slot?: TextureSlot): THREE.Texture {
function getPresetTexture(
path: string,
props: MaterialMapProperties,
slot?: TextureSlot,
): THREE.Texture {
const cacheKey = getPresetTextureCacheKey(path, props, slot)
const cached = textureCache.get(cacheKey)
if (cached) return cached
@@ -177,14 +184,17 @@ function queueTextureAssignment(
material[slot] = null
void loadPresetTexture(path, props, slot).then((texture) => {
loadPresetTexture(path, props, slot).then((texture) => {
if (!texture) return
material[slot] = texture
material.needsUpdate = true
})
}
function applyMaterialMapProperties(material: StandardMaterial, mapProperties: MaterialMapProperties) {
function applyMaterialMapProperties(
material: StandardMaterial,
mapProperties: MaterialMapProperties,
) {
material.color.set(mapProperties.color)
material.roughness = mapProperties.roughness
material.metalness = mapProperties.metalness
@@ -206,10 +216,7 @@ function applyMaterialMapProperties(material: StandardMaterial, mapProperties: M
material.needsUpdate = true
}
function applyMaterialPresetTextures(
material: StandardMaterial,
preset: MaterialPresetPayload,
) {
function applyMaterialPresetTextures(material: StandardMaterial, preset: MaterialPresetPayload) {
const { maps, mapProperties } = preset
queueTextureAssignment(material, 'map', maps.albedoMap, mapProperties)
@@ -243,7 +250,9 @@ export function applyMaterialPresetToMaterials(
}
}
export function createMaterialFromPreset(preset: MaterialPresetPayload): THREE.MeshStandardMaterial {
export function createMaterialFromPreset(
preset: MaterialPresetPayload,
): THREE.MeshStandardMaterial {
const cacheKey = JSON.stringify(preset)
if (materialCache.has(cacheKey)) {
@@ -256,7 +265,9 @@ export function createMaterialFromPreset(preset: MaterialPresetPayload): THREE.M
return material
}
export function createMaterialFromPresetRef(materialPreset?: string): THREE.MeshStandardMaterial | null {
export function createMaterialFromPresetRef(
materialPreset?: string,
): THREE.MeshStandardMaterial | null {
const preset = getMaterialPresetByRef(materialPreset)
if (!preset) return null
return createMaterialFromPreset(preset)
@@ -622,14 +622,9 @@ export class MergedOutlineNode extends TempNode {
private _buildCache(objects: Object3D[], cache: Set<Object3D>) {
for (const obj of objects) {
if (!obj || !obj.traverse) continue
try {
obj.traverse((child: any) => {
if (child.isMesh || child.isSprite) cache.add(child)
})
} catch {
// Skip objects that were disposed or removed from the scene graph
}
obj.traverse((child: any) => {
if (child.isMesh || child.isSprite) cache.add(child)
})
}
}
}
@@ -1,63 +0,0 @@
/**
* Suppresses the `THREE.Clock: This module has been deprecated` warning
* emitted by three.js r183+ on every `new THREE.Clock()` call.
*
* We don't instantiate `Clock` ourselves — `@react-three/fiber` 9.x
* (current stable) creates one internally per `<Canvas>` mount. The
* migration to `THREE.Timer` lands in R3F 10.x (still alpha).
*
* Uses three's `setConsoleFunction` hook so we don't touch `console.warn`
* globally. Only the exact Clock deprecation message is suppressed; all
* other three.js logs (including TSL stack-trace warnings) pass through
* untouched.
*
* Runs in production as well as dev — the suppressed message is a pure
* deprecation notice with no user-actionable content.
*
* REMOVAL: safe to delete once `@react-three/fiber` no longer constructs
* `new THREE.Clock()`. To verify after an R3F upgrade:
* grep -n "new THREE.Clock" node_modules/@react-three/fiber/dist/*.js
* No hits → delete this file and its import in components/viewer/index.tsx.
*
* @see https://github.com/pascalorg/editor/issues/213
*/
import { setConsoleFunction } from 'three'
// three's warn() prepends 'THREE.' to its first argument, and the Clock
// constructor passes 'THREE.Clock: ...', producing a double-prefixed
// string. Exact equality keeps the suppression surgical — if three ever
// rewords this, the filter stops matching and the message resurfaces.
const CLOCK_DEPRECATION_MESSAGE =
'THREE.THREE.Clock: This module has been deprecated. Please use THREE.Timer instead.'
type ConsoleMethod = 'log' | 'warn' | 'error'
// HMR-idempotent install guard. setConsoleFunction is a single-slot global
// in three, so re-evaluating this module on HMR would reinstall the hook.
const INSTALLED = Symbol.for('@pascal-app/viewer/suppress-three-clock-warning')
type GlobalWithFlag = typeof globalThis & { [INSTALLED]?: true }
if (!(globalThis as GlobalWithFlag)[INSTALLED]) {
;(globalThis as GlobalWithFlag)[INSTALLED] = true
setConsoleFunction((method: ConsoleMethod, message: string, ...params: unknown[]) => {
if (method === 'warn' && message === CLOCK_DEPRECATION_MESSAGE) {
return
}
// Mirror three's default stack-trace handling so TSL warnings/errors
// keep their clickable stack frames.
if (method !== 'log') {
const first = params[0] as
| { isStackTrace?: boolean; getError?: (m: string) => Error }
| undefined
if (first?.isStackTrace && typeof first.getError === 'function') {
console[method](first.getError(message))
return
}
}
console[method](message, ...params)
})
}
+1 -7
View File
@@ -1,10 +1,4 @@
import type {
AnyNode,
BaseNode,
BuildingNode,
LevelNode,
ZoneNode,
} from '@pascal-app/core'
import type { AnyNode, BaseNode, BuildingNode, LevelNode, ZoneNode } from '@pascal-app/core'
import type { Object3D } from 'three'
type SelectionPath = {
buildingId: BuildingNode['id'] | null
@@ -1,5 +1,5 @@
import { useFrame } from '@react-three/fiber'
import { type AnyNodeId, type CeilingNode, sceneRegistry, useScene } from '@pascal-app/core'
import { useFrame } from '@react-three/fiber'
import * as THREE from 'three'
function ensureUv2Attribute(geometry: THREE.BufferGeometry) {
@@ -1,6 +1,6 @@
import {
clampDoorOperationState,
type AnyNodeId,
clampDoorOperationState,
type DoorNode,
getDoorRenderOpenAmount,
sceneRegistry,
@@ -1,73 +0,0 @@
'use client'
import { useThree } from '@react-three/fiber'
import { useEffect } from 'react'
import type { Scene } from 'three'
import * as THREE from 'three'
import { STLExporter } from 'three/examples/jsm/exporters/STLExporter.js'
import { GLTFExporter } from 'three/examples/jsm/exporters/GLTFExporter.js'
import { OBJExporter } from 'three/examples/jsm/exporters/OBJExporter.js'
import useViewer from '../../store/use-viewer'
const EDITOR_LAYER = 1 // same constant used across the editor
function downloadBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
export const ExportSystem = () => {
const { scene } = useThree()
const setExportScene = useViewer((state) => state.setExportScene)
useEffect(() => {
const exportFn = async (format: 'glb' | 'stl' | 'obj' = 'glb') => {
const date = new Date().toISOString().split('T')[0]
const filename = `pascal-export-${date}`
// Clone scene and strip editor-only objects (layer 1 = EDITOR_LAYER)
const exportRoot = scene.clone(true) as Scene
const toRemove: THREE.Object3D[] = []
exportRoot.traverse((obj) => {
if (obj.layers.isEnabled(EDITOR_LAYER)) {
toRemove.push(obj)
}
})
for (const obj of toRemove) {
obj.parent?.remove(obj)
}
if (format === 'glb') {
const exporter = new GLTFExporter()
const result = await new Promise<ArrayBuffer>((resolve, reject) => {
exporter.parse(
exportRoot,
(output) => resolve(output as ArrayBuffer),
(err) => reject(err),
{ binary: true }
)
})
downloadBlob(new Blob([result], { type: 'model/gltf-binary' }), `${filename}.glb`)
} else if (format === 'stl') {
const exporter = new STLExporter()
const result = exporter.parse(exportRoot, { binary: true }) as DataView
downloadBlob(new Blob([result.buffer as ArrayBuffer], { type: 'model/stl' }), `${filename}.stl`)
} else if (format === 'obj') {
const exporter = new OBJExporter()
const result = exporter.parse(exportRoot)
downloadBlob(new Blob([result], { type: 'model/obj' }), `${filename}.obj`)
}
}
setExportScene(exportFn)
return () => setExportScene(null)
}, [scene, setExportScene])
return null
}
@@ -1,4 +1,3 @@
import { useFrame } from '@react-three/fiber'
import {
type AnyNodeId,
type FenceNode,
@@ -7,6 +6,7 @@ import {
sceneRegistry,
useScene,
} from '@pascal-app/core'
import { useFrame } from '@react-three/fiber'
import * as THREE from 'three'
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
@@ -69,7 +69,9 @@ function createFenceCurveSpanParts(
const parts: FencePart[] = []
const frameCount = Math.max(
1,
Math.ceil((getWallCurveLength(fence) * Math.max(1e-4, endT - startT)) / MIN_CURVE_SEGMENT_LENGTH),
Math.ceil(
(getWallCurveLength(fence) * Math.max(1e-4, endT - startT)) / MIN_CURVE_SEGMENT_LENGTH,
),
)
let previous = getFencePointAt(fence, startT)
@@ -1,4 +1,4 @@
import { emitter, sceneRegistry } from '@pascal-app/core'
import { sceneRegistry } from '@pascal-app/core'
import { useEffect } from 'react'
import useViewer from '../../store/use-viewer'
@@ -15,29 +15,5 @@ export const GuideSystem = () => {
})
}, [showGuides])
useEffect(() => {
const hideForCapture = () => {
const guides = sceneRegistry.byType.guide || new Set()
guides.forEach((guideId) => {
const node = sceneRegistry.nodes.get(guideId)
if (node) node.visible = false
})
}
const restoreAfterCapture = () => {
const showGuidesNow = useViewer.getState().showGuides
const guides = sceneRegistry.byType.guide || new Set()
guides.forEach((guideId) => {
const node = sceneRegistry.nodes.get(guideId)
if (node) node.visible = showGuidesNow
})
}
emitter.on('thumbnail:before-capture', hideForCapture)
emitter.on('thumbnail:after-capture', restoreAfterCapture)
return () => {
emitter.off('thumbnail:before-capture', hideForCapture)
emitter.off('thumbnail:after-capture', restoreAfterCapture)
}
}, [])
return null
}
@@ -1,4 +1,3 @@
import { useFrame } from '@react-three/fiber'
import {
type AnyNodeId,
getScaledDimensions,
@@ -9,6 +8,7 @@ import {
useScene,
type WallNode,
} from '@pascal-app/core'
import { useFrame } from '@react-three/fiber'
import type * as THREE from 'three'
// ============================================================================
@@ -1,4 +1,3 @@
import { useFrame } from '@react-three/fiber'
import {
type AnyNode,
type AnyNodeId,
@@ -8,11 +7,21 @@ import {
sceneRegistry,
useScene,
} from '@pascal-app/core'
import { useFrame } from '@react-three/fiber'
import * as THREE from 'three'
import { mergeVertices } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
import { ADDITION, Brush, Evaluator, SUBTRACTION } from 'three-bvh-csg'
import { computeBoundsTree } from 'three-mesh-bvh'
function csgGeometry(brush: Brush): THREE.BufferGeometry {
return brush.geometry as unknown as THREE.BufferGeometry
}
function csgMaterials(brush: Brush): THREE.Material[] {
const mat = (brush as any).material
return Array.isArray(mat) ? mat : [mat]
}
const csgEvaluator = new Evaluator()
csgEvaluator.useGroups = true
;(csgEvaluator as any).consolidateGroups = false // shared dummyMats across brushes causes consolidation to misalign groupIndices vs groupOrder indices → crash
@@ -183,7 +192,7 @@ function updateMergedRoofGeometry(
)
const applyTransform = (brush: Brush) => {
brush.geometry.applyMatrix4(_matrix)
csgGeometry(brush).applyMatrix4(_matrix)
brush.updateMatrixWorld()
}
@@ -242,11 +251,9 @@ function updateMergedRoofGeometry(
const shinDeck = csgEvaluator.evaluate(finalShinTrimmed, finalDeckTrimmed, ADDITION)
const combined = csgEvaluator.evaluate(shinDeck, finalWallTrimmed, ADDITION)
const resultGeo = combined.geometry
const resultGeo = csgGeometry(combined)
const resultMaterials: THREE.Material[] = Array.isArray(combined.material)
? combined.material
: [combined.material]
const resultMaterials = csgMaterials(combined)
const matToIndex = new Map<THREE.Material, number>([
[dummyMats[0], 0],
@@ -259,8 +266,8 @@ function updateMergedRoofGeometry(
g.materialIndex = mapRoofGroupMaterialIndex(g.materialIndex, resultMaterials, matToIndex)
}
ensureUv2Attribute(resultGeo)
resultGeo.computeVertexNormals()
ensureUv2Attribute(resultGeo)
mergedMesh.geometry.dispose()
mergedMesh.geometry = resultGeo
@@ -614,11 +621,9 @@ export function generateRoofSegmentGeometry(node: RoofSegmentNode): THREE.Buffer
const shinDeck = csgEvaluator.evaluate(shinSlab, deckSlab, ADDITION)
const combined = csgEvaluator.evaluate(shinDeck, hollowWall, ADDITION)
resultGeo = combined.geometry
resultGeo = csgGeometry(combined)
const resultMaterials: THREE.Material[] = Array.isArray(combined.material)
? combined.material
: [combined.material]
const resultMaterials = csgMaterials(combined)
const matToIndex = new Map<THREE.Material, number>([
[dummyMats[0], 0],
@@ -641,7 +646,7 @@ export function generateRoofSegmentGeometry(node: RoofSegmentNode): THREE.Buffer
shinDeck.geometry.dispose()
} catch (e) {
console.error('Roof CSG failed:', e)
resultGeo = wallBrush.geometry.clone()
resultGeo = csgGeometry(wallBrush).clone()
}
deckSlab.geometry.dispose()
@@ -649,8 +654,8 @@ export function generateRoofSegmentGeometry(node: RoofSegmentNode): THREE.Buffer
wallBrush.geometry.dispose()
innerBrush.geometry.dispose()
ensureUv2Attribute(resultGeo)
resultGeo.computeVertexNormals()
ensureUv2Attribute(resultGeo)
return resultGeo
}
@@ -1014,8 +1019,8 @@ function createGeometryFromFaces(
// Merge identical vertices to optimize geometry for CSG and create clean topology
const mergedGeo = mergeVertices(geometry, 1e-4)
geometry.dispose()
ensureUv2Attribute(mergedGeo)
ensureUv2Attribute(mergedGeo)
return mergedGeo
}
@@ -1,4 +1,4 @@
import { emitter, sceneRegistry } from '@pascal-app/core'
import { sceneRegistry } from '@pascal-app/core'
import { useEffect } from 'react'
import useViewer from '../../store/use-viewer'
@@ -15,29 +15,5 @@ export const ScanSystem = () => {
})
}, [showScans])
useEffect(() => {
const hideForCapture = () => {
const scans = sceneRegistry.byType.scan || new Set()
scans.forEach((scanId) => {
const node = sceneRegistry.nodes.get(scanId)
if (node) node.visible = false
})
}
const restoreAfterCapture = () => {
const showScansNow = useViewer.getState().showScans
const scans = sceneRegistry.byType.scan || new Set()
scans.forEach((scanId) => {
const node = sceneRegistry.nodes.get(scanId)
if (node) node.visible = showScansNow
})
}
emitter.on('thumbnail:before-capture', hideForCapture)
emitter.on('thumbnail:after-capture', restoreAfterCapture)
return () => {
emitter.off('thumbnail:before-capture', hideForCapture)
emitter.off('thumbnail:after-capture', restoreAfterCapture)
}
}, [])
return null
}
@@ -1,11 +1,11 @@
import { useFrame } from '@react-three/fiber'
import {
type AnyNodeId,
getRenderableSlabPolygon,
sceneRegistry,
type SlabNode,
sceneRegistry,
useScene,
} from '@pascal-app/core'
import { useFrame } from '@react-three/fiber'
import * as THREE from 'three'
function ensureUv2Attribute(geometry: THREE.BufferGeometry) {
+108 -116
View File
@@ -1,15 +1,15 @@
import { useFrame } from '@react-three/fiber'
import {
type AnyNode,
type AnyNodeId,
resolveLevelId,
sceneRegistry,
spatialGridManager,
type StairNode,
type StairSegmentNode,
sceneRegistry,
spatialGridManager,
syncAutoStairOpenings,
useScene,
} from '@pascal-app/core'
import { useFrame } from '@react-three/fiber'
import { useEffect, useRef } from 'react'
import * as THREE from 'three'
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
@@ -459,7 +459,7 @@ function applyStairSegmentUvs(geometry: THREE.BufferGeometry) {
const position = geometry.getAttribute('position')
const normal = geometry.getAttribute('normal')
if (!position || !normal || position.count === 0) {
if (!(position && normal) || position.count === 0) {
geometry.deleteAttribute('uv')
return
}
@@ -609,7 +609,13 @@ function generateStairRailingGeometry(
const landingInset = 0.08
const geometries: THREE.BufferGeometry[] = []
const segmentRailPaths = buildStairRailPaths(segments, transforms, railingMode, inset, landingInset)
const segmentRailPaths = buildStairRailPaths(
segments,
transforms,
railingMode,
inset,
landingInset,
)
for (const segmentRailPath of segmentRailPaths) {
for (const sidePath of segmentRailPath.sidePaths) {
@@ -618,9 +624,7 @@ function generateStairRailingGeometry(
geometries.push(...buildBalusterGeometries(points, railHeight, postRadius))
geometries.push(...buildOffsetRailSegmentGeometries(points, railHeight, railRadius))
geometries.push(
...buildOffsetRailSegmentGeometries(points, midRailHeight, railRadius * 0.8),
)
geometries.push(...buildOffsetRailSegmentGeometries(points, midRailHeight, railRadius * 0.8))
}
}
@@ -695,42 +699,34 @@ function buildStairRailPaths(
previousSegment?.segmentType === 'stair' &&
nextSegment?.segmentType === 'stair'
const visualTurnSide = nextSegment?.attachmentSide
const sideCandidates =
hideLandingRailing
? visualTurnSide === 'left'
? (['front', 'right'] as const)
: visualTurnSide === 'right'
? (['front', 'left'] as const)
: (['left', 'right'] as const)
: segment.segmentType === 'landing'
? nextSegment?.segmentType === 'landing' && visualTurnSide === 'left'
? (['front', 'right'] as const)
: nextSegment?.segmentType === 'landing' && visualTurnSide === 'right'
? (['front', 'left'] as const)
: visualTurnSide === 'left'
? (['right'] as const)
: visualTurnSide === 'right'
? (['left'] as const)
: (['left', 'right'] as const)
const sideCandidates = hideLandingRailing
? visualTurnSide === 'left'
? (['front', 'right'] as const)
: visualTurnSide === 'right'
? (['front', 'left'] as const)
: (['left', 'right'] as const)
: segment.segmentType === 'landing'
? nextSegment?.segmentType === 'landing' && visualTurnSide === 'left'
? (['front', 'right'] as const)
: nextSegment?.segmentType === 'landing' && visualTurnSide === 'right'
? (['front', 'left'] as const)
: visualTurnSide === 'left'
? (['right'] as const)
: visualTurnSide === 'right'
? (['left'] as const)
: (['left', 'right'] as const)
: (['left', 'right'] as const)
const sidePaths = sideCandidates
.map((side) =>
buildSegmentRailPath(
layout,
side,
previousSegment,
nextSegment,
inset,
landingInset,
),
buildSegmentRailPath(layout, side, previousSegment, nextSegment, inset, landingInset),
)
.filter((entry): entry is StairRailSidePath => entry !== null)
return {
segment,
sidePaths:
sidePaths:
isStraightLineDoubleLandingLayout && index === 1
? ((['left', 'right'] as const)
? (['left', 'right'] as const)
.map((side) =>
buildSegmentRailPath(
layout,
@@ -741,7 +737,7 @@ function buildStairRailPaths(
landingInset,
),
)
.filter((entry): entry is StairRailSidePath => entry !== null))
.filter((entry): entry is StairRailSidePath => entry !== null)
: sidePaths,
connectFromPrevious:
index > 0 &&
@@ -780,10 +776,20 @@ function buildStairRailPaths(
nextAttachmentSide === railingMode
: true
const sidePaths =
suppressLandingRailing
? []
: segment.segmentType !== 'landing'
const sidePaths = suppressLandingRailing
? []
: segment.segmentType !== 'landing'
? [
buildSegmentRailPath(
layout,
railingMode,
previousSegment,
nextSegment,
inset,
landingInset,
),
]
: isStraightLineDoubleLandingLayout
? [
buildSegmentRailPath(
layout,
@@ -794,19 +800,29 @@ function buildStairRailPaths(
landingInset,
),
]
: isStraightLineDoubleLandingLayout
? [
buildSegmentRailPath(
layout,
railingMode,
previousSegment,
nextSegment,
inset,
landingInset,
),
]
: isMiddleLandingBetweenFlights && railingMode === 'left'
? nextAttachmentSide === 'right'
: isMiddleLandingBetweenFlights && railingMode === 'left'
? nextAttachmentSide === 'right'
? [
buildSegmentRailPath(
layout,
'front',
previousSegment,
nextSegment,
inset,
landingInset,
),
buildSegmentRailPath(
layout,
'left',
previousSegment,
nextSegment,
inset,
landingInset,
),
]
: []
: isMiddleLandingBetweenFlights && railingMode === 'right'
? nextAttachmentSide === 'left'
? [
buildSegmentRailPath(
layout,
@@ -818,7 +834,7 @@ function buildStairRailPaths(
),
buildSegmentRailPath(
layout,
'left',
'right',
previousSegment,
nextSegment,
inset,
@@ -826,59 +842,38 @@ function buildStairRailPaths(
),
]
: []
: isMiddleLandingBetweenFlights && railingMode === 'right'
? nextAttachmentSide === 'left'
? [
buildSegmentRailPath(
layout,
'front',
previousSegment,
nextSegment,
inset,
landingInset,
),
buildSegmentRailPath(
layout,
'right',
previousSegment,
nextSegment,
inset,
landingInset,
),
]
: []
: nextSegment?.segmentType === 'landing' &&
nextAttachmentSide != null &&
nextAttachmentSide !== 'front' &&
nextAttachmentSide !== railingMode
? [
buildSegmentRailPath(
layout,
'front',
previousSegment,
nextSegment,
inset,
landingInset,
),
buildSegmentRailPath(
layout,
railingMode,
previousSegment,
nextSegment,
inset,
landingInset,
),
]
: [
buildSegmentRailPath(
layout,
railingMode,
previousSegment,
nextSegment,
inset,
landingInset,
),
]
: nextSegment?.segmentType === 'landing' &&
nextAttachmentSide != null &&
nextAttachmentSide !== 'front' &&
nextAttachmentSide !== railingMode
? [
buildSegmentRailPath(
layout,
'front',
previousSegment,
nextSegment,
inset,
landingInset,
),
buildSegmentRailPath(
layout,
railingMode,
previousSegment,
nextSegment,
inset,
landingInset,
),
]
: [
buildSegmentRailPath(
layout,
railingMode,
previousSegment,
nextSegment,
inset,
landingInset,
),
]
resolved.push({
segment,
@@ -924,10 +919,11 @@ function buildSegmentRailPath(
const segmentStepDepth = segment.length / segmentSteps
const segmentStepHeight = segment.segmentType === 'landing' ? 0 : segment.height / segmentSteps
const segmentTopThickness = getSegmentTopThickness(segment)
const flightSideOffset =
side === 'left' ? segment.width / 2 - 0.045 : -segment.width / 2 + 0.045
const flightSideOffset = side === 'left' ? segment.width / 2 - 0.045 : -segment.width / 2 + 0.045
const flightStartX =
previousSegment?.segmentType === 'landing' ? -segment.length / 2 + landingInset : -segment.length / 2
previousSegment?.segmentType === 'landing'
? -segment.length / 2 + landingInset
: -segment.length / 2
const flightEndX =
nextSegment?.segmentType === 'landing' ? segment.length / 2 - landingInset : segment.length / 2
@@ -947,9 +943,7 @@ function buildSegmentRailPath(
points: [
...(previousSegment?.segmentType === 'landing'
? []
: [
toRailLayoutWorldPoint(layout, flightStartX, segmentTopThickness, flightSideOffset),
]),
: [toRailLayoutWorldPoint(layout, flightStartX, segmentTopThickness, flightSideOffset)]),
...Array.from({ length: segmentSteps }).map((_, index) =>
toRailLayoutWorldPoint(
layout,
@@ -960,9 +954,7 @@ function buildSegmentRailPath(
),
...(nextSegment?.segmentType === 'landing'
? []
: [
toRailLayoutWorldPoint(layout, flightEndX, segment.height, flightSideOffset),
]),
: [toRailLayoutWorldPoint(layout, flightEndX, segment.height, flightSideOffset)]),
],
}
}
@@ -37,6 +37,10 @@ function computeGeometryBoundsTree(geometry: THREE.BufferGeometry) {
;(geometry as any).computeBoundsTree({ maxLeafSize: 10 })
}
function csgGeometry(brush: Brush): THREE.BufferGeometry {
return brush.geometry as unknown as THREE.BufferGeometry
}
type WallBoundaryEdgeTag = 'front' | 'back' | 'base'
type TaggedWallBoundaryEdge = {
@@ -57,7 +61,7 @@ function insetCurvedWallBoundaryPointsFor3D(
boundaryPoints: ReturnType<typeof getWallMiterBoundaryPoints>,
miterData: WallMiterData,
) {
if (!boundaryPoints || !isCurvedWall(wall)) {
if (!(boundaryPoints && isCurvedWall(wall))) {
return boundaryPoints
}
@@ -431,7 +435,7 @@ export function generateExtrudedWall(
childrenNodes: AnyNode[],
miterData: WallMiterData,
slabElevation = 0,
) {
): THREE.BufferGeometry {
const wallStart: Point2D = { x: wallNode.start[0], y: wallNode.start[1] }
const wallEnd: Point2D = { x: wallNode.end[0], y: wallNode.end[1] }
// Positive slab: shift the whole wall up (full height preserved)
@@ -519,18 +523,18 @@ export function generateExtrudedWall(
cutoutBrush.updateMatrixWorld()
const newResult = csgEvaluator.evaluate(resultBrush, cutoutBrush, SUBTRACTION)
if (resultBrush !== wallBrush) {
resultBrush.geometry.dispose()
csgGeometry(resultBrush).dispose()
}
resultBrush = newResult
}
// Clean up
wallBrush.geometry.dispose()
csgGeometry(wallBrush).dispose()
for (const brush of cutoutBrushes) {
brush.geometry.dispose()
csgGeometry(brush).dispose()
}
const resultGeometry = resultBrush.geometry
const resultGeometry = csgGeometry(resultBrush)
resultGeometry.computeVertexNormals()
assignWallMaterialGroups(resultGeometry, wallNode, boundaryEdges)
ensureUv2Attribute(resultGeometry)
+2 -1
View File
@@ -5,7 +5,8 @@
"rootDir": "src",
"noEmit": false,
"composite": true,
"incremental": true
"incremental": true,
"types": ["node"]
},
"include": ["src"],
"exclude": ["node_modules", "dist"],