From ff4e2fc2fec09e0f923a7c19810deec77e62cd97 Mon Sep 17 00:00:00 2001 From: wass08 Date: Mon, 2 Feb 2026 09:40:11 +0900 Subject: [PATCH 01/10] fix entire rebuild on updates --- packages/core/src/store/use-scene.ts | 42 +++++++++++++++---- .../core/src/systems/wall/wall-system.tsx | 6 ++- 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index a49cba2a..822cbabd 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -170,14 +170,40 @@ const useScene: UseSceneStore = create()( export default useScene -// Subscribe to the temporal store (Undo/Redo events) -useScene.temporal.subscribe((state, prevState) => { - // Check if we just jumped in time (Undo/Redo) - // If the 'nodes' object changed but it wasn't a normal 'set' - const currentNodes = useScene.getState().nodes +// Track previous temporal state lengths +let prevPastLength = 0 +let prevFutureLength = 0 - // Trigger a full scene re-validation - Object.values(currentNodes).forEach((node) => { - useScene.getState().markDirty(node.id) +// Subscribe to the temporal store (Undo/Redo events) +useScene.temporal.subscribe((state) => { + const currentPastLength = state.pastStates.length + const currentFutureLength = state.futureStates.length + + console.log('Temporal state changed:', { + pastStates: { prev: prevPastLength, current: currentPastLength }, + futureStates: { prev: prevFutureLength, current: currentFutureLength }, }) + + // Undo: futureStates increases (state moved from past to future) + // Redo: pastStates increases while futureStates decreases (state moved from future to past) + const didUndo = currentFutureLength > prevFutureLength + const didRedo = currentPastLength > prevPastLength && currentFutureLength < prevFutureLength + + console.log('Detection:', { didUndo, didRedo }) + + if (didUndo || didRedo) { + // Use RAF to ensure all middleware and store updates are complete + requestAnimationFrame(() => { + const currentNodes = useScene.getState().nodes + + // Trigger a full scene re-validation after undo/redo + Object.values(currentNodes).forEach((node) => { + useScene.getState().markDirty(node.id) + }) + }) + } + + // Update tracked lengths + prevPastLength = currentPastLength + prevFutureLength = currentFutureLength }) diff --git a/packages/core/src/systems/wall/wall-system.tsx b/packages/core/src/systems/wall/wall-system.tsx index a40544ee..072dece3 100644 --- a/packages/core/src/systems/wall/wall-system.tsx +++ b/packages/core/src/systems/wall/wall-system.tsx @@ -22,14 +22,17 @@ const csgEvaluator = new Evaluator() // ============================================================================ export const WallSystem = () => { - const { nodes, dirtyNodes, clearDirty } = useScene() + const dirtyNodes = useScene((state) => state.dirtyNodes) + const clearDirty = useScene((state) => state.clearDirty) + console.log('wall system rerendering') useFrame(() => { if (dirtyNodes.size === 0) return // Collect dirty walls and their levels const dirtyWallsByLevel = new Map>() + const nodes = useScene.getState().nodes dirtyNodes.forEach((id) => { const node = nodes[id] if (!node || node.type !== 'wall') return @@ -45,6 +48,7 @@ export const WallSystem = () => { // Process each level that has dirty walls for (const [levelId, dirtyWallIds] of dirtyWallsByLevel) { + console.log(`Updating walls for level ${levelId}`) const levelWalls = getLevelWalls(levelId) const miterData = calculateLevelMiters(levelWalls) From f98ead45858f4951ddbf774fdff62611f0b4b4b0 Mon Sep 17 00:00:00 2001 From: wass08 Date: Mon, 2 Feb 2026 10:24:57 +0900 Subject: [PATCH 02/10] camera for zones and levels --- .../ui/sidebar/panels/site-panel/index.tsx | 132 ++++ .../ui/sidebar/panels/zone-panel/index.tsx | 67 +- apps/editor/public/demos/demo_1.json | 583 +++++++++++++++++- packages/core/src/store/use-scene.ts | 5 - 4 files changed, 760 insertions(+), 27 deletions(-) diff --git a/apps/editor/components/ui/sidebar/panels/site-panel/index.tsx b/apps/editor/components/ui/sidebar/panels/site-panel/index.tsx index b53da349..a6924faf 100644 --- a/apps/editor/components/ui/sidebar/panels/site-panel/index.tsx +++ b/apps/editor/components/ui/sidebar/panels/site-panel/index.tsx @@ -1,5 +1,6 @@ import { type BuildingNode, + emitter, LevelNode, useScene, type ZoneNode, @@ -7,6 +8,7 @@ import { import { useViewer } from "@pascal-app/viewer"; import { Building2, + Camera, ChevronDown, Layers, MoreHorizontal, @@ -155,11 +157,13 @@ function BuildingSelector() { function LevelsSection() { const nodes = useScene((state) => state.nodes); const createNode = useScene((state) => state.createNode); + const updateNode = useScene((state) => state.updateNode); const selectedBuildingId = useViewer((state) => state.selection.buildingId); const selectedLevelId = useViewer((state) => state.selection.levelId); const setSelection = useViewer((state) => state.setSelection); const [referencesLevelId, setReferencesLevelId] = useState(null); + const [cameraPopoverOpen, setCameraPopoverOpen] = useState(null); const building = selectedBuildingId ? (nodes[selectedBuildingId] as BuildingNode) @@ -215,6 +219,72 @@ function LevelsSection() { {level.name || `Level ${level.level}`} + {/* Camera snapshot button */} + setCameraPopoverOpen(open ? level.id : null)}> + + + + e.stopPropagation()} + > +
+ {level.camera && ( + + )} + + {level.camera && ( + + )} +
+
+
+ + e.stopPropagation()} + > +
+ {zone.camera && ( + + )} + + {zone.camera && ( + + )} +
+
+
+ + e.stopPropagation()} + > +
+ {zone.camera && ( + + )} + + {zone.camera && ( + + )} +
+
+ + + + {/* Content */} +
+
+ {/* Elevation */} +
+ +
+ { + const value = Number.parseFloat(e.target.value) + if (!Number.isNaN(value)) { + handleUpdate({ elevation: value }) + } + }} + step="0.05" + type="number" + value={Math.round(node.elevation * 1000) / 1000} + /> + m +
+

