diff --git a/apps/editor/components/tools/ceiling/ceiling-tool.tsx b/apps/editor/components/tools/ceiling/ceiling-tool.tsx
index 3a56a74b..89567f85 100644
--- a/apps/editor/components/tools/ceiling/ceiling-tool.tsx
+++ b/apps/editor/components/tools/ceiling/ceiling-tool.tsx
@@ -2,8 +2,9 @@ import { CeilingNode, emitter, type GridEvent, type LevelNode, useScene } from '
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef, useState } from 'react'
import { BufferGeometry, DoubleSide, type Line, type Mesh, Shape, Vector3 } from 'three'
-import useEditor from '@/store/use-editor'
import { sfxEmitter } from '@/lib/sfx-bus'
+import useEditor from '@/store/use-editor'
+import { CursorSphere } from '../shared/cursor-sphere'
const CEILING_HEIGHT = 2.52
const GRID_OFFSET = 0.02
@@ -97,12 +98,19 @@ export const CeilingTool: React.FC = () => {
// Calculate snapped display position (bypass snap when Shift is held)
const lastPoint = points[points.length - 1]
- const displayPoint = (shiftPressed.current || !lastPoint) ? gridPosition : calculateSnapPoint(lastPoint, gridPosition)
+ const displayPoint =
+ shiftPressed.current || !lastPoint
+ ? gridPosition
+ : calculateSnapPoint(lastPoint, gridPosition)
setSnappedCursorPosition(displayPoint)
// Play snap sound when the snapped position actually changes (only when drawing)
- if (points.length > 0 && previousSnappedPointRef.current &&
- (displayPoint[0] !== previousSnappedPointRef.current[0] || displayPoint[1] !== previousSnappedPointRef.current[1])) {
+ if (
+ points.length > 0 &&
+ previousSnappedPointRef.current &&
+ (displayPoint[0] !== previousSnappedPointRef.current[0] ||
+ displayPoint[1] !== previousSnappedPointRef.current[1])
+ ) {
sfxEmitter.emit('sfx:grid-snap')
}
@@ -150,8 +158,12 @@ export const CeilingTool: React.FC = () => {
setPoints([])
}
- const onKeyDown = (e: KeyboardEvent) => { if (e.key === 'Shift') shiftPressed.current = true }
- const onKeyUp = (e: KeyboardEvent) => { if (e.key === 'Shift') shiftPressed.current = false }
+ const onKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Shift') shiftPressed.current = true
+ }
+ const onKeyUp = (e: KeyboardEvent) => {
+ if (e.key === 'Shift') shiftPressed.current = false
+ }
document.addEventListener('keydown', onKeyDown)
document.addEventListener('keyup', onKeyUp)
@@ -242,15 +254,12 @@ export const CeilingTool: React.FC = () => {
return (
{/* Cursor at ceiling height */}
-
-
-
-
+
{/* Grid-level cursor indicator */}
-
+
-
+
{/* Preview fill */}
@@ -294,14 +303,11 @@ export const CeilingTool: React.FC = () => {
{/* Point markers */}
{points.map(([x, z], index) => (
-
-
-
-
+
))}
)
diff --git a/apps/editor/components/tools/roof/roof-tool.tsx b/apps/editor/components/tools/roof/roof-tool.tsx
index 9f351007..6698ae75 100644
--- a/apps/editor/components/tools/roof/roof-tool.tsx
+++ b/apps/editor/components/tools/roof/roof-tool.tsx
@@ -1,37 +1,44 @@
-import { emitter, type GridEvent, useScene, RoofNode, type LevelNode, type AnyNode } from "@pascal-app/core";
-import { useViewer } from "@pascal-app/viewer";
-import { useEffect, useMemo, useRef, useState } from "react";
-import { BufferGeometry, DoubleSide, type Line, Vector3 } from "three";
-import useEditor from "@/store/use-editor";
-import { sfxEmitter } from '@/lib/sfx-bus';
+import {
+ type AnyNode,
+ emitter,
+ type GridEvent,
+ type LevelNode,
+ RoofNode,
+ useScene,
+} from '@pascal-app/core'
+import { useViewer } from '@pascal-app/viewer'
+import { useEffect, useMemo, useRef, useState } from 'react'
+import { BufferGeometry, DoubleSide, type Line, Vector3 } from 'three'
+import { sfxEmitter } from '@/lib/sfx-bus'
+import useEditor from '@/store/use-editor'
// Default roof dimensions
-const DEFAULT_HEIGHT = 1.5;
-const PREVIEW_LINE_HEIGHT = 0.03; // Very thin preview
+const DEFAULT_HEIGHT = 1.5
+const PREVIEW_LINE_HEIGHT = 0.03 // Very thin preview
/**
* Creates a roof with the given corners
*/
const commitRoofPlacement = (
- levelId: LevelNode["id"],
+ levelId: LevelNode['id'],
corner1: [number, number, number],
- corner2: [number, number, number]
-): RoofNode["id"] => {
- const { createNode, nodes } = useScene.getState();
+ corner2: [number, number, number],
+): RoofNode['id'] => {
+ const { createNode, nodes } = useScene.getState()
// Calculate center position and dimensions from corners
- const centerX = (corner1[0] + corner2[0]) / 2;
- const centerZ = (corner1[2] + corner2[2]) / 2;
+ const centerX = (corner1[0] + corner2[0]) / 2
+ const centerZ = (corner1[2] + corner2[2]) / 2
- const length = Math.abs(corner2[0] - corner1[0]);
- const width = Math.abs(corner2[2] - corner1[2]);
+ const length = Math.abs(corner2[0] - corner1[0])
+ const width = Math.abs(corner2[2] - corner1[2])
// Split width evenly between left and right slopes
- const slopeWidth = Math.max(width / 2, 0.5);
+ const slopeWidth = Math.max(width / 2, 0.5)
// Count existing roofs for naming
- const roofCount = Object.values(nodes).filter((n) => n.type === "roof").length;
- const name = `Roof ${roofCount + 1}`;
+ const roofCount = Object.values(nodes).filter((n) => n.type === 'roof').length
+ const name = `Roof ${roofCount + 1}`
const roof = RoofNode.parse({
name,
@@ -40,151 +47,153 @@ const commitRoofPlacement = (
height: DEFAULT_HEIGHT,
leftWidth: slopeWidth,
rightWidth: slopeWidth,
- });
+ })
- createNode(roof, levelId);
- sfxEmitter.emit('sfx:structure-build');
- return roof.id;
-};
+ createNode(roof, levelId)
+ sfxEmitter.emit('sfx:structure-build')
+ return roof.id
+}
type PreviewState = {
- corner1: [number, number, number] | null;
- cursorPosition: [number, number, number];
- levelY: number;
-};
+ corner1: [number, number, number] | null
+ cursorPosition: [number, number, number]
+ levelY: number
+}
export const RoofTool: React.FC = () => {
- const outlineRef = useRef(null!);
- const currentLevelId = useViewer((state) => state.selection.levelId);
- const setSelection = useViewer((state) => state.setSelection);
- const setTool = useEditor((state) => state.setTool);
- const setMode = useEditor((state) => state.setMode);
+ const outlineRef = useRef(null!)
+ const currentLevelId = useViewer((state) => state.selection.levelId)
+ const setSelection = useViewer((state) => state.setSelection)
+ const setTool = useEditor((state) => state.setTool)
+ const setMode = useEditor((state) => state.setMode)
- const corner1Ref = useRef<[number, number, number] | null>(null);
- const previousGridPosRef = useRef<[number, number] | null>(null);
+ const corner1Ref = useRef<[number, number, number] | null>(null)
+ const previousGridPosRef = useRef<[number, number] | null>(null)
const [preview, setPreview] = useState({
corner1: null,
cursorPosition: [0, 0, 0],
levelY: 0,
- });
+ })
useEffect(() => {
- if (!currentLevelId) return;
+ if (!currentLevelId) return
// Initialize outline geometry
- outlineRef.current.geometry = new BufferGeometry();
+ outlineRef.current.geometry = new BufferGeometry()
- const updateOutline = (corner1: [number, number, number], corner2: [number, number, number]) => {
- const y = corner1[1] + PREVIEW_LINE_HEIGHT;
+ const updateOutline = (
+ corner1: [number, number, number],
+ corner2: [number, number, number],
+ ) => {
+ const y = corner1[1] + PREVIEW_LINE_HEIGHT
const points = [
new Vector3(corner1[0], y, corner1[2]),
new Vector3(corner2[0], y, corner1[2]),
new Vector3(corner2[0], y, corner2[2]),
new Vector3(corner1[0], y, corner2[2]),
new Vector3(corner1[0], y, corner1[2]), // Close the loop
- ];
- outlineRef.current.geometry.dispose();
- outlineRef.current.geometry = new BufferGeometry().setFromPoints(points);
- outlineRef.current.visible = true;
- };
+ ]
+ outlineRef.current.geometry.dispose()
+ outlineRef.current.geometry = new BufferGeometry().setFromPoints(points)
+ outlineRef.current.visible = true
+ }
const onGridMove = (event: GridEvent) => {
// Snap to 0.5 grid
- const gridX = Math.round(event.position[0] * 2) / 2;
- const gridZ = Math.round(event.position[2] * 2) / 2;
- const y = event.position[1];
+ const gridX = Math.round(event.position[0] * 2) / 2
+ const gridZ = Math.round(event.position[2] * 2) / 2
+ const y = event.position[1]
- const cursorPosition: [number, number, number] = [gridX, y, gridZ];
+ const cursorPosition: [number, number, number] = [gridX, y, gridZ]
// Play snap sound when grid position changes (only when placing)
- if (corner1Ref.current && previousGridPosRef.current &&
- (gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])) {
- sfxEmitter.emit('sfx:grid-snap');
+ if (
+ corner1Ref.current &&
+ previousGridPosRef.current &&
+ (gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])
+ ) {
+ sfxEmitter.emit('sfx:grid-snap')
}
- previousGridPosRef.current = [gridX, gridZ];
+ previousGridPosRef.current = [gridX, gridZ]
setPreview({
corner1: corner1Ref.current,
cursorPosition,
levelY: y,
- });
+ })
// Update outline if we have first corner
if (corner1Ref.current) {
- updateOutline(corner1Ref.current, cursorPosition);
+ updateOutline(corner1Ref.current, cursorPosition)
}
- };
+ }
const onGridClick = (event: GridEvent) => {
- if (!currentLevelId) return;
+ if (!currentLevelId) return
- const gridX = Math.round(event.position[0] * 2) / 2;
- const gridZ = Math.round(event.position[2] * 2) / 2;
- const y = event.position[1];
+ const gridX = Math.round(event.position[0] * 2) / 2
+ const gridZ = Math.round(event.position[2] * 2) / 2
+ const y = event.position[1]
if (!corner1Ref.current) {
// First click - set corner 1
- corner1Ref.current = [gridX, y, gridZ];
+ corner1Ref.current = [gridX, y, gridZ]
setPreview((prev) => ({
...prev,
corner1: corner1Ref.current,
- }));
+ }))
} else {
// Second click - create the roof
- const roofId = commitRoofPlacement(
- currentLevelId,
- corner1Ref.current,
- [gridX, y, gridZ]
- );
+ const roofId = commitRoofPlacement(currentLevelId, corner1Ref.current, [gridX, y, gridZ])
// Auto-select the newly created roof
- setSelection({ selectedIds: [roofId as AnyNode["id"]] });
+ setSelection({ selectedIds: [roofId as AnyNode['id']] })
// Reset state
- corner1Ref.current = null;
- outlineRef.current.visible = false;
+ corner1Ref.current = null
+ outlineRef.current.visible = false
// Switch to select mode and deactivate tool
- setMode('select');
- setTool(null);
+ setMode('select')
+ setTool(null)
}
- };
+ }
const onCancel = () => {
if (corner1Ref.current) {
- corner1Ref.current = null;
- outlineRef.current.visible = false;
- setPreview((prev) => ({ ...prev, corner1: null }));
+ corner1Ref.current = null
+ outlineRef.current.visible = false
+ setPreview((prev) => ({ ...prev, corner1: null }))
}
- };
+ }
// Subscribe to events
- emitter.on("grid:move", onGridMove);
- emitter.on("grid:click", onGridClick);
- emitter.on("tool:cancel", onCancel);
+ emitter.on('grid:move', onGridMove)
+ emitter.on('grid:click', onGridClick)
+ emitter.on('tool:cancel', onCancel)
return () => {
- emitter.off("grid:move", onGridMove);
- emitter.off("grid:click", onGridClick);
- emitter.off("tool:cancel", onCancel);
+ emitter.off('grid:move', onGridMove)
+ emitter.off('grid:click', onGridClick)
+ emitter.off('tool:cancel', onCancel)
// Reset state on unmount
- corner1Ref.current = null;
- };
- }, [currentLevelId, setTool, setSelection, setMode]);
+ corner1Ref.current = null
+ }
+ }, [currentLevelId, setTool, setSelection, setMode])
- const { corner1, cursorPosition, levelY } = preview;
+ const { corner1, cursorPosition, levelY } = preview
// Calculate preview dimensions for display
const previewDimensions = useMemo(() => {
- if (!corner1) return null;
- const length = Math.abs(cursorPosition[0] - corner1[0]);
- const width = Math.abs(cursorPosition[2] - corner1[2]);
- const centerX = (corner1[0] + cursorPosition[0]) / 2;
- const centerZ = (corner1[2] + cursorPosition[2]) / 2;
- return { length, width, centerX, centerZ };
- }, [corner1, cursorPosition]);
+ if (!corner1) return null
+ const length = Math.abs(cursorPosition[0] - corner1[0])
+ const width = Math.abs(cursorPosition[2] - corner1[2])
+ const centerX = (corner1[0] + cursorPosition[0]) / 2
+ const centerZ = (corner1[2] + cursorPosition[2]) / 2
+ return { length, width, centerX, centerZ }
+ }, [corner1, cursorPosition])
return (
@@ -192,34 +201,25 @@ export const RoofTool: React.FC = () => {
{/* @ts-ignore */}
-
+
{/* First corner marker */}
{corner1 && (
-
+
-
+
)}
{/* Cursor marker on ground */}
-
+
-
+
{/* Thin preview fill when drawing */}
@@ -240,5 +240,5 @@ export const RoofTool: React.FC = () => {
)}
- );
-};
+ )
+}
diff --git a/apps/editor/components/tools/shared/cursor-sphere.tsx b/apps/editor/components/tools/shared/cursor-sphere.tsx
new file mode 100644
index 00000000..641edbe8
--- /dev/null
+++ b/apps/editor/components/tools/shared/cursor-sphere.tsx
@@ -0,0 +1,19 @@
+import { forwardRef } from 'react'
+import type { Mesh } from 'three'
+
+interface CursorSphereProps extends Omit {
+ color?: string
+ depthWrite?: boolean
+}
+
+export const CursorSphere = forwardRef(function CursorSphere(
+ { color = '#f1c066', ...props },
+ ref,
+) {
+ return (
+
+
+
+
+ )
+})
diff --git a/apps/editor/components/tools/slab/slab-tool.tsx b/apps/editor/components/tools/slab/slab-tool.tsx
index facdc8de..7f88f54f 100644
--- a/apps/editor/components/tools/slab/slab-tool.tsx
+++ b/apps/editor/components/tools/slab/slab-tool.tsx
@@ -3,6 +3,7 @@ import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef, useState } from 'react'
import { BufferGeometry, DoubleSide, type Line, type Mesh, Shape, Vector3 } from 'three'
import { sfxEmitter } from '@/lib/sfx-bus'
+import { CursorSphere } from '../shared/cursor-sphere'
const Y_OFFSET = 0.02
@@ -236,10 +237,7 @@ export const SlabTool: React.FC = () => {
return (
{/* Cursor */}
-
-
-
-
+
{/* Preview fill */}
{previewShape && (
@@ -282,14 +280,7 @@ export const SlabTool: React.FC = () => {
{/* Point markers */}
{points.map(([x, z], index) => (
-
-
-
-
+
))}
)
diff --git a/apps/editor/components/tools/wall/wall-tool.tsx b/apps/editor/components/tools/wall/wall-tool.tsx
index fa51d65b..72755cb0 100644
--- a/apps/editor/components/tools/wall/wall-tool.tsx
+++ b/apps/editor/components/tools/wall/wall-tool.tsx
@@ -3,6 +3,7 @@ import { useViewer } from '@pascal-app/viewer'
import { useEffect, useRef } from 'react'
import { DoubleSide, type Mesh, Shape, ShapeGeometry, Vector3 } from 'three'
import { sfxEmitter } from '@/lib/sfx-bus'
+import { CursorSphere } from '../shared/cursor-sphere'
const WALL_HEIGHT = 2.5
const WALL_THICKNESS = 0.15
@@ -183,10 +184,7 @@ export const WallTool: React.FC = () => {
return (
{/* Cursor indicator */}
-
-
-
-
+
{/* Wall preview */}
diff --git a/apps/editor/components/tools/zone/zone-tool.tsx b/apps/editor/components/tools/zone/zone-tool.tsx
index c9af47b9..dfc5dae7 100644
--- a/apps/editor/components/tools/zone/zone-tool.tsx
+++ b/apps/editor/components/tools/zone/zone-tool.tsx
@@ -3,6 +3,7 @@ import { useViewer } from "@pascal-app/viewer";
import { useEffect, useMemo, useRef, useState } from "react";
import { BufferGeometry, DoubleSide, type Line, type Mesh, Shape, Vector3 } from "three";
import useEditor from "@/store/use-editor";
+import { CursorSphere } from "../shared/cursor-sphere";
// Zone colors for cycling through
const ZONE_COLORS = [
@@ -317,14 +318,7 @@ export const ZoneTool: React.FC = () => {
return (
{/* Cursor */}
-
-
-
-
+
{/* Preview fill */}
{previewShape && (
@@ -373,14 +367,7 @@ export const ZoneTool: React.FC = () => {
{/* Point markers */}
{points.map(([x, z], index) =>
isValidPoint([x, z]) ? (
-
-
-
-
+
) : null
)}
diff --git a/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx b/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx
index 7094a4d9..ee86c9a5 100644
--- a/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx
+++ b/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx
@@ -1,7 +1,7 @@
import { type CeilingNode, useRegistry } from '@pascal-app/core'
import { useRef } from 'react'
import { faceDirection, float, mix, positionWorld, smoothstep, step } from 'three/tsl'
-import { DoubleSide, type Mesh, MeshBasicNodeMaterial } from 'three/webgpu'
+import { type Mesh, MeshBasicNodeMaterial } from 'three/webgpu'
import { useNodeEvents } from '../../../hooks/use-node-events'
import { NodeRenderer } from '../node-renderer'
@@ -10,7 +10,6 @@ import { NodeRenderer } from '../node-renderer'
// - Front face (looking down at ceiling from above): 30% opacity
const ceilingMaterial = new MeshBasicNodeMaterial({
color: 0x999999,
- side: DoubleSide,
transparent: true,
depthWrite: false,
})
@@ -30,8 +29,8 @@ const lineY = smoothstep(lineWidth, 0, gridY).add(smoothstep(1.0 - lineWidth, 1.
// Combine: if either X or Y is a line, show the line
const gridPattern = lineX.max(lineY)
-// Grid lines at 0.8 opacity, spaces at 0.1 opacity
-const gridOpacity = mix(float(0.1), float(0.8), gridPattern)
+// Grid lines at 0.5 opacity, spaces at 0 opacity
+const gridOpacity = mix(float(0.0), float(0.5), gridPattern)
// faceDirection is 1.0 for front face, -1.0 for back face
// Front face (top, looking down): grid pattern, Back face (bottom, looking up): solid