Merge pull request #100 from pascalorg/feat/window-editor
Feat/window editor
This commit is contained in:
@@ -13,6 +13,7 @@ export type {
|
||||
SiteEvent,
|
||||
SlabEvent,
|
||||
WallEvent,
|
||||
WindowEvent,
|
||||
ZoneEvent,
|
||||
} from './events/bus'
|
||||
// Events
|
||||
@@ -22,12 +23,21 @@ export {
|
||||
sceneRegistry,
|
||||
useRegistry,
|
||||
} from './hooks/scene-registry/scene-registry'
|
||||
export { pointInPolygon, spatialGridManager } from './hooks/spatial-grid/spatial-grid-manager'
|
||||
export {
|
||||
initSpatialGridSync,
|
||||
resolveLevelId,
|
||||
} from './hooks/spatial-grid/spatial-grid-sync'
|
||||
export { useSpatialQuery } from './hooks/spatial-grid/use-spatial-query'
|
||||
export { pointInPolygon, spatialGridManager } from './hooks/spatial-grid/spatial-grid-manager'
|
||||
// Asset storage
|
||||
export { loadAssetUrl, saveAsset } from './lib/asset-storage'
|
||||
// Space detection
|
||||
export {
|
||||
detectSpacesForLevel,
|
||||
initSpaceDetectionSync,
|
||||
type Space,
|
||||
wallTouchesOthers,
|
||||
} from './lib/space-detection'
|
||||
// Schema
|
||||
export * from './schema'
|
||||
export { default as useScene } from './store/use-scene'
|
||||
@@ -38,9 +48,4 @@ export { RoofSystem } from './systems/roof/roof-system'
|
||||
export { SlabSystem } from './systems/slab/slab-system'
|
||||
export { WallSystem } from './systems/wall/wall-system'
|
||||
export { WindowSystem } from './systems/window/window-system'
|
||||
|
||||
export { isObject } from './utils/types'
|
||||
// Asset storage
|
||||
export { saveAsset, loadAssetUrl } from './lib/asset-storage'
|
||||
// Space detection
|
||||
export { detectSpacesForLevel, wallTouchesOthers, initSpaceDetectionSync, type Space } from './lib/space-detection'
|
||||
|
||||
@@ -15,6 +15,11 @@ const assetSchema = z.object({
|
||||
offset: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||
rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||
scale: z.tuple([z.number(), z.number(), z.number()]).default([1, 1, 1]),
|
||||
surface: z
|
||||
.object({
|
||||
height: z.number(), // where things rest
|
||||
})
|
||||
.optional(), // undefined = can't place things on it
|
||||
})
|
||||
|
||||
export type AssetInput = z.input<typeof assetSchema>
|
||||
@@ -26,6 +31,7 @@ export const ItemNode = BaseNode.extend({
|
||||
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||
rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||
side: z.enum(['front', 'back']).optional(),
|
||||
children: z.array(objectId('item')).default([]),
|
||||
|
||||
// Wall attachment properties (only used when asset.attachTo is "wall" or "wall-side")
|
||||
wallId: z.string().optional(),
|
||||
|
||||
@@ -28,7 +28,8 @@ export const WindowNode = BaseNode.extend({
|
||||
// [1] = single pane (no division)
|
||||
columnRatios: z.array(z.number()).default([1]),
|
||||
rowRatios: z.array(z.number()).default([1]),
|
||||
dividerThickness: z.number().default(0.03),
|
||||
columnDividerThickness: z.number().default(0.03),
|
||||
rowDividerThickness: z.number().default(0.03),
|
||||
|
||||
// Sill
|
||||
sill: z.boolean().default(true),
|
||||
|
||||
@@ -35,15 +35,19 @@ export const ItemSystem = () => {
|
||||
mesh.position.z = (wallThickness / 2) * side;
|
||||
}
|
||||
} else if (!item.asset.attachTo) {
|
||||
// Floor item: elevate by slab height (using full footprint overlap)
|
||||
const levelId = resolveLevelId(item, nodes)
|
||||
const slabElevation = spatialGridManager.getSlabElevationForItem(
|
||||
levelId,
|
||||
item.position,
|
||||
item.asset.dimensions,
|
||||
item.rotation,
|
||||
)
|
||||
mesh.position.y = slabElevation
|
||||
// If parented to another item (surface placement), R3F handles positioning via the hierarchy
|
||||
const parentNode = item.parentId ? nodes[item.parentId as AnyNodeId] : undefined
|
||||
if (parentNode?.type !== 'item') {
|
||||
// Floor item: elevate by slab height (using full footprint overlap)
|
||||
const levelId = resolveLevelId(item, nodes)
|
||||
const slabElevation = spatialGridManager.getSlabElevationForItem(
|
||||
levelId,
|
||||
item.position,
|
||||
item.asset.dimensions,
|
||||
item.rotation,
|
||||
)
|
||||
mesh.position.y = slabElevation + item.position[1]
|
||||
}
|
||||
}
|
||||
|
||||
clearDirty(id as AnyNodeId)
|
||||
|
||||
@@ -132,7 +132,7 @@ function updateWallGeometry(wallId: string, miterData: WallMiterData) {
|
||||
collisionMesh.geometry = collisionGeo
|
||||
}
|
||||
|
||||
mesh.position.set(node.start[0], 0, node.start[1])
|
||||
mesh.position.set(node.start[0], slabElevation, node.start[1])
|
||||
const angle = Math.atan2(node.end[1] - node.start[1], node.end[0] - node.start[0])
|
||||
mesh.rotation.y = -angle
|
||||
}
|
||||
@@ -153,8 +153,10 @@ 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
|
||||
// Positive slab: shift the whole wall up (full height preserved)
|
||||
// Negative slab: extend wall downward so top stays fixed at wallNode.height
|
||||
const wallHeight = wallNode.height ?? 2.5
|
||||
const height = slabElevation > 0 ? wallHeight : wallHeight - slabElevation
|
||||
|
||||
const thickness = wallNode.thickness ?? 0.1
|
||||
const halfT = thickness / 2
|
||||
@@ -248,10 +250,6 @@ export function generateExtrudedWall(
|
||||
|
||||
// Rotate so extrusion direction (Z) becomes height direction (Y)
|
||||
geometry.rotateX(-Math.PI / 2)
|
||||
// Translate by slab elevation (works for both positive and negative values)
|
||||
if (slabElevation !== 0) {
|
||||
geometry.translate(0, slabElevation, 0)
|
||||
}
|
||||
geometry.computeVertexNormals()
|
||||
|
||||
// Apply CSG subtraction for cutouts (doors/windows)
|
||||
|
||||
@@ -7,15 +7,25 @@ import useScene from '../../store/use-scene'
|
||||
|
||||
const glassMaterial = new MeshStandardNodeMaterial({
|
||||
name: 'glass',
|
||||
color: 'lightgray',
|
||||
roughness: 0.8,
|
||||
metalness: 0,
|
||||
color: 'lightblue',
|
||||
roughness: 0.05,
|
||||
metalness: 0.1,
|
||||
transparent: true,
|
||||
opacity: 0.35,
|
||||
opacity: 0.3,
|
||||
side: DoubleSide,
|
||||
depthWrite: false,
|
||||
})
|
||||
|
||||
const frameMaterial = new MeshStandardNodeMaterial({
|
||||
name: 'window-frame',
|
||||
color: '#e8e8e8',
|
||||
roughness: 0.6,
|
||||
metalness: 0,
|
||||
})
|
||||
|
||||
// Invisible material for root mesh — used as selection hitbox only
|
||||
const hitboxMaterial = new THREE.MeshBasicMaterial({ visible: false })
|
||||
|
||||
export const WindowSystem = () => {
|
||||
const dirtyNodes = useScene((state) => state.dirtyNodes)
|
||||
const clearDirty = useScene((state) => state.clearDirty)
|
||||
@@ -45,17 +55,117 @@ export const WindowSystem = () => {
|
||||
return null
|
||||
}
|
||||
|
||||
function addBox(
|
||||
parent: THREE.Object3D,
|
||||
material: THREE.Material,
|
||||
w: number, h: number, d: number,
|
||||
x: number, y: number, z: number,
|
||||
) {
|
||||
const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), material)
|
||||
m.position.set(x, y, z)
|
||||
parent.add(m)
|
||||
}
|
||||
|
||||
function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) {
|
||||
// Replace geometry with a box matching the overall window dimensions
|
||||
// Root mesh is an invisible hitbox; all visuals live in child meshes
|
||||
mesh.geometry.dispose()
|
||||
mesh.geometry = new THREE.BoxGeometry(node.width, node.height, node.frameDepth)
|
||||
mesh.material = glassMaterial
|
||||
mesh.material = hitboxMaterial
|
||||
|
||||
// Sync transform from node (React may lag behind the system by a frame during drag)
|
||||
mesh.position.set(node.position[0], node.position[1], node.position[2])
|
||||
mesh.rotation.set(node.rotation[0], node.rotation[1], node.rotation[2])
|
||||
|
||||
// Update (or create) the named cutout mesh used by wall-system for CSG subtraction
|
||||
// Dispose and remove all old visual children; preserve 'cutout'
|
||||
for (const child of [...mesh.children]) {
|
||||
if (child.name === 'cutout') continue
|
||||
if (child instanceof THREE.Mesh) child.geometry.dispose()
|
||||
mesh.remove(child)
|
||||
}
|
||||
|
||||
const {
|
||||
width, height, frameDepth, frameThickness,
|
||||
columnRatios, rowRatios, columnDividerThickness, rowDividerThickness,
|
||||
sill, sillDepth, sillThickness,
|
||||
} = node
|
||||
|
||||
const innerW = width - 2 * frameThickness
|
||||
const innerH = height - 2 * frameThickness
|
||||
|
||||
// ── Frame members ──
|
||||
// Top / bottom — full width
|
||||
addBox(mesh, frameMaterial, width, frameThickness, frameDepth, 0, height / 2 - frameThickness / 2, 0)
|
||||
addBox(mesh, frameMaterial, width, frameThickness, frameDepth, 0, -height / 2 + frameThickness / 2, 0)
|
||||
// Left / right — inner height to avoid corner overlap
|
||||
addBox(mesh, frameMaterial, frameThickness, innerH, frameDepth, -width / 2 + frameThickness / 2, 0, 0)
|
||||
addBox(mesh, frameMaterial, frameThickness, innerH, frameDepth, width / 2 - frameThickness / 2, 0, 0)
|
||||
|
||||
// ── Pane grid ──
|
||||
const numCols = columnRatios.length
|
||||
const numRows = rowRatios.length
|
||||
|
||||
const usableW = innerW - (numCols - 1) * columnDividerThickness
|
||||
const usableH = innerH - (numRows - 1) * rowDividerThickness
|
||||
|
||||
const colSum = columnRatios.reduce((a, b) => a + b, 0)
|
||||
const rowSum = rowRatios.reduce((a, b) => a + b, 0)
|
||||
const colWidths = columnRatios.map(r => (r / colSum) * usableW)
|
||||
const rowHeights = rowRatios.map(r => (r / rowSum) * usableH)
|
||||
|
||||
// Compute column x-centers starting from left edge of inner area
|
||||
const colXCenters: number[] = []
|
||||
let cx = -innerW / 2
|
||||
for (let c = 0; c < numCols; c++) {
|
||||
colXCenters.push(cx + colWidths[c]! / 2)
|
||||
cx += colWidths[c]!
|
||||
if (c < numCols - 1) cx += columnDividerThickness
|
||||
}
|
||||
|
||||
// Compute row y-centers starting from top edge of inner area (R1 = top)
|
||||
const rowYCenters: number[] = []
|
||||
let cy = innerH / 2
|
||||
for (let r = 0; r < numRows; r++) {
|
||||
rowYCenters.push(cy - rowHeights[r]! / 2)
|
||||
cy -= rowHeights[r]!
|
||||
if (r < numRows - 1) cy -= rowDividerThickness
|
||||
}
|
||||
|
||||
// Column dividers — full inner height
|
||||
cx = -innerW / 2
|
||||
for (let c = 0; c < numCols - 1; c++) {
|
||||
cx += colWidths[c]!
|
||||
addBox(mesh, frameMaterial, columnDividerThickness, innerH, frameDepth, cx + columnDividerThickness / 2, 0, 0)
|
||||
cx += columnDividerThickness
|
||||
}
|
||||
|
||||
// Row dividers — per column width, so they don't overlap column dividers (top to bottom)
|
||||
cy = innerH / 2
|
||||
for (let r = 0; r < numRows - 1; r++) {
|
||||
cy -= rowHeights[r]!
|
||||
const divY = cy - rowDividerThickness / 2
|
||||
for (let c = 0; c < numCols; c++) {
|
||||
addBox(mesh, frameMaterial, colWidths[c]!, rowDividerThickness, frameDepth, colXCenters[c]!, divY, 0)
|
||||
}
|
||||
cy -= rowDividerThickness
|
||||
}
|
||||
|
||||
// Glass panes
|
||||
const glassDepth = Math.max(0.004, frameDepth * 0.08)
|
||||
for (let c = 0; c < numCols; c++) {
|
||||
for (let r = 0; r < numRows; r++) {
|
||||
addBox(mesh, glassMaterial, colWidths[c]!, rowHeights[r]!, glassDepth, colXCenters[c]!, rowYCenters[r]!, 0)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sill ──
|
||||
if (sill) {
|
||||
const sillW = width + sillDepth * 0.4 // slightly wider than frame
|
||||
// Protrudes from the front face of the frame (+Z)
|
||||
const sillZ = frameDepth / 2 + sillDepth / 2
|
||||
addBox(mesh, frameMaterial, sillW, sillThickness, sillDepth, 0, -height / 2 - sillThickness / 2, sillZ)
|
||||
}
|
||||
|
||||
// ── Cutout (for wall CSG) — always full window dimensions, 1m deep ──
|
||||
let cutout = mesh.getObjectByName('cutout') as THREE.Mesh | undefined
|
||||
if (!cutout) {
|
||||
cutout = new THREE.Mesh()
|
||||
@@ -63,7 +173,6 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) {
|
||||
mesh.add(cutout)
|
||||
}
|
||||
cutout.geometry.dispose()
|
||||
// Extends 1m through the wall so the CSG brush covers full wall thickness
|
||||
cutout.geometry = new THREE.BoxGeometry(node.width, node.height, 1.0)
|
||||
cutout.visible = false;
|
||||
cutout.visible = false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { pgTable } from 'drizzle-orm/pg-core'
|
||||
import { createInsertSchema, createSelectSchema } from 'drizzle-zod'
|
||||
import { id, createdAt } from '../../helpers'
|
||||
|
||||
export const feedback = pgTable('feedback', (t) => ({
|
||||
id: id('feedback'),
|
||||
userId: t.text('user_id'), // nullable — stores Better Auth user ID or null for anonymous
|
||||
message: t.text('message').notNull(),
|
||||
createdAt,
|
||||
})).enableRLS()
|
||||
|
||||
export type Feedback = typeof feedback.$inferSelect
|
||||
export type NewFeedback = typeof feedback.$inferInsert
|
||||
export const insertFeedbackSchema = createInsertSchema(feedback)
|
||||
export const selectFeedbackSchema = createSelectSchema(feedback)
|
||||
@@ -5,6 +5,9 @@ export * from './auth/sessions'
|
||||
export * from './auth/users'
|
||||
export * from './auth/verifications'
|
||||
|
||||
// Feedback table
|
||||
export * from './feedback/feedback'
|
||||
|
||||
// Project tables
|
||||
export * from './projects/addresses'
|
||||
export * from './projects/likes'
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
-- Create feedback table
|
||||
CREATE TABLE IF NOT EXISTS feedback (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
user_id TEXT,
|
||||
message TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Enable Row Level Security
|
||||
ALTER TABLE feedback ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Allow anyone (authenticated or anonymous) to submit feedback
|
||||
CREATE POLICY "Anyone can insert feedback"
|
||||
ON feedback
|
||||
FOR INSERT
|
||||
TO anon, authenticated
|
||||
WITH CHECK (true);
|
||||
|
||||
-- Allow service role full access (for admin review)
|
||||
CREATE POLICY "Service role full access"
|
||||
ON feedback
|
||||
TO service_role
|
||||
USING (true)
|
||||
WITH CHECK (true);
|
||||
@@ -7,6 +7,7 @@ import { positionLocal, smoothstep, time } from 'three/tsl'
|
||||
import { DoubleSide, MeshStandardNodeMaterial } from 'three/webgpu'
|
||||
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||
import { resolveCdnUrl } from '../../../lib/asset-url'
|
||||
import { NodeRenderer } from '../node-renderer'
|
||||
|
||||
// Shared materials to avoid creating new instances for every mesh
|
||||
const defaultMaterial = new MeshStandardNodeMaterial({
|
||||
@@ -43,6 +44,9 @@ export const ItemRenderer = ({ node }: { node: ItemNode }) => {
|
||||
<Suspense fallback={<PreviewModel node={node} />}>
|
||||
<ModelRenderer node={node} />
|
||||
</Suspense>
|
||||
{node.children?.map((childId) => (
|
||||
<NodeRenderer key={childId} nodeId={childId } />
|
||||
))}
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useRegistry, type WindowNode } from '@pascal-app/core'
|
||||
import { useRef } from 'react'
|
||||
import type { Mesh } from 'three'
|
||||
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||
|
||||
export const WindowRenderer = ({ node }: { node: WindowNode }) => {
|
||||
const ref = useRef<Mesh>(null!)
|
||||
|
||||
useRegistry(node.id, 'window', ref)
|
||||
const handlers = useNodeEvents(node, 'window')
|
||||
|
||||
return (
|
||||
<mesh
|
||||
@@ -15,6 +17,7 @@ export const WindowRenderer = ({ node }: { node: WindowNode }) => {
|
||||
visible={node.visible}
|
||||
position={node.position}
|
||||
rotation={node.rotation}
|
||||
{...handlers}
|
||||
>
|
||||
{/* WindowSystem replaces this geometry each time the node is dirty */}
|
||||
<boxGeometry args={[0, 0, 0]} />
|
||||
|
||||
@@ -17,11 +17,13 @@ import {
|
||||
type SlabNode,
|
||||
type WallEvent,
|
||||
type WallNode,
|
||||
type WindowEvent,
|
||||
type WindowNode,
|
||||
type ZoneEvent,
|
||||
type ZoneNode,
|
||||
} from '@pascal-app/core'
|
||||
import type { ThreeEvent } from '@react-three/fiber'
|
||||
import useViewer from '../store/use-viewer';
|
||||
import useViewer from '../store/use-viewer'
|
||||
|
||||
type NodeConfig = {
|
||||
site: { node: SiteNode; event: SiteEvent }
|
||||
@@ -33,6 +35,7 @@ type NodeConfig = {
|
||||
slab: { node: SlabNode; event: SlabEvent }
|
||||
ceiling: { node: CeilingNode; event: CeilingEvent }
|
||||
roof: { node: RoofNode; event: RoofEvent }
|
||||
window: { node: WindowNode; event: WindowEvent }
|
||||
}
|
||||
|
||||
type NodeType = keyof NodeConfig
|
||||
@@ -69,10 +72,25 @@ export function useNodeEvents<T extends NodeType>(node: NodeConfig[T]['node'], t
|
||||
if (e.button !== 0) return
|
||||
emit('click', e)
|
||||
},
|
||||
onPointerEnter: (e: ThreeEvent<PointerEvent>) => {if (useViewer.getState().cameraDragging) return; emit('enter', e)},
|
||||
onPointerLeave: (e: ThreeEvent<PointerEvent>) => {if (useViewer.getState().cameraDragging) return; emit('leave', e)},
|
||||
onPointerMove: (e: ThreeEvent<PointerEvent>) => {if (useViewer.getState().cameraDragging) return; emit('move', e)},
|
||||
onDoubleClick: (e: ThreeEvent<PointerEvent>) => {if (useViewer.getState().cameraDragging) return; emit('double-click', e)},
|
||||
onContextMenu: (e: ThreeEvent<PointerEvent>) => {if (useViewer.getState().cameraDragging) return; emit('context-menu', e)},
|
||||
onPointerEnter: (e: ThreeEvent<PointerEvent>) => {
|
||||
if (useViewer.getState().cameraDragging) return
|
||||
emit('enter', e)
|
||||
},
|
||||
onPointerLeave: (e: ThreeEvent<PointerEvent>) => {
|
||||
if (useViewer.getState().cameraDragging) return
|
||||
emit('leave', e)
|
||||
},
|
||||
onPointerMove: (e: ThreeEvent<PointerEvent>) => {
|
||||
if (useViewer.getState().cameraDragging) return
|
||||
emit('move', e)
|
||||
},
|
||||
onDoubleClick: (e: ThreeEvent<PointerEvent>) => {
|
||||
if (useViewer.getState().cameraDragging) return
|
||||
emit('double-click', e)
|
||||
},
|
||||
onContextMenu: (e: ThreeEvent<PointerEvent>) => {
|
||||
if (useViewer.getState().cameraDragging) return
|
||||
emit('context-menu', e)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user