+ Height offset from the level base (positive = raised, negative = sunken) +

+
+ + {/* Quick preset buttons */} +
+ +
+ + + + +
+
+ + {/* Area info */} +
+ +
+ {area.toFixed(2)} m² +
+
+
+
+ + ) +} diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts index 8e8e75ad..ba129bbc 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts @@ -435,7 +435,7 @@ export class SpatialGridManager { const slabMap = this.slabsByLevel.get(levelId) if (!slabMap) return 0 - let maxElevation = 0 + let maxElevation = -Infinity for (const slab of slabMap.values()) { if (slab.polygon.length >= 3 && itemOverlapsPolygon(position, dimensions, rotation, slab.polygon, 0.01)) { const elevation = slab.elevation ?? 0.05 @@ -444,7 +444,7 @@ export class SpatialGridManager { } } } - return maxElevation + return maxElevation === -Infinity ? 0 : maxElevation } /** diff --git a/packages/core/src/systems/wall/wall-system.tsx b/packages/core/src/systems/wall/wall-system.tsx index eb292331..213d9e8b 100644 --- a/packages/core/src/systems/wall/wall-system.tsx +++ b/packages/core/src/systems/wall/wall-system.tsx @@ -151,6 +151,7 @@ export function generateExtrudedWall( const wallStart: Point2D = { x: wallNode.start[0], y: wallNode.start[1] } const wallEnd: Point2D = { x: wallNode.end[0], y: wallNode.end[1] } + // Wall height is adjusted by slab elevation (positive reduces, negative increases) const height = (wallNode.height ?? 2.5) - slabElevation const thickness = wallNode.thickness ?? 0.1 const halfT = thickness / 2 @@ -244,7 +245,8 @@ export function generateExtrudedWall( // Rotate so extrusion direction (Z) becomes height direction (Y) geometry.rotateX(-Math.PI / 2) - if (slabElevation > 0) { + // Translate by slab elevation (works for both positive and negative values) + if (slabElevation !== 0) { geometry.translate(0, slabElevation, 0) } geometry.computeVertexNormals() From 5392d66bd6f29066163dbaf7715651749732a6ab Mon Sep 17 00:00:00 2001 From: wass08 Date: Mon, 2 Feb 2026 11:35:44 +0900 Subject: [PATCH 06/10] handle better slab polygon detection --- .../spatial-grid/spatial-grid-manager.ts | 68 +++++++++++++++++-- .../core/src/systems/wall/wall-system.tsx | 2 +- 2 files changed, 62 insertions(+), 8 deletions(-) diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts index ba129bbc..e4c321aa 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts @@ -137,20 +137,74 @@ export function itemOverlapsPolygon( return false } +/** + * Check if wall segment (a) is substantially on polygon edge segment (b). + * Returns true only if BOTH endpoints of the wall are on or very close to the edge. + * This prevents walls that just touch one point from being detected. + */ +function segmentsCollinearAndOverlap( + ax1: number, az1: number, ax2: number, az2: number, + bx1: number, bz1: number, bx2: number, bz2: number, +): boolean { + const EPSILON = 1e-6 + + // Cross product to check collinearity + const cross1 = (ax2 - ax1) * (bz1 - az1) - (az2 - az1) * (bx1 - ax1) + const cross2 = (ax2 - ax1) * (bz2 - az1) - (az2 - az1) * (bx2 - ax1) + + if (Math.abs(cross1) > EPSILON || Math.abs(cross2) > EPSILON) { + return false // Not collinear + } + + // Check if a point is on segment b + const onSegment = (px: number, pz: number, qx: number, qz: number, rx: number, rz: number) => + Math.min(px, qx) - EPSILON <= rx && rx <= Math.max(px, qx) + EPSILON && + Math.min(pz, qz) - EPSILON <= rz && rz <= Math.max(pz, qz) + EPSILON + + // BOTH endpoints of wall (a) must be on edge (b) for substantial overlap + const a1OnB = onSegment(bx1, bz1, bx2, bz2, ax1, az1) + const a2OnB = onSegment(bx1, bz1, bx2, bz2, ax2, az2) + + return a1OnB && a2OnB +} + /** * Test if a wall segment overlaps with a polygon. + * A wall is considered to overlap if: + * - Its midpoint is inside the polygon (wall crosses through) + * - At least one endpoint is inside (wall partially or fully in slab) + * - It's collinear with and overlaps a polygon edge (wall on slab boundary) + * + * Note: A wall with just one endpoint touching the edge but the rest outside + * is NOT considered overlapping (adjacent only). */ export function wallOverlapsPolygon( start: [number, number], end: [number, number], polygon: Array<[number, number]>, ): boolean { - // Either endpoint inside the polygon - if (pointInPolygon(start[0], start[1], polygon)) return true - if (pointInPolygon(end[0], end[1], polygon)) return true + const startInside = pointInPolygon(start[0], start[1], polygon) + const endInside = pointInPolygon(end[0], end[1], polygon) - // Wall segment intersects any polygon edge - if (segmentIntersectsPolygon(start[0], start[1], end[0], end[1], polygon)) return true + // At least one endpoint strictly inside the polygon + if (startInside || endInside) return true + + // Check if midpoint is inside (catches walls crossing through) + const midX = (start[0] + end[0]) / 2 + const midZ = (start[1] + end[1]) / 2 + if (pointInPolygon(midX, midZ, polygon)) return true + + // Check if the wall is collinear with and overlaps any polygon edge + const n = polygon.length + for (let i = 0; i < n; i++) { + const j = (i + 1) % n + const [p1x, p1z] = polygon[i]! + const [p2x, p2z] = polygon[j]! + + if (segmentsCollinearAndOverlap(start[0], start[1], end[0], end[1], p1x, p1z, p2x, p2z)) { + return true + } + } return false } @@ -460,7 +514,7 @@ export class SpatialGridManager { const slabMap = this.slabsByLevel.get(levelId) if (!slabMap) return 0 - let maxElevation = 0 + let maxElevation = -Infinity for (const slab of slabMap.values()) { if (slab.polygon.length < 3) continue if (wallOverlapsPolygon(start, end, slab.polygon)) { @@ -470,7 +524,7 @@ export class SpatialGridManager { } } } - return maxElevation + return maxElevation === -Infinity ? 0 : maxElevation } /** diff --git a/packages/core/src/systems/wall/wall-system.tsx b/packages/core/src/systems/wall/wall-system.tsx index 213d9e8b..8042b1da 100644 --- a/packages/core/src/systems/wall/wall-system.tsx +++ b/packages/core/src/systems/wall/wall-system.tsx @@ -49,7 +49,6 @@ export const WallSystem = () => { // Process each level that has dirty walls for (const [levelId, dirtyWallIds] of dirtyWallsByLevel) { - console.log(`Updating walls for level ${levelId}`) const levelWalls = getLevelWalls(levelId) const miterData = calculateLevelMiters(levelWalls) @@ -153,6 +152,7 @@ export function generateExtrudedWall( const wallEnd: Point2D = { x: wallNode.end[0], y: wallNode.end[1] } // Wall height is adjusted by slab elevation (positive reduces, negative increases) const height = (wallNode.height ?? 2.5) - slabElevation + const thickness = wallNode.thickness ?? 0.1 const halfT = thickness / 2 From 73dfac795ab8c6931c5c4cf301b85bab5a126aab Mon Sep 17 00:00:00 2001 From: wass08 Date: Mon, 2 Feb 2026 11:39:38 +0900 Subject: [PATCH 07/10] fix placement --- .../tools/item/use-placement-coordinator.tsx | 4 +++- packages/core/src/store/actions/node-actions.ts | 12 +++++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/apps/editor/components/tools/item/use-placement-coordinator.tsx b/apps/editor/components/tools/item/use-placement-coordinator.tsx index 34d02fae..f1c12000 100644 --- a/apps/editor/components/tools/item/use-placement-coordinator.tsx +++ b/apps/editor/components/tools/item/use-placement-coordinator.tsx @@ -503,12 +503,14 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea if (!asset.attachTo) { const levelId = useViewer.getState().selection.levelId if (levelId) { - mesh.position.y = spatialGridManager.getSlabElevationForItem( + const slabElevation = spatialGridManager.getSlabElevationForItem( levelId, [gridPosition.current.x, gridPosition.current.y, gridPosition.current.z], asset.dimensions ?? DEFAULT_DIMENSIONS, draftNode.current.rotation, ) + mesh.position.y = slabElevation + cursorRef.current.position.y = slabElevation } } } diff --git a/packages/core/src/store/actions/node-actions.ts b/packages/core/src/store/actions/node-actions.ts index 0281729f..5ced050b 100644 --- a/packages/core/src/store/actions/node-actions.ts +++ b/packages/core/src/store/actions/node-actions.ts @@ -97,9 +97,15 @@ export const updateNodesAction = ( return { nodes: nextNodes } }) - // Mark dirty - updates.forEach((u) => get().markDirty(u.id)) - parentsToUpdate.forEach((pId) => get().markDirty(pId)) + // Mark dirty after the next frame to ensure React renders complete + requestAnimationFrame(() => { + updates.forEach((u) => { + get().markDirty(u.id) + }) + parentsToUpdate.forEach((pId) => { + get().markDirty(pId) + }) + }) } export const deleteNodesAction = ( From 4f75c9b7507a8017598fd9387eb9383fb6159f09 Mon Sep 17 00:00:00 2001 From: wass08 Date: Mon, 2 Feb 2026 14:22:21 +0900 Subject: [PATCH 08/10] soft shadows & lighting --- .../renderers/item/item-renderer.tsx | 7 +- .../viewer/src/components/viewer/index.tsx | 67 ++++++++++--------- .../viewer/src/components/viewer/lights.tsx | 41 ++++++++++++ 3 files changed, 83 insertions(+), 32 deletions(-) create mode 100644 packages/viewer/src/components/viewer/lights.tsx diff --git a/packages/viewer/src/components/renderers/item/item-renderer.tsx b/packages/viewer/src/components/renderers/item/item-renderer.tsx index ad07d14a..ad9a2528 100644 --- a/packages/viewer/src/components/renderers/item/item-renderer.tsx +++ b/packages/viewer/src/components/renderers/item/item-renderer.tsx @@ -68,15 +68,18 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => { return } - mesh.castShadow = true - mesh.receiveShadow = true + let hasGlass = false; // Handle both single material and material array cases if (Array.isArray(mesh.material)) { mesh.material = mesh.material.map((mat) => getMaterialForOriginal(mat)) + hasGlass = mesh.material.some(mat => mat.name === 'glass'); } else { mesh.material = getMaterialForOriginal(mesh.material) + hasGlass = mesh.material.name === 'glass'; } + mesh.castShadow = !hasGlass + mesh.receiveShadow = !hasGlass } }) }, [scene]) diff --git a/packages/viewer/src/components/viewer/index.tsx b/packages/viewer/src/components/viewer/index.tsx index 14687e9c..37588f9b 100644 --- a/packages/viewer/src/components/viewer/index.tsx +++ b/packages/viewer/src/components/viewer/index.tsx @@ -1,13 +1,14 @@ 'use client' import { CeilingSystem, ItemSystem, RoofSystem, SlabSystem, WallSystem } from '@pascal-app/core' -import { Bvh, Environment } from '@react-three/drei' +import { Bvh } from '@react-three/drei' import { Canvas, extend, type ThreeToJSXElements } from '@react-three/fiber' import * as THREE from 'three/webgpu' import { GuideSystem } from '../../systems/guide/guide-system' import { LevelSystem } from '../../systems/level/level-system' import { ScanSystem } from '../../systems/scan/scan-system' import { SceneRenderer } from '../renderers/scene-renderer' +import { Lights } from './lights' import PostProcessing from './post-processing' import { SelectionManager } from './selection-manager' import { ViewerCamera } from './viewer-camera' @@ -26,38 +27,44 @@ interface ViewerProps { const Viewer: React.FC = ({ children, selectionManager = 'default' }) => { return ( { - const renderer = new THREE.WebGPURenderer(props as any) - await renderer.init() - return renderer - }} - shadows - camera={{ position: [50, 50, 50], fov: 50 }} - > - - + className={'bg-[#303035]'} + gl={async (props) => { + const renderer = new THREE.WebGPURenderer(props as any) + await renderer.init() + renderer.toneMapping = THREE.ACESFilmicToneMapping + renderer.toneMappingExposure = 1.2 + return renderer + }} + shadows={{ + type: THREE.PCFShadowMap, + enabled: true, + }} + camera={{ position: [50, 50, 50], fov: 50 }} + > + + - - - - - + {/* */} + + + + - {/* Default Systems */} - - - - {/* Core systems */} - - - - - - + {/* Default Systems */} + + + + {/* Core systems */} + + + + + + - {selectionManager === 'default' && } - {children} + {selectionManager === 'default' && } + {children} ) } diff --git a/packages/viewer/src/components/viewer/lights.tsx b/packages/viewer/src/components/viewer/lights.tsx new file mode 100644 index 00000000..8739b72f --- /dev/null +++ b/packages/viewer/src/components/viewer/lights.tsx @@ -0,0 +1,41 @@ +import { Environment } from '@react-three/drei' +import { useRef } from 'react' +import type { DirectionalLight, OrthographicCamera } from 'three/webgpu' + +export function Lights() { + const lightRef = useRef(null) + const shadowCamera = useRef(null) + const shadowCameraSize = 50 // The "area" around the camera to shadow + + // useHelper(lightRef, DirectionalLightHelper, 1, 'red') + // useHelper(shadowCamera, CameraHelper) + + return ( + <> + + + + + + + + ) +} From 05ce0323e2fd1ddefc45eefb294361f63978e6cc Mon Sep 17 00:00:00 2001 From: wass08 Date: Mon, 2 Feb 2026 14:48:36 +0900 Subject: [PATCH 09/10] gable-roof back --- .../core/src/systems/roof/roof-system.tsx | 312 ++++++++++++++---- .../renderers/wall/wall-renderer.tsx | 2 +- 2 files changed, 251 insertions(+), 63 deletions(-) diff --git a/packages/core/src/systems/roof/roof-system.tsx b/packages/core/src/systems/roof/roof-system.tsx index 302489f7..3afce72c 100644 --- a/packages/core/src/systems/roof/roof-system.tsx +++ b/packages/core/src/systems/roof/roof-system.tsx @@ -4,6 +4,18 @@ import { sceneRegistry } from '../../hooks/scene-registry/scene-registry' import type { AnyNodeId, RoofNode } from '../../schema' import useScene from '../../store/use-scene' +// ============================================================================ +// ROOF GEOMETRY CONSTANTS +// ============================================================================ + +const THICKNESS_A = 0.05 // Roof cover thickness (5cm) +const THICKNESS_B = 0.1 // Structure thickness (10cm) +const ROOF_COVER_OVERHANG = 0.05 // Extension of cover past structure (5cm) +const EAVE_OVERHANG = 0.4 // Horizontal eave overhang (40cm) +const RAKE_OVERHANG = 0.3 // Overhang at gable ends (30cm) +const WALL_THICKNESS = 0.2 // Gable wall thickness (20cm) +const BASE_HEIGHT = 0.5 // Base height / knee wall / truss heel (50cm) + // ============================================================================ // ROOF SYSTEM // ============================================================================ @@ -49,81 +61,257 @@ function updateRoofGeometry(node: RoofNode, mesh: THREE.Mesh) { } /** - * Generates gable roof geometry from length, height, leftWidth, rightWidth - * - * The roof is centered at origin (position applied via mesh transform) - * - Ridge runs along the X axis (length direction) - * - Left slope goes down toward -Z with horizontal distance leftWidth - * - Right slope goes down toward +Z with horizontal distance rightWidth - * - Total width = leftWidth + rightWidth - * - Gable ends at -X/2 and +X/2 + * Helper to solve pitch angle analytically given rise, run and thicknesses + * Solves: run * tan(a) + (ThickA + ThickB)/cos(a) = rise + */ +function solvePitch(rise: number, run: number, thickA: number, thickB: number): number { + const T = thickA + thickB + if (run < 0.01) return 0 + + const R = Math.sqrt(run * run + rise * rise) + if (R <= T) { + return Math.atan2(rise, run) * 0.5 // Fallback + } + + const phi = Math.atan2(rise, run) + const shift = Math.asin(T / R) + + return phi - shift +} + +/** + * Helper to create a Three.js Shape from polygon points + */ +function createShape(points: { x: number; y: number }[]): THREE.Shape { + const shape = new THREE.Shape() + if (points.length === 0) return shape + const firstPoint = points[0] + if (!firstPoint) return shape + shape.moveTo(firstPoint.x, firstPoint.y) + for (let i = 1; i < points.length; i++) { + const point = points[i] + if (point) { + shape.lineTo(point.x, point.y) + } + } + shape.closePath() + return shape +} + +/** + * Generate profile for one side of the roof (left or right) + */ +function getSideProfile( + dir: 1 | -1, + width: number, + roofHeight: number, +): { + pointsA: { x: number; y: number }[] + pointsB: { x: number; y: number }[] + pointsSide: { x: number; y: number }[] + pointsC1: { x: number; y: number }[] + pointsC2: { x: number; y: number }[] +} { + const halfWall = WALL_THICKNESS / 2 + + const rise = Math.max(0, roofHeight - BASE_HEIGHT) + const run = width - halfWall + + const angle = solvePitch(rise, run, THICKNESS_A, THICKNESS_B) + const tanA = Math.tan(angle) + const cosA = Math.cos(angle) + const sinA = Math.sin(angle) + + const ridgeUnderY = BASE_HEIGHT + run * tanA + const ridgeInterfaceY = ridgeUnderY + THICKNESS_B / cosA + const ridgeTopY = ridgeInterfaceY + THICKNESS_A / cosA + + const wallOuterTopY = BASE_HEIGHT - WALL_THICKNESS * tanA + + const overhangDx = EAVE_OVERHANG * cosA + + const eaveTopZ = width + halfWall + overhangDx + const eaveTopY = ridgeTopY - eaveTopZ * tanA + + const coverExtDx = ROOF_COVER_OVERHANG * cosA + const coverExtDy = ROOF_COVER_OVERHANG * sinA + + const eaveTopExtZ = eaveTopZ + coverExtDx + const eaveTopExtY = eaveTopY - coverExtDy + + const eaveInterfaceExtZ = eaveTopExtZ - THICKNESS_A * sinA + const eaveInterfaceExtY = eaveTopExtY - THICKNESS_A * cosA + + const eaveInterfaceZ = eaveTopZ + + const eaveBottomZ = eaveTopZ + const eaveBottomY = ridgeUnderY - eaveTopZ * tanA + + // Layer A (Cover) + const pointsA = [ + { x: 0, y: ridgeTopY }, + { x: dir * eaveTopExtZ, y: eaveTopExtY }, + { x: dir * eaveInterfaceExtZ, y: eaveInterfaceExtY }, + { x: 0, y: ridgeInterfaceY }, + ] + + // Layer B (Structure) + const pointsB = [ + { x: 0, y: ridgeInterfaceY }, + { x: dir * eaveInterfaceZ, y: ridgeInterfaceY - eaveTopZ * tanA }, + { x: dir * eaveBottomZ, y: eaveBottomY }, + { x: 0, y: ridgeUnderY }, + ] + + // Side Wall + const zInner = width - halfWall + const zOuter = width + halfWall + + const pointsSide = [ + { x: dir * zInner, y: 0 }, + { x: dir * zOuter, y: 0 }, + { x: dir * zOuter, y: Math.max(0, wallOuterTopY) }, + { x: dir * zInner, y: BASE_HEIGHT }, + ] + + // Gable Top (C1) + const pointsC1 = [ + { x: 0, y: BASE_HEIGHT }, + { x: dir * zInner, y: BASE_HEIGHT }, + { x: dir * zInner, y: BASE_HEIGHT }, + { x: 0, y: ridgeUnderY }, + ] + + // Gable Base (C2) + const pointsC2 = [ + { x: 0, y: 0 }, + { x: dir * zInner, y: 0 }, + { x: dir * zInner, y: BASE_HEIGHT }, + { x: 0, y: BASE_HEIGHT }, + ] + + return { pointsA, pointsB, pointsSide, pointsC1, pointsC2 } +} + +/** + * Generates detailed gable roof geometry with layers, walls, and overhangs */ export function generateRoofGeometry(roofNode: RoofNode): THREE.BufferGeometry { const { length, height, leftWidth, rightWidth } = roofNode - // Half length for centering - const halfLength = length / 2 + const ridgeLength = length - // Ridge is at Y = height, centered at Z = 0 - // Left eave is at Z = -leftWidth, Y = 0 - // Right eave is at Z = +rightWidth, Y = 0 + // Get profiles for both sides + const leftP = getSideProfile(1, leftWidth, height) + const rightP = getSideProfile(-1, rightWidth, height) - const positions: number[] = [] - const normals: number[] = [] - const indices: number[] = [] - - const addVertex = (x: number, y: number, z: number, nx: number, ny: number, nz: number) => { - const idx = positions.length / 3 - positions.push(x, y, z) - normals.push(nx, ny, nz) - return idx + // Create shapes from profiles + const shapes = { + ALeft: createShape(leftP.pointsA), + ARight: createShape(rightP.pointsA), + BLeft: createShape(leftP.pointsB), + BRight: createShape(rightP.pointsB), + SideLeft: createShape(leftP.pointsSide), + SideRight: createShape(rightP.pointsSide), + C1Left: createShape(leftP.pointsC1), + C1Right: createShape(rightP.pointsC1), + C2Left: createShape(leftP.pointsC2), + C2Right: createShape(rightP.pointsC2), } - // Calculate slope normals - // Left slope: from (0, height, 0) to (0, 0, -leftWidth) - const leftSlopeLen = Math.sqrt(height * height + leftWidth * leftWidth) - const leftNormalY = leftWidth / leftSlopeLen - const leftNormalZ = height / leftSlopeLen + // Calculate extrusion lengths and offsets + const lengths = { + A: ridgeLength + 2 * RAKE_OVERHANG + 2 * ROOF_COVER_OVERHANG + WALL_THICKNESS, + B: ridgeLength + 2 * RAKE_OVERHANG + WALL_THICKNESS, + Side: ridgeLength + WALL_THICKNESS, + Gable: WALL_THICKNESS, + } - // Right slope: from (0, height, 0) to (0, 0, +rightWidth) - const rightSlopeLen = Math.sqrt(height * height + rightWidth * rightWidth) - const rightNormalY = rightWidth / rightSlopeLen - const rightNormalZ = height / rightSlopeLen + const offsets = { + A: -RAKE_OVERHANG - ROOF_COVER_OVERHANG - WALL_THICKNESS / 2, + B: -RAKE_OVERHANG - WALL_THICKNESS / 2, + Side: -WALL_THICKNESS / 2, + GableFront: -WALL_THICKNESS / 2, + GableBack: ridgeLength - WALL_THICKNESS / 2, + } - // Left slope (negative Z side) - CCW winding for outward-facing - const leftNormal = [0, leftNormalY, -leftNormalZ] as const - const v0 = addVertex(-halfLength, 0, -leftWidth, ...leftNormal) // back-left eave - const v1 = addVertex(halfLength, 0, -leftWidth, ...leftNormal) // front-left eave - const v2 = addVertex(halfLength, height, 0, ...leftNormal) // front ridge - const v3 = addVertex(-halfLength, height, 0, ...leftNormal) // back ridge - indices.push(v0, v2, v1, v0, v3, v2) + // Helper to create and position extruded geometry + const createPart = (shape: THREE.Shape, depth: number, xOffset: number) => { + const geo = new THREE.ExtrudeGeometry(shape, { depth, bevelEnabled: false }) + // Rotate to align: extrusion goes along X axis + geo.rotateY(Math.PI / 2) + geo.translate(xOffset, 0, 0) + return geo + } - // Right slope (positive Z side) - CCW winding for outward-facing - const rightNormal = [0, rightNormalY, rightNormalZ] as const - const v4 = addVertex(halfLength, 0, rightWidth, ...rightNormal) // front-right eave - const v5 = addVertex(-halfLength, 0, rightWidth, ...rightNormal) // back-right eave - const v6 = addVertex(-halfLength, height, 0, ...rightNormal) // back ridge - const v7 = addVertex(halfLength, height, 0, ...rightNormal) // front ridge - indices.push(v4, v6, v5, v4, v7, v6) + // Create all parts + const geometries: THREE.BufferGeometry[] = [] - // Front gable end (positive X) - CCW winding for outward-facing - const frontNormal = [1, 0, 0] as const - const v8 = addVertex(halfLength, 0, -leftWidth, ...frontNormal) - const v9 = addVertex(halfLength, 0, rightWidth, ...frontNormal) - const v10 = addVertex(halfLength, height, 0, ...frontNormal) - indices.push(v8, v10, v9) + // Layer A (Cover) - both sides + geometries.push(createPart(shapes.ALeft, lengths.A, offsets.A)) + geometries.push(createPart(shapes.ARight, lengths.A, offsets.A)) - // Back gable end (negative X) - CCW winding for outward-facing - const backNormal = [-1, 0, 0] as const - const v11 = addVertex(-halfLength, 0, rightWidth, ...backNormal) - const v12 = addVertex(-halfLength, 0, -leftWidth, ...backNormal) - const v13 = addVertex(-halfLength, height, 0, ...backNormal) - indices.push(v11, v13, v12) + // Layer B (Structure) - both sides + geometries.push(createPart(shapes.BLeft, lengths.B, offsets.B)) + geometries.push(createPart(shapes.BRight, lengths.B, offsets.B)) - const geometry = new THREE.BufferGeometry() - geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)) - geometry.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3)) - geometry.setIndex(indices) + // Side Walls - both sides + geometries.push(createPart(shapes.SideLeft, lengths.Side, offsets.Side)) + geometries.push(createPart(shapes.SideRight, lengths.Side, offsets.Side)) - return geometry + // Gable Walls (Front) + geometries.push(createPart(shapes.C1Left, lengths.Gable, offsets.GableFront)) + geometries.push(createPart(shapes.C1Right, lengths.Gable, offsets.GableFront)) + geometries.push(createPart(shapes.C2Left, lengths.Gable, offsets.GableFront)) + geometries.push(createPart(shapes.C2Right, lengths.Gable, offsets.GableFront)) + + // Gable Walls (Back) + geometries.push(createPart(shapes.C1Left, lengths.Gable, offsets.GableBack)) + geometries.push(createPart(shapes.C1Right, lengths.Gable, offsets.GableBack)) + geometries.push(createPart(shapes.C2Left, lengths.Gable, offsets.GableBack)) + geometries.push(createPart(shapes.C2Right, lengths.Gable, offsets.GableBack)) + + // Merge all geometries + const mergedGeometry = new THREE.BufferGeometry() + const positions: number[] = [] + const normals: number[] = [] + const uvs: number[] = [] + + for (const geo of geometries) { + const posAttr = geo.getAttribute('position') + const normAttr = geo.getAttribute('normal') + const uvAttr = geo.getAttribute('uv') + + if (posAttr) { + for (let i = 0; i < posAttr.count; i++) { + positions.push(posAttr.getX(i), posAttr.getY(i), posAttr.getZ(i)) + } + } + if (normAttr) { + for (let i = 0; i < normAttr.count; i++) { + normals.push(normAttr.getX(i), normAttr.getY(i), normAttr.getZ(i)) + } + } + if (uvAttr) { + for (let i = 0; i < uvAttr.count; i++) { + uvs.push(uvAttr.getX(i), uvAttr.getY(i)) + } + } + + geo.dispose() + } + + mergedGeometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)) + mergedGeometry.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3)) + if (uvs.length > 0) { + mergedGeometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2)) + } + + mergedGeometry.computeVertexNormals() + + // Center the geometry at X=0 (translate by -ridgeLength/2) + // This matches the old geometry centering behavior + mergedGeometry.translate(-ridgeLength / 2, 0, 0) + + return mergedGeometry } diff --git a/packages/viewer/src/components/renderers/wall/wall-renderer.tsx b/packages/viewer/src/components/renderers/wall/wall-renderer.tsx index 436e324a..7dfe9767 100644 --- a/packages/viewer/src/components/renderers/wall/wall-renderer.tsx +++ b/packages/viewer/src/components/renderers/wall/wall-renderer.tsx @@ -15,7 +15,7 @@ export const WallRenderer = ({ node }: { node: WallNode }) => { {/* WallSystem will replace this geometry in the next frame */} - + From 310a4ae33504167a6278dc4c5e5457cbdaf547e8 Mon Sep 17 00:00:00 2001 From: wass08 Date: Mon, 2 Feb 2026 14:52:45 +0900 Subject: [PATCH 10/10] fix build --- apps/editor/components/ui/panels/panel-manager.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/editor/components/ui/panels/panel-manager.tsx b/apps/editor/components/ui/panels/panel-manager.tsx index 25a2bf70..92d26f68 100644 --- a/apps/editor/components/ui/panels/panel-manager.tsx +++ b/apps/editor/components/ui/panels/panel-manager.tsx @@ -1,6 +1,6 @@ 'use client' -import { useScene } from '@pascal-app/core' +import { AnyNodeId, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import useEditor from '@/store/use-editor' import { ReferencePanel } from './reference-panel' @@ -19,7 +19,8 @@ export function PanelManager() { // Show appropriate panel based on selected node type if (selectedIds.length === 1) { - const node = nodes[selectedIds[0]!] + const selectedNode = selectedIds[0] + const node = nodes[selectedNode as AnyNodeId] if (node) { switch (node.type) { case 'roof':