Merge branch 'main' into feat/elevator-system

# Conflicts:
#	packages/editor/src/components/tools/item/move-tool.tsx
#	packages/editor/src/components/tools/tool-manager.tsx
#	packages/editor/src/components/ui/panels/panel-manager.tsx
#	packages/editor/src/store/use-editor.tsx
#	packages/viewer/src/components/renderers/site/site-renderer.tsx
#	packages/viewer/src/components/viewer/ground-occluder.tsx
#	packages/viewer/src/components/viewer/index.tsx
#	packages/viewer/src/components/viewer/post-processing.tsx
This commit is contained in:
sudhir
2026-05-13 01:38:55 +05:30
256 changed files with 9027 additions and 5479 deletions
+4 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@pascal-app/core",
"version": "0.6.0",
"version": "0.8.0",
"description": "Core library for Pascal 3D building editor",
"type": "module",
"main": "./dist/index.js",
@@ -73,9 +73,10 @@
},
"devDependencies": {
"@pascal/typescript-config": "*",
"@types/bun": "^1.3.0",
"@types/react": "^19.2.2",
"typescript": "5.9.3",
"@types/three": "^0.184.0"
"@types/three": "^0.184.0",
"typescript": "6.0.2"
},
"keywords": [
"3d",
+10 -7
View File
@@ -29,12 +29,13 @@ export interface GridEvent {
/** World-space intersection point on the grid plane. */
position: [number, number, number]
/**
* Building-local intersection point, relative to the currently selected building.
* Building-local intersection point relative to the currently selected building.
* Equals `position` when no building is selected.
* Use this for placing or committing anything that lives inside a building
* (walls, slabs, items, etc.).
* Use this for placing/committing anything that lives inside a building (walls, slabs, items, etc.).
*/
localPosition: [number, number, number]
faceIndex?: number
object: Object3D
nativeEvent: ThreeEvent<PointerEvent>
}
@@ -68,7 +69,7 @@ export type WindowEvent = NodeEvent<WindowNode>
export type DoorEvent = NodeEvent<DoorNode>
export type ElevatorEvent = NodeEvent<ElevatorNode>
// Event suffixes, exported for use in hooks
// Event suffixes - exported for use in hooks
export const eventSuffixes = [
'click',
'move',
@@ -101,7 +102,7 @@ export interface ThumbnailGenerateEvent {
/**
* When true, snap levels to their true positions before capturing (for a
* consistent auto-thumbnail angle) and defer the capture if the tab is
* hidden, the background auto-save path. Omit for user-driven captures
* hidden the background auto-save path. Omit for user-driven captures
* that should fire immediately from the current camera pose.
*/
snapLevels?: boolean
@@ -109,8 +110,10 @@ export interface ThumbnailGenerateEvent {
export interface CameraControlFitSceneEvent {
/**
* XZ-plane axis-aligned bounds for camera framing. Omitted values let the
* listener choose its default framing pose.
* XZ-plane axis-aligned bounds of the scene's geometry, computed from the
* scene graph (see `@pascal-app/editor`'s `computeSceneBoundsXZ`). The
* viewer's camera-controls listener frames the camera onto this box.
* Omitted values fall back to the camera's default pose.
*/
bounds?: {
min: [number, number]
+11
View File
@@ -46,6 +46,8 @@ export { getRenderableSlabPolygon } from './lib/slab-polygon'
export {
detectSpacesForLevel,
initSpaceDetectionSync,
planAutoSlabsForLevel,
type AutoSlabSyncPlan,
type Space,
wallTouchesOthers,
} from './lib/space-detection'
@@ -122,6 +124,15 @@ export {
type WallMiterBoundaryPoints,
type WallMiterData,
} from './systems/wall/wall-mitering'
export {
constrainWallMoveDeltaToAxis,
getPerpendicularWallMoveAxis,
planWallMoveJunctions,
type WallMoveBridgePlan,
type WallMoveAxis,
type WallMoveJunctionPlan,
type WallPlanPoint,
} from './systems/wall/wall-move'
export type { SceneGraph } from './utils/clone-scene-graph'
export { cloneLevelSubtree, cloneSceneGraph, forkSceneGraph } from './utils/clone-scene-graph'
export { isObject } from './utils/types'
+5 -5
View File
@@ -6,10 +6,7 @@ export function insetPolygonFromCentroid(
return polygon.map(([x, z]) => [x, z] as [number, number])
}
const centroid = polygon.reduce(
(acc, [x, z]) => ({ x: acc.x + x, z: acc.z + z }),
{ x: 0, z: 0 },
)
const centroid = polygon.reduce((acc, [x, z]) => ({ x: acc.x + x, z: acc.z + z }), { x: 0, z: 0 })
centroid.x /= Math.max(polygon.length, 1)
centroid.z /= Math.max(polygon.length, 1)
@@ -70,7 +67,10 @@ function dedupePolygonPoints(
return deduped
}
function simplifyPolyline(points: Array<[number, number]>, tolerance: number): Array<[number, number]> {
function simplifyPolyline(
points: Array<[number, number]>,
tolerance: number,
): Array<[number, number]> {
if (points.length <= 2) {
return points.map(([x, z]) => [x, z] as [number, number])
}
+56 -26
View File
@@ -1,14 +1,20 @@
import {
getClampedWallCurveOffset,
getWallCurveFrameAt,
isCurvedWall,
} from '../systems/wall/wall-curve'
import { CeilingNode, SlabNode, type CeilingNode as CeilingNodeType, type SlabNode as SlabNodeType, type WallNode } from '../schema'
CeilingNode,
type CeilingNode as CeilingNodeType,
SlabNode,
type SlabNode as SlabNodeType,
type WallNode,
} from '../schema'
import {
getSceneHistoryPauseDepth,
pauseSceneHistory,
resumeSceneHistory,
} from '../store/history-control'
import {
getClampedWallCurveOffset,
getWallCurveFrameAt,
isCurvedWall,
} from '../systems/wall/wall-curve'
import { simplifyClosedPolygon } from './polygon-geometry'
type Point2D = { x: number; y: number }
@@ -35,6 +41,12 @@ type DetectedRoom = {
bbox: ReturnType<typeof bboxOf>
}
export type AutoSlabSyncPlan = {
create: SlabNodeType[]
update: Array<{ id: SlabNodeType['id']; data: Partial<SlabNodeType> }>
delete: Array<SlabNodeType['id']>
}
const DEFAULT_AUTO_SLAB_ELEVATION = 0.05
const DEFAULT_AUTO_CEILING_HEIGHT = 2.5
const ROOM_CURVE_TOLERANCE = 0.04
@@ -147,10 +159,10 @@ function polygonCentroid(points: Point2D[]) {
}
function bboxOf(points: Point2D[]) {
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
let minX = Number.POSITIVE_INFINITY
let minY = Number.POSITIVE_INFINITY
let maxX = Number.NEGATIVE_INFINITY
let maxY = Number.NEGATIVE_INFINITY
for (const point of points) {
minX = Math.min(minX, point.x)
@@ -216,7 +228,13 @@ function sampleWallPointsForRoomDetection(
return [start, end]
}
const subdivide = (t0: number, p0: Point2D, t1: number, p1: Point2D, depth: number): Point2D[] => {
const subdivide = (
t0: number,
p0: Point2D,
t1: number,
p1: Point2D,
depth: number,
): Point2D[] => {
const midT = (t0 + t1) / 2
const midPoint = getWallCurveFrameAt(wall, midT).point
const deviation = pointLineDistance(midPoint, p0, p1)
@@ -361,7 +379,7 @@ function extractRoomPolygons(walls: WallNode[]): Point2D[][] {
const signedArea = polygonArea(polygon)
if (signedArea <= 0) continue
if (signedArea < 0.5 || signedArea > 10000) continue
if (signedArea < 0.5 || signedArea > 10_000) continue
const signature = polygonSignature(polygon)
if (faces.some((face) => polygonSignature(face) === signature)) continue
@@ -442,10 +460,7 @@ function nextAutoRoomName(
return `Room ${maxIndex + 1} ${suffix}`
}
function sameTuplePolygon(
current: Array<[number, number]>,
next: Array<[number, number]>,
) {
function sameTuplePolygon(current: Array<[number, number]>, next: Array<[number, number]>) {
return (
current.length === next.length &&
current.every((point, index) => point[0] === next[index]?.[0] && point[1] === next[index]?.[1])
@@ -479,12 +494,10 @@ function buildSpace(levelId: string, polygon: Point2D[]): Space {
}
}
function syncAutoSlabsForLevel(
levelId: string,
export function planAutoSlabsForLevel(
roomPolygons: Point2D[][],
existingSlabs: SlabNodeType[],
sceneStore: any,
) {
): AutoSlabSyncPlan {
const manualSlabs = existingSlabs.filter((slab) => !slab.autoFromWalls)
const manualSignatures = new Set(
manualSlabs.map((slab) => polygonSignature(slab.polygon.map(pointFromTuple))),
@@ -609,16 +622,31 @@ function syncAutoSlabsForLevel(
)
}
if (slabsToDelete.length > 0) {
sceneStore.getState().deleteNodes(slabsToDelete)
return {
create: slabsToCreate,
update: slabsToUpdate,
delete: slabsToDelete,
}
}
function syncAutoSlabsForLevel(
levelId: string,
roomPolygons: Point2D[][],
existingSlabs: SlabNodeType[],
sceneStore: any,
) {
const plan = planAutoSlabsForLevel(roomPolygons, existingSlabs)
if (plan.delete.length > 0) {
sceneStore.getState().deleteNodes(plan.delete)
}
if (slabsToUpdate.length > 0) {
sceneStore.getState().updateNodes(slabsToUpdate)
if (plan.update.length > 0) {
sceneStore.getState().updateNodes(plan.update)
}
if (slabsToCreate.length > 0) {
sceneStore.getState().createNodes(slabsToCreate.map((node) => ({ node, parentId: levelId })))
if (plan.create.length > 0) {
sceneStore.getState().createNodes(plan.create.map((node) => ({ node, parentId: levelId })))
}
}
@@ -727,7 +755,9 @@ function syncAutoCeilingsForLevel(
const polygon = updatesById.get(ceiling.id)
if (!polygon) return []
return sameTuplePolygon(ceiling.polygon, polygon) ? [] : [{ id: ceiling.id, data: { polygon } }]
return sameTuplePolygon(ceiling.polygon, polygon)
? []
: [{ id: ceiling.id, data: { polygon } }]
})
const plannedCeilingsForNaming: Array<{ name?: string }> = [...existingCeilings]
+28
View File
@@ -1,5 +1,7 @@
import {
type MaterialPresetPayload,
type MaterialTarget,
MaterialTarget as MaterialTargetSchema,
} from './schema/material'
export type MaterialCatalogItem = {
@@ -12,6 +14,32 @@ export type MaterialCatalogItem = {
preset: MaterialPresetPayload
}
const WALL_TARGETS: MaterialTarget[] = [MaterialTargetSchema.enum.wall]
const SLAB_TARGETS: MaterialTarget[] = [MaterialTargetSchema.enum.slab]
const WALL_AND_SLAB_TARGETS: MaterialTarget[] = [
MaterialTargetSchema.enum.wall,
MaterialTargetSchema.enum.slab,
]
const STAIR_TARGETS: MaterialTarget[] = [
MaterialTargetSchema.enum.stair,
MaterialTargetSchema.enum['stair-segment'],
]
const STAIR_AND_FENCE_TARGETS: MaterialTarget[] = [
...STAIR_TARGETS,
MaterialTargetSchema.enum.fence,
]
const ROOF_TARGETS: MaterialTarget[] = [
MaterialTargetSchema.enum.roof,
MaterialTargetSchema.enum['roof-segment'],
]
const CEILING_TARGETS: MaterialTarget[] = [MaterialTargetSchema.enum.ceiling]
export const MATERIAL_CATEGORIES = [
'wood',
'wallpaper',
@@ -1,7 +1,3 @@
// @ts-expect-error — bun:test is provided by the Bun runtime; core does not
// depend on @types/bun so the import type is unresolved at compile time.
// The tsconfig in packages/core still emits this file; the @ts-expect-error
// keeps the build green while letting `bun test` pick it up normally.
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import { ALLOWED_ORIGINS_ENV, AssetUrl } from './asset-url'
+2 -2
View File
@@ -13,9 +13,9 @@ export const FenceNode = BaseNode.extend({
materialPreset: z.string().optional(),
start: z.tuple([z.number(), z.number()]),
end: z.tuple([z.number(), z.number()]),
curveOffset: z.number().optional(),
height: z.number().default(1.8),
thickness: z.number().default(0.08),
curveOffset: z.number().optional(),
baseHeight: z.number().default(0.22),
postSpacing: z.number().default(2),
postSize: z.number().default(0.1),
@@ -30,8 +30,8 @@ export const FenceNode = BaseNode.extend({
dedent`
Fence node - used to represent a fence segment in the building/site level coordinate system
- start/end: fence endpoints in level coordinate system
- curveOffset: midpoint sagitta offset used to bend the fence into an arc
- height/thickness: overall fence dimensions in meters
- curveOffset: midpoint sagitta offset used to bend the fence into an arc
- baseHeight/postSpacing/postSize/topRailHeight: exact geometric controls from the plan3D fence model
- groundClearance/edgeInset/baseStyle: fence support and inset configuration
- showInfill: whether to draw intermediate posts/slats between end posts
+12
View File
@@ -83,6 +83,18 @@ const assetSchema = z.object({
// Optional top-down 2D image shown inside the item's footprint on the
// floor plan. When present, replaces the default diagonal-cross marker.
floorPlanUrl: z.string().optional(),
// Where the item came from in the catalog. Used by the editor's items
// panel to filter Library / Community / Mine. The server populates it
// from `items.userId`: null → 'library', current user → 'mine',
// other user → 'community'. Defaults to 'library' when absent (e.g.
// the seeded built-in catalog).
source: z.enum(['library', 'community', 'mine']).default('library'),
// True when the item belongs to the caller and is still in draft status.
// The catalog only loads my drafts (other users' drafts are never
// published to the catalog). Used so the Community filter can include
// *my* published items alongside other users', while leaving drafts
// visible only under Mine.
isDraft: z.boolean().optional(),
src: AssetUrl,
dimensions: z.tuple([z.number(), z.number(), z.number()]).default([1, 1, 1]), // [w, h, d]
attachTo: z.enum(['wall', 'wall-side', 'ceiling']).optional(),
+9 -7
View File
@@ -1,25 +1,26 @@
import dedent from 'dedent'
import { z } from 'zod'
import { BaseNode, nodeType, objectId } from '../base'
import { type MaterialSchema, MaterialSchema as MaterialSchemaSchema } from '../material'
import type { MaterialSchema as MaterialSchemaType } from '../material'
import { MaterialSchema } from '../material'
import { RoofSegmentNode } from './roof-segment'
export type RoofSurfaceMaterialRole = 'top' | 'edge' | 'wall'
export type RoofSurfaceMaterialSpec = {
material?: MaterialSchema
material?: MaterialSchemaType
materialPreset?: string
}
export const RoofNode = BaseNode.extend({
id: objectId('roof'),
type: nodeType('roof'),
material: MaterialSchemaSchema.optional(),
material: MaterialSchema.optional(),
materialPreset: z.string().optional(),
topMaterial: MaterialSchemaSchema.optional(),
topMaterial: MaterialSchema.optional(),
topMaterialPreset: z.string().optional(),
edgeMaterial: MaterialSchemaSchema.optional(),
edgeMaterial: MaterialSchema.optional(),
edgeMaterialPreset: z.string().optional(),
wallMaterial: MaterialSchemaSchema.optional(),
wallMaterial: MaterialSchema.optional(),
wallMaterialPreset: z.string().optional(),
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
// Rotation around Y axis in radians
@@ -54,7 +55,8 @@ export function getEffectiveRoofSurfaceMaterial(
if (node.topMaterial !== undefined || typeof node.topMaterialPreset === 'string') {
return {
material: node.topMaterial,
materialPreset: typeof node.topMaterialPreset === 'string' ? node.topMaterialPreset : undefined,
materialPreset:
typeof node.topMaterialPreset === 'string' ? node.topMaterialPreset : undefined,
}
}
}
+19 -10
View File
@@ -1,7 +1,8 @@
import dedent from 'dedent'
import { z } from 'zod'
import { BaseNode, nodeType, objectId } from '../base'
import { type MaterialSchema, MaterialSchema as MaterialSchemaSchema } from '../material'
import type { MaterialSchema as MaterialSchemaType } from '../material'
import { MaterialSchema } from '../material'
import { StairSegmentNode } from './stair-segment'
export const StairRailingMode = z.enum(['none', 'left', 'right', 'both'])
@@ -15,20 +16,20 @@ export type StairTopLandingMode = z.infer<typeof StairTopLandingMode>
export type StairSlabOpeningMode = z.infer<typeof StairSlabOpeningMode>
export type StairSurfaceMaterialRole = 'railing' | 'tread' | 'side'
export type StairSurfaceMaterialSpec = {
material?: MaterialSchema
material?: MaterialSchemaType
materialPreset?: string
}
export const StairNode = BaseNode.extend({
id: objectId('stair'),
type: nodeType('stair'),
material: MaterialSchemaSchema.optional(),
material: MaterialSchema.optional(),
materialPreset: z.string().optional(),
railingMaterial: MaterialSchemaSchema.optional(),
railingMaterial: MaterialSchema.optional(),
railingMaterialPreset: z.string().optional(),
treadMaterial: MaterialSchemaSchema.optional(),
treadMaterial: MaterialSchema.optional(),
treadMaterialPreset: z.string().optional(),
sideMaterial: MaterialSchemaSchema.optional(),
sideMaterial: MaterialSchema.optional(),
sideMaterialPreset: z.string().optional(),
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
// Rotation around Y axis in radians
@@ -126,18 +127,26 @@ export function getEffectiveStairSurfaceMaterial(
const treadFallback = {
material: node.treadMaterial,
materialPreset: typeof node.treadMaterialPreset === 'string' ? node.treadMaterialPreset : undefined,
materialPreset:
typeof node.treadMaterialPreset === 'string' ? node.treadMaterialPreset : undefined,
}
const sideFallback = {
material: node.sideMaterial,
materialPreset: typeof node.sideMaterialPreset === 'string' ? node.sideMaterialPreset : undefined,
materialPreset:
typeof node.sideMaterialPreset === 'string' ? node.sideMaterialPreset : undefined,
}
if (role === 'tread' && (sideFallback.material !== undefined || sideFallback.materialPreset !== undefined)) {
if (
role === 'tread' &&
(sideFallback.material !== undefined || sideFallback.materialPreset !== undefined)
) {
return sideFallback
}
if (role === 'side' && (treadFallback.material !== undefined || treadFallback.materialPreset !== undefined)) {
if (
role === 'side' &&
(treadFallback.material !== undefined || treadFallback.materialPreset !== undefined)
) {
return treadFallback
}
+149 -6
View File
@@ -9,6 +9,9 @@ import type { CollectionId } from '../../schema/collections'
import type { SceneState } from '../use-scene'
type AnyContainerNode = AnyNode & { children: string[] }
type NodeCreateOp = { node: AnyNode; parentId?: AnyNodeId }
type NodeUpdateOp = { id: AnyNodeId; data: Partial<AnyNode> }
type NodeDeleteOp = AnyNodeId
type WallAttachmentUpdate = { id: AnyNodeId; data: Partial<AnyNode> }
type WallMergePlan = {
primaryWallId: AnyNodeId
@@ -122,7 +125,7 @@ function buildMergedWallAttachmentUpdates(
const wallChildren = [...(primary.children ?? []), ...(secondary.children ?? [])] as AnyNodeId[]
for (const childId of wallChildren) {
const child = nodes[childId]
if (!child || !('position' in child) || !Array.isArray(child.position)) {
if (!(child && 'position' in child && Array.isArray(child.position))) {
continue
}
@@ -190,10 +193,12 @@ function buildWallMergePlans(
})
const [primary, secondary] = sortedCandidates
if (
!primary ||
!secondary ||
!areWallStylesCompatible(primary, secondary) ||
!areWallsCollinearAcrossPoint(primary, secondary, junction)
!(
primary &&
secondary &&
areWallStylesCompatible(primary, secondary) &&
areWallsCollinearAcrossPoint(primary, secondary, junction)
)
) {
continue
}
@@ -230,7 +235,7 @@ function buildWallMergePlans(
export const createNodesAction = (
set: (fn: (state: SceneState) => Partial<SceneState>) => void,
get: () => SceneState,
ops: { node: AnyNode; parentId?: AnyNodeId }[],
ops: NodeCreateOp[],
) => {
if (get().readOnly) return
set((state) => {
@@ -275,6 +280,144 @@ export const createNodesAction = (
ops.forEach(({ node, parentId }) => {
get().markDirty(node.id)
if (parentId) get().markDirty(parentId)
else if (node.parentId) get().markDirty(node.parentId as AnyNodeId)
})
}
export const applyNodeChangesAction = (
set: (fn: (state: SceneState) => Partial<SceneState>) => void,
get: () => SceneState,
changes: { create?: NodeCreateOp[]; update?: NodeUpdateOp[]; delete?: NodeDeleteOp[] },
) => {
if (get().readOnly) return
const createOps = changes.create ?? []
const updateOps = changes.update ?? []
const deleteOps = changes.delete ?? []
const nodesToMarkDirty = new Set<AnyNodeId>()
const parentsToMarkDirty = new Set<AnyNodeId>()
set((state) => {
const nextNodes = { ...state.nodes }
const nextCollections = { ...state.collections }
const nextRootIds = [...state.rootNodeIds]
let resolvedRootIds = nextRootIds
for (const { id, data } of updateOps) {
const currentNode = nextNodes[id]
if (!currentNode) continue
if (data.parentId !== undefined && data.parentId !== currentNode.parentId) {
const oldParentId = currentNode.parentId as AnyNodeId | null
if (oldParentId && nextNodes[oldParentId]) {
const oldParent = nextNodes[oldParentId] as AnyContainerNode
nextNodes[oldParent.id] = {
...oldParent,
children: oldParent.children.filter((childId) => childId !== id),
} as AnyNode
parentsToMarkDirty.add(oldParent.id)
}
const newParentId = data.parentId as AnyNodeId | null
if (newParentId && nextNodes[newParentId]) {
const newParent = nextNodes[newParentId] as AnyContainerNode
nextNodes[newParent.id] = {
...newParent,
children: Array.from(new Set([...newParent.children, id])),
} as AnyNode
parentsToMarkDirty.add(newParent.id)
}
}
nextNodes[id] = { ...currentNode, ...data } as AnyNode
nodesToMarkDirty.add(id)
}
for (const { node, parentId } of createOps) {
const effectiveParentId = parentId ?? (node.parentId as AnyNodeId | null) ?? null
const newNode = {
...node,
parentId: effectiveParentId,
} as AnyNode
nextNodes[newNode.id as AnyNodeId] = newNode
nodesToMarkDirty.add(newNode.id as AnyNodeId)
if (effectiveParentId && nextNodes[effectiveParentId]) {
const parent = nextNodes[effectiveParentId]
if ('children' in parent && Array.isArray(parent.children)) {
nextNodes[effectiveParentId] = {
...parent,
children: Array.from(new Set([...parent.children, newNode.id])) as any,
}
parentsToMarkDirty.add(effectiveParentId)
}
} else if (!effectiveParentId && !nextRootIds.includes(newNode.id as AnyNodeId)) {
nextRootIds.push(newNode.id as AnyNodeId)
}
}
const allIdsToDelete = new Set<AnyNodeId>()
const collectDelete = (id: AnyNodeId) => {
if (allIdsToDelete.has(id)) return
allIdsToDelete.add(id)
const node = nextNodes[id]
if (node && 'children' in node && Array.isArray(node.children)) {
for (const childId of node.children) {
collectDelete(childId as AnyNodeId)
}
}
}
for (const id of deleteOps) {
collectDelete(id)
}
for (const id of allIdsToDelete) {
const node = nextNodes[id]
if (!node) continue
const parentId = node.parentId as AnyNodeId | null
if (parentId && nextNodes[parentId] && !allIdsToDelete.has(parentId)) {
const parent = nextNodes[parentId] as AnyContainerNode
if (parent.children) {
nextNodes[parent.id] = {
...parent,
children: parent.children.filter((childId) => childId !== id),
} as AnyNode
parentsToMarkDirty.add(parent.id)
}
}
resolvedRootIds = resolvedRootIds.filter((rootId) => rootId !== id)
if ('collectionIds' in node && node.collectionIds) {
for (const collectionId of node.collectionIds as CollectionId[]) {
const collection = nextCollections[collectionId]
if (collection) {
nextCollections[collectionId] = {
...collection,
nodeIds: collection.nodeIds.filter((nodeId) => nodeId !== id),
}
}
}
}
delete nextNodes[id]
}
return { nodes: nextNodes, rootNodeIds: resolvedRootIds, collections: nextCollections }
})
nodesToMarkDirty.forEach((id) => get().markDirty(id))
parentsToMarkDirty.forEach((id) => {
get().markDirty(id)
const parent = get().nodes[id]
if (parent && 'children' in parent && Array.isArray(parent.children)) {
for (const childId of parent.children) {
get().markDirty(childId as AnyNodeId)
}
}
})
}
+18 -5
View File
@@ -111,7 +111,7 @@ function migrateWallSurfaceMaterials(node: Record<string, any>) {
materialPreset: typeof node.materialPreset === 'string' ? node.materialPreset : undefined,
}
if (!hasInterior && !hasExterior) {
if (!(hasInterior || hasExterior)) {
if (legacyFinish.material === undefined && legacyFinish.materialPreset === undefined) {
return node
}
@@ -174,7 +174,7 @@ function migrateStairSurfaceMaterials(node: Record<string, any>) {
return legacyFinish
}
if (!hasRailing && !hasTread && !hasSide) {
if (!(hasRailing || hasTread || hasSide)) {
if (legacyFinish.material === undefined && legacyFinish.materialPreset === undefined) {
return node
}
@@ -236,7 +236,7 @@ function migrateRoofSurfaceMaterials(node: Record<string, any>) {
materialPreset: typeof node.materialPreset === 'string' ? node.materialPreset : undefined,
}
if (!hasTop && !hasEdge && !hasWall) {
if (!(hasTop || hasEdge || hasWall)) {
if (legacyFinish.material === undefined && legacyFinish.materialPreset === undefined) {
return node
}
@@ -350,7 +350,7 @@ function migrateNodes(nodes: Record<string, any>): Record<string, AnyNode> {
}
function getNodeChildIds(node: AnyNode): AnyNodeId[] {
if (!('children' in node) || !Array.isArray(node.children)) {
if (!('children' in node && Array.isArray(node.children))) {
return []
}
@@ -438,6 +438,11 @@ export type SceneState = {
createNode: (node: AnyNode, parentId?: AnyNodeId) => void
createNodes: (ops: { node: AnyNode; parentId?: AnyNodeId }[]) => void
applyNodeChanges: (changes: {
create?: { node: AnyNode; parentId?: AnyNodeId }[]
update?: { id: AnyNodeId; data: Partial<AnyNode> }[]
delete?: AnyNodeId[]
}) => void
updateNode: (id: AnyNodeId, data: Partial<AnyNode>) => void
updateNodes: (updates: { id: AnyNodeId; data: Partial<AnyNode> }[]) => void
@@ -511,6 +516,13 @@ const useScene: UseSceneStore = create<SceneState>()(
}
}
set({
nodes: cleanedNodes,
rootNodeIds,
dirtyNodes: new Set<AnyNodeId>(),
collections: {},
})
const normalizedRootNodeIds = normalizeRootNodeIds(cleanedNodes, rootNodeIds)
const reachableNodeIds = collectReachableNodeIds(cleanedNodes, normalizedRootNodeIds)
if (normalizedRootNodeIds.length > 0) {
@@ -579,6 +591,7 @@ const useScene: UseSceneStore = create<SceneState>()(
createNodes: (ops) => nodeActions.createNodesAction(set, get, ops),
createNode: (node, parentId) => nodeActions.createNodesAction(set, get, [{ node, parentId }]),
applyNodeChanges: (changes) => nodeActions.applyNodeChangesAction(set, get, changes),
updateNodes: (updates) => nodeActions.updateNodesAction(set, get, updates),
updateNode: (id, data) => nodeActions.updateNodesAction(set, get, [{ id, data }]),
@@ -701,8 +714,8 @@ let prevFutureLength = 0
let prevNodesSnapshot: Record<AnyNodeId, AnyNode> | null = null
export function clearSceneHistory() {
useScene.temporal.getState().clear()
resetSceneHistoryPauseDepth()
useScene.temporal.getState().clear()
prevPastLength = 0
prevFutureLength = 0
prevNodesSnapshot = null
@@ -1,5 +1,3 @@
// @ts-expect-error — bun:test is provided by the Bun runtime; core does not
// depend on @types/bun so the import type is unresolved at compile time.
import { describe, expect, test } from 'bun:test'
import type { AnyNode } from '../../schema'
import { BuildingNode, CeilingNode, ElevatorNode, LevelNode, SlabNode } from '../../schema'
@@ -1,5 +1,3 @@
// @ts-expect-error — bun:test is provided by the Bun runtime; core does not
// depend on @types/bun so the import type is unresolved at compile time.
import { describe, expect, test } from 'bun:test'
import type { AnyNode } from '../../schema'
import {
@@ -35,10 +35,9 @@ type AxisAlignedRect = {
maxZ: number
}
const CURVED_STAIR_SLAB_OPENING_RATIO = 0.9
const CURVED_STAIR_SLAB_OPENING_RATIO = 0.8
const STRAIGHT_STAIR_TARGET_THRESHOLD_MIN = 0.35
const STAIR_SLAB_OPENING_TIGHTENING = 0
const CURVED_STAIR_OPENING_STEP_PADDING = 3
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value))
@@ -280,7 +279,7 @@ function polygonArea(points: Point2D[]) {
for (let index = 0; index < points.length; index += 1) {
const current = points[index]
const next = points[(index + 1) % points.length]
if (!current || !next) continue
if (!(current && next)) continue
area += current[0] * next[1] - next[0] * current[1]
}
return area / 2
@@ -430,39 +429,24 @@ function buildUnionPolygonsFromRects(rects: AxisAlignedRect[]): Point2D[][] {
return polygons
}
function getCurvedOpeningStepCount(
stair: StairNode,
innerRadius: number,
outerRadius: number,
totalSweep: number,
) {
const stepCount = Math.max(2, Math.round(stair.stepCount ?? 10))
const stepSweep = Math.abs(totalSweep) / stepCount
const midRadius = Math.max((innerRadius + outerRadius) * 0.5, 0.01)
const treadDepth = Math.max(stepSweep * midRadius, 0.2)
return Math.min(
stepCount,
function getCurvedOpeningPolygon(stair: StairNode): Point2D[] {
const width = Math.max(stair.width ?? 1, 0.4)
const innerRadius = Math.max(0.2, stair.innerRadius ?? 0.9)
const outerRadius = innerRadius + width
const totalSweep = stair.sweepAngle ?? Math.PI / 2
const openingSweep =
Math.sign(totalSweep || 1) *
Math.max(
1,
Math.ceil(1.8 / treadDepth),
Math.ceil(stepCount * CURVED_STAIR_SLAB_OPENING_RATIO),
),
)
}
function buildArcOpeningPolygon(
stair: StairNode,
innerRadius: number,
outerRadius: number,
startAngle: number,
endAngle: number,
): Point2D[] {
const sweep = endAngle - startAngle
Math.abs(totalSweep) * CURVED_STAIR_SLAB_OPENING_RATIO,
Math.abs(totalSweep) / Math.max(stair.stepCount ?? 1, 1),
)
const startAngle = totalSweep / 2 - openingSweep
const endAngle = totalSweep / 2
const segmentCount = Math.max(
10,
Math.min(
32,
Math.ceil(Math.abs(sweep) / (Math.PI / 24) + Math.max(stair.stepCount ?? 1, 1) * 0.5),
Math.ceil(Math.abs(openingSweep) / (Math.PI / 24) + Math.max(stair.stepCount ?? 1, 1) * 0.5),
),
)
const outerPoints: Point2D[] = []
@@ -470,7 +454,7 @@ function buildArcOpeningPolygon(
for (let index = 0; index <= segmentCount; index++) {
const t = index / segmentCount
const angle = startAngle + sweep * t
const angle = startAngle + (endAngle - startAngle) * t
outerPoints.push(
toWorldPlanPoint(stair, Math.cos(angle) * outerRadius, Math.sin(angle) * outerRadius),
)
@@ -478,8 +462,7 @@ function buildArcOpeningPolygon(
for (let index = segmentCount; index >= 0; index--) {
const t = index / segmentCount
const angle = startAngle + sweep * t
const angle = startAngle + (endAngle - startAngle) * t
innerPoints.push(
toWorldPlanPoint(stair, Math.cos(angle) * innerRadius, Math.sin(angle) * innerRadius),
)
@@ -488,39 +471,6 @@ function buildArcOpeningPolygon(
return [...outerPoints, ...innerPoints]
}
function getCurvedOpeningPolygon(stair: StairNode, targetElevation?: number): Point2D[] {
const width = Math.max(stair.width ?? 1, 0.4)
const innerRadius = Math.max(0.2, stair.innerRadius ?? 0.9)
const outerRadius = innerRadius + width
const totalSweep = stair.sweepAngle ?? Math.PI / 2
const stepCount = Math.max(2, Math.round(stair.stepCount ?? 10))
const stepHeight = Math.max(stair.totalRise ?? 2.5, 0.1) / stepCount
const stepSweep = totalSweep / stepCount
const targetThreshold = Math.max(stepHeight * 2, STRAIGHT_STAIR_TARGET_THRESHOLD_MIN)
const endAngle = totalSweep / 2
const fallbackStartStepIndex = Math.max(
0,
stepCount - getCurvedOpeningStepCount(stair, innerRadius, outerRadius, totalSweep),
)
let startStepIndex = fallbackStartStepIndex
if (typeof targetElevation === 'number') {
for (let index = 0; index < stepCount; index += 1) {
const stepTopElevation = stepHeight * (index + 1)
if (stepTopElevation >= targetElevation - targetThreshold) {
startStepIndex = Math.max(
0,
Math.min(fallbackStartStepIndex, index - CURVED_STAIR_OPENING_STEP_PADDING),
)
break
}
}
}
const startAngle = -totalSweep / 2 + stepSweep * startStepIndex
return buildArcOpeningPolygon(stair, innerRadius, outerRadius, startAngle, endAngle)
}
function getSpiralOpeningPolygon(stair: StairNode): Point2D[] {
const radius = Math.max(0.05, stair.innerRadius ?? 0.9) + Math.max(stair.width ?? 1, 0.4)
const segmentCount = 48
@@ -625,7 +575,7 @@ function getStairOpeningPolygons(
}
if (stair.stairType === 'curved') {
return [getCurvedOpeningPolygon(stair, targetElevation)]
return [getCurvedOpeningPolygon(stair)]
}
if (stair.stairType === 'spiral') {
@@ -784,8 +734,7 @@ export function syncAutoStairOpenings(nodes: Record<string, AnyNode>) {
]
if (
!polygonsEqual(existingHoles, nextHoles) ||
!metadataEqual(existingMetadata, nextMetadata)
!(polygonsEqual(existingHoles, nextHoles) && metadataEqual(existingMetadata, nextMetadata))
) {
updates.push({
id: slab.id,
@@ -840,8 +789,7 @@ export function syncAutoStairOpenings(nodes: Record<string, AnyNode>) {
]
if (
!polygonsEqual(existingHoles, nextHoles) ||
!metadataEqual(existingMetadata, nextMetadata)
!(polygonsEqual(existingHoles, nextHoles) && metadataEqual(existingMetadata, nextMetadata))
) {
updates.push({
id: ceiling.id,
+6 -3
View File
@@ -1,5 +1,5 @@
import type { Point2D } from './wall-mitering'
import type { FenceNode, WallNode } from '../../schema'
import type { Point2D } from './wall-mitering'
const CURVE_EPSILON = 1e-6
const DEFAULT_SAMPLE_SEGMENTS = 24
@@ -115,7 +115,7 @@ function getWallArcData(wall: WallCurveLike) {
}
const absSagitta = Math.abs(sagitta)
const radius = chord.length * chord.length / (8 * absSagitta) + absSagitta / 2
const radius = (chord.length * chord.length) / (8 * absSagitta) + absSagitta / 2
const centerOffset = radius - absSagitta
const direction = Math.sign(sagitta) || 1
const center = {
@@ -183,7 +183,10 @@ export function getWallMidpointHandlePoint(wall: WallCurveLike) {
export function sampleWallCenterline(wall: WallCurveLike, segments = DEFAULT_SAMPLE_SEGMENTS) {
const count = Math.max(1, segments)
return Array.from({ length: count + 1 }, (_, index) => getWallCurveFrameAt(wall, index / count).point)
return Array.from(
{ length: count + 1 },
(_, index) => getWallCurveFrameAt(wall, index / count).point,
)
}
export function getWallCurveLength(wall: WallCurveLike, segments = DEFAULT_SAMPLE_SEGMENTS) {
@@ -135,10 +135,7 @@ function findJunctions(walls: WallNode[]): Map<string, Junction> {
return actualJunctions
}
function getWallDirectionFromJunction(
wall: WallNode,
endType: 'start' | 'end' | 'passthrough',
) {
function getWallDirectionFromJunction(wall: WallNode, endType: 'start' | 'end' | 'passthrough') {
if (endType === 'passthrough') {
return {
x: wall.end[0] - wall.start[0],
@@ -148,9 +145,7 @@ function getWallDirectionFromJunction(
if (isCurvedWall(wall)) {
const frame = getWallCurveFrameAt(wall, endType === 'start' ? 0 : 1)
return endType === 'start'
? frame.tangent
: { x: -frame.tangent.x, y: -frame.tangent.y }
return endType === 'start' ? frame.tangent : { x: -frame.tangent.x, y: -frame.tangent.y }
}
return endType === 'start'
@@ -158,18 +153,12 @@ function getWallDirectionFromJunction(
: { x: wall.start[0] - wall.end[0], y: wall.start[1] - wall.end[1] }
}
function getWallBoundaryFrame(
wall: WallNode,
endType: 'start' | 'end',
) {
function getWallBoundaryFrame(wall: WallNode, endType: 'start' | 'end') {
if (isCurvedWall(wall)) {
const frame = getWallCurveFrameAt(wall, endType === 'start' ? 0 : 1)
return {
point: frame.point,
tangent:
endType === 'start'
? frame.tangent
: { x: -frame.tangent.x, y: -frame.tangent.y },
tangent: endType === 'start' ? frame.tangent : { x: -frame.tangent.x, y: -frame.tangent.y },
normal: frame.normal,
}
}
+227
View File
@@ -0,0 +1,227 @@
import type { WallNode } from '../../schema'
const AXIS_EPSILON = 1e-6
export type WallPlanPoint = [number, number]
export type WallMoveAxis = 'x' | 'z'
export type WallMoveEndpoint = 'start' | 'end'
export type WallMoveBridgePlan<TWall extends Pick<WallNode, 'id' | 'start' | 'end'>> = {
wall: TWall
originalPoint: WallPlanPoint
movedEndpoint: WallMoveEndpoint
}
export type WallMoveLinkedWallTargetPlan<
TWall extends Pick<WallNode, 'id' | 'start' | 'end'>,
> = {
wall: TWall
originalPoint: WallPlanPoint
targetPoint: WallPlanPoint
}
export type WallMoveJunctionPlan<TWall extends Pick<WallNode, 'id' | 'start' | 'end'>> = {
linkedWallsToMove: TWall[]
linkedWallTargetPlans: Array<WallMoveLinkedWallTargetPlan<TWall>>
bridgePlans: Array<WallMoveBridgePlan<TWall>>
wallsToDelete: TWall[]
}
export function getPerpendicularWallMoveAxis(
start: WallPlanPoint,
end: WallPlanPoint,
): WallMoveAxis | null {
const wallDeltaX = Math.abs(end[0] - start[0])
const wallDeltaZ = Math.abs(end[1] - start[1])
if (wallDeltaX < AXIS_EPSILON && wallDeltaZ < AXIS_EPSILON) return null
return wallDeltaX >= wallDeltaZ ? 'z' : 'x'
}
export function constrainWallMoveDeltaToAxis(
deltaX: number,
deltaZ: number,
axis: WallMoveAxis | null,
): WallPlanPoint {
if (axis === 'x') return [deltaX, 0]
if (axis === 'z') return [0, deltaZ]
return [deltaX, deltaZ]
}
function pointsEqual(a: WallPlanPoint, b: WallPlanPoint) {
return Math.abs(a[0] - b[0]) <= AXIS_EPSILON && Math.abs(a[1] - b[1]) <= AXIS_EPSILON
}
function wallTouchesPoint(wall: Pick<WallNode, 'start' | 'end'>, point: WallPlanPoint) {
return pointsEqual(wall.start, point) || pointsEqual(wall.end, point)
}
function otherWallEndpoint(wall: Pick<WallNode, 'start' | 'end'>, point: WallPlanPoint) {
return pointsEqual(wall.start, point) ? wall.end : wall.start
}
type MoveWallRelation = 'same-direction' | 'opposite-direction' | 'off-axis' | 'stationary'
type RelatedWallEntry<TWall extends Pick<WallNode, 'id' | 'start' | 'end'>> = {
wall: TWall
relation: MoveWallRelation
}
function wallLengthFromPoint(wall: Pick<WallNode, 'start' | 'end'>, point: WallPlanPoint) {
const freeEndpoint = otherWallEndpoint(wall, point)
return Math.hypot(freeEndpoint[0] - point[0], freeEndpoint[1] - point[1])
}
function getMoveWallRelation(
wall: Pick<WallNode, 'start' | 'end'>,
sharedPoint: WallPlanPoint,
nextPoint: WallPlanPoint,
): MoveWallRelation {
const moveX = nextPoint[0] - sharedPoint[0]
const moveZ = nextPoint[1] - sharedPoint[1]
const moveLength = Math.hypot(moveX, moveZ)
if (moveLength < AXIS_EPSILON) return 'stationary'
const freeEndpoint = otherWallEndpoint(wall, sharedPoint)
const wallX = freeEndpoint[0] - sharedPoint[0]
const wallZ = freeEndpoint[1] - sharedPoint[1]
const wallLength = Math.hypot(wallX, wallZ)
if (wallLength < AXIS_EPSILON) return 'stationary'
const normalizedCross = Math.abs(moveX * wallZ - moveZ * wallX) / (moveLength * wallLength)
if (normalizedCross > 1e-4) return 'off-axis'
const normalizedDot = (moveX * wallX + moveZ * wallZ) / (moveLength * wallLength)
return normalizedDot >= 0 ? 'same-direction' : 'opposite-direction'
}
export function planWallMoveJunctions<TWall extends Pick<WallNode, 'id' | 'start' | 'end'>>(
linkedWalls: TWall[],
originalStart: WallPlanPoint,
originalEnd: WallPlanPoint,
nextStart: WallPlanPoint,
nextEnd: WallPlanPoint,
): WallMoveJunctionPlan<TWall> {
const linkedWallsToMove = new Map<TWall['id'], TWall>()
const linkedWallTargetPlans = new Map<TWall['id'], WallMoveLinkedWallTargetPlan<TWall>>()
const bridgePlans = new Map<string, WallMoveBridgePlan<TWall>>()
const wallsToDelete = new Map<TWall['id'], TWall>()
const addStandardEndpointPlan = (
endpoint: WallMoveEndpoint,
point: WallPlanPoint,
nextPoint: WallPlanPoint,
relatedWalls: Array<RelatedWallEntry<TWall>>,
keySuffix = '',
useTargetPlans = false,
) => {
const hasSideBranch = relatedWalls.some((entry) => entry.relation === 'off-axis')
const hasOppositeBridge = relatedWalls.some(
(entry) => entry.relation === 'opposite-direction' && hasSideBranch,
)
for (const { wall, relation } of relatedWalls) {
if (
relation === 'stationary' ||
relation === 'same-direction' ||
(relation === 'opposite-direction' && !hasSideBranch)
) {
if (useTargetPlans) {
linkedWallTargetPlans.set(wall.id, {
wall,
originalPoint: point,
targetPoint: nextPoint,
})
} else {
linkedWallsToMove.set(wall.id, wall)
}
continue
}
if (relation === 'off-axis' && hasOppositeBridge) {
continue
}
bridgePlans.set(`${wall.id}:${endpoint}${keySuffix}`, {
wall,
originalPoint: point,
movedEndpoint: endpoint,
})
}
}
const addEndpointPlan = (
endpoint: WallMoveEndpoint,
point: WallPlanPoint,
nextPoint: WallPlanPoint,
) => {
const moveLength = Math.hypot(nextPoint[0] - point[0], nextPoint[1] - point[1])
const linkedAtEndpoint = linkedWalls
.filter((wall) => wallTouchesPoint(wall, point))
.map((wall) => ({
wall,
relation: getMoveWallRelation(wall, point, nextPoint),
}))
const consumedSameDirectionWall = linkedAtEndpoint
.filter((entry) => entry.relation === 'same-direction')
.map((entry) => ({
...entry,
distance: wallLengthFromPoint(entry.wall, point),
}))
.filter((entry) => moveLength + AXIS_EPSILON >= entry.distance)
.sort((a, b) => a.distance - b.distance)[0]
if (consumedSameDirectionWall) {
const pivotPoint = [...otherWallEndpoint(consumedSameDirectionWall.wall, point)] as WallPlanPoint
const bridgeSource = linkedAtEndpoint.find((entry) => entry.relation === 'opposite-direction')
wallsToDelete.set(consumedSameDirectionWall.wall.id, consumedSameDirectionWall.wall)
linkedWallTargetPlans.set(consumedSameDirectionWall.wall.id, {
wall: consumedSameDirectionWall.wall,
originalPoint: point,
targetPoint: pivotPoint,
})
if (bridgeSource) {
linkedWallTargetPlans.set(bridgeSource.wall.id, {
wall: bridgeSource.wall,
originalPoint: point,
targetPoint: pivotPoint,
})
bridgePlans.set(`${bridgeSource.wall.id}:${endpoint}:through`, {
wall: bridgeSource.wall,
originalPoint: pivotPoint,
movedEndpoint: endpoint,
})
return
}
const linkedAtPivot = linkedWalls
.filter(
(wall) => wall.id !== consumedSameDirectionWall.wall.id && wallTouchesPoint(wall, pivotPoint),
)
.map((wall) => ({
wall,
relation: getMoveWallRelation(wall, pivotPoint, nextPoint),
}))
addStandardEndpointPlan(endpoint, pivotPoint, nextPoint, linkedAtPivot, ':through-pivot', true)
return
}
addStandardEndpointPlan(endpoint, point, nextPoint, linkedAtEndpoint)
}
addEndpointPlan('start', originalStart, nextStart)
addEndpointPlan('end', originalEnd, nextEnd)
return {
linkedWallsToMove: Array.from(linkedWallsToMove.values()),
linkedWallTargetPlans: Array.from(linkedWallTargetPlans.values()),
bridgePlans: Array.from(bridgePlans.values()),
wallsToDelete: Array.from(wallsToDelete.values()),
}
}
+2 -1
View File
@@ -5,7 +5,8 @@
"rootDir": "src",
"noEmit": false,
"composite": true,
"incremental": true
"incremental": true,
"types": ["bun"]
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
+10 -10
View File
@@ -1,6 +1,6 @@
{
"name": "@pascal-app/editor",
"version": "0.6.0",
"version": "0.8.0",
"description": "Pascal building editor component",
"type": "module",
"exports": {
@@ -11,8 +11,8 @@
"check-types": "tsc --noEmit"
},
"peerDependencies": {
"@pascal-app/core": "^0.6.0",
"@pascal-app/viewer": "^0.6.0",
"@pascal-app/core": "^0.8.0",
"@pascal-app/viewer": "^0.8.0",
"@react-three/drei": "^10",
"@react-three/fiber": "^9",
"next": ">=15",
@@ -44,24 +44,24 @@
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"howler": "^2.2.4",
"lucide-react": "^0.562.0",
"lucide-react": "^1.7.0",
"mitt": "^3.0.1",
"motion": "^12.34.3",
"nanoid": "^5.1.6",
"tailwind-merge": "^3.5.0",
"three-mesh-bvh": "^0.9.8",
"zod": "^4.3.6",
"zustand": "^5.0.11"
"zustand": "^5.0.11",
"three-mesh-bvh": "~0.9.8"
},
"devDependencies": {
"@pascal-app/core": "^0.6.0",
"@pascal-app/viewer": "^0.6.0",
"@pascal-app/core": "^0.8.0",
"@pascal-app/viewer": "^0.8.0",
"@pascal/typescript-config": "*",
"@types/bun": "^1.3.0",
"@types/howler": "^2.2.12",
"@types/node": "^22.19.12",
"@types/react": "19.2.2",
"@types/react-dom": "19.2.2",
"@types/three": "^0.184.0",
"typescript": "5.9.3"
"typescript": "6.0.2"
}
}
@@ -16,6 +16,7 @@ export type FloorplanActionMenuEntry = {
onDelete: FloorplanActionMenuHandler
onMove: FloorplanActionMenuHandler
onAddHole?: FloorplanActionMenuHandler
onCurve?: FloorplanActionMenuHandler
onDuplicate?: FloorplanActionMenuHandler
}
@@ -84,6 +85,7 @@ export const FloorplanActionMenuLayer = memo(function FloorplanActionMenuLayer({
>
<NodeActionMenu
onAddHole={entry.onAddHole}
onCurve={entry.onCurve}
onDelete={entry.onDelete}
onDuplicate={entry.onDuplicate}
onMove={entry.onMove}
@@ -72,7 +72,12 @@ export const FloorplanDraftLayer = memo(function FloorplanDraftLayer({
)}
{polygonDraftPolygonPoints && (
<polygon fill={draftFill} fillOpacity={0.2} points={polygonDraftPolygonPoints} stroke="none" />
<polygon
fill={draftFill}
fillOpacity={0.2}
points={polygonDraftPolygonPoints}
stroke="none"
/>
)}
{polygonDraftPolylinePoints && (
@@ -125,7 +125,12 @@ export const FloorplanMeasurementsLayer = memo(function FloorplanMeasurementsLay
return (
<>
{measurements.map((measurement) => (
<g className={className} key={measurement.id} pointerEvents="none" style={{ userSelect: 'none' }}>
<g
className={className}
key={measurement.id}
pointerEvents="none"
style={{ userSelect: 'none' }}
>
<FloorplanMeasurementLine
dashed={measurement.dashedExtensions ?? true}
isSelected={measurement.isSelected}
@@ -396,8 +396,8 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
<>
<polyline
fill="none"
points={formatSvgPolygonPoints(arrow.polyline)}
pointerEvents="none"
points={formatSvgPolygonPoints(arrow.polyline)}
stroke={straightAccent}
strokeWidth="1.15"
vectorEffect="non-scaling-stroke"
@@ -411,8 +411,8 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
/>
<polygon
fill={straightAccent}
points={formatSvgPolygonPoints(arrow.head)}
pointerEvents="none"
points={formatSvgPolygonPoints(arrow.head)}
/>
</>
) : null}
@@ -438,8 +438,6 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
}
: undefined
}
onPointerEnter={canSelectStairs ? () => onStairHoverEnter(stair.id) : undefined}
onPointerLeave={canSelectStairs ? () => onStairHoverChange(null) : undefined}
onPointerDown={
canFocusStairs && stairSelected
? (event) => {
@@ -449,6 +447,8 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
}
: undefined
}
onPointerEnter={canSelectStairs ? () => onStairHoverEnter(stair.id) : undefined}
onPointerLeave={canSelectStairs ? () => onStairHoverChange(null) : undefined}
pointerEvents={canSelectStairs ? undefined : 'none'}
style={canSelectStairs ? { cursor } : undefined}
>
@@ -456,8 +456,8 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
<polygon
fill="transparent"
key={`${stair.id}:hit:${polygonIndex}`}
points={formatSvgPolygonPoints(polygon)}
pointerEvents={canSelectStairs ? 'all' : 'none'}
points={formatSvgPolygonPoints(polygon)}
stroke="transparent"
strokeLinejoin="round"
strokeWidth={hitStrokeWidth}
@@ -1,4 +1,5 @@
'use client'
import {
type CameraControlEvent,
type CameraControlFitSceneEvent,
@@ -6,7 +7,7 @@ import {
sceneRegistry,
useScene,
} from '@pascal-app/core'
import { useViewer, ZONE_LAYER } from '@pascal-app/viewer'
import { useViewer, WalkthroughControls, ZONE_LAYER } from '@pascal-app/viewer'
import { CameraControls, CameraControlsImpl } from '@react-three/drei'
import { useThree } from '@react-three/fiber'
import { useCallback, useEffect, useMemo, useRef } from 'react'
+4 -3
View File
@@ -205,6 +205,7 @@ export function EditorLayoutV2({
viewerContent,
overlays,
}: EditorLayoutV2Props) {
const isCaptureMode = useEditor((s) => s.isCaptureMode)
const isMobile = useIsMobile()
if (isMobile) {
@@ -229,7 +230,7 @@ export function EditorLayoutV2({
{/* Main content: left column + right column */}
<div className="flex min-h-0 flex-1">
{sidebarTabs.length > 0 && (
{!isCaptureMode && sidebarTabs.length > 0 && (
<LeftColumn
renderTabContent={renderTabContent}
sidebarOverlay={sidebarOverlay}
@@ -238,8 +239,8 @@ export function EditorLayoutV2({
)}
<RightColumn
overlays={overlays}
toolbarLeft={viewerToolbarLeft}
toolbarRight={viewerToolbarRight}
toolbarLeft={isCaptureMode ? undefined : viewerToolbarLeft}
toolbarRight={isCaptureMode ? undefined : viewerToolbarRight}
>
{viewerContent}
</RightColumn>
@@ -1311,14 +1311,15 @@ export const FirstPersonControls = () => {
{controllerStart && (
<KeyboardControls map={keyboardMap}>
<BVHEcctrl
ref={controllerRef}
key="first-person-controller"
acceleration={26}
airDragFactor={0.3}
colliderCapsuleArgs={[0.25, 0.8, 4, 8]}
colliderMeshes={firstPersonColliderMeshes}
collisionCheckIteration={3}
collisionPushBackDamping={0.1}
collisionPushBackThreshold={0.001}
debug={false}
deceleration={30}
delay={0}
fallGravityFactor={4}
floatCheckType="BOTH"
@@ -1329,14 +1330,13 @@ export const FirstPersonControls = () => {
floatSpringK={1200}
gravity={9.81}
jumpVel={6}
key="first-person-controller"
maxRunSpeed={5.5}
maxSlope={1.2}
maxWalkSpeed={4}
paused={isElevatorRideLocked}
position={controllerStart.position}
acceleration={26}
airDragFactor={0.3}
deceleration={30}
ref={controllerRef}
/>
</KeyboardControls>
)}
@@ -1408,12 +1408,12 @@ export const FirstPersonOverlay = ({ onExit }: { onExit: () => void }) => {
{isLocked && (
<div className="pointer-events-none fixed top-1/2 right-6 z-40 -translate-y-1/2">
<div className="flex min-w-[148px] flex-col gap-3 rounded-2xl border border-border/35 bg-background/80 px-4 py-4 shadow-lg backdrop-blur-xl">
<ControlHint label="Move" keys={['W', 'A', 'S', 'D']} />
<ControlHint keys={['W', 'A', 'S', 'D']} label="Move" />
<div className="h-px w-full bg-border/30" />
<InlineControlHint label="Jump" keyLabel="Space" />
<InlineControlHint label="Sprint" keyLabel="Shift" />
<InlineControlHint label="Interact" keyLabel="E / R" />
<InlineControlHint label="Close" keyLabel="T" />
<InlineControlHint keyLabel="Space" label="Jump" />
<InlineControlHint keyLabel="Shift" label="Sprint" />
<InlineControlHint keyLabel="E / R" label="Interact" />
<InlineControlHint keyLabel="T" label="Close" />
<div className="h-px w-full bg-border/30" />
<span className="text-center text-muted-foreground/60 text-xs">
Click to look around
@@ -1448,7 +1448,7 @@ function ControlHint({ label, keys }: { label: string; keys: string[] }) {
function InlineControlHint({ label, keyLabel }: { label: string; keyLabel: string }) {
return (
<div className="flex items-center justify-between gap-3">
<span className="font-medium text-[10px] text-muted-foreground/60 tracking-[0.03em] uppercase">
<span className="font-medium text-[10px] text-muted-foreground/60 uppercase tracking-[0.03em]">
{label}
</span>
<kbd className="flex h-5 min-w-5 items-center justify-center rounded border border-border/50 bg-accent/40 px-1.5 font-mono text-[10px] text-foreground/80 leading-none">
@@ -1,7 +1,7 @@
import {
getGarageVisibleOpeningRatio,
type AnyNodeId,
type DoorNode,
getGarageVisibleOpeningRatio,
isOperationDoorType,
sceneRegistry,
useInteractive,
@@ -97,11 +97,11 @@ function shouldSkipColliderNode(nodeId: string, type: (typeof COLLIDER_NODE_TYPE
const node = useScene.getState().nodes[nodeId as AnyNodeId]
if (!node || node.type !== 'door') return false
if (node.openingKind === 'opening') return true
if (!node.segments.length) return true
return node.segments.every((segment) => segment.type === 'empty')
if (node.openingKind === 'opening') return true
return node.segments.every((segment: { type: string }) => segment.type === 'empty')
}
function createDoorLeafColliderGeometry(root: THREE.Object3D, node: DoorNode) {
@@ -273,9 +273,7 @@ export function buildFirstPersonColliderWorldFromRegistry(): FirstPersonCollider
}
const mergedGeometry = mergeGeometries(geometries, false)
geometries.forEach((geometry) => {
geometry.dispose()
})
geometries.forEach((geometry) => geometry.dispose())
if (!mergedGeometry || mergedGeometry.getAttribute('position') == null) {
mergedGeometry?.dispose()
@@ -1,15 +1,8 @@
import '../../../three-types'
import { TransformControls, useKeyboardControls } from '@react-three/drei'
import { useFrame, useThree, type ThreeElements } from '@react-three/fiber'
import {
Suspense,
forwardRef,
useCallback,
useImperativeHandle,
useMemo,
useRef,
} from 'react'
import { type ThreeElements, useFrame, useThree } from '@react-three/fiber'
import type { ReactNode } from 'react'
import { forwardRef, Suspense, useCallback, useImperativeHandle, useMemo, useRef } from 'react'
import * as THREE from 'three'
import { clamp } from 'three/src/math/MathUtils.js'
@@ -156,16 +149,7 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
const moveDirRef = useRef<THREE.ArrowHelper | null>(null)
const elapsedRef = useRef(0)
function useIsInsideKeyboardControls() {
try {
return !!useKeyboardControls()
} catch {
return false
}
}
const isInsideKeyboardControls = useIsInsideKeyboardControls()
const [_, getKeys] = isInsideKeyboardControls ? useKeyboardControls() : [null, null]
const [, getKeys] = useKeyboardControls()
const presetKeys = {
forward: false,
backward: false,
@@ -222,11 +206,11 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
const scaledContactRadiusVec = useRef(new THREE.Vector3())
const deltaDist = useRef(new THREE.Vector3())
const currSlopeAngle = useRef(0)
const localMinDistance = useRef(Infinity)
const localMinDistance = useRef(Number.POSITIVE_INFINITY)
const localClosestPoint = useRef(new THREE.Vector3())
const localHitNormal = useRef(new THREE.Vector3())
const triNormal = useRef(new THREE.Vector3())
const globalMinDistance = useRef(Infinity)
const globalMinDistance = useRef(Number.POSITIVE_INFINITY)
const globalClosestPoint = useRef(new THREE.Vector3())
const triHitPoint = useRef(new THREE.Vector3())
const segHitPoint = useRef(new THREE.Vector3())
@@ -286,7 +270,13 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
const moving = currentLinVel.current.lengthSq() > 1e-6
const platformIsMoving = totalPlatformDeltaPos.current.lengthSq() > 1e-6
if (!moving && isOnGround.current && !jump && !isOnMovingPlatform.current && !platformIsMoving) {
if (
!moving &&
isOnGround.current &&
!jump &&
!isOnMovingPlatform.current &&
!platformIsMoving
) {
idleTime.current += delta
if (idleTime.current > sleepTimeout) isSleeping.current = true
} else {
@@ -340,7 +330,10 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
upAxis.current,
)
characterModelTargetQuat.current.setFromRotationMatrix(characterModelLookMatrix.current)
characterModelRef.current.quaternion.slerp(characterModelTargetQuat.current, delta * turnSpeed)
characterModelRef.current.quaternion.slerp(
characterModelTargetQuat.current,
delta * turnSpeed,
)
}
const maxSpeed = run ? maxRunSpeed : maxWalkSpeed
@@ -358,18 +351,33 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
)
currentLinVel.current.add(deltaLinVel.current)
} else if (isOnGround.current) {
deltaLinVel.current.copy(currentLinVelOnPlane.current).clampLength(0, deceleration * friction * delta)
deltaLinVel.current
.copy(currentLinVelOnPlane.current)
.clampLength(0, deceleration * friction * delta)
currentLinVel.current.sub(deltaLinVel.current)
}
},
[acceleration, airDragFactor, counterAccFactor, deceleration, maxRunSpeed, maxWalkSpeed, turnSpeed, characterOrigin],
[
acceleration,
airDragFactor,
counterAccFactor,
deceleration,
maxRunSpeed,
maxWalkSpeed,
turnSpeed,
characterOrigin,
],
)
const updateSegmentBBox = useCallback(() => {
if (!characterGroupRef.current) return
characterSegment.current.start.set(0, capsuleLength / 2, 0).add(characterGroupRef.current.position)
characterSegment.current.end.set(0, -capsuleLength / 2, 0).add(characterGroupRef.current.position)
characterSegment.current.start
.set(0, capsuleLength / 2, 0)
.add(characterGroupRef.current.position)
characterSegment.current.end
.set(0, -capsuleLength / 2, 0)
.add(characterGroupRef.current.position)
characterBbox.current
.makeEmpty()
@@ -394,11 +402,18 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
const collisionCheck = useCallback(
(mesh: THREE.Mesh, originMatrix: THREE.Matrix4, delta: number) => {
if (!mesh.visible || !mesh.geometry.boundsTree || mesh.userData.excludeCollisionCheck) return
if (!(mesh.visible && mesh.geometry.boundsTree) || mesh.userData.excludeCollisionCheck)
return
originMatrix.decompose(contactTempPos.current, contactTempQuat.current, contactTempScale.current)
originMatrix.decompose(
contactTempPos.current,
contactTempQuat.current,
contactTempScale.current,
)
collideInvertMatrix.current.copy(originMatrix).invert()
localCharacterSegment.current.copy(characterSegment.current).applyMatrix4(collideInvertMatrix.current)
localCharacterSegment.current
.copy(characterSegment.current)
.applyMatrix4(collideInvertMatrix.current)
scaledContactRadiusVec.current.set(
capsuleRadius / contactTempScale.current.x,
@@ -445,7 +460,10 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
contactDepth.current =
capsuleRadius - capsuleContactPoint.current.distanceTo(triContactPoint.current)
accumulatedContactNormal.current.addScaledVector(contactNormal.current, contactDepth.current)
accumulatedContactNormal.current.addScaledVector(
contactNormal.current,
contactDepth.current,
)
accumulatedContactPoint.current.add(triContactPoint.current)
totalDepth.current += contactDepth.current
triangleCount.current += 1
@@ -492,14 +510,16 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
const floatingCheck = useCallback(
(mesh: THREE.Mesh, originMatrix: THREE.Matrix4) => {
if (!mesh.visible || !mesh.geometry.boundsTree || mesh.userData.excludeFloatHit) return
if (!(mesh.visible && mesh.geometry.boundsTree) || mesh.userData.excludeFloatHit) return
originMatrix.decompose(floatTempPos.current, floatTempQuat.current, floatTempScale.current)
floatInvertMatrix.current.copy(originMatrix).invert()
floatNormalInverseMatrix.current.getNormalMatrix(floatInvertMatrix.current)
floatNormalMatrix.current.getNormalMatrix(originMatrix)
localFloatSensorSegment.current.copy(floatSensorSegment.current).applyMatrix4(floatInvertMatrix.current)
localFloatSensorSegment.current
.copy(floatSensorSegment.current)
.applyMatrix4(floatInvertMatrix.current)
localFloatSensorBboxExpendPoint.current
.copy(floatSensorBboxExpendPoint.current)
.applyMatrix4(floatInvertMatrix.current)
@@ -517,20 +537,33 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
localFloatSensorBbox.current.min.addScaledVector(scaledFloatRadiusVec.current, -1)
localFloatSensorBbox.current.max.add(scaledFloatRadiusVec.current)
localMinDistance.current = Infinity
localClosestPoint.current.set(Infinity, Infinity, Infinity)
localMinDistance.current = Number.POSITIVE_INFINITY
localClosestPoint.current.set(
Number.POSITIVE_INFINITY,
Number.POSITIVE_INFINITY,
Number.POSITIVE_INFINITY,
)
mesh.geometry.boundsTree.shapecast({
intersectsBounds: (box) => box.intersectsBox(localFloatSensorBbox.current),
intersectsTriangle: (tri) => {
tri.closestPointToSegment(localFloatSensorSegment.current, triHitPoint.current, segHitPoint.current)
localUpAxis.current.copy(upAxis.current).applyMatrix3(floatNormalInverseMatrix.current).normalize()
tri.closestPointToSegment(
localFloatSensorSegment.current,
triHitPoint.current,
segHitPoint.current,
)
localUpAxis.current
.copy(upAxis.current)
.applyMatrix3(floatNormalInverseMatrix.current)
.normalize()
deltaHit.current.subVectors(triHitPoint.current, localFloatSensorSegment.current.start)
deltaHit.current.divide(scaledFloatRadiusVec.current)
const totalLengthSq = deltaHit.current.lengthSq()
const dot = deltaHit.current.dot(localUpAxis.current)
const verticalLength = Math.abs(dot) / ((capsuleRadius + floatHeight + floatPullBackHeight) / floatSensorRadius)
const verticalLength =
Math.abs(dot) /
((capsuleRadius + floatHeight + floatPullBackHeight) / floatSensorRadius)
const horizontalLength = Math.sqrt(Math.max(0, totalLengthSq - dot * dot))
if (horizontalLength < 1 && verticalLength < 1) {
@@ -560,9 +593,14 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
const handleFloatingResponse = useCallback(
(meshes: THREE.Mesh[], jump: boolean, delta: number) => {
if (meshes.length === 0) return
let shouldJump = jump
globalMinDistance.current = Infinity
globalClosestPoint.current.set(Infinity, Infinity, Infinity)
globalMinDistance.current = Number.POSITIVE_INFINITY
globalClosestPoint.current.set(
Number.POSITIVE_INFINITY,
Number.POSITIVE_INFINITY,
Number.POSITIVE_INFINITY,
)
floatHitNormal.current.set(0, 1, 0)
isOnGround.current = false
totalPlatformDeltaPos.current.set(0, 0, 0)
@@ -574,7 +612,11 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
}
}
if (floatCheckType !== 'SHAPECAST' && floatRaycastCandidates.length > 0 && globalMinDistance.current === Infinity) {
if (
floatCheckType !== 'SHAPECAST' &&
floatRaycastCandidates.length > 0 &&
globalMinDistance.current === Number.POSITIVE_INFINITY
) {
floatRaycaster.current.ray.origin.copy(floatSensorSegment.current.start)
floatRaycaster.current.ray.direction.copy(gravityDir.current)
const hits = floatRaycaster.current.intersectObjects(floatRaycastCandidates, false)
@@ -582,23 +624,28 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
if (hit?.point) {
globalClosestPoint.current.copy(hit.point)
if (hit.face) {
floatHitNormal.current.copy(hit.face.normal).transformDirection(hit.object.matrixWorld).normalize()
floatHitNormal.current
.copy(hit.face.normal)
.transformDirection(hit.object.matrixWorld)
.normalize()
}
}
}
if (globalClosestPoint.current.x === Infinity) return
if (globalClosestPoint.current.x === Number.POSITIVE_INFINITY) return
relativeHitPoint.current.copy(globalClosestPoint.current).sub(floatSensorSegment.current.start)
relativeHitPoint.current
.copy(globalClosestPoint.current)
.sub(floatSensorSegment.current.start)
const currentDistance = relativeHitPoint.current.length()
currSlopeAngle.current = floatHitNormal.current.angleTo(upAxis.current)
if (currentDistance < floatHeight + capsuleRadius) {
isOnGround.current = true
jump = false
shouldJump = false
}
if (!jump) {
if (!shouldJump) {
const displacement = floatHeight + capsuleRadius - currentDistance
const velocityOnHitNormal = currentLinVel.current.dot(floatHitNormal.current)
const springForce = displacement * floatSpringK
@@ -608,7 +655,17 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
currentLinVel.current.addScaledVector(floatHitNormal.current, (totalForce / mass) * delta)
}
},
[capsuleRadius, floatCheckType, floatDampingC, floatHeight, floatRaycastCandidates, floatSpringK, floatingCheck, gravity, mass],
[
capsuleRadius,
floatCheckType,
floatDampingC,
floatHeight,
floatRaycastCandidates,
floatSpringK,
floatingCheck,
gravity,
mass,
],
)
const updateCharacterWithPlatform = useCallback(() => {
@@ -646,8 +703,14 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
)
const resetLinVel = useCallback(() => currentLinVel.current.set(0, 0, 0), [])
const addLinVel = useCallback((velocity: THREE.Vector3) => currentLinVel.current.add(velocity), [])
const setLinVel = useCallback((velocity: THREE.Vector3) => currentLinVel.current.copy(velocity), [])
const addLinVel = useCallback(
(velocity: THREE.Vector3) => currentLinVel.current.add(velocity),
[],
)
const setLinVel = useCallback(
(velocity: THREE.Vector3) => currentLinVel.current.copy(velocity),
[],
)
const setMovement = useCallback((movement: MovementInput) => {
if (movement.forward !== undefined) forwardState.current = movement.forward
if (movement.backward !== undefined) backwardState.current = movement.backward
@@ -682,7 +745,9 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
debugRaySensorEnd.current?.position.copy(floatSensorSegment.current.end)
standPointRef.current?.position.copy(globalClosestPoint.current)
if (characterGroupRef.current) {
lookDirRef.current?.position.copy(characterGroupRef.current.position).addScaledVector(upAxis.current, 0.7)
lookDirRef.current?.position
.copy(characterGroupRef.current.position)
.addScaledVector(upAxis.current, 0.7)
}
lookDirRef.current?.lookAt(lookDirRef.current.position.clone().add(camProjDir.current))
inputDirRef.current?.position.copy(characterSegment.current.end)
@@ -698,13 +763,13 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
if (paused || elapsedRef.current < delay) return
const deltaTime = Math.min(1 / 45, delta) * slowMotionFactor
const keys = isInsideKeyboardControls && getKeys ? getKeys() : presetKeys
const forward = forwardState.current || keys.forward
const backward = backwardState.current || keys.backward
const leftward = leftwardState.current || keys.leftward
const rightward = rightwardState.current || keys.rightward
const run = runState.current || keys.run
const jump = jumpState.current || keys.jump
const keys = getKeys() ?? presetKeys
const forward = forwardState.current || (keys.forward ?? false)
const backward = backwardState.current || (keys.backward ?? false)
const leftward = leftwardState.current || (keys.leftward ?? false)
const rightward = rightwardState.current || (keys.rightward ?? false)
const run = runState.current || (keys.run ?? false)
const jump = jumpState.current || (keys.jump ?? false)
setInputDirection({
forward,
@@ -740,7 +805,7 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
return (
<Suspense fallback={null}>
<group {...props} ref={characterGroupRef} dispose={null}>
<group {...props} dispose={null} ref={characterGroupRef}>
{debug && (
<mesh ref={characterColliderRef}>
<capsuleGeometry args={colliderCapsuleArgs} />
@@ -777,8 +842,8 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
<octahedronGeometry args={[0.1, 0]} />
<meshNormalMaterial />
</mesh>
<arrowHelper ref={inputDirRef} args={[undefined, undefined, undefined, '#00f']} />
<arrowHelper ref={moveDirRef} args={[undefined, undefined, undefined, '#f00']} />
<arrowHelper args={[undefined, undefined, undefined, '#00f']} ref={inputDirRef} />
<arrowHelper args={[undefined, undefined, undefined, '#f00']} ref={moveDirRef} />
<mesh ref={standPointRef}>
<octahedronGeometry args={[0.12, 0]} />
<meshBasicMaterial color="red" opacity={0.2} transparent />
+5 -1
View File
@@ -444,7 +444,11 @@ export function FloatingActionMenu() {
? handleDuplicate
: undefined
}
onMove={node && !DELETE_ONLY_TYPES.includes(node.type) ? handleMove : undefined}
onMove={
node && node.type !== 'wall' && !DELETE_ONLY_TYPES.includes(node.type)
? handleMove
: undefined
}
onPointerDown={(e) => e.stopPropagation()}
onPointerUp={(e) => e.stopPropagation()}
/>
@@ -43,6 +43,7 @@ import {
useLiveNodeOverrides,
useLiveTransforms,
useScene,
WallNode as WallNodeSchema,
type WallNode,
WindowNode,
ZoneNode as ZoneNodeSchema,
@@ -4213,7 +4214,6 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
d={path}
fill={isDeleteHovered ? palette.deleteFill : palette.slabFill}
fillRule="evenodd"
opacity={slabFillOpacity}
onClick={
canSelectSlabs
? (event) => {
@@ -4232,9 +4232,10 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
}
onPointerEnter={canSelectSlabs ? () => onSlabHoverChange(slab.id) : undefined}
onPointerLeave={canSelectSlabs ? () => onSlabHoverChange(null) : undefined}
opacity={slabFillOpacity}
pointerEvents={canSelectSlabs ? undefined : 'none'}
style={canSelectSlabs ? { cursor: EDITOR_CURSOR } : undefined}
stroke="none"
style={canSelectSlabs ? { cursor: EDITOR_CURSOR } : undefined}
/>
{isSelected && !isDeleteHovered ? (
<path
@@ -4289,7 +4290,6 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
d={path}
fill={isDeleteHovered ? palette.deleteFill : palette.ceilingFill}
fillRule="evenodd"
opacity={ceilingFillOpacity}
onClick={
canSelectCeilings
? (event) => {
@@ -4310,9 +4310,10 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
canSelectCeilings ? () => onCeilingHoverChange(ceiling.id) : undefined
}
onPointerLeave={canSelectCeilings ? () => onCeilingHoverChange(null) : undefined}
opacity={ceilingFillOpacity}
pointerEvents={canSelectCeilings ? undefined : 'none'}
style={canSelectCeilings ? { cursor: EDITOR_CURSOR } : undefined}
stroke="none"
style={canSelectCeilings ? { cursor: EDITOR_CURSOR } : undefined}
/>
{isSelected && !isDeleteHovered ? (
<path
@@ -4419,8 +4420,8 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
<polygon
fill={`url(#${wallSelectionHatchId})`}
opacity={1}
points={points}
pointerEvents="none"
points={points}
/>
) : null}
</g>
@@ -4548,8 +4549,8 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
{canSelectGeometry && (
<polygon
fill="transparent"
points={points}
pointerEvents="all"
points={points}
stroke="transparent"
strokeWidth={FLOORPLAN_OPENING_HIT_STROKE_WIDTH}
vectorEffect="non-scaling-stroke"
@@ -4659,8 +4660,8 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
{canSelectGeometry && (
<polygon
fill="transparent"
points={points}
pointerEvents="all"
points={points}
stroke="transparent"
strokeWidth={FLOORPLAN_OPENING_HIT_STROKE_WIDTH}
vectorEffect="non-scaling-stroke"
@@ -5339,8 +5340,8 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
{canSelectGeometry && (
<polygon
fill="transparent"
points={points}
pointerEvents="all"
points={points}
stroke="transparent"
strokeWidth={FLOORPLAN_OPENING_HIT_STROKE_WIDTH}
vectorEffect="non-scaling-stroke"
@@ -5411,8 +5412,8 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
d={foldingPath}
fill="none"
stroke={doorStroke}
strokeLinejoin="round"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={isSelected || isSelectionHighlighted ? '1.8' : '1.25'}
vectorEffect="non-scaling-stroke"
/>
@@ -6513,6 +6514,12 @@ function FloorplanItemImage({
}) {
const resolvedUrl = useResolvedAssetUrl(url)
if (!resolvedUrl) return null
// The PNG is captured with the modal's top-down camera (default up = +Y),
// so its pixel-right is world +X and pixel-up is world -Z. The plan SVG
// negates both axes (`toSvgX(v) = -v`, `toSvgY(v) = -v`), which together
// are a 180° rotation — so the captured image lands upside-down /
// mirrored when overlaid as-is. Bake that 180° into the image transform
// here; the panel / modal previews use the PNG directly and stay correct.
const rotationDeg = (-rotation * 180) / Math.PI + 180
return (
<g
@@ -6718,6 +6725,7 @@ const FloorplanNodeLayer = memo(function FloorplanNodeLayer({
}
points={points}
stroke={stroke}
strokeOpacity={1}
strokeWidth={
isSelectionActive ? FLOORPLAN_SELECTED_WALL_STROKE_WIDTH : FLOORPLAN_WALL_STROKE_WIDTH
}
@@ -6767,8 +6775,8 @@ const FloorplanNodeLayer = memo(function FloorplanNodeLayer({
<polygon
fill={`url(#${wallSelectionHatchId})`}
opacity={1}
points={points}
pointerEvents="none"
points={points}
/>
) : null}
{itemDimensionMeasurements.length > 0 ? (
@@ -6858,8 +6866,8 @@ const FloorplanNodeLayer = memo(function FloorplanNodeLayer({
<polygon
fill={fill}
fillOpacity={isDeleteHovered ? 0.82 : 0.92}
points={FLOORPLAN_SPAWN_ARROW_POINTS}
pointerEvents="none"
points={FLOORPLAN_SPAWN_ARROW_POINTS}
stroke={stroke}
strokeLinejoin="round"
strokeWidth={0.055}
@@ -7774,6 +7782,11 @@ const FloorplanPolygonHandleLayer = memo(function FloorplanPolygonHandleLayer({
y2={endSvg.y}
/>
<line
onPointerDown={
onEdgePointerDown
? (event) => onEdgePointerDown(nodeId, edgeIndex, event)
: undefined
}
pointerEvents="stroke"
stroke="transparent"
strokeLinecap="round"
@@ -7784,11 +7797,6 @@ const FloorplanPolygonHandleLayer = memo(function FloorplanPolygonHandleLayer({
x2={endSvg.x}
y1={startSvg.y}
y2={endSvg.y}
onPointerDown={
onEdgePointerDown
? (event) => onEdgePointerDown(nodeId, edgeIndex, event)
: undefined
}
/>
</g>
)
@@ -8035,6 +8043,7 @@ export function FloorplanPanel() {
const setPhase = useEditor((state) => state.setPhase)
const setMovingFenceEndpoint = useEditor((state) => state.setMovingFenceEndpoint)
const setMovingNode = useEditor((state) => state.setMovingNode)
const setCurvingWall = useEditor((state) => state.setCurvingWall)
const movingFenceEndpoint = useEditor((state) => state.movingFenceEndpoint)
const structureLayer = useEditor((state) => state.structureLayer)
const setStructureLayer = useEditor((state) => state.setStructureLayer)
@@ -10004,6 +10013,7 @@ export function FloorplanPanel() {
selectedWallEntry,
wallCurveDraft,
])
const canCurveSelectedWall = wallCurveHandles.length > 0
const slabVertexHandles = useMemo(() => {
if (!shouldShowSlabBoundaryHandles) {
return []
@@ -10611,7 +10621,7 @@ export function FloorplanPanel() {
zoneVertexDragState != null ||
isPolygonDraftBuildActive
if (!hasUserAdjustedViewportRef.current && !transientFloorplanFit) {
if (!(hasUserAdjustedViewportRef.current || transientFloorplanFit)) {
setViewport((current) =>
floorplanViewportEquals(current, fittedViewport) ? current : fittedViewport,
)
@@ -11864,6 +11874,14 @@ export function FloorplanPanel() {
}
}, [isItemPlacementPreviewActive, scheduleMovingFloorplanNodeRefresh])
useEffect(() => {
if (!hasPendingItemMeshFootprints) {
return
}
scheduleMovingFloorplanNodeRefresh()
}, [hasPendingItemMeshFootprints, scheduleMovingFloorplanNodeRefresh])
// Subscribe to the live-transforms store so rotation/position changes that
// *don't* go through pointer events still refresh the floorplan — e.g. R/T
// keyboard rotation during placement updates `useLiveTransforms` but emits
@@ -11879,14 +11897,6 @@ export function FloorplanPanel() {
return unsubscribe
}, [isItemPlacementPreviewActive, scheduleMovingFloorplanNodeRefresh])
useEffect(() => {
if (!hasPendingItemMeshFootprints) {
return
}
scheduleMovingFloorplanNodeRefresh()
}, [hasPendingItemMeshFootprints, scheduleMovingFloorplanNodeRefresh])
useEffect(() => {
if (!(movingNode?.type === 'door' || movingNode?.type === 'window')) {
return
@@ -14985,6 +14995,57 @@ export function FloorplanPanel() {
},
[selectedWallEntry, setMovingNode, setSelection],
)
const duplicateSelectedWall = useCallback(() => {
const wall = selectedWallEntry?.wall
if (!wall?.parentId) {
return
}
sfxEmitter.emit('sfx:item-pick')
const cloned = structuredClone(wall) as Record<string, unknown>
delete cloned.id
cloned.children = []
cloned.metadata = {
...(typeof cloned.metadata === 'object' && cloned.metadata !== null ? cloned.metadata : {}),
isNew: true,
}
const temporal = useScene.temporal.getState()
temporal.pause()
try {
const duplicate = WallNodeSchema.parse(cloned)
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
setMovingNode(duplicate)
setSelection({ selectedIds: [] })
} catch (error) {
console.error('Failed to duplicate wall', error)
} finally {
temporal.resume()
}
}, [selectedWallEntry, setMovingNode, setSelection])
const handleSelectedWallDuplicate = useCallback(
(event: ReactMouseEvent<HTMLButtonElement>) => {
event.stopPropagation()
duplicateSelectedWall()
},
[duplicateSelectedWall],
)
const handleSelectedWallCurve = useCallback(
(event: ReactMouseEvent<HTMLButtonElement>) => {
event.stopPropagation()
const wall = selectedWallEntry?.wall
if (!(wall && canCurveSelectedWall)) {
return
}
sfxEmitter.emit('sfx:item-pick')
setCurvingWall(wall)
setSelection({ selectedIds: [] })
},
[canCurveSelectedWall, selectedWallEntry, setCurvingWall, setSelection],
)
const handleSelectedWallDelete = useCallback(
(event: ReactMouseEvent<HTMLButtonElement>) => {
event.stopPropagation()
@@ -17036,9 +17097,17 @@ export function FloorplanPanel() {
site,
])
const hasDuplicatableFloorplanSelection = Boolean(
selectedItemEntry || selectedOpeningEntry || selectedStairEntry || selectedRoofEntry,
selectedItemEntry ||
selectedOpeningEntry ||
selectedStairEntry ||
selectedRoofEntry ||
selectedWallEntry,
)
const handleDuplicateFloorplanSelection = useCallback(() => {
if (selectedWallEntry) {
duplicateSelectedWall()
return
}
if (selectedOpeningEntry) {
duplicateSelectedOpening()
return
@@ -17055,6 +17124,7 @@ export function FloorplanPanel() {
duplicateSelectedRoof()
}
}, [
duplicateSelectedWall,
duplicateSelectedItem,
duplicateSelectedOpening,
duplicateSelectedRoof,
@@ -17063,6 +17133,7 @@ export function FloorplanPanel() {
selectedOpeningEntry,
selectedRoofEntry,
selectedStairEntry,
selectedWallEntry,
])
const activeDraftAnchorPoint =
referenceScaleDraft?.start ??
@@ -17160,17 +17231,13 @@ export function FloorplanPanel() {
onDuplicate: handleSelectedItemDuplicate,
onMove: handleSelectedItemMove,
}}
offsetY={FLOORPLAN_ACTION_MENU_OFFSET_Y}
opening={{
position: selectedOpeningActionMenuPosition,
onDelete: handleSelectedOpeningDelete,
onDuplicate: handleSelectedOpeningDuplicate,
onMove: handleSelectedOpeningMove,
}}
spawn={{
position: selectedSpawnActionMenuPosition,
onDelete: handleSelectedSpawnDelete,
onMove: handleSelectedSpawnMove,
}}
roof={{
position: selectedRoofActionMenuPosition,
onDelete: handleSelectedRoofDelete,
@@ -17185,6 +17252,11 @@ export function FloorplanPanel() {
: handleSelectedSlabDelete,
onMove: selectedSlabEditingHole ? handleSelectedSlabHoleMove : handleSelectedSlabMove,
}}
spawn={{
position: selectedSpawnActionMenuPosition,
onDelete: handleSelectedSpawnDelete,
onMove: handleSelectedSpawnMove,
}}
stair={{
position: selectedStairActionMenuPosition,
onDelete: handleSelectedStairDelete,
@@ -17193,10 +17265,11 @@ export function FloorplanPanel() {
}}
wall={{
position: selectedWallActionMenuPosition,
onCurve: canCurveSelectedWall ? handleSelectedWallCurve : undefined,
onDelete: handleSelectedWallDelete,
onDuplicate: handleSelectedWallDuplicate,
onMove: handleSelectedWallMove,
}}
offsetY={FLOORPLAN_ACTION_MENU_OFFSET_Y}
/>
{referenceScaleDraft && (
@@ -17229,7 +17302,7 @@ export function FloorplanPanel() {
</div>
<div className="mb-3 rounded-xl border border-border/70 bg-white/5 px-3 py-2">
<div className="text-muted-foreground text-[11px] uppercase tracking-wide">
<div className="text-[11px] text-muted-foreground uppercase tracking-wide">
Drawn line
</div>
<div className="mt-1 font-medium text-sm">
@@ -17391,8 +17464,8 @@ export function FloorplanPanel() {
<FloorplanGuideLayer
activeGuideInteractionGuideId={activeGuideInteractionGuideId}
activeGuideInteractionMode={activeGuideInteractionMode}
guideUi={guideUi}
guides={displayGuides}
guideUi={guideUi}
isInteractive={canInteractWithGuides}
onGuideSelect={handleGuideSelect}
onGuideTranslateStart={handleGuideTranslateStart}
@@ -17413,6 +17486,8 @@ export function FloorplanPanel() {
hoveredSlabId={hoveredSlabId}
hoveredWallId={hoveredWallId}
isDeleteMode={isDeleteMode}
isGuideTraceVisible={isGuideTraceVisible}
metersPerUnit={calibratedMetersPerUnit}
onCeilingDoubleClick={handleCeilingDoubleClick}
onCeilingHoverChange={handleCeilingHoverChange}
onCeilingSelect={handleCeilingSelect}
@@ -17429,11 +17504,9 @@ export function FloorplanPanel() {
openingsPolygons={openingsPolygons}
palette={palette}
selectedIdSet={selectedIdSet}
slabSelectionHatchId={slabSelectionHatchId}
slabPolygons={displaySlabPolygons}
slabSelectionHatchId={slabSelectionHatchId}
unit={unit}
metersPerUnit={calibratedMetersPerUnit}
isGuideTraceVisible={isGuideTraceVisible}
wallPolygons={displayWallPolygons}
wallSelectionHatchId={wallSelectionHatchId}
/>
@@ -17529,8 +17602,8 @@ export function FloorplanPanel() {
<FloorplanReferenceScaleLayer
draft={referenceScaleDraft}
guideUi={guideUi}
guides={displayGuides}
guideUi={guideUi}
palette={palette}
unit={unit}
unitsPerPixel={floorplanUnitsPerPixel}
+70 -53
View File
@@ -20,6 +20,7 @@ import {
import { ViewerOverlay } from '../../components/viewer-overlay'
import { ViewerZoneSystem } from '../../components/viewer-zone-system'
import { type PresetsAdapter, PresetsProvider } from '../../contexts/presets-context'
import { useAutoFrame } from '../../hooks/use-auto-frame'
import { type SaveStatus, useAutoSave } from '../../hooks/use-auto-save'
import { useKeyboard } from '../../hooks/use-keyboard'
import {
@@ -64,8 +65,10 @@ import { Grid } from './grid'
import { PresetThumbnailGenerator } from './preset-thumbnail-generator'
import { SelectionManager } from './selection-manager'
import { SiteEdgeLabels } from './site-edge-labels'
import { SnapshotCaptureOverlay } from './snapshot-capture-overlay'
import { type SnapshotCameraData, ThumbnailGenerator } from './thumbnail-generator'
import { WallMeasurementLabel } from './wall-measurement-label'
import { WallMoveSideHandles } from './wall-move-side-handles'
const CAMERA_CONTROLS_HINT_DISMISSED_STORAGE_KEY = 'editor-camera-controls-hint-dismissed:v1'
const DELETE_CURSOR_BADGE_COLOR = '#ef4444'
@@ -76,12 +79,12 @@ const PAINT_CURSOR_BADGE_DISABLED_COLOR = '#94a3b8'
const PAINT_CURSOR_BADGE_OFFSET_X = 14
const PAINT_CURSOR_BADGE_OFFSET_Y = 14
const EDITOR_HOVER_STYLES: HoverStyles = {
default: { visibleColor: 0x00_aaff, hiddenColor: 0xf3_ff47, strength: 5, pulse: true },
delete: { visibleColor: 0xef_4444, hiddenColor: 0x99_1b1b, strength: 6, pulse: false },
'paint-ready': { visibleColor: 0xf5_9e0b, hiddenColor: 0xfd_e068, strength: 5, pulse: true },
default: { visibleColor: 0x00_aa_ff, hiddenColor: 0xf3_ff_47, strength: 5, pulse: true },
delete: { visibleColor: 0xef_44_44, hiddenColor: 0x99_1b_1b, strength: 6, pulse: false },
'paint-ready': { visibleColor: 0xf5_9e_0b, hiddenColor: 0xfd_e0_68, strength: 5, pulse: true },
'paint-disabled': {
visibleColor: 0x94_a3b8,
hiddenColor: 0x47_5569,
visibleColor: 0x94_a3_b8,
hiddenColor: 0x47_55_69,
strength: 4,
pulse: false,
},
@@ -462,7 +465,7 @@ function ViewerCanvasControlsHint({
<div className="pointer-events-none absolute top-14 left-1/2 z-40 max-w-[calc(100%-2rem)] -translate-x-1/2">
<section
aria-label="Camera controls hint"
className="pointer-events-auto flex items-start gap-3 rounded-2xl border border-border/35 bg-background/90 px-3.5 py-2.5 shadow-[0_22px_40px_-28px_rgba(15,23,42,0.65),0_10px_24px_-20px_rgba(15,23,42,0.55)] backdrop-blur-xl"
className="pointer-events-auto flex items-start gap-3 rounded-2xl border border-border/35 bg-background/90 px-3.5 py-2.5 shadow-elevation-4 backdrop-blur-xl"
>
<div className="grid min-w-0 flex-1 grid-cols-3 items-start divide-x divide-border/18">
{hints.map((hint) => (
@@ -585,6 +588,7 @@ const ViewerSceneContent = memo(function ViewerSceneContent({
<>
{!isFirstPersonMode && <SelectionManager />}
{!(isVersionPreviewMode || isFirstPersonMode) && <BoxSelectTool />}
{!(isVersionPreviewMode || isFirstPersonMode) && <WallMoveSideHandles />}
{!(isVersionPreviewMode || isFirstPersonMode) && <FloatingActionMenu />}
{!(isVersionPreviewMode || isFirstPersonMode) && <FloatingBuildingActionMenu />}
{!isFirstPersonMode && <WallMeasurementLabel />}
@@ -753,7 +757,7 @@ function PaintCursorLayer({
(activePaintMaterial.material !== undefined ||
activePaintMaterial.materialPreset !== undefined),
)
const label = !hasMaterial ? 'Choose material' : `Paint ${activePaintTarget}`
const label = hasMaterial ? `Paint ${activePaintTarget}` : 'Choose material'
const icon = 'mdi:format-color-fill'
useLayoutEffect(() => {
@@ -786,16 +790,16 @@ function PaintCursorLayer({
const ViewerCanvas = memo(function ViewerCanvas({
isVersionPreviewMode,
isLoading,
isFirstPersonMode,
hasLoadedInitialScene,
showLoader,
isFirstPersonMode,
onThumbnailCapture,
}: {
isVersionPreviewMode: boolean
isLoading: boolean
isFirstPersonMode: boolean
hasLoadedInitialScene: boolean
showLoader: boolean
isFirstPersonMode: boolean
onThumbnailCapture?: (blob: Blob, cameraData: SnapshotCameraData) => void
}) {
const viewMode = useEditor((s) => s.viewMode)
@@ -837,7 +841,7 @@ const ViewerCanvas = memo(function ViewerCanvas({
window.removeEventListener('pointermove', handlePointerMove)
window.removeEventListener('pointerup', handlePointerUp)
}
}, [setFloorplanPaneRatio])
}, [])
useEffect(() => {
setIsCameraControlsHintVisible(!readCameraControlsHintDismissed())
@@ -940,7 +944,9 @@ export default function Editor({
commandPaletteEmptyAction,
}: EditorProps) {
const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode)
useKeyboard({ isVersionPreviewMode, disabled: isFirstPersonMode })
const { isLoadingSceneRef } = useAutoSave({
onSave,
onDirty,
@@ -951,8 +957,7 @@ export default function Editor({
const [isSceneLoading, setIsSceneLoading] = useState(false)
const [hasLoadedInitialScene, setHasLoadedInitialScene] = useState(false)
const isPreviewMode = useEditor((s) => s.isPreviewMode)
const firstPersonPreviousLevelRef = useRef(useViewer.getState().selection.levelId)
const wasFirstPersonModeRef = useRef(isFirstPersonMode)
const isCaptureMode = useEditor((s) => s.isCaptureMode)
const sidebarWidth = useSidebarStore((s) => s.width)
const isSidebarCollapsed = useSidebarStore((s) => s.isCollapsed)
@@ -970,39 +975,6 @@ export default function Editor({
}
}, [projectId])
useEffect(() => {
const wasFirstPersonMode = wasFirstPersonModeRef.current
wasFirstPersonModeRef.current = isFirstPersonMode
if (isFirstPersonMode && !wasFirstPersonMode) {
const viewer = useViewer.getState()
firstPersonPreviousLevelRef.current = viewer.selection.levelId
viewer.setCameraMode('perspective')
viewer.setWallMode('up')
viewer.setWalkthroughMode(true)
viewer.setSelection({ selectedIds: [], zoneId: null })
return
}
if (!(wasFirstPersonMode && !isFirstPersonMode)) return
const viewer = useViewer.getState()
const previousLevelId = firstPersonPreviousLevelRef.current
firstPersonPreviousLevelRef.current = null
viewer.setWalkthroughMode(false)
if (!previousLevelId) return
const previousLevelNode = useScene.getState().nodes[previousLevelId]
if (previousLevelNode?.type === 'level') {
viewer.setSelection({
levelId: previousLevelId,
zoneId: null,
selectedIds: [],
})
}
}, [isFirstPersonMode])
// Load scene on mount (or when onLoad identity changes, e.g. project switch)
useEffect(() => {
let cancelled = false
@@ -1064,6 +1036,42 @@ export default function Editor({
const showLoader = isLoading || isSceneLoading
const firstPersonPreviousLevelRef = useRef(useViewer.getState().selection.levelId)
const wasFirstPersonModeRef = useRef(isFirstPersonMode)
useEffect(() => {
const wasFirstPersonMode = wasFirstPersonModeRef.current
wasFirstPersonModeRef.current = isFirstPersonMode
if (isFirstPersonMode && !wasFirstPersonMode) {
const viewer = useViewer.getState()
firstPersonPreviousLevelRef.current = viewer.selection.levelId
viewer.setCameraMode('perspective')
viewer.setWallMode('up')
viewer.setWalkthroughMode(true)
viewer.setSelection({ selectedIds: [], zoneId: null })
return
}
if (!(wasFirstPersonMode && !isFirstPersonMode)) return
const viewer = useViewer.getState()
const previousLevelId = firstPersonPreviousLevelRef.current
firstPersonPreviousLevelRef.current = null
viewer.setWalkthroughMode(false)
if (!previousLevelId) return
const previousLevelNode = useScene.getState().nodes[previousLevelId]
if (previousLevelNode?.type === 'level') {
viewer.setSelection({
levelId: previousLevelId,
zoneId: null,
selectedIds: [],
})
}
}, [isFirstPersonMode])
const previewViewerContent = (
<Viewer hoverStyles={EDITOR_HOVER_STYLES} selectionManager="default">
<ExportManager />
@@ -1135,21 +1143,24 @@ export default function Editor({
navbarSlot={navbarSlot}
overlays={
<>
<FloatingLevelSelector />
{!isVersionPreviewMode && (
{!isCaptureMode && <FloatingLevelSelector />}
{!(isVersionPreviewMode || isCaptureMode) && (
<div className="pointer-events-auto">
<ActionMenu />
</div>
)}
{!isVersionPreviewMode && (
{!(isVersionPreviewMode || isCaptureMode) && (
<div className="pointer-events-auto">
<PanelManager />
</div>
)}
<div className="pointer-events-auto">
<HelperManager />
</div>
{!isCaptureMode && (
<div className="pointer-events-auto">
<HelperManager />
</div>
)}
{viewerBanner}
{projectId ? <SnapshotCaptureOverlay projectId={projectId} /> : null}
</>
}
renderTabContent={renderTabContent}
@@ -1159,14 +1170,14 @@ export default function Editor({
viewerToolbarLeft={viewerToolbarLeft}
viewerToolbarRight={viewerToolbarRight}
/>
<EditorCommands />
<CommandPalette emptyAction={commandPaletteEmptyAction} />
{/* First-person overlay — rendered on top of normal layout */}
{isFirstPersonMode && (
<div className="pointer-events-none fixed inset-0 z-50">
<FirstPersonOverlay onExit={() => useEditor.getState().setFirstPersonMode(false)} />
</div>
)}
<EditorCommands />
<CommandPalette emptyAction={commandPaletteEmptyAction} />
</>
)}
</PresetsProvider>
@@ -1222,6 +1233,12 @@ export default function Editor({
<HelperManager />
</div>
</ViewerOverlays>
{/* First-person overlay — rendered on top of normal layout */}
{isFirstPersonMode && (
<div className="pointer-events-none fixed inset-0 z-50">
<FirstPersonOverlay onExit={() => useEditor.getState().setFirstPersonMode(false)} />
</div>
)}
</>
)}
</div>
+11 -5
View File
@@ -343,7 +343,7 @@ function applySingleSurfacePaintPreview(
if (node.type === 'ceiling') {
const root = getRegisteredMesh(node.id)
const overlay = root?.getObjectByName('ceiling-grid') as Mesh | undefined
if (!root || !overlay) return null
if (!(root && overlay)) return null
const previewColor =
getMaterialPresetByRef(material.materialPreset)?.mapProperties.color ??
@@ -1011,7 +1011,6 @@ export const SelectionManager = () => {
'roof-segment',
'stair',
'stair-segment',
'spawn',
'window',
'door',
'zone',
@@ -1408,6 +1407,7 @@ export const SelectionManager = () => {
'roof-segment',
'stair',
'stair-segment',
'spawn',
'window',
'door',
'zone',
@@ -1652,6 +1652,7 @@ const EditorOutlinerSync = () => {
const previewSelectedIds = useViewer((s) => s.previewSelectedIds)
const hoveredId = useViewer((s) => s.hoveredId)
const outliner = useViewer((s) => s.outliner)
const nodes = useScene((s) => s.nodes)
useEffect(() => {
let idsToHighlight: string[] = []
@@ -1688,16 +1689,21 @@ const EditorOutlinerSync = () => {
// 2. Sync with the imperative outliner arrays (mutate in place to keep references)
outliner.selectedObjects.length = 0
for (const id of idsToHighlight) {
if (!nodes[id as AnyNodeId]) continue
const obj = sceneRegistry.nodes.get(id)
if (obj?.parent) outliner.selectedObjects.push(obj)
}
outliner.hoveredObjects.length = 0
if (hoveredId) {
const obj = sceneRegistry.nodes.get(hoveredId)
if (obj?.parent) outliner.hoveredObjects.push(obj)
if (!nodes[hoveredId as AnyNodeId]) {
useViewer.setState({ hoveredId: null })
} else {
const obj = sceneRegistry.nodes.get(hoveredId)
if (obj?.parent) outliner.hoveredObjects.push(obj)
}
}
}, [phase, previewSelectedIds, selection, hoveredId, outliner])
}, [phase, previewSelectedIds, selection, hoveredId, outliner, nodes])
return null
}
@@ -0,0 +1,465 @@
'use client'
import { emitter } from '@pascal-app/core'
import { Camera, Check, Crop, Loader2, Maximize2, Monitor, X } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useIsMobile } from '../../hooks/use-mobile'
import { triggerSFX } from '../../lib/sfx-bus'
import useEditor from '../../store/use-editor'
type CaptureMode = 'standard' | 'viewport' | 'area'
type CaptureState = 'idle' | 'capturing' | 'saved'
interface DragPoint {
x: number
y: number
}
interface Drag {
start: DragPoint
end: DragPoint
}
function getResolution(
mode: CaptureMode,
overlayEl: HTMLDivElement | null,
drag: Drag | null,
): { w: number; h: number } | null {
if (mode === 'standard') return { w: 1920, h: 1080 }
if (!overlayEl) return null
const rect = overlayEl.getBoundingClientRect()
const dpr = Math.min(window.devicePixelRatio, 1.5)
if (mode === 'viewport') {
return { w: Math.round(rect.width * dpr), h: Math.round(rect.height * dpr) }
}
if (mode === 'area' && drag) {
const w = Math.abs(drag.end.x - drag.start.x)
const h = Math.abs(drag.end.y - drag.start.y)
if (w < 4 || h < 4) return null
return { w: Math.round(w * dpr), h: Math.round(h * dpr) }
}
return null
}
export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
const isCaptureMode = useEditor((s) => s.isCaptureMode)
const setCaptureMode = useEditor((s) => s.setCaptureMode)
const isMobile = useIsMobile()
const [mode, setMode] = useState<CaptureMode>('standard')
const [drag, setDrag] = useState<Drag | null>(null)
const [isDragging, setIsDragging] = useState(false)
const [captureState, setCaptureState] = useState<CaptureState>('idle')
const overlayRef = useRef<HTMLDivElement>(null)
// Dismiss on Esc
useEffect(() => {
if (!isCaptureMode) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setCaptureMode(false)
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [isCaptureMode, setCaptureMode])
// Reset local state when entering capture mode
useEffect(() => {
if (isCaptureMode) {
setMode('standard')
setDrag(null)
setIsDragging(false)
setCaptureState('idle')
}
}, [isCaptureMode])
// Listen for snapshot saved to show feedback then exit
useEffect(() => {
const handler = () => {
setCaptureState('saved')
setTimeout(() => {
setCaptureMode(false)
setCaptureState('idle')
}, 1500)
}
emitter.on('snapshot:saved', handler)
return () => emitter.off('snapshot:saved', handler)
}, [setCaptureMode])
const dismiss = useCallback(() => setCaptureMode(false), [setCaptureMode])
// Tracks whether the active drag is a "move entire rect" gesture
const moveStartRef = useRef<{ pt: DragPoint; drag: Drag } | null>(null)
// Area drag handlers — relative to the overlay container
const onPointerDown = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (mode !== 'area' || captureState !== 'idle') return
e.preventDefault()
const rect = overlayRef.current!.getBoundingClientRect()
const pt = { x: e.clientX - rect.left, y: e.clientY - rect.top }
// If clicking inside an existing selection → move mode
if (drag) {
const x0 = Math.min(drag.start.x, drag.end.x)
const y0 = Math.min(drag.start.y, drag.end.y)
const x1 = Math.max(drag.start.x, drag.end.x)
const y1 = Math.max(drag.start.y, drag.end.y)
if (pt.x >= x0 && pt.x <= x1 && pt.y >= y0 && pt.y <= y1) {
moveStartRef.current = { pt, drag }
setIsDragging(true)
;(e.target as HTMLElement).setPointerCapture(e.pointerId)
return
}
}
// Outside / no selection → start new drag
moveStartRef.current = null
setDrag({ start: pt, end: pt })
setIsDragging(true)
;(e.target as HTMLElement).setPointerCapture(e.pointerId)
},
[mode, captureState, drag],
)
const onPointerMove = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (!isDragging) return
const rect = overlayRef.current!.getBoundingClientRect()
const pt = {
x: Math.max(0, Math.min(e.clientX - rect.left, rect.width)),
y: Math.max(0, Math.min(e.clientY - rect.top, rect.height)),
}
if (moveStartRef.current) {
// Move mode: translate the whole rect by the delta
const { pt: origin, drag: snapshot } = moveStartRef.current
const dx = pt.x - origin.x
const dy = pt.y - origin.y
setDrag({
start: { x: snapshot.start.x + dx, y: snapshot.start.y + dy },
end: { x: snapshot.end.x + dx, y: snapshot.end.y + dy },
})
} else {
setDrag((d) => (d ? { start: d.start, end: pt } : null))
}
},
[isDragging],
)
const onPointerUp = useCallback(() => {
const wasMoving = moveStartRef.current !== null
setIsDragging(false)
moveStartRef.current = null
// Clear the rect if the user just clicked without drawing (not a move gesture)
if (!wasMoving) {
setDrag((d) => {
if (!d) return null
const w = Math.abs(d.end.x - d.start.x)
const h = Math.abs(d.end.y - d.start.y)
return w < 4 && h < 4 ? null : d
})
}
}, [])
// Corner-handle resize: re-anchor to the opposite corner then reuse the same drag machinery
const onCornerPointerDown = useCallback(
(e: React.PointerEvent<HTMLDivElement>, cornerIndex: number) => {
if (captureState !== 'idle' || !drag) return
e.stopPropagation()
e.preventDefault()
moveStartRef.current = null
const x0 = Math.min(drag.start.x, drag.end.x)
const y0 = Math.min(drag.start.y, drag.end.y)
const x1 = Math.max(drag.start.x, drag.end.x)
const y1 = Math.max(drag.start.y, drag.end.y)
// anchor = opposite corner; dragged = current corner
const corners: [DragPoint, DragPoint][] = [
[
{ x: x1, y: y1 },
{ x: x0, y: y0 },
], // TL → anchor BR
[
{ x: x0, y: y1 },
{ x: x1, y: y0 },
], // TR → anchor BL
[
{ x: x1, y: y0 },
{ x: x0, y: y1 },
], // BL → anchor TR
[
{ x: x0, y: y0 },
{ x: x1, y: y1 },
], // BR → anchor TL
]
const [anchor, current] = corners[cornerIndex]!
setDrag({ start: anchor, end: current })
setIsDragging(true)
},
[captureState, drag],
)
const handleCapture = useCallback(() => {
if (captureState !== 'idle') return
let cropRegion: { x: number; y: number; width: number; height: number } | undefined
if (mode === 'area' && drag && overlayRef.current) {
const rect = overlayRef.current.getBoundingClientRect()
const x0 = Math.min(drag.start.x, drag.end.x)
const y0 = Math.min(drag.start.y, drag.end.y)
const w = Math.abs(drag.end.x - drag.start.x)
const h = Math.abs(drag.end.y - drag.start.y)
cropRegion = {
x: x0 / rect.width,
y: y0 / rect.height,
width: w / rect.width,
height: h / rect.height,
}
}
setCaptureState('capturing')
triggerSFX('sfx:snapshot-capture')
emitter.emit('camera-controls:generate-thumbnail', {
projectId,
captureMode: mode,
cropRegion,
})
}, [captureState, mode, drag, projectId])
if (!isCaptureMode) return null
const resolution = getResolution(mode, overlayRef.current, drag)
// Area selection rect (CSS px, relative to overlay)
const selectionStyle =
mode === 'area' && drag
? {
left: Math.min(drag.start.x, drag.end.x),
top: Math.min(drag.start.y, drag.end.y),
width: Math.abs(drag.end.x - drag.start.x),
height: Math.abs(drag.end.y - drag.start.y),
}
: null
const hasSelection =
selectionStyle != null && selectionStyle.width > 3 && selectionStyle.height > 3
const captureDisabled = captureState !== 'idle' || (mode === 'area' && !hasSelection)
return (
<div className="pointer-events-none absolute inset-0 z-40" ref={overlayRef}>
{/* Area mode: dim layer + crosshair cursor + drag-to-select */}
{mode === 'area' && (
<div
className="pointer-events-auto absolute inset-0 bg-black/30"
onPointerDown={onPointerDown}
onPointerMove={(e) => {
onPointerMove(e)
// Update cursor: 'move' when hovering inside an existing selection
if (!isDragging && drag && overlayRef.current) {
const rect = overlayRef.current.getBoundingClientRect()
const px = e.clientX - rect.left
const py = e.clientY - rect.top
const x0 = Math.min(drag.start.x, drag.end.x)
const y0 = Math.min(drag.start.y, drag.end.y)
const x1 = Math.max(drag.start.x, drag.end.x)
const y1 = Math.max(drag.start.y, drag.end.y)
e.currentTarget.style.cursor =
px >= x0 && px <= x1 && py >= y0 && py <= y1 ? 'move' : 'crosshair'
}
}}
onPointerUp={onPointerUp}
style={{ cursor: 'crosshair' }}
>
{/* "No selection" hint */}
{!selectionStyle && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<span className="rounded-full bg-black/40 px-4 py-2 text-sm text-white backdrop-blur-sm">
Drag the area you want to capture
</span>
</div>
)}
{/* Selection rect */}
{selectionStyle && (
<div
style={{
position: 'absolute',
left: selectionStyle.left,
top: selectionStyle.top,
width: selectionStyle.width,
height: selectionStyle.height,
pointerEvents: 'none',
boxShadow: '0 0 0 9999px rgba(0,0,0,0.35)',
border: '2px dashed rgba(255,255,255,0.85)',
background: 'rgba(255,255,255,0.04)',
}}
>
{/* Corner handles */}
{(
[
{ pos: { top: -5, left: -5 }, cursor: 'nwse-resize' },
{ pos: { top: -5, right: -5 }, cursor: 'nesw-resize' },
{ pos: { bottom: -5, left: -5 }, cursor: 'nesw-resize' },
{ pos: { bottom: -5, right: -5 }, cursor: 'nwse-resize' },
] as const
).map(({ pos, cursor }, i) => (
<div
key={i}
onPointerDown={(e) => onCornerPointerDown(e, i)}
style={{
position: 'absolute',
width: 10,
height: 10,
borderRadius: '50%',
background: 'white',
boxShadow: '0 1px 4px rgba(0,0,0,0.4)',
pointerEvents: 'auto',
cursor,
...pos,
}}
/>
))}
</div>
)}
</div>
)}
{/* Top-right dismiss button (icon-only on mobile) */}
<div className="pointer-events-auto absolute top-4 right-4">
<button
aria-label="Close capture mode"
className="flex items-center gap-1.5 rounded-full border border-white/20 bg-black/60 px-3 py-1.5 text-white/80 text-xs backdrop-blur-sm transition-colors hover:bg-black/80 hover:text-white"
onClick={dismiss}
type="button"
>
<X className="h-3 w-3" />
{!isMobile && 'Esc to cancel'}
</button>
</div>
{/* Bottom-center mode toolbar */}
<div className="pointer-events-auto absolute bottom-6 left-1/2 -translate-x-1/2">
{(() => {
const modeButtons = (
<>
<ModeButton
active={mode === 'standard'}
badge="16:9"
icon={<Monitor className="h-3.5 w-3.5" />}
label="Standard"
onClick={() => {
setMode('standard')
setDrag(null)
}}
/>
<ModeButton
active={mode === 'viewport'}
icon={<Maximize2 className="h-3.5 w-3.5" />}
label="Viewport"
onClick={() => {
setMode('viewport')
setDrag(null)
}}
/>
<ModeButton
active={mode === 'area'}
icon={<Crop className="h-3.5 w-3.5" />}
label="Area"
onClick={() => setMode('area')}
/>
</>
)
const resolutionDisplay = (
<span className="min-w-[80px] text-center text-white/50 text-xs tabular-nums">
{resolution ? `${resolution.w} × ${resolution.h}` : '—'}
</span>
)
const captureButton = (
<button
className="flex items-center gap-1.5 rounded-full bg-primary px-4 py-1.5 font-medium text-primary-foreground text-xs transition-opacity disabled:opacity-50"
disabled={captureDisabled}
onClick={handleCapture}
type="button"
>
{captureState === 'capturing' ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Capturing
</>
) : captureState === 'saved' ? (
<>
<Check className="h-3.5 w-3.5" />
Saved
</>
) : (
<>
<Camera className="h-3.5 w-3.5" />
Capture
</>
)}
</button>
)
if (isMobile) {
return (
<div className="flex flex-col items-stretch gap-2 rounded-2xl border border-white/10 bg-neutral-900/95 px-2 py-2 shadow-xl backdrop-blur-md">
<div className="flex items-center justify-center gap-1">{modeButtons}</div>
<div className="flex items-center justify-center gap-2 border-white/10 border-t pt-2">
{resolutionDisplay}
{captureButton}
</div>
</div>
)
}
return (
<div className="flex items-center gap-1 rounded-full border border-white/10 bg-neutral-900/95 px-2 py-2 shadow-xl backdrop-blur-md">
{modeButtons}
<div className="mx-1 h-4 w-px bg-white/10" />
{resolutionDisplay}
<div className="mx-1 h-4 w-px bg-white/10" />
{captureButton}
</div>
)
})()}
</div>
</div>
)
}
function ModeButton({
active,
icon,
label,
badge,
onClick,
}: {
active: boolean
icon: React.ReactNode
label: string
badge?: string
onClick: () => void
}) {
return (
<button
className={`flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs transition-colors ${
active ? 'bg-white/15 text-white ring-1 ring-white/20' : 'text-white/50 hover:text-white/90'
}`}
onClick={onClick}
type="button"
>
{icon}
{label}
{badge && (
<span className="rounded-sm bg-white/10 px-1 py-0.5 font-medium text-[10px] text-white/40 leading-none">
{badge}
</span>
)}
</button>
)
}
@@ -1,6 +1,6 @@
'use client'
import { emitter, sceneRegistry, useScene } from '@pascal-app/core'
import { emitter, sceneRegistry } from '@pascal-app/core'
import { SSGI_PARAMS, snapLevelsToTruePositions, useViewer } from '@pascal-app/viewer'
import type { CameraControls } from '@react-three/drei'
import { useThree } from '@react-three/fiber'
@@ -28,7 +28,6 @@ import { EDITOR_LAYER } from '../../lib/constants'
const THUMBNAIL_WIDTH = 1920
const THUMBNAIL_HEIGHT = 1080
const AUTO_SAVE_DELAY = 10_000
export interface SnapshotCameraData {
position: [number, number, number]
@@ -49,8 +48,6 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
const mainCamera = useThree((state) => state.camera)
const controls = useThree((state) => state.controls) as CameraControls | null
const isGenerating = useRef(false)
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const pendingAutoRef = useRef(false)
const onThumbnailCaptureRef = useRef(onThumbnailCapture)
const thumbnailCameraRef = useRef<THREE.PerspectiveCamera | null>(null)
@@ -61,7 +58,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
onThumbnailCaptureRef.current = onThumbnailCapture
}, [onThumbnailCapture])
// Build the thumbnail camera, SSGI pipeline, and render target once, reused on every capture.
// Build the thumbnail camera, SSGI pipeline, and render target once reused on every capture.
useEffect(() => {
const cam = new THREE.PerspectiveCamera(60, THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT, 0.1, 1000)
cam.layers.disable(EDITOR_LAYER)
@@ -75,7 +72,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
if (!mounted) return
// pass() handles MRT internally for all material types, including custom
// shaders, unlike renderer.setMRT() which crashes on non-NodeMaterials.
// shaders unlike renderer.setMRT() which crashes on non-NodeMaterials.
// pass() also respects camera.layers, so EDITOR_LAYER objects are filtered.
const scenePass = pass(scene, cam)
scenePass.setMRT(
@@ -117,15 +114,15 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
const ao = (denoisePass as any).r
const finalOutput = vec4(scenePassColor.rgb.mul(ao), scenePassColor.a)
// FXAA requires a texture node as input, convertToTexture renders finalOutput
// into an intermediate RT so FXAA can sample it with neighbor UV offsets.
// FXAA requires a texture node as input; convertToTexture renders finalOutput
// into an intermediate RT so FXAA can sample it with neighbour UV offsets.
const aaOutput = fxaa(convertToTexture(finalOutput))
const pipeline = new RenderPipeline(gl as unknown as WebGPURenderer)
pipeline.outputNode = aaOutput
pipelineRef.current = pipeline
// Dedicated render target, pipeline outputs here instead of the canvas,
// Dedicated render target pipeline outputs here instead of the canvas,
// so R3F's main render loop can never overwrite our capture.
const { width, height } = gl.domElement
renderTargetRef.current = new RenderTarget(width, height, { depthBuffer: true })
@@ -176,7 +173,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
thumbnailCamera.aspect = width / height
thumbnailCamera.updateProjectionMatrix()
// Capture camera data for snapshot storage.
// Capture camera data for snapshot storage
const pos = mainCamera.position
let tgt: [number, number, number] | null = null
if (controls && 'getTarget' in controls) {
@@ -192,7 +189,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
...(isOrtho && { zoom: (mainCamera as THREE.OrthographicCamera).zoom }),
}
// For auto-save, snap levels to stacked positions and reset levelMode.
// For auto-save: snap levels to stacked positions and reset levelMode
let restoreLevelMode: (() => void) | null = null
let restoreLevels: () => void = () => {}
if (snapLevels) {
@@ -205,7 +202,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
}
// Hide scan and guide nodes directly so they are excluded from the
// thumbnail regardless of whether ScanSystem or GuideSystem listeners are
// thumbnail regardless of whether ScanSystem/GuideSystem listeners are
// registered. Returns a function that restores the original visibility.
const restoreNodeVisibility = (() => {
const saved = new Map<THREE.Object3D, boolean>()
@@ -231,7 +228,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
if (pipelineRef.current && renderTargetRef.current) {
const rt = renderTargetRef.current
// Resize RT if the canvas dimensions changed.
// Resize RT if the canvas dimensions changed
if (rt.width !== width || rt.height !== height) {
rt.setSize(width, height)
}
@@ -248,7 +245,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
emitter.emit('thumbnail:after-capture', undefined)
// Restore level positions, levelMode, and node visibility immediately after the
// render, before the async GPU readback.
// render before the async GPU readback.
restoreLevels()
restoreLevelMode?.()
restoreNodeVisibility()
@@ -339,6 +336,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
offscreen.getContext('2d')!.drawImage(srcCanvas, sx, sy, outW, outH, 0, 0, outW, outH)
blob = await offscreen.convertToBlob({ type: 'image/png' })
} else {
// Standard: center-crop to 1920×1080 aspect ratio
const srcAspect = width / height
const dstAspect = THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT
let sx = 0,
@@ -364,7 +362,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
if (captureMode !== undefined) cameraData.captureMode = captureMode
cameraData.resolution = { w: outW, h: outH }
} else {
// Fallback: plain render directly to the canvas.
// Fallback: plain render directly to the canvas
emitter.emit('thumbnail:before-capture', undefined)
gl.render(scene, thumbnailCamera)
emitter.emit('thumbnail:after-capture', undefined)
@@ -450,10 +448,11 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
)
// Thumbnail request via emitter. Two call shapes:
// - user-driven capture: `{ projectId, captureMode, cropRegion }`, captures
// - user-driven capture: `{ projectId, captureMode, cropRegion }` captures
// the current pose with the supplied crop.
// - auto-save capture: `{ projectId, snapLevels: true }`, snaps levels to
// their true positions first for a consistent auto-thumbnail angle.
// - host-driven auto-save: `{ projectId, snapLevels: true }` snaps levels
// to their true positions first for a consistent auto-thumbnail angle.
// The caller owns policy (when to fire, whether the tab is visible).
useEffect(() => {
if (!onThumbnailCapture) return
@@ -469,49 +468,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
return () => emitter.off('camera-controls:generate-thumbnail', handleGenerateThumbnail)
}, [generate, onThumbnailCapture])
// OSS adaptation: keep local debounced auto-capture behavior because the
// community host-side autosave hook is not part of this repo.
useEffect(() => {
if (!onThumbnailCapture) return
const triggerNow = () => {
void generate(true)
}
const scheduleOrDefer = () => {
if (document.visibilityState === 'visible') {
triggerNow()
} else {
pendingAutoRef.current = true
}
}
const onSceneChange = () => {
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current)
debounceTimerRef.current = setTimeout(scheduleOrDefer, AUTO_SAVE_DELAY)
}
const onVisibilityChange = () => {
if (document.visibilityState === 'visible' && pendingAutoRef.current) {
pendingAutoRef.current = false
triggerNow()
}
}
const unsubscribe = useScene.subscribe((state, prevState) => {
if (state.nodes !== prevState.nodes) onSceneChange()
})
document.addEventListener('visibilitychange', onVisibilityChange)
return () => {
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current)
unsubscribe()
document.removeEventListener('visibilitychange', onVisibilityChange)
}
}, [generate, onThumbnailCapture])
// Go-to-camera: animate camera to a saved snapshot position or target.
// Go-to-camera: animate camera to a saved snapshot position/target
useEffect(() => {
const handler = ({
position,
@@ -144,11 +144,11 @@ export function useFloorplanBackgroundPlacement({
emitFloorplanGridEvent('click', snappedPoint, event)
setCursorPoint(snappedPoint)
if (!roofDraftStart) {
if (roofDraftStart) {
clearRoofPlacementDraft()
} else {
setRoofDraftStart(snappedPoint)
setRoofDraftEnd(snappedPoint)
} else {
clearRoofPlacementDraft()
}
return true
}
View File
@@ -0,0 +1,259 @@
'use client'
import {
type AnyNodeId,
DEFAULT_WALL_HEIGHT,
getWallThickness,
sceneRegistry,
useScene,
type WallNode,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { createPortal, type ThreeEvent } from '@react-three/fiber'
import { useEffect, useMemo, useState } from 'react'
import {
BufferGeometry,
ConeGeometry,
CylinderGeometry,
DoubleSide,
Float32BufferAttribute,
type Object3D,
} from 'three'
import { sfxEmitter } from '../../lib/sfx-bus'
import useEditor from '../../store/use-editor'
const HANDLE_OFFSET = 0.42
const HANDLE_MIN_OFFSET = 0.5
const HANDLE_MIN_HEIGHT = 0.62
const HANDLE_TOP_INSET = 0.08
const ARROW_COLOR = '#8381ed'
const ARROW_HOVER_COLOR = '#a5b4fc'
type WallMoveHandle = {
direction: [number, number]
key: string
position: [number, number, number]
rotationY: number
}
function createArrowHandleGeometry() {
const shaft = new CylinderGeometry(0.04, 0.064, 0.25, 36)
const head = new ConeGeometry(0.13, 0.3, 48)
shaft.rotateZ(-Math.PI / 2)
shaft.translate(-0.085, 0, 0)
head.rotateZ(-Math.PI / 2)
head.translate(0.17, 0, 0)
const positions: number[] = []
const normals: number[] = []
const uvs: number[] = []
for (const sourceGeometry of [shaft, head]) {
const geometry = sourceGeometry.index ? sourceGeometry.toNonIndexed() : sourceGeometry
const position = geometry.getAttribute('position')
const normal = geometry.getAttribute('normal')
const uv = geometry.getAttribute('uv')
for (let index = 0; index < position.count; index += 1) {
positions.push(position.getX(index), position.getY(index), position.getZ(index))
normals.push(normal.getX(index), normal.getY(index), normal.getZ(index))
uvs.push(uv?.getX(index) ?? 0, uv?.getY(index) ?? 0)
}
if (geometry !== sourceGeometry) {
geometry.dispose()
}
sourceGeometry.dispose()
}
const geometry = new BufferGeometry()
geometry.setAttribute('position', new Float32BufferAttribute(positions, 3))
geometry.setAttribute('normal', new Float32BufferAttribute(normals, 3))
geometry.setAttribute('uv', new Float32BufferAttribute(uvs, 2))
geometry.setAttribute('uv2', new Float32BufferAttribute([...uvs], 2))
geometry.computeVertexNormals()
geometry.computeBoundingSphere()
return geometry
}
export function WallMoveSideHandles() {
const selectedIds = useViewer((state) => state.selection.selectedIds)
const mode = useEditor((state) => state.mode)
const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered)
const movingNode = useEditor((state) => state.movingNode)
const movingWallEndpoint = useEditor((state) => state.movingWallEndpoint)
const movingFenceEndpoint = useEditor((state) => state.movingFenceEndpoint)
const curvingWall = useEditor((state) => state.curvingWall)
const curvingFence = useEditor((state) => state.curvingFence)
const selectedId = selectedIds.length === 1 ? selectedIds[0] : null
const wall = useScene((state) => {
const node = selectedId ? state.nodes[selectedId as AnyNodeId] : null
return node?.type === 'wall' ? node : null
})
const shouldRender =
Boolean(wall) &&
!isFloorplanHovered &&
mode !== 'delete' &&
!movingNode &&
!movingWallEndpoint &&
!movingFenceEndpoint &&
!curvingWall &&
!curvingFence
if (!shouldRender || !wall) return null
return <WallMoveSideHandlesForWall wall={wall} />
}
function WallMoveSideHandlesForWall({ wall }: { wall: WallNode }) {
const [levelObject, setLevelObject] = useState<Object3D | null>(() =>
wall.parentId ? (sceneRegistry.nodes.get(wall.parentId) ?? null) : null,
)
useEffect(() => {
let frameId = 0
const resolveLevelObject = () => {
const nextLevelObject = wall.parentId
? (sceneRegistry.nodes.get(wall.parentId) ?? null)
: null
setLevelObject((currentLevelObject) => {
if (currentLevelObject === nextLevelObject) {
return currentLevelObject
}
return nextLevelObject
})
if (!nextLevelObject) {
frameId = window.requestAnimationFrame(resolveLevelObject)
}
}
resolveLevelObject()
return () => {
if (frameId) {
window.cancelAnimationFrame(frameId)
}
}
}, [wall.parentId])
const handles = useMemo(() => getWallMoveHandles(wall), [wall])
if (!levelObject || handles.length === 0) return null
return createPortal(
<group>
{handles.map((handle) => (
<WallMoveArrowHandle handle={handle} key={handle.key} wall={wall} />
))}
</group>,
levelObject,
)
}
function WallMoveArrowHandle({ wall, handle }: { wall: WallNode; handle: WallMoveHandle }) {
const [isHovered, setIsHovered] = useState(false)
const arrowGeometry = useMemo(() => createArrowHandleGeometry(), [])
useEffect(() => {
return () => {
if (document.body.style.cursor === 'grab' || document.body.style.cursor === 'grabbing') {
document.body.style.cursor = ''
}
}
}, [])
useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry])
const activateWallMove = (event: ThreeEvent<PointerEvent>) => {
event.stopPropagation()
event.nativeEvent.preventDefault()
document.body.style.cursor = 'grabbing'
sfxEmitter.emit('sfx:item-pick')
useEditor.getState().setMovingNode(wall)
useEditor.getState().setMovingWallEndpoint(null)
useEditor.getState().setMovingFenceEndpoint(null)
useEditor.getState().setCurvingWall(null)
useEditor.getState().setCurvingFence(null)
useViewer.getState().setSelection({ selectedIds: [] })
}
return (
<group
position={handle.position}
rotation={[0, handle.rotationY, 0]}
scale={isHovered ? 1.12 : 1}
>
<mesh
frustumCulled={false}
onPointerDown={activateWallMove}
onPointerEnter={(event) => {
event.stopPropagation()
setIsHovered(true)
document.body.style.cursor = 'grab'
}}
onPointerLeave={(event) => {
event.stopPropagation()
setIsHovered(false)
if (document.body.style.cursor === 'grab') {
document.body.style.cursor = ''
}
}}
renderOrder={1002}
>
<primitive attach="geometry" object={arrowGeometry} />
<meshBasicMaterial
color={isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR}
depthTest
depthWrite
opacity={1}
side={DoubleSide}
transparent={false}
/>
</mesh>
</group>
)
}
function getWallMoveHandles(wall: WallNode): WallMoveHandle[] {
const dx = wall.end[0] - wall.start[0]
const dz = wall.end[1] - wall.start[1]
const length = Math.hypot(dx, dz)
if (length < 1e-6) {
return []
}
const normal: [number, number] = [-dz / length, dx / length]
const midpoint: [number, number] = [
(wall.start[0] + wall.end[0]) / 2,
(wall.start[1] + wall.end[1]) / 2,
]
const wallHeight = wall.height ?? DEFAULT_WALL_HEIGHT
const handleHeight = Math.max(wallHeight - HANDLE_TOP_INSET, HANDLE_MIN_HEIGHT)
const offset = Math.max(getWallThickness(wall) / 2 + HANDLE_OFFSET, HANDLE_MIN_OFFSET)
return [
buildWallMoveHandle('front', midpoint, normal, offset, handleHeight),
buildWallMoveHandle('back', midpoint, [-normal[0], -normal[1]], offset, handleHeight),
]
}
function buildWallMoveHandle(
key: string,
midpoint: [number, number],
direction: [number, number],
offset: number,
height: number,
): WallMoveHandle {
return {
direction,
key,
position: [midpoint[0] + direction[0] * offset, height, midpoint[1] + direction[1] * offset],
rotationY: Math.atan2(-direction[1], direction[0]),
}
}
@@ -1,265 +0,0 @@
'use client'
import { useScene } from '@pascal-app/core'
import { ImageIcon, MessageSquare, X } from 'lucide-react'
import { useCallback, useRef, useState } from 'react'
import { Button } from './ui/primitives/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from './ui/primitives/dialog'
const MAX_IMAGES = 5
const MAX_IMAGE_SIZE = 5 * 1024 * 1024
type ImagePreview = { file: File; url: string }
export function FeedbackDialog({
projectId: projectIdProp,
onSubmit,
}: {
projectId?: string
onSubmit?: (data: {
message: string
projectId?: string
sceneGraph: unknown
images: File[]
}) => Promise<{ success: boolean; error?: string }>
}) {
const projectId = projectIdProp
const [open, setOpen] = useState(false)
const [message, setMessage] = useState('')
const [images, setImages] = useState<ImagePreview[]>([])
const [isDragging, setIsDragging] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [sent, setSent] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const dragCounter = useRef(0)
const handleOpen = () => {
setOpen(true)
setSent(false)
setError(null)
setMessage('')
setImages([])
setIsDragging(false)
dragCounter.current = 0
}
const handleClose = () => {
if (isSubmitting) return
setOpen(false)
images.forEach((img) => {
URL.revokeObjectURL(img.url)
})
}
const addFiles = useCallback((files: FileList | File[]) => {
const incoming = Array.from(files).filter(
(f) => f.type.startsWith('image/') && f.size <= MAX_IMAGE_SIZE,
)
setImages((prev) => {
const remaining = MAX_IMAGES - prev.length
const added = incoming.slice(0, remaining).map((file) => ({
file,
url: URL.createObjectURL(file),
}))
return [...prev, ...added]
})
}, [])
const removeImage = (index: number) => {
setImages((prev) => {
const img = prev[index]
if (img) URL.revokeObjectURL(img.url)
return prev.filter((_, i) => i !== index)
})
}
// ── Drag handlers (on the entire dialog content) ──
const onDragEnter = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
dragCounter.current++
if (e.dataTransfer.types.includes('Files')) {
setIsDragging(true)
}
}
const onDragLeave = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
dragCounter.current--
if (dragCounter.current === 0) {
setIsDragging(false)
}
}
const onDragOver = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
}
const onDrop = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
dragCounter.current = 0
setIsDragging(false)
if (e.dataTransfer.files.length > 0) {
addFiles(e.dataTransfer.files)
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
setIsSubmitting(true)
try {
if (!onSubmit) return
const { nodes, rootNodeIds } = useScene.getState()
const sceneGraph = { nodes, rootNodeIds }
const result = await onSubmit({
message,
projectId,
sceneGraph,
images: images.map((img) => img.file),
})
if (result.success) {
setSent(true)
setTimeout(() => setOpen(false), 1500)
} else {
setError(result.error ?? 'Something went wrong')
}
} finally {
setIsSubmitting(false)
}
}
return (
<>
<button
className="flex items-center gap-2 rounded-lg border border-border bg-background/95 px-3 py-2 font-medium text-sm shadow-lg backdrop-blur-md transition-colors hover:bg-accent/90"
onClick={handleOpen}
>
<MessageSquare className="h-4 w-4" />
Feedback
</button>
<Dialog onOpenChange={handleClose} open={open}>
<DialogContent
className="sm:max-w-[460px]"
onDragEnter={onDragEnter}
onDragLeave={onDragLeave}
onDragOver={onDragOver}
onDrop={onDrop}
>
{/* Drag overlay — only visible when dragging files over the dialog */}
{isDragging && (
<div className="absolute inset-0 z-50 flex items-center justify-center rounded-lg border-2 border-primary/50 border-dashed bg-primary/5 backdrop-blur-sm transition-all">
<div className="flex flex-col items-center gap-2 text-primary/70">
<ImageIcon className="h-8 w-8" />
<p className="font-medium text-sm">Drop images here</p>
</div>
</div>
)}
<DialogHeader>
<DialogTitle>Send Feedback</DialogTitle>
<DialogDescription>We&apos;d love to hear your thoughts</DialogDescription>
</DialogHeader>
{sent ? (
<p className="py-4 text-center text-muted-foreground text-sm">
Thanks for your feedback!
</p>
) : (
<form className="space-y-4" onSubmit={handleSubmit}>
<div>
<label className="font-medium text-sm" htmlFor="feedback-message">
Your feedback
</label>
<textarea
autoFocus
className="mt-1 w-full resize-none rounded-md border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary"
disabled={isSubmitting}
id="feedback-message"
onChange={(e) => setMessage(e.target.value)}
placeholder="Share your thoughts, suggestions, feature requests, or report issues..."
rows={5}
value={message}
/>
</div>
{/* Image thumbnails */}
{images.length > 0 && (
<div className="flex flex-wrap gap-2">
{images.map((img, i) => (
<div
className="group relative h-14 w-14 overflow-hidden rounded-md border border-border"
key={img.url}
>
<img alt="" className="h-full w-full object-cover" src={img.url} />
<button
className="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 transition-opacity group-hover:opacity-100"
onClick={() => removeImage(i)}
type="button"
>
<X className="h-4 w-4 text-white" />
</button>
</div>
))}
</div>
)}
{error && <p className="text-destructive text-sm">{error}</p>}
<div className="flex items-center justify-between">
{/* Subtle attach button */}
<button
className="flex items-center gap-1.5 text-muted-foreground text-xs transition-colors hover:text-foreground disabled:opacity-40"
disabled={isSubmitting || images.length >= MAX_IMAGES}
onClick={() => fileInputRef.current?.click()}
type="button"
>
<ImageIcon className="h-3.5 w-3.5" />
{images.length > 0 ? `${images.length}/${MAX_IMAGES}` : 'Attach'}
</button>
<input
accept="image/*"
className="hidden"
multiple
onChange={(e) => {
if (e.target.files) addFiles(e.target.files)
e.target.value = ''
}}
ref={fileInputRef}
type="file"
/>
<div className="flex gap-2">
<Button
disabled={isSubmitting}
onClick={handleClose}
type="button"
variant="outline"
>
Cancel
</Button>
<Button disabled={isSubmitting || !message.trim() || !onSubmit} type="submit">
{isSubmitting ? 'Sending...' : 'Send Feedback'}
</Button>
</div>
</div>
</form>
)}
</DialogContent>
</Dialog>
</>
)
}
@@ -1,280 +0,0 @@
'use client'
import { Howl } from 'howler'
import { Disc3, Settings2, SkipBack, SkipForward, Volume2, VolumeX } from 'lucide-react'
import { AnimatePresence, motion } from 'motion/react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Slider } from '../components/ui/slider'
import { cn } from '../lib/utils'
import useAudio from '../store/use-audio'
const PLAYLIST = [
{
title: 'Ballroom in Miniature',
file: '/audios/radios/classic/Ballroom in Miniature.mp3',
},
{
title: 'Blueprints in Springtime',
file: '/audios/radios/classic/Blueprints in Springtime.mp3',
},
{
title: 'Clockwork Tea Party',
file: '/audios/radios/classic/Clockwork Tea Party.mp3',
},
{
title: 'Clockwork Tea Party (Alternate)',
file: '/audios/radios/classic/Clockwork Tea Party (Alternate).mp3',
},
{
title: 'Clockwork Teacups',
file: '/audios/radios/classic/Clockwork Teacups.mp3',
},
{
title: 'Evening in the Parlor',
file: '/audios/radios/classic/Evening in the Parlor.mp3',
},
{
title: 'Glass Atrium',
file: '/audios/radios/classic/Glass Atrium.mp3',
},
{
title: 'Moonlight On The Drafting Table',
file: '/audios/radios/classic/Moonlight On The Drafting Table.mp3',
},
{
title: 'Sunlit Garden Reverie',
file: '/audios/radios/classic/Sunlit Garden Reverie.mp3',
},
{
title: 'Sunlit Waltz in Pastel Hues',
file: '/audios/radios/classic/Sunlit Waltz in Pastel Hues.mp3',
},
]
// Shuffle array helper
function shuffleArray<T>(array: T[]): T[] {
const shuffled = [...array]
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[shuffled[i], shuffled[j]] = [shuffled[j]!, shuffled[i]!]
}
return shuffled
}
export function PascalRadio() {
const [shuffledPlaylist] = useState(() => shuffleArray(PLAYLIST))
const [currentTrackIndex, setCurrentTrackIndex] = useState(0)
const { masterVolume, radioVolume, muted, isRadioPlaying, setRadioPlaying } = useAudio()
const soundRef = useRef<Howl | null>(null)
const [isOpen, setIsOpen] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const currentTrack = shuffledPlaylist[currentTrackIndex]!
// Calculate effective volume (masterVolume * radioVolume, both are 0-100)
const effectiveVolume = (masterVolume / 100) * (radioVolume / 100)
// Keep a ref so the track-init effect can read current volume/muted/isRadioPlaying
// without those values being part of its dependency array (which would restart the song).
const effectiveVolumeRef = useRef(effectiveVolume)
const mutedRef = useRef(muted)
const isPlayingRef = useRef(isRadioPlaying)
effectiveVolumeRef.current = effectiveVolume
mutedRef.current = muted
isPlayingRef.current = isRadioPlaying
const handleNext = useCallback(() => {
setCurrentTrackIndex((prev) => (prev + 1) % shuffledPlaylist.length)
}, [shuffledPlaylist.length])
const handlePrevious = useCallback(() => {
setCurrentTrackIndex((prev) => (prev - 1 + shuffledPlaylist.length) % shuffledPlaylist.length)
}, [shuffledPlaylist.length])
// Initialize Howler only when the track changes — not on volume/mute/play-state changes.
// Volume and mute are handled by the separate effect below.
useEffect(() => {
if (soundRef.current) {
soundRef.current.unload()
}
const wasPlaying = isPlayingRef.current
soundRef.current = new Howl({
src: [currentTrack.file],
volume: mutedRef.current ? 0 : effectiveVolumeRef.current,
onend: handleNext,
})
if (wasPlaying && !mutedRef.current) {
soundRef.current?.play()
}
return () => {
soundRef.current?.unload()
}
}, [handleNext, currentTrack.file])
// Update volume when settings change
useEffect(() => {
if (soundRef.current) {
soundRef.current.volume(muted ? 0 : effectiveVolume)
// Pause if muted, resume if unmuted and was playing
if (muted && isRadioPlaying) {
soundRef.current.pause()
} else if (!muted && isRadioPlaying && !soundRef.current.playing()) {
soundRef.current.play()
} else if (!isRadioPlaying && soundRef.current.playing()) {
soundRef.current.pause()
}
}
}, [effectiveVolume, muted, isRadioPlaying])
const handlePlayPause = () => {
if (!soundRef.current || muted) return
if (isRadioPlaying) {
soundRef.current.pause()
} else {
soundRef.current.play()
}
setRadioPlaying(!isRadioPlaying)
}
const handleVolumeChange = (value: number[]) => {
useAudio.setState({ radioVolume: value[0] })
}
// Handle click outside to close
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsOpen(false)
}
}
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside)
}
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}, [isOpen])
return (
<motion.div
className={cn(
'flex flex-col overflow-hidden rounded-lg border border-border bg-background/95 shadow-lg backdrop-blur-md',
!isOpen && 'cursor-pointer transition-colors hover:bg-accent/90',
)}
layout
onClick={() => {
if (!isOpen) setIsOpen(true)
}}
ref={containerRef}
transition={{ type: 'spring', bounce: 0.2, duration: 0.6 }}
>
<div className="flex items-center justify-between gap-2 px-3 py-2 font-medium text-sm">
<div className="flex items-center gap-2">
<Disc3 className={cn('h-4 w-4 shrink-0', isRadioPlaying && 'animate-spin')} />
<span className="hidden whitespace-nowrap sm:inline">Radio Pascal</span>
</div>
<div className="flex items-center gap-2">
<div
aria-label={isRadioPlaying ? 'Pause' : 'Play'}
className="cursor-pointer rounded-sm bg-accent/30 p-1 transition-all hover:bg-accent hover:text-accent-foreground hover:shadow-sm"
onClick={(e) => {
e.stopPropagation()
handlePlayPause()
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
e.stopPropagation()
handlePlayPause()
}
}}
role="button"
tabIndex={0}
>
{isRadioPlaying ? (
<Volume2 className="h-3.5 w-3.5" />
) : (
<VolumeX className="h-3.5 w-3.5" />
)}
</div>
<button
aria-label="Radio Settings"
className={cn(
'cursor-pointer rounded-sm p-1 transition-all hover:bg-accent hover:text-accent-foreground',
isOpen && 'bg-accent text-accent-foreground',
)}
onClick={(e) => {
e.stopPropagation()
setIsOpen(!isOpen)
}}
>
<Settings2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
<AnimatePresence>
{isOpen && (
<motion.div
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
initial={{ opacity: 0, height: 0 }}
transition={{ type: 'spring', bounce: 0.2, duration: 0.6 }}
>
<div className="w-[16rem] space-y-3 px-3 pb-3">
<div className="mb-3 h-px w-full bg-border/50" />
{/* Current song info with prev/next */}
<div>
<p className="mb-2 text-muted-foreground text-xs">Now Playing</p>
<div className="flex items-center justify-between gap-2">
<button
aria-label="Previous"
className="shrink-0 rounded-full p-1.5 transition-colors hover:bg-accent"
onClick={handlePrevious}
>
<SkipBack className="h-4 w-4" />
</button>
<p
className="flex-1 truncate text-center font-medium text-sm"
title={currentTrack.title}
>
{currentTrack.title}
</p>
<button
aria-label="Next"
className="shrink-0 rounded-full p-1.5 transition-colors hover:bg-accent"
onClick={handleNext}
>
<SkipForward className="h-4 w-4" />
</button>
</div>
</div>
{/* Volume control */}
<div className="flex items-center gap-2">
<Volume2 className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<Slider
aria-label="Radio Volume"
className="flex-1"
max={100}
onValueChange={handleVolumeChange}
step={1}
value={[radioVolume]}
/>
<span className="w-8 shrink-0 text-right text-muted-foreground text-xs">
{radioVolume}%
</span>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
)
}
@@ -1,16 +0,0 @@
'use client'
import { Eye } from 'lucide-react'
import useEditor from '../store/use-editor'
export function PreviewButton() {
return (
<button
className="flex cursor-pointer items-center gap-2 rounded-lg border border-border bg-background/95 px-3 py-2 font-medium text-sm shadow-lg backdrop-blur-md transition-colors hover:bg-accent/90"
onClick={() => useEditor.getState().setPreviewMode(true)}
>
<Eye className="h-4 w-4 shrink-0" />
<span className="hidden whitespace-nowrap sm:inline">Preview</span>
</button>
)
}
@@ -75,7 +75,9 @@ const CeilingSelectionAffordance = ({
ceiling: CeilingNode
levelId: string
}) => {
const [levelObject, setLevelObject] = useState<Object3D | null>(() => sceneRegistry.nodes.get(levelId) ?? null)
const [levelObject, setLevelObject] = useState<Object3D | null>(
() => sceneRegistry.nodes.get(levelId) ?? null,
)
const corners = useMemo(() => buildCornerBrackets(ceiling.polygon), [ceiling.polygon])
@@ -110,11 +112,7 @@ const CeilingSelectionAffordance = ({
return createPortal(
<group position={[0, (ceiling.height ?? 2.5) + BRACKET_Y_OFFSET, 0]}>
{corners.map((corner, index) => (
<CornerBracket
ceiling={ceiling}
corner={corner}
key={`${ceiling.id}-corner-${index}`}
/>
<CornerBracket ceiling={ceiling} corner={corner} key={`${ceiling.id}-corner-${index}`} />
))}
</group>,
levelObject,
@@ -210,11 +208,7 @@ const BracketLeg = ({
]
return (
<mesh
onClick={onClick}
position={position}
rotation={[0, angle, 0]}
>
<mesh onClick={onClick} position={position} rotation={[0, angle, 0]}>
<boxGeometry args={[length, BRACKET_HEIGHT, BRACKET_THICKNESS]} />
<meshBasicMaterial color={color} depthWrite={false} opacity={opacity} transparent />
</mesh>
@@ -234,7 +228,11 @@ function buildCornerBrackets(polygon: Array<[number, number]>): CornerBracketDat
const incomingLength = Math.hypot(incomingVector[0], incomingVector[1])
const outgoingLength = Math.hypot(outgoingVector[0], outgoingVector[1])
const cornerStrength = 1 - Math.abs(incomingDirection[0] * outgoingDirection[0] + incomingDirection[1] * outgoingDirection[1])
const cornerStrength =
1 -
Math.abs(
incomingDirection[0] * outgoingDirection[0] + incomingDirection[1] * outgoingDirection[1],
)
return {
corner,
@@ -6,7 +6,7 @@ import { useEffect, useRef } from 'react'
* Imperatively toggles the Three.js visibility of roof objects based on the
* editor selection — without causing React re-renders in RoofRenderer.
*
* When a roof-segment is selected:
* When a roof (or one of its segments) is selected:
* - merged-roof mesh is hidden
* - segments-wrapper group is shown (individual segments visible for editing)
* - all children are marked dirty so RoofSystem rebuilds their geometry
@@ -68,7 +68,7 @@ export const StairEditSystem = () => {
const segmentsWrapper = group.getObjectByName('segments-wrapper')
const isActive = activeStairIds.has(stairId)
if (mergedMesh) mergedMesh.visible = !isActive && !isCurved
if (mergedMesh) mergedMesh.visible = !(isActive || isCurved)
if (segmentsWrapper) segmentsWrapper.visible = isActive && !isCurved
if (stairNode?.children?.length) {
View File
@@ -1,13 +1,19 @@
'use client'
import { type AnyNodeId, emitter, type GridEvent, useScene, type CeilingNode } from '@pascal-app/core'
import {
type AnyNodeId,
type CeilingNode,
emitter,
type GridEvent,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { BufferGeometry, DoubleSide, Path, Shape, ShapeGeometry, Vector3 } from 'three'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere'
import { BufferGeometry, DoubleSide, Path, Shape, ShapeGeometry, Vector3 } from 'three'
function snap(value: number) {
return Math.round(value * 2) / 2
@@ -202,6 +208,7 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
transparent
/>
</mesh>
{/* @ts-ignore */}
<line geometry={previewOutlineGeometry} position={[0, (node.height ?? 2.5) + 0.02, 0]}>
<lineBasicMaterial color="#ffffff" depthWrite={false} opacity={0.95} transparent />
</line>
@@ -83,11 +83,10 @@ export const CurveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
? event.localPosition[2]
: snapScalarToGrid(event.localPosition[2], snapStep)
const offsetFromMidpoint =
-(
(localX - chord.midpoint.x) * chord.normal.x +
(localZ - chord.midpoint.y) * chord.normal.y
)
const offsetFromMidpoint = -(
(localX - chord.midpoint.x) * chord.normal.x +
(localZ - chord.midpoint.y) * chord.normal.y
)
const snappedOffset = shiftPressedRef.current
? offsetFromMidpoint
: snapScalarToGrid(offsetFromMidpoint, snapStep)
@@ -297,7 +297,7 @@ export const FenceTool: React.FC = () => {
return (
<group>
<CursorSphere ref={cursorRef} height={FENCE_PREVIEW_HEIGHT} />
<CursorSphere height={FENCE_PREVIEW_HEIGHT} ref={cursorRef} />
<mesh layers={EDITOR_LAYER} ref={previewRef} renderOrder={1} visible={false}>
<shapeGeometry />
<meshBasicMaterial
@@ -338,7 +338,7 @@ function DraftMeasurementLabel({
}) {
return (
<Html center position={position} style={{ pointerEvents: 'none' }} zIndexRange={[100, 0]}>
<div className="whitespace-nowrap rounded-full border border-border bg-background/95 px-2 py-1 font-mono text-[11px] font-semibold text-foreground shadow-lg backdrop-blur-md">
<div className="whitespace-nowrap rounded-full border border-border bg-background/95 px-2 py-1 font-mono font-semibold text-[11px] text-foreground shadow-lg backdrop-blur-md">
{label}
</div>
</Html>
@@ -291,8 +291,9 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
}
const preview = previewRef.current ?? { start: originalStart, end: originalEnd }
const hasChanged =
!samePoint(preview.start, originalStart) || !samePoint(preview.end, originalEnd)
const hasChanged = !(
samePoint(preview.start, originalStart) && samePoint(preview.end, originalEnd)
)
if (hasChanged && isWallLongEnough(preview.start, preview.end)) {
wasCommitted = true
@@ -392,7 +393,7 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
>
<div className="translate-y-10">
<div
className={`whitespace-nowrap rounded-full border px-2 py-1 text-[11px] font-medium shadow-lg backdrop-blur-md transition-colors ${
className={`whitespace-nowrap rounded-full border px-2 py-1 font-medium text-[11px] shadow-lg backdrop-blur-md transition-colors ${
altPressed
? 'border-amber-500/70 bg-amber-500/15 text-amber-100'
: 'border-border/70 bg-background/90 text-foreground/80'
@@ -416,7 +417,7 @@ function EndpointAngleLabel({
}) {
return (
<Html center position={position} style={{ pointerEvents: 'none' }} zIndexRange={[100, 0]}>
<div className="whitespace-nowrap rounded-full border border-border bg-background/95 px-2 py-1 font-mono text-[11px] font-semibold text-foreground shadow-lg backdrop-blur-md">
<div className="whitespace-nowrap rounded-full border border-border bg-background/95 px-2 py-1 font-mono font-semibold text-[11px] text-foreground shadow-lg backdrop-blur-md">
{label}
</div>
</Html>
@@ -17,8 +17,8 @@ import type * as THREE from 'three'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { snapFenceDraftPoint } from './fence-drafting'
import { CursorSphere } from '../shared/cursor-sphere'
import { snapFenceDraftPoint } from './fence-drafting'
function samePoint(a: [number, number], b: [number, number]) {
return a[0] === b[0] && a[1] === b[1]
@@ -50,10 +50,12 @@ function getLinkedFenceSnapshots(args: {
}
if (
!samePoint(node.start, originalStart) &&
!samePoint(node.start, originalEnd) &&
!samePoint(node.end, originalStart) &&
!samePoint(node.end, originalEnd)
!(
samePoint(node.start, originalStart) ||
samePoint(node.start, originalEnd) ||
samePoint(node.end, originalStart) ||
samePoint(node.end, originalEnd)
)
) {
continue
}
@@ -164,7 +166,9 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
}
}
const applyNodePreview = (updates: Array<{ id: FenceNode['id']; start: [number, number]; end: [number, number] }>) => {
const applyNodePreview = (
updates: Array<{ id: FenceNode['id']; start: [number, number]; end: [number, number] }>,
) => {
useScene.getState().updateNodes(
updates.map((entry) => ({
id: entry.id as AnyNodeId,
@@ -275,13 +279,13 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
emitter.on('tool:cancel', onCancel)
return () => {
if (!wasCommitted) {
clearPreviewState()
} else {
if (wasCommitted) {
useLiveTransforms.getState().clear(nodeId)
for (const linkedFence of linkedOriginalsRef.current) {
useLiveTransforms.getState().clear(linkedFence.id)
}
} else {
clearPreviewState()
}
useScene.temporal.getState().resume()
emitter.off('grid:move', onGridMove)
@@ -106,11 +106,11 @@ export const MoveTool: React.FC<{
if (movingNode.type === 'elevator')
return <MoveElevatorTool node={movingNode as ElevatorNode} onCommitted={onNodeMoved} />
if (movingNode.type === 'window') return <MoveWindowTool node={movingNode as WindowNode} />
if (movingNode.type === 'fence') return <MoveFenceTool node={movingNode as FenceNode} />
if (movingNode.type === 'ceiling') return <MoveCeilingTool node={movingNode as CeilingNode} />
if (movingNode.type === 'column') return <MoveColumnTool node={movingNode as ColumnNode} />
if (movingNode.type === 'slab') return <MoveSlabTool node={movingNode as SlabNode} />
if (movingNode.type === 'wall') return <MoveWallTool node={movingNode as WallNode} />
if (movingNode.type === 'fence') return <MoveFenceTool node={movingNode as FenceNode} />
if (movingNode.type === 'roof' || movingNode.type === 'roof-segment')
return <MoveRoofTool node={movingNode as RoofNode | RoofSegmentNode} />
if (movingNode.type === 'spawn')
@@ -10,9 +10,7 @@ function positiveModulo(value: number, divisor: number): number {
}
/**
* Snaps a position to 0.5 grid, with an offset to align item edges to grid lines.
* For items with dimensions like 2.5, the center would be at 1.25 from the edge,
* which doesn't align with 0.5 grid. This adds an offset so edges align instead.
* Snaps a position to the active grid step, aligning item edges to grid lines.
*/
export function snapToGrid(position: number, dimension: number, step = getGridSnapStep()): number {
const halfDim = dimension / 2
@@ -21,7 +19,7 @@ export function snapToGrid(position: number, dimension: number, step = getGridSn
}
/**
* Snap a value to 0.5 increments (used for wall-local positions).
* Snap a value to the active grid step (used for wall-local positions).
*/
export function snapToHalf(value: number, step = getGridSnapStep()): number {
return Math.round(value / step) * step
@@ -506,21 +506,22 @@ export const itemSurfaceStrategy = {
const worldSnapped = surfaceMesh.localToWorld(new Vector3(x, y, z))
// Counter-rotate so the draft's world Y rotation stays continuous when
// the user drags onto a rotated surface item. The cursor wireframe
// already shows the user's intended world rotation; we just need to
// store the right local value relative to the new parent.
const surfaceQuat = new Quaternion()
surfaceMesh.getWorldQuaternion(surfaceQuat)
const surfaceWorldY = new Euler().setFromQuaternion(surfaceQuat, 'YXZ').y
const localRotationY = ctx.currentCursorRotationY - surfaceWorldY
const draftRotation = ctx.draftItem?.rotation ?? [0, 0, 0]
return {
stateUpdate: { surface: 'item-surface', surfaceItemId: surfaceItem.id },
nodeUpdate: {
position: [x, y, z],
parentId: surfaceItem.id,
rotation: [
(ctx.draftItem?.rotation ?? [0, 0, 0])[0],
(() => {
const surfaceQuat = new Quaternion()
surfaceMesh.getWorldQuaternion(surfaceQuat)
const surfaceWorldY = new Euler().setFromQuaternion(surfaceQuat, 'YXZ').y
return ctx.currentCursorRotationY - surfaceWorldY
})(),
(ctx.draftItem?.rotation ?? [0, 0, 0])[2],
] as [number, number, number],
rotation: [draftRotation[0], localRotationY, draftRotation[2]],
},
cursorRotationY: ctx.currentCursorRotationY,
gridPosition: [x, y, z],
@@ -130,7 +130,6 @@ export function useDraftNode(): DraftNodeHandle {
useScene.getState().updateNode(draft.id, {
position: updateProps.position ?? draft.position,
rotation: updateProps.rotation ?? draft.rotation,
scale: updateProps.scale ?? draft.scale,
side: updateProps.side ?? draft.side,
metadata: updateProps.metadata ?? stripTransient(draft.metadata),
parentId: parentId as string,
@@ -20,15 +20,12 @@ import { Html } from '@react-three/drei'
import { useFrame } from '@react-three/fiber'
import { useEffect, useMemo, useRef, useState } from 'react'
import {
Box3,
BufferGeometry,
Euler,
Float32BufferAttribute,
type Group,
type LineSegments,
Matrix4,
type Mesh,
type Object3D,
PlaneGeometry,
Quaternion,
Vector3,
@@ -117,79 +114,6 @@ function expandBoundsToGrid(
}
}
function getPreviewBoundsFromObject(object: Object3D | null): PreviewBounds | null {
if (!object) return null
object.updateWorldMatrix(true, true)
const inverseRootMatrix = new Matrix4().copy(object.matrixWorld).invert()
const localMatrix = new Matrix4()
const localBounds = new Box3()
const scratchBounds = new Box3()
const hasBounds = { current: false }
const registeredNodeObjects = new Set(sceneRegistry.nodes.values())
const expandBounds = (child: Object3D) => {
if (child !== object && registeredNodeObjects.has(child)) {
return
}
const mesh = child as Object3D & {
isMesh?: boolean
name?: string
geometry?: {
boundingBox: Box3 | null
computeBoundingBox?: () => void
}
}
if (mesh.isMesh && mesh.name !== 'cutout' && mesh.geometry) {
if (!mesh.geometry.boundingBox && mesh.geometry.computeBoundingBox) {
mesh.geometry.computeBoundingBox()
}
if (mesh.geometry.boundingBox) {
localMatrix.copy(inverseRootMatrix).multiply(mesh.matrixWorld)
scratchBounds.copy(mesh.geometry.boundingBox).applyMatrix4(localMatrix)
if (Number.isFinite(scratchBounds.min.x) && Number.isFinite(scratchBounds.max.x)) {
if (!hasBounds.current) {
localBounds.copy(scratchBounds)
hasBounds.current = true
} else {
localBounds.union(scratchBounds)
}
}
}
}
for (const grandchild of child.children) {
expandBounds(grandchild)
}
}
for (const child of object.children) {
expandBounds(child)
}
if (!hasBounds.current) return null
const size = new Vector3()
const center = new Vector3()
localBounds.getSize(size)
localBounds.getCenter(center)
if (size.x <= 0 || size.y <= 0 || size.z <= 0) {
return null
}
return {
min: [localBounds.min.x, localBounds.min.y, localBounds.min.z],
max: [localBounds.max.x, localBounds.max.y, localBounds.max.z],
dimensions: [size.x, size.y, size.z],
center: [center.x, center.y, center.z],
}
}
function getFallbackPreviewBounds(
item: import('@pascal-app/core').ItemNode | null,
asset: AssetInput,
@@ -366,7 +290,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
)
const shiftFreeRef = useRef(false)
const previewBoundsSignatureRef = useRef<string | null>(null)
const meshPreviewAppliedRef = useRef(false)
const [dimensionBounds, setDimensionBounds] = useState<PreviewBounds | null>(null)
// Store config callbacks in refs to avoid re-running effect when they change
@@ -503,7 +426,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
useEffect(() => {
if (!asset) return
useScene.temporal.getState().pause()
meshPreviewAppliedRef.current = false
const validators = { canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling }
@@ -1264,21 +1186,17 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
window.addEventListener('contextmenu', onContextMenu)
// ---- Bounding box geometry ----
// Always derive the wireframe from `asset.dimensions × scale` rather than
// the rendered mesh bounds. Asset dimensions describe the item's footprint
// (e.g. only the trunk for a palm tree), while the mesh bbox would include
// foliage or other visual overhang the snap logic intentionally ignores.
const draft = draftNode.current
const fallbackBounds = expandBoundsToGrid(
const previewBounds = expandBoundsToGrid(
getFallbackPreviewBounds(draft, asset, asset.attachTo),
asset.attachTo,
gridSnapStep,
)
const previewBounds = draft
? expandBoundsToGrid(
getPreviewBoundsFromObject(sceneRegistry.nodes.get(draft.id) ?? null) ??
getFallbackPreviewBounds(draft, asset, asset.attachTo),
asset.attachTo,
gridSnapStep,
)
: fallbackBounds
updatePreviewGeometry(previewBounds)
updateDimensionGuides(previewBounds)
@@ -1324,7 +1242,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
return () => {
tearingDown = true
meshPreviewAppliedRef.current = false
unsubDraftWatch()
// Clear live transform for any remaining draft
if (draftNode.current) {
@@ -1358,17 +1275,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
useEffect(() => {
if (!asset) return
const draft = draftNode.current
const fallbackBounds = expandBoundsToGrid(
const previewBounds = expandBoundsToGrid(
getFallbackPreviewBounds(draft, asset, asset.attachTo),
asset.attachTo,
gridSnapStep,
)
const meshBounds = draft
? getPreviewBoundsFromObject(sceneRegistry.nodes.get(draft.id) ?? null)
: null
const previewBounds = meshBounds
? expandBoundsToGrid(meshBounds, asset.attachTo, gridSnapStep)
: fallbackBounds
updatePreviewGeometry(previewBounds)
updateDimensionGuides(previewBounds)
}, [gridSnapStep, asset, draftNode])
@@ -1388,19 +1299,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
if (!draftNode.current) return
const mesh = sceneRegistry.nodes.get(draftNode.current.id)
if (!mesh) return
if (!meshPreviewAppliedRef.current) {
const previewBounds = getPreviewBoundsFromObject(mesh)
if (previewBounds) {
const expandedBounds = expandBoundsToGrid(
previewBounds,
asset.attachTo,
useEditor.getState().gridSnapStep,
)
updatePreviewGeometry(expandedBounds)
updateDimensionGuides(expandedBounds)
meshPreviewAppliedRef.current = true
}
}
// Hide wall/ceiling-attached items when between surfaces (only cursor visible)
if (asset.attachTo && placementState.current.surface === 'floor') {
@@ -1490,22 +1388,22 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
const measurementContent = (
<>
<lineSegments
layers={EDITOR_LAYER}
geometry={initialWidthGuideGeometry}
layers={EDITOR_LAYER}
material={measurementMaterial}
ref={measurementWidthRef}
renderOrder={998}
/>
<lineSegments
layers={EDITOR_LAYER}
geometry={initialDepthGuideGeometry}
layers={EDITOR_LAYER}
material={measurementMaterial}
ref={measurementDepthRef}
renderOrder={998}
/>
<lineSegments
layers={EDITOR_LAYER}
geometry={initialHeightGuideGeometry}
layers={EDITOR_LAYER}
material={measurementMaterial}
ref={measurementHeightRef}
renderOrder={998}
@@ -158,7 +158,9 @@ export const MoveRoofTool: React.FC<{
const localToWorldPoint = (localPoint: WallPlanPoint, y: number): [number, number, number] => {
if (buildingObj) {
const worldPoint = buildingObj.localToWorld(new THREE.Vector3(localPoint[0], y, localPoint[1]))
const worldPoint = buildingObj.localToWorld(
new THREE.Vector3(localPoint[0], y, localPoint[1]),
)
return [worldPoint.x, worldPoint.y, worldPoint.z]
}
@@ -211,7 +213,10 @@ export const MoveRoofTool: React.FC<{
})
const [gridX, , gridZ] = localToWorldPoint(snappedLocal, y)
if (previousGridPosRef.current && (gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])) {
if (
previousGridPosRef.current &&
(gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])
) {
sfxEmitter.emit('sfx:grid-snap')
}
@@ -16,8 +16,8 @@ import {
type ZoneNode,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useThree } from '@react-three/fiber'
import type { ThreeElements } from '@react-three/fiber'
import { useThree } from '@react-three/fiber'
import { useEffect, useRef } from 'react'
import {
Box3,
@@ -202,7 +202,17 @@ function collectNodeIdsInBounds(bounds: Bounds): string[] {
const result: string[] = []
if (phase === 'structure' && structureLayer === 'elements') {
if (phase === 'structure' && structureLayer === 'zones') {
for (const childId of levelNode.children) {
const node = nodes[childId as AnyNodeId]
if (!node || node.type !== 'zone') continue
const zone = node as ZoneNode
if (polygonIntersectsBounds(zone.polygon, bounds)) {
result.push(zone.id)
}
}
} else {
// structure (elements) and furnish: collect all node types
for (const childId of levelNode.children) {
const node = nodes[childId as AnyNodeId]
if (!node) continue
@@ -255,22 +265,7 @@ function collectNodeIdsInBounds(bounds: Bounds): string[] {
if (objectBoundsIntersectsBounds(column.id, bounds)) {
result.push(column.id)
}
}
}
} else if (phase === 'structure' && structureLayer === 'zones') {
for (const childId of levelNode.children) {
const node = nodes[childId as AnyNodeId]
if (!node || node.type !== 'zone') continue
const zone = node as ZoneNode
if (polygonIntersectsBounds(zone.polygon, bounds)) {
result.push(zone.id)
}
}
} else if (phase === 'furnish') {
for (const childId of levelNode.children) {
const node = nodes[childId as AnyNodeId]
if (!node) continue
if (node.type === 'item') {
} else if (node.type === 'item') {
const item = node as ItemNode
if (item.asset.category === 'door' || item.asset.category === 'window') continue
const xz = getNodeWorldXZ(item.id)
@@ -145,7 +145,7 @@ export function getSegmentAngleReferenceAtPoint(
}
const projected = getProjectedPointOnSegment(point, segment)
if (!projected || !pointsMatch(point, projected, SEGMENT_POINT_TOLERANCE)) {
if (!(projected && pointsMatch(point, projected, SEGMENT_POINT_TOLERANCE))) {
return null
}
@@ -64,9 +64,9 @@ export const ToolManager: React.FC = () => {
const curvingFence = useEditor((state) => state.curvingFence)
const editingHole = useEditor((state) => state.editingHole)
const selectedZoneId = useViewer((state) => state.selection.zoneId)
const selectedLevelId = useViewer((state) => state.selection.levelId)
const buildingId = useViewer((state) => state.selection.buildingId)
const selectedIds = useViewer((state) => state.selection.selectedIds)
const buildingId = useViewer((state) => state.selection.buildingId)
const activeLevelId = useViewer((state) => state.selection.levelId)
const setSelection = useViewer((state) => state.setSelection)
const nodes = useScene((state) => state.nodes)
@@ -140,8 +140,8 @@ export const ToolManager: React.FC = () => {
return (
<>
{showSiteBoundaryEditor && <SiteBoundaryEditor />}
{/* World-space tools: site boundary and building movement operate in world coordinates */}
{showSiteBoundaryEditor && <SiteBoundaryEditor />}
{movingNode?.type === 'building' && (
<MoveTool onNodeMoved={handlePlacedNodeSelected} onSpawnMoved={handlePlacedNodeSelected} />
)}
@@ -175,21 +175,25 @@ export const ToolManager: React.FC = () => {
/>
)}
{!movingNode && showBuildTool && tool === 'spawn' && (
<SpawnTool currentLevelId={selectedLevelId} onPlaced={handlePlacedNodeSelected} />
<SpawnTool currentLevelId={activeLevelId ?? null} onPlaced={handlePlacedNodeSelected} />
)}
{!movingNode && showBuildTool && tool === 'column' && (
<ColumnTool currentLevelId={selectedLevelId} onPlaced={handlePlacedNodeSelected} />
<ColumnTool currentLevelId={activeLevelId ?? null} onPlaced={handlePlacedNodeSelected} />
)}
{!movingNode && showBuildTool && tool === 'elevator' && (
<ElevatorTool
buildingId={buildingId as BuildingNode['id'] | null}
levelId={selectedLevelId}
levelId={activeLevelId ?? null}
onPlaced={handlePlacedElevatorSelected}
/>
)}
{!movingNode && BuildToolComponent && tool !== 'column' && tool !== 'elevator' && (
{!movingNode &&
BuildToolComponent &&
tool !== 'spawn' &&
tool !== 'column' &&
tool !== 'elevator' ? (
<BuildToolComponent />
)}
) : null}
</group>
</>
)
@@ -81,15 +81,17 @@ export const CurveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
? event.localPosition[2]
: snapScalarToGrid(event.localPosition[2], snapStep)
const offsetFromMidpoint =
-(
(localX - chord.midpoint.x) * chord.normal.x +
(localZ - chord.midpoint.y) * chord.normal.y
)
const offsetFromMidpoint = -(
(localX - chord.midpoint.x) * chord.normal.x +
(localZ - chord.midpoint.y) * chord.normal.y
)
const snappedOffset = shiftPressedRef.current
? offsetFromMidpoint
: snapScalarToGrid(offsetFromMidpoint, snapStep)
const nextCurveOffset = normalizeWallCurveOffset(node, Math.max(-maxCurveOffset, Math.min(maxCurveOffset, snappedOffset)))
const nextCurveOffset = normalizeWallCurveOffset(
node,
Math.max(-maxCurveOffset, Math.min(maxCurveOffset, snappedOffset)),
)
if (
previousCurveOffsetRef.current !== null &&
@@ -112,10 +112,12 @@ function getLinkedWallSnapshots(args: {
}
if (
!samePoint(node.start, originalStart) &&
!samePoint(node.start, originalEnd) &&
!samePoint(node.end, originalStart) &&
!samePoint(node.end, originalEnd)
!(
samePoint(node.start, originalStart) ||
samePoint(node.start, originalEnd) ||
samePoint(node.end, originalStart) ||
samePoint(node.end, originalEnd)
)
) {
continue
}
@@ -286,8 +288,9 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
}
const preview = previewRef.current ?? { start: originalStart, end: originalEnd }
const hasChanged =
!samePoint(preview.start, originalStart) || !samePoint(preview.end, originalEnd)
const hasChanged = !(
samePoint(preview.start, originalStart) && samePoint(preview.end, originalEnd)
)
if (hasChanged && isWallLongEnough(preview.start, preview.end)) {
wasCommitted = true
@@ -391,7 +394,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
>
<div className="translate-y-10">
<div
className={`whitespace-nowrap rounded-full border px-2 py-1 text-[11px] font-medium shadow-lg backdrop-blur-md transition-colors ${
className={`whitespace-nowrap rounded-full border px-2 py-1 font-medium text-[11px] shadow-lg backdrop-blur-md transition-colors ${
altPressed
? 'border-amber-500/80 bg-amber-500/15 text-amber-100'
: 'border-border bg-background/95 text-muted-foreground'
@@ -415,7 +418,7 @@ function EndpointAngleLabel({
}) {
return (
<Html center position={position} style={{ pointerEvents: 'none' }} zIndexRange={[100, 0]}>
<div className="whitespace-nowrap rounded-full border border-border bg-background/95 px-2 py-1 font-mono text-[11px] font-semibold text-foreground shadow-lg backdrop-blur-md">
<div className="whitespace-nowrap rounded-full border border-border bg-background/95 px-2 py-1 font-mono font-semibold text-[11px] text-foreground shadow-lg backdrop-blur-md">
{label}
</div>
</Html>
@@ -2,20 +2,35 @@
import {
type AnyNodeId,
constrainWallMoveDeltaToAxis,
DEFAULT_WALL_HEIGHT,
detectSpacesForLevel,
emitter,
type GridEvent,
getMaterialPresetByRef,
getPerpendicularWallMoveAxis,
pauseSceneHistory,
planAutoSlabsForLevel,
planWallMoveJunctions,
resolveMaterial,
resumeSceneHistory,
type SlabNode,
useScene,
type WallMoveAxis,
type WallMoveBridgePlan,
type WallMoveJunctionPlan,
type WallNode,
WallNode as WallSchema,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { BufferGeometry, DoubleSide, Float32BufferAttribute } from 'three'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { EDITOR_LAYER } from '../../../lib/constants'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere'
import { getWallGridStep, snapScalarToGrid } from './wall-drafting'
import { getWallGridStep, isWallLongEnough, snapScalarToGrid } from './wall-drafting'
function rotateVector([x, z]: [number, number], angle: number): [number, number] {
const cos = Math.cos(angle)
@@ -27,6 +42,10 @@ function samePoint(a: [number, number], b: [number, number]) {
return a[0] === b[0] && a[1] === b[1]
}
function pointKey(point: [number, number]) {
return `${point[0]}:${point[1]}`
}
function stripWallIsNewMetadata(meta: WallNode['metadata']): WallNode['metadata'] {
if (!meta || typeof meta !== 'object' || Array.isArray(meta)) {
return meta
@@ -37,10 +56,14 @@ function stripWallIsNewMetadata(meta: WallNode['metadata']): WallNode['metadata'
return nextMeta as WallNode['metadata']
}
type LinkedWallSnapshot = {
id: WallNode['id']
type LinkedWallSnapshot = WallNode
type GhostWallPreview = {
id: string
start: [number, number]
end: [number, number]
color: string
height: number
}
function getLinkedWallSnapshots(args: {
@@ -51,30 +74,42 @@ function getLinkedWallSnapshots(args: {
}) {
const { wallId, wallParentId, originalStart, originalEnd } = args
const { nodes } = useScene.getState()
const walls = Object.values(nodes).filter(
(node): node is WallNode =>
node?.type === 'wall' && node.id !== wallId && (node.parentId ?? null) === wallParentId,
)
const directlyLinkedWalls = walls.filter(
(wall) =>
samePoint(wall.start, originalStart) ||
samePoint(wall.start, originalEnd) ||
samePoint(wall.end, originalStart) ||
samePoint(wall.end, originalEnd),
)
const contextPoints = new Set([pointKey(originalStart), pointKey(originalEnd)])
for (const wall of directlyLinkedWalls) {
contextPoints.add(pointKey(wall.start))
contextPoints.add(pointKey(wall.end))
}
const snapshots: LinkedWallSnapshot[] = []
const seenWallIds = new Set<WallNode['id']>()
for (const node of Object.values(nodes)) {
if (!(node?.type === 'wall' && node.id !== wallId)) {
for (const node of walls) {
if (!contextPoints.has(pointKey(node.start)) && !contextPoints.has(pointKey(node.end))) {
continue
}
if ((node.parentId ?? null) !== wallParentId) {
continue
}
if (
!samePoint(node.start, originalStart) &&
!samePoint(node.start, originalEnd) &&
!samePoint(node.end, originalStart) &&
!samePoint(node.end, originalEnd)
) {
if (seenWallIds.has(node.id)) {
continue
}
seenWallIds.add(node.id)
snapshots.push({
id: node.id,
...node,
start: [...node.start] as [number, number],
end: [...node.end] as [number, number],
children: [...(node.children ?? [])],
})
}
@@ -82,25 +117,283 @@ function getLinkedWallSnapshots(args: {
}
function getLinkedWallUpdates(
linkedWalls: LinkedWallSnapshot[],
linkedWalls: Array<{
wall: LinkedWallSnapshot
matchPoint?: [number, number]
targetPoint?: [number, number]
}>,
originalStart: [number, number],
originalEnd: [number, number],
nextStart: [number, number],
nextEnd: [number, number],
) {
return linkedWalls.map((wall) => ({
id: wall.id,
start: samePoint(wall.start, originalStart)
? nextStart
: samePoint(wall.start, originalEnd)
? nextEnd
: wall.start,
end: samePoint(wall.end, originalStart)
? nextStart
: samePoint(wall.end, originalEnd)
? nextEnd
: wall.end,
}))
return linkedWalls.map(({ wall, matchPoint, targetPoint }) => {
if (matchPoint && targetPoint) {
return {
id: wall.id,
start: samePoint(wall.start, matchPoint) ? targetPoint : wall.start,
end: samePoint(wall.end, matchPoint) ? targetPoint : wall.end,
}
}
const targetStart = targetPoint ?? nextStart
const targetEnd = targetPoint ?? nextEnd
return {
id: wall.id,
start: samePoint(wall.start, originalStart)
? targetStart
: samePoint(wall.start, originalEnd)
? targetEnd
: wall.start,
end: samePoint(wall.end, originalStart)
? targetStart
: samePoint(wall.end, originalEnd)
? targetEnd
: wall.end,
}
})
}
function getPlannedLinkedWallUpdates(
plan: WallMoveJunctionPlan<LinkedWallSnapshot>,
originalStart: [number, number],
originalEnd: [number, number],
nextStart: [number, number],
nextEnd: [number, number],
) {
const movePlans = new Map<
WallNode['id'],
{ wall: LinkedWallSnapshot; matchPoint?: [number, number]; targetPoint?: [number, number] }
>()
for (const wall of plan.linkedWallsToMove) {
movePlans.set(wall.id, { wall })
}
for (const targetPlan of plan.linkedWallTargetPlans) {
movePlans.set(targetPlan.wall.id, {
wall: targetPlan.wall,
matchPoint: targetPlan.originalPoint,
targetPoint: targetPlan.targetPoint,
})
}
return getLinkedWallUpdates(
Array.from(movePlans.values()),
originalStart,
originalEnd,
nextStart,
nextEnd,
)
}
function wallSegmentExists(
walls: Array<Pick<WallNode, 'start' | 'end'>>,
start: [number, number],
end: [number, number],
) {
return walls.some(
(wall) =>
(samePoint(wall.start, start) && samePoint(wall.end, end)) ||
(samePoint(wall.start, end) && samePoint(wall.end, start)),
)
}
function getWallGhostColor(wall: WallNode) {
const presetColor =
getMaterialPresetByRef(wall.materialPreset)?.mapProperties.color ??
getMaterialPresetByRef(wall.interiorMaterialPreset)?.mapProperties.color ??
getMaterialPresetByRef(wall.exteriorMaterialPreset)?.mapProperties.color
if (presetColor) {
return presetColor
}
return resolveMaterial(wall.material ?? wall.interiorMaterial ?? wall.exteriorMaterial).color
}
function getWallsAfterUpdates(
nodes: ReturnType<typeof useScene.getState>['nodes'],
updates: Array<{ id: AnyNodeId; data: Partial<WallNode> }>,
) {
const updateById = new Map(updates.map((update) => [update.id, update.data]))
return Object.values(nodes)
.filter((node): node is WallNode => node?.type === 'wall')
.map((wall) => {
const update = updateById.get(wall.id as AnyNodeId)
return update ? ({ ...wall, ...update } as WallNode) : wall
})
}
function cloneSlabSnapshot(slab: SlabNode): SlabNode {
return {
...slab,
polygon: slab.polygon.map(([x, z]) => [x, z] as [number, number]),
holes: slab.holes.map((hole) => hole.map(([x, z]) => [x, z] as [number, number])),
holeMetadata: slab.holeMetadata.map((metadata) => ({ ...metadata })),
}
}
function getLevelSlabs(levelId: string, nodes: ReturnType<typeof useScene.getState>['nodes']) {
return Object.values(nodes).filter(
(entry): entry is SlabNode => entry?.type === 'slab' && (entry.parentId ?? null) === levelId,
)
}
function getLevelAutoSlabs(
levelId: string,
nodes: ReturnType<typeof useScene.getState>['nodes'],
) {
return getLevelSlabs(levelId, nodes).filter((slab) => slab.autoFromWalls)
}
function getLevelAutoSlabSnapshots(levelId: string) {
return getLevelAutoSlabs(levelId, useScene.getState().nodes).map(cloneSlabSnapshot)
}
function buildBridgeWallCreates(args: {
bridgePlans: Array<WallMoveBridgePlan<LinkedWallSnapshot>>
nextStart: [number, number]
nextEnd: [number, number]
existingWalls: WallNode[]
wallCount: number
}): Array<{ node: WallNode; parentId?: AnyNodeId }> {
const { bridgePlans, nextStart, nextEnd, existingWalls, wallCount } = args
const wallsForDuplicateCheck = [...existingWalls]
const creates: Array<{ node: WallNode; parentId?: AnyNodeId }> = []
for (const plan of bridgePlans) {
const nextPoint = plan.movedEndpoint === 'start' ? nextStart : nextEnd
if (!isWallLongEnough(plan.originalPoint, nextPoint)) {
continue
}
if (wallSegmentExists(wallsForDuplicateCheck, plan.originalPoint, nextPoint)) {
continue
}
const { id: _id, parentId: _parentId, children: _children, ...sourceWall } = plan.wall
const bridgeWall = WallSchema.parse({
...sourceWall,
name: `Wall ${wallCount + creates.length + 1}`,
start: plan.originalPoint,
end: nextPoint,
children: [],
metadata: stripWallIsNewMetadata(plan.wall.metadata),
})
creates.push({
node: bridgeWall,
parentId: (plan.wall.parentId ?? undefined) as AnyNodeId | undefined,
})
wallsForDuplicateCheck.push(bridgeWall)
}
return creates
}
function buildBridgeWallPreviews(args: {
bridgePlans: Array<WallMoveBridgePlan<LinkedWallSnapshot>>
nextStart: [number, number]
nextEnd: [number, number]
existingWalls: WallNode[]
}): Array<{ ghost: GhostWallPreview; wall: WallNode }> {
const { bridgePlans, nextStart, nextEnd, existingWalls } = args
const wallsForDuplicateCheck: Array<Pick<WallNode, 'start' | 'end'>> = [...existingWalls]
const previews: Array<{ ghost: GhostWallPreview; wall: WallNode }> = []
for (const plan of bridgePlans) {
const nextPoint = plan.movedEndpoint === 'start' ? nextStart : nextEnd
if (!isWallLongEnough(plan.originalPoint, nextPoint)) {
continue
}
if (wallSegmentExists(wallsForDuplicateCheck, plan.originalPoint, nextPoint)) {
continue
}
const { id: _id, children: _children, ...sourceWall } = plan.wall
const wall = WallSchema.parse({
...sourceWall,
name: 'Wall Preview',
start: plan.originalPoint,
end: nextPoint,
children: [],
metadata: stripWallIsNewMetadata(plan.wall.metadata),
})
const ghost = {
id: `${plan.wall.id}:${plan.movedEndpoint}:${previews.length}`,
start: [...plan.originalPoint] as [number, number],
end: [...nextPoint] as [number, number],
color: getWallGhostColor(plan.wall),
height: plan.wall.height ?? DEFAULT_WALL_HEIGHT,
}
previews.push({ ghost, wall })
wallsForDuplicateCheck.push(wall)
}
return previews
}
function setPreviewGeometryAttributes(
geometry: BufferGeometry,
positions: number[],
normals: number[],
uvs: number[],
) {
geometry.setAttribute('position', new Float32BufferAttribute(positions, 3))
geometry.setAttribute('normal', new Float32BufferAttribute(normals, 3))
geometry.setAttribute('uv', new Float32BufferAttribute(uvs, 2))
geometry.setAttribute('uv2', new Float32BufferAttribute([...uvs], 2))
}
function createWallPreviewGeometry(length: number, height: number) {
const geometry = new BufferGeometry()
setPreviewGeometryAttributes(
geometry,
[0, 0, 0, length, 0, 0, length, height, 0, 0, height, 0],
[0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1],
[0, 0, 1, 0, 1, 1, 0, 1],
)
geometry.setIndex([0, 1, 2, 0, 2, 3])
geometry.computeBoundingSphere()
return geometry
}
function GhostWallPreviewMesh({ preview }: { preview: GhostWallPreview }) {
const dx = preview.end[0] - preview.start[0]
const dz = preview.end[1] - preview.start[1]
const length = Math.hypot(dx, dz)
const angle = -Math.atan2(dz, dx)
const geometry = useMemo(() => {
return length < 0.01 ? null : createWallPreviewGeometry(length, preview.height)
}, [length, preview.height])
useEffect(() => () => geometry?.dispose(), [geometry])
if (!geometry) {
return null
}
return (
<group position={[preview.start[0], 0.02, preview.start[1]]} rotation={[0, angle, 0]}>
<mesh frustumCulled={false} layers={EDITOR_LAYER} renderOrder={2}>
<primitive attach="geometry" object={geometry} />
<meshBasicMaterial
color={preview.color}
depthTest={false}
depthWrite={false}
opacity={0.32}
side={DoubleSide}
transparent
/>
</mesh>
</group>
)
}
export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
@@ -121,7 +414,10 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
(node.end[0] - node.start[0]) / 2,
(node.end[1] - node.start[1]) / 2,
])
const linkedOriginalsRef = useRef(
const moveAxisRef = useRef<WallMoveAxis | null>(
getPerpendicularWallMoveAxis(node.start, node.end),
)
const linkedOriginalsRef = useRef<LinkedWallSnapshot[]>(
isNew
? []
: getLinkedWallSnapshots({
@@ -131,6 +427,9 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
originalEnd: node.end,
}),
)
const originalAutoSlabsRef = useRef<SlabNode[]>(
node.parentId ? getLevelAutoSlabSnapshots(node.parentId) : [],
)
const dragAnchorRef = useRef<[number, number] | null>(null)
const nodeIdRef = useRef(node.id)
const previewRef = useRef<{ start: [number, number]; end: [number, number] } | null>(null)
@@ -142,6 +441,7 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
const centerZ = (node.start[1] + node.end[1]) / 2
return [centerX, 0, centerZ]
})
const [ghostWallPreviews, setGhostWallPreviews] = useState<GhostWallPreview[]>([])
const exitMoveMode = useCallback(() => {
useEditor.getState().setMovingNode(null)
@@ -153,9 +453,11 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
const originalEnd = originalEndRef.current
const originalCenter = originalCenterRef.current
const originalHalfVector = originalHalfVectorRef.current
const levelId = node.parentId ?? null
const originalAutoSlabs = originalAutoSlabsRef.current
pauseSceneHistory(useScene)
let wasCommitted = false
let shouldRestoreOnCleanup = true
const applyNodePreview = (
updates: Array<{ id: WallNode['id']; start: [number, number]; end: [number, number] }>,
@@ -171,6 +473,72 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
}
}
const applyLiveAutoSlabPreview = (walls: WallNode[]) => {
if (!levelId) {
return
}
const levelWalls = walls.filter((wall) => (wall.parentId ?? null) === levelId)
const sceneState = useScene.getState()
const { roomPolygons } = detectSpacesForLevel(levelId, levelWalls)
const slabPlan = planAutoSlabsForLevel(roomPolygons, getLevelSlabs(levelId, sceneState.nodes))
if (
slabPlan.create.length === 0 &&
slabPlan.update.length === 0 &&
slabPlan.delete.length === 0
) {
return
}
sceneState.applyNodeChanges({
update: slabPlan.update.map((entry) => ({
id: entry.id as AnyNodeId,
data: entry.data,
})),
create: slabPlan.create.map((slab) => ({
node: slab,
parentId: levelId as AnyNodeId,
})),
delete: slabPlan.delete.map((id) => id as AnyNodeId),
})
}
const restoreAutoSlabPreview = () => {
if (!levelId) {
return
}
const sceneState = useScene.getState()
const originalIds = new Set(originalAutoSlabs.map((slab) => slab.id))
const currentAutoSlabs = getLevelAutoSlabs(levelId, sceneState.nodes)
const update = originalAutoSlabs
.filter((slab) => sceneState.nodes[slab.id as AnyNodeId])
.map((slab) => ({
id: slab.id as AnyNodeId,
data: cloneSlabSnapshot(slab),
}))
const create = originalAutoSlabs
.filter((slab) => !sceneState.nodes[slab.id as AnyNodeId])
.map((slab) => ({
node: cloneSlabSnapshot(slab),
parentId: levelId as AnyNodeId,
}))
const deleteIds = currentAutoSlabs
.filter((slab) => !originalIds.has(slab.id))
.map((slab) => slab.id as AnyNodeId)
if (update.length === 0 && create.length === 0 && deleteIds.length === 0) {
return
}
sceneState.applyNodeChanges({
update,
create,
delete: deleteIds,
})
}
const buildWallFromCenter = (center: [number, number]) => {
const rotatedHalf = rotateVector(originalHalfVector, pendingRotationRef.current)
const nextStart: [number, number] = [center[0] - rotatedHalf[0], center[1] - rotatedHalf[1]]
@@ -178,28 +546,77 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
return { start: nextStart, end: nextEnd }
}
const getMovePlan = (nextStart: [number, number], nextEnd: [number, number]) =>
planWallMoveJunctions(
linkedOriginalsRef.current,
originalStart,
originalEnd,
nextStart,
nextEnd,
)
const getLinkedPreviewUpdates = (
plan: WallMoveJunctionPlan<LinkedWallSnapshot>,
nextStart: [number, number],
nextEnd: [number, number],
) => {
const movedUpdates = getPlannedLinkedWallUpdates(
plan,
originalStart,
originalEnd,
nextStart,
nextEnd,
)
const movedById = new Map(movedUpdates.map((entry) => [entry.id, entry]))
return linkedOriginalsRef.current.map(
(wall) => movedById.get(wall.id) ?? { id: wall.id, start: wall.start, end: wall.end },
)
}
const applyPreview = (nextStart: [number, number], nextEnd: [number, number]) => {
previewRef.current = { start: nextStart, end: nextEnd }
const centerX = (nextStart[0] + nextEnd[0]) / 2
const centerZ = (nextStart[1] + nextEnd[1]) / 2
setCursorLocalPos([centerX, 0, centerZ])
applyNodePreview([
const previewPlan = getMovePlan(nextStart, nextEnd)
const previewUpdates = [
{ id: nodeId, start: nextStart, end: nextEnd },
...getLinkedWallUpdates(
linkedOriginalsRef.current,
originalStart,
originalEnd,
nextStart,
nextEnd,
),
...getLinkedPreviewUpdates(previewPlan, nextStart, nextEnd),
]
const previewCollapsedWallIds = new Set([
...previewUpdates
.filter((entry) => entry.id !== nodeId && !isWallLongEnough(entry.start, entry.end))
.map((entry) => entry.id as AnyNodeId),
...previewPlan.wallsToDelete.map((wall) => wall.id as AnyNodeId),
])
const previewSceneWalls = getWallsAfterUpdates(
useScene.getState().nodes,
previewUpdates.map((entry) => ({
id: entry.id as AnyNodeId,
data: { start: entry.start, end: entry.end },
})),
).filter((wall) => !previewCollapsedWallIds.has(wall.id as AnyNodeId))
const bridgePreviews = buildBridgeWallPreviews({
bridgePlans: previewPlan.bridgePlans,
nextStart,
nextEnd,
existingWalls: previewSceneWalls,
})
const nextGhostWalls = bridgePreviews.map((preview) => preview.ghost)
const virtualBridgeWalls = bridgePreviews.map((preview) => preview.wall)
setGhostWallPreviews(nextGhostWalls)
applyNodePreview(previewUpdates)
applyLiveAutoSlabPreview([...previewSceneWalls, ...virtualBridgeWalls])
}
const restoreOriginal = () => {
setGhostWallPreviews([])
applyNodePreview([
{ id: nodeId, start: originalStart, end: originalEnd },
...linkedOriginalsRef.current,
])
restoreAutoSlabPreview()
}
const onGridMove = (event: GridEvent) => {
@@ -209,19 +626,24 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
const localX = shiftPressedRef.current ? rawX : snapScalarToGrid(rawX, snapStep)
const localZ = shiftPressedRef.current ? rawZ : snapScalarToGrid(rawZ, snapStep)
if (
previousGridPosRef.current &&
(localX !== previousGridPosRef.current[0] || localZ !== previousGridPosRef.current[1])
) {
sfxEmitter.emit('sfx:grid-snap')
}
previousGridPosRef.current = [localX, localZ]
const anchor = dragAnchorRef.current ?? [localX, localZ]
dragAnchorRef.current = anchor
const deltaX = localX - anchor[0]
const deltaZ = localZ - anchor[1]
const [deltaX, deltaZ] = constrainWallMoveDeltaToAxis(
localX - anchor[0],
localZ - anchor[1],
moveAxisRef.current,
)
const constrainedGridPos: [number, number] = [anchor[0] + deltaX, anchor[1] + deltaZ]
if (
previousGridPosRef.current &&
(constrainedGridPos[0] !== previousGridPosRef.current[0] ||
constrainedGridPos[1] !== previousGridPosRef.current[1])
) {
sfxEmitter.emit('sfx:grid-snap')
}
previousGridPosRef.current = constrainedGridPos
const nextCenter: [number, number] = [originalCenter[0] + deltaX, originalCenter[1] + deltaZ]
const nextWall = buildWallFromCenter(nextCenter)
@@ -236,16 +658,32 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
const preview = previewRef.current ?? { start: originalStart, end: originalEnd }
wasCommitted = true
shouldRestoreOnCleanup = false
// Restore original baseline while paused so the next resume+update
// registers as a single tracked change (undo reverts to original).
setGhostWallPreviews([])
applyNodePreview([
{ id: nodeId, start: originalStart, end: originalEnd },
...linkedOriginalsRef.current,
])
restoreAutoSlabPreview()
resumeSceneHistory(useScene)
const commitPlan = getMovePlan(preview.start, preview.end)
const linkedWallUpdates = getPlannedLinkedWallUpdates(
commitPlan,
originalStart,
originalEnd,
preview.start,
preview.end,
)
const collapsedLinkedWallIds = new Set([
...linkedWallUpdates
.filter((entry) => !isWallLongEnough(entry.start, entry.end))
.map((entry) => entry.id as AnyNodeId),
...commitPlan.wallsToDelete.map((wall) => wall.id as AnyNodeId),
])
const commitUpdates = [
{
@@ -258,21 +696,29 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
}
: { start: preview.start, end: preview.end },
},
...getLinkedWallUpdates(
linkedOriginalsRef.current,
originalStart,
originalEnd,
preview.start,
preview.end,
).map((entry) => ({
id: entry.id as AnyNodeId,
data: { start: entry.start, end: entry.end },
})),
...linkedWallUpdates
.filter((entry) => !collapsedLinkedWallIds.has(entry.id as AnyNodeId))
.map((entry) => ({
id: entry.id as AnyNodeId,
data: { start: entry.start, end: entry.end },
})),
]
useScene.getState().updateNodes(commitUpdates)
for (const { id } of commitUpdates) {
useScene.getState().markDirty(id)
}
const sceneState = useScene.getState()
const existingWalls = getWallsAfterUpdates(sceneState.nodes, commitUpdates).filter(
(wall) => !collapsedLinkedWallIds.has(wall.id as AnyNodeId),
)
const bridgeCreates = buildBridgeWallCreates({
bridgePlans: commitPlan.bridgePlans,
nextStart: preview.start,
nextEnd: preview.end,
existingWalls,
wallCount: Object.values(sceneState.nodes).filter((entry) => entry?.type === 'wall').length,
})
sceneState.applyNodeChanges({
update: commitUpdates,
create: bridgeCreates,
delete: Array.from(collapsedLinkedWallIds),
})
pauseSceneHistory(useScene)
@@ -311,6 +757,7 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
(preview.start[1] + preview.end[1]) / 2,
]
const nextWall = buildWallFromCenter(currentCenter)
moveAxisRef.current = getPerpendicularWallMoveAxis(nextWall.start, nextWall.end)
applyPreview(nextWall.start, nextWall.end)
}
@@ -321,6 +768,7 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
}
const onCancel = () => {
shouldRestoreOnCleanup = false
restoreOriginal()
useViewer.getState().setSelection({ selectedIds: [nodeId] })
resumeSceneHistory(useScene)
@@ -335,7 +783,7 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
window.addEventListener('keyup', onKeyUp)
return () => {
if (!wasCommitted) {
if (shouldRestoreOnCleanup) {
restoreOriginal()
}
shiftPressedRef.current = false
@@ -346,11 +794,14 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp)
}
}, [exitMoveMode, isNew, node.metadata])
}, [exitMoveMode, isNew, node.metadata, node.parentId])
return (
<group>
<CursorSphere position={cursorLocalPos} showTooltip={false} />
{ghostWallPreviews.map((preview) => (
<GhostWallPreviewMesh key={preview.id} preview={preview} />
))}
</group>
)
}
View File
+3 -3
View File
@@ -156,6 +156,8 @@ export const WallTool: React.FC = () => {
const unit = useViewer((state) => state.unit)
const cursorRef = useRef<Group>(null)
const wallPreviewRef = useRef<Mesh>(null!)
// All positions are building-local: this tool is inside the ToolManager building group,
// so local coords are used for both data and visual positioning.
const startingPoint = useRef(new Vector3(0, 0, 0))
const endingPoint = useRef(new Vector3(0, 0, 0))
const buildingState = useRef(0)
@@ -166,8 +168,6 @@ export const WallTool: React.FC = () => {
let gridPosition: WallPlanPoint = [0, 0]
let previousWallEnd: [number, number] | null = null
// All positions are building-local: this tool is inside the ToolManager building group,
// so local coords are used for both data and visual positioning.
const onGridMove = (event: GridEvent) => {
if (!(cursorRef.current && wallPreviewRef.current)) return
@@ -324,7 +324,7 @@ function DraftMeasurementLabel({
}) {
return (
<Html center position={position} style={{ pointerEvents: 'none' }} zIndexRange={[100, 0]}>
<div className="whitespace-nowrap rounded-full border border-border bg-background/95 px-2 py-1 font-mono text-[11px] font-semibold text-foreground shadow-lg backdrop-blur-md">
<div className="whitespace-nowrap rounded-full border border-border bg-background/95 px-2 py-1 font-mono font-semibold text-[11px] text-foreground shadow-lg backdrop-blur-md">
{label}
</div>
</Html>
+20 -5
View File
@@ -3,6 +3,7 @@ import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef, useState } from 'react'
import { BufferGeometry, DoubleSide, type Group, type Line, Shape, Vector3 } from 'three'
import { EDITOR_LAYER } from './../../../lib/constants'
import { sfxEmitter } from './../../../lib/sfx-bus'
import useEditor from './../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere'
@@ -67,6 +68,9 @@ const commitZoneDrawing = (levelId: LevelNode['id'], points: Array<[number, numb
// Select the newly created zone
useViewer.getState().setSelection({ zoneId: zone.id })
// Play structure build sound
sfxEmitter.emit('sfx:structure-build')
}
type PreviewState = {
@@ -86,6 +90,7 @@ export const ZoneTool: React.FC = () => {
const mainLineRef = useRef<Line>(null!)
const closingLineRef = useRef<Line>(null!)
const pointsRef = useRef<Array<[number, number]>>([])
const previousSnappedPointRef = useRef<[number, number] | null>(null)
const levelYRef = useRef(0) // Track current level Y position
const currentLevelId = useViewer((state) => state.selection.levelId)
const setTool = useEditor((state) => state.setTool)
@@ -181,12 +186,22 @@ export const ZoneTool: React.FC = () => {
// If we have points, snap to axis from last point
const lastPoint = pointsRef.current[pointsRef.current.length - 1]
if (lastPoint) {
const snapped = calculateSnapPoint(lastPoint, cursorPosition)
cursorRef.current.position.set(snapped[0], event.localPosition[1], snapped[1])
} else {
cursorRef.current.position.set(gridX, event.localPosition[1], gridZ)
const displayPoint = lastPoint
? calculateSnapPoint(lastPoint, cursorPosition)
: cursorPosition
// Play snap sound when the snapped position changes during drawing
if (
pointsRef.current.length > 0 &&
previousSnappedPointRef.current &&
(displayPoint[0] !== previousSnappedPointRef.current[0] ||
displayPoint[1] !== previousSnappedPointRef.current[1])
) {
sfxEmitter.emit('sfx:grid-snap')
}
previousSnappedPointRef.current = displayPoint
cursorRef.current.position.set(displayPoint[0], event.localPosition[1], displayPoint[1])
updatePreview()
}
View File
+7 -1
View File
@@ -104,7 +104,9 @@ export function ControlModes() {
const setPhase = useEditor((state) => state.setPhase)
const setStructureLayer = useEditor((state) => state.setStructureLayer)
const setSelectionTool = useEditor((state) => state.setFloorplanSelectionTool)
const primeMaterialPaintFromSelection = useEditor((state) => state.primeMaterialPaintFromSelection)
const primeMaterialPaintFromSelection = useEditor(
(state) => state.primeMaterialPaintFromSelection,
)
const levelId = useViewer((s) => s.selection.levelId)
// Only subscribe to the primitive `level` number — when walls are added to
@@ -148,6 +150,8 @@ export function ControlModes() {
// setPhase('site') calls viewer.resetSelection() which clears levelId,
// breaking the 2D floorplan (it needs a level to render the SVG).
useEditor.setState({ phase: 'site', mode: 'select', tool: null, catalogCategory: null })
// Clear object selection so the polygon editor handles receive pointer events
useViewer.getState().setSelection({ selectedIds: [] })
}
return
}
@@ -188,6 +192,8 @@ export function ControlModes() {
} else {
setPhase('furnish')
setMode('build')
// Auto-switch sidebar to the items panel so the user can pick furniture
useEditor.getState().setActiveSidebarPanel('items')
}
} else if (id === 'zone') {
if (getIsActive('zone')) {
@@ -1,9 +1,4 @@
'use client'
import NextImage from 'next/image'
import { cn } from './../../../lib/utils'
import useEditor, { type CatalogCategory } from './../../../store/use-editor'
import { ActionButton } from './action-button'
import type { CatalogCategory } from './../../../store/use-editor'
export type FurnishToolConfig = {
id: 'item'
@@ -12,91 +7,10 @@ export type FurnishToolConfig = {
catalogCategory: CatalogCategory
}
// Furnish mode tools: furniture, appliances, decoration (painting is now a control mode)
export const furnishTools: FurnishToolConfig[] = [
{
id: 'item',
iconSrc: '/icons/couch.png',
label: 'Furniture',
catalogCategory: 'furniture',
},
{
id: 'item',
iconSrc: '/icons/appliance.png',
label: 'Appliance',
catalogCategory: 'appliance',
},
{
id: 'item',
iconSrc: '/icons/kitchen.png',
label: 'Kitchen',
catalogCategory: 'kitchen',
},
{
id: 'item',
iconSrc: '/icons/bathroom.png',
label: 'Bathroom',
catalogCategory: 'bathroom',
},
{
id: 'item',
iconSrc: '/icons/tree.png',
label: 'Outdoor',
catalogCategory: 'outdoor',
},
{ id: 'item', iconSrc: '/icons/couch.png', label: 'Furniture', catalogCategory: 'furniture' },
{ id: 'item', iconSrc: '/icons/appliance.png', label: 'Appliance', catalogCategory: 'appliance' },
{ id: 'item', iconSrc: '/icons/kitchen.png', label: 'Kitchen', catalogCategory: 'kitchen' },
{ id: 'item', iconSrc: '/icons/bathroom.png', label: 'Bathroom', catalogCategory: 'bathroom' },
{ id: 'item', iconSrc: '/icons/tree.png', label: 'Outdoor', catalogCategory: 'outdoor' },
]
export function FurnishTools() {
const mode = useEditor((state) => state.mode)
const activeTool = useEditor((state) => state.tool)
const setActiveTool = useEditor((state) => state.setTool)
const setMode = useEditor((state) => state.setMode)
const catalogCategory = useEditor((state) => state.catalogCategory)
const setCatalogCategory = useEditor((state) => state.setCatalogCategory)
const hasActiveTool = furnishTools.some(
(tool) => mode === 'build' && activeTool === 'item' && catalogCategory === tool.catalogCategory,
)
return (
<div className="flex items-center gap-1.5 px-1">
{furnishTools.map((tool, index) => {
// For item tools with catalog category, check both tool and category match
const isActive =
mode === 'build' && activeTool === 'item' && catalogCategory === tool.catalogCategory
return (
<ActionButton
className={cn(
'rounded-lg duration-300',
isActive
? 'z-10 scale-110 bg-black/40 hover:bg-black/40'
: 'scale-95 bg-transparent opacity-60 grayscale hover:bg-black/20 hover:opacity-100 hover:grayscale-0',
)}
key={`${tool.id}-${tool.catalogCategory ?? index}`}
label={tool.label}
onClick={() => {
if (!isActive) {
setCatalogCategory(tool.catalogCategory)
setActiveTool('item')
if (mode !== 'build') {
setMode('build')
}
}
}}
size="icon"
variant="ghost"
>
<NextImage
alt={tool.label}
className="size-full object-contain"
height={28}
src={tool.iconSrc}
width={28}
/>
</ActionButton>
)
})}
</div>
)
}
@@ -1,22 +1,25 @@
'use client'
import { useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { AnimatePresence, motion } from 'motion/react'
import { useEffect, useMemo } from 'react'
import { useViewer } from '@pascal-app/viewer'
import { useReducedMotion } from './../../../hooks/use-reduced-motion'
import { useIsMobile } from './../../../hooks/use-mobile'
import { TooltipProvider } from './../../../components/ui/primitives/tooltip'
import { MaterialPicker } from './../../../components/ui/controls/material-picker'
import { TooltipProvider } from './../../../components/ui/primitives/tooltip'
import { useIsMobile } from './../../../hooks/use-mobile'
import { useReducedMotion } from './../../../hooks/use-reduced-motion'
import { resolvePaintTargetFromSelection } from './../../../lib/material-paint'
import { cn } from './../../../lib/utils'
import useEditor from './../../../store/use-editor'
import { ItemCatalog } from '../item-catalog/item-catalog'
import { CameraActions } from './camera-actions'
import { ControlModes } from './control-modes'
import { FurnishTools } from './furnish-tools'
import { StructureTools } from './structure-tools'
import { ViewToggles } from './view-toggles'
import { GridSnapControl, SecondaryToggles } from './view-toggles'
// Mobile bottom offset matches the viewer's overlap behind the sheet's
// rounded corners (SHEET_OVERLAP_PX in editor-layout-mobile) so the menu sits
// just above that strip instead of inside it.
const MOBILE_BOTTOM_OFFSET = 24
function PaintMaterialTray() {
const activePaintMaterial = useEditor((state) => state.activePaintMaterial)
@@ -84,84 +87,16 @@ export function ActionMenu({ className }: { className?: string }) {
<TooltipProvider>
<motion.div
className={cn(
'fixed bottom-6 left-1/2 z-50 -translate-x-1/2',
'left-1/2 z-50 -translate-x-1/2',
isMobile ? 'absolute origin-bottom scale-90' : 'fixed bottom-6',
'rounded-2xl border border-border bg-background/90 shadow-2xl backdrop-blur-md',
'transition-colors duration-200 ease-out',
className,
)}
layout
style={isMobile ? { bottom: MOBILE_BOTTOM_OFFSET } : undefined}
transition={transition}
>
{/* Item Catalog Row - Only show when in build mode with item tool */}
<AnimatePresence>
{mode === 'build' && tool === 'item' && catalogCategory && (
<motion.div
animate={{
opacity: 1,
maxHeight: 160,
paddingTop: 8,
paddingBottom: 8,
borderBottomWidth: 1,
}}
className={cn('overflow-hidden border-border border-b px-2 py-2')}
exit={{
opacity: 0,
maxHeight: 0,
paddingTop: 0,
paddingBottom: 0,
borderBottomWidth: 0,
}}
initial={{
opacity: 0,
maxHeight: 0,
paddingTop: 0,
paddingBottom: 0,
borderBottomWidth: 0,
}}
transition={transition}
>
<ItemCatalog category={catalogCategory} key={catalogCategory} />
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{phase === 'furnish' && mode === 'build' && (
<motion.div
animate={{
opacity: 1,
maxHeight: 80,
paddingTop: 8,
paddingBottom: 8,
borderBottomWidth: 1,
}}
className={cn(
'overflow-hidden border-border',
'max-h-20 border-b px-2 py-2 opacity-100',
)}
exit={{
opacity: 0,
maxHeight: 0,
paddingTop: 0,
paddingBottom: 0,
borderBottomWidth: 0,
}}
initial={{
opacity: 0,
maxHeight: 0,
paddingTop: 0,
paddingBottom: 0,
borderBottomWidth: 0,
}}
transition={transition}
>
<div className="mx-auto w-max">
<FurnishTools />
</div>
</motion.div>
)}
</AnimatePresence>
{/* Structure Tools Row - Animated */}
<AnimatePresence>
{phase === 'structure' && mode === 'build' && (
@@ -228,14 +163,28 @@ export function ActionMenu({ className }: { className?: string }) {
</motion.div>
)}
</AnimatePresence>
{/* Control Mode Row - Always visible, centered */}
<div className="flex items-center justify-center gap-1 px-2 py-1.5">
<ControlModes />
<div className="mx-1 h-5 w-px bg-border" />
<ViewToggles />
<div className="mx-1 h-5 w-px bg-border" />
<CameraActions />
</div>
{isMobile ? (
<div className="flex flex-col items-stretch gap-0.5 px-2 py-1.5">
{/* Row 1: control modes only */}
<div className="flex items-center justify-center gap-1">
<ControlModes />
</div>
{/* Row 2: grid snap + secondary toggles (orbit + top view hidden) */}
<div className="flex items-center justify-center gap-1 border-border/50 border-t pt-1">
<GridSnapControl />
<SecondaryToggles />
</div>
</div>
) : (
<div className="flex items-center justify-center gap-1 px-2 py-1.5">
<ControlModes />
<div className="mx-1 h-5 w-px bg-border" />
<GridSnapControl />
<SecondaryToggles />
<div className="mx-1 h-5 w-px bg-border" />
<CameraActions />
</div>
)}
</motion.div>
</TooltipProvider>
)
+19 -31
View File
@@ -1,5 +1,6 @@
'use client'
import { Icon } from '@iconify/react'
import {
type AnyNodeId,
type BuildingNode,
@@ -258,7 +259,7 @@ function GuidesControl() {
<PopoverContent
align="center"
className="w-72 rounded-xl border-border/45 bg-background/96 p-3 shadow-[0_14px_28px_-18px_rgba(15,23,42,0.55),0_6px_16px_-10px_rgba(15,23,42,0.2)] backdrop-blur-xl"
className="w-72 rounded-xl border-border/45 bg-background/96 p-3 shadow-elevation-3 backdrop-blur-xl"
side="top"
sideOffset={14}
>
@@ -353,9 +354,9 @@ function GuidesControl() {
)
}
// ── Grid snap ──────────────────────────────────────────────────────────────
// ── Grid snap toggle ────────────────────────────────────────────────────────
export function GridSnapControl() {
function GridSnapControl() {
const [isOpen, setIsOpen] = useState(false)
const gridSnapStep = useEditor((state) => state.gridSnapStep)
const setGridSnapStep = useEditor((state) => state.setGridSnapStep)
@@ -374,20 +375,7 @@ export function GridSnapControl() {
)}
type="button"
>
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3 3h7v7H3V3zm11 0h7v7h-7V3zm0 11h7v7h-7v-7zm-11 0h7v7H3v-7z"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<Icon height={16} icon="lucide:grid-2x2" width={16} />
<span className="mt-1 font-medium text-[9px] leading-none">
{formatGridSnapStep(gridSnapStep)}
</span>
@@ -510,7 +498,7 @@ function ScansControl() {
<PopoverContent
align="center"
className="w-72 rounded-xl border-border/45 bg-background/96 p-3 shadow-[0_14px_28px_-18px_rgba(15,23,42,0.55),0_6px_16px_-10px_rgba(15,23,42,0.2)] backdrop-blur-xl"
className="w-72 rounded-xl border-border/45 bg-background/96 p-3 shadow-elevation-3 backdrop-blur-xl"
side="top"
sideOffset={14}
>
@@ -605,6 +593,8 @@ function ScansControl() {
)
}
// ── Reference floor control ────────────────────────────────────────────────────────────────────
function ReferenceFloorControl() {
const showReferenceFloor = useEditor((state) => state.showReferenceFloor)
const toggleReferenceFloor = useEditor((state) => state.toggleReferenceFloor)
@@ -694,7 +684,11 @@ function ReferenceFloorControl() {
onClick={toggleReferenceFloor}
type="button"
>
{showReferenceFloor ? <Eye className="h-3.5 w-3.5" /> : <EyeOff className="h-3.5 w-3.5" />}
{showReferenceFloor ? (
<Eye className="h-3.5 w-3.5" />
) : (
<EyeOff className="h-3.5 w-3.5" />
)}
</button>
</div>
@@ -728,9 +722,7 @@ function ReferenceFloorControl() {
)}
/>
<span className="min-w-0 flex-1 truncate">{levelName}</span>
<span className="text-[10px] text-muted-foreground">
{index + 1} below
</span>
<span className="text-[10px] text-muted-foreground">{index + 1} below</span>
</button>
)
})}
@@ -757,24 +749,20 @@ function ReferenceFloorControl() {
)
}
// ── Main ViewToggles ────────────────────────────────────────────────────────
// ── Exports ─────────────────────────────────────────────────────────────────
export function ViewToggles() {
export { GridSnapControl }
export function SecondaryToggles() {
return (
<div className="flex items-center gap-1">
{/* Scans (toggle + dropdown) */}
<ScansControl />
{/* Guides (toggle + dropdown) */}
<GuidesControl />
<ReferenceFloorControl />
</div>
)
}
// Secondary toggles for mobile (grid snap + scans + guides)
export function SecondaryToggles() {
export function ViewToggles() {
return (
<div className="flex items-center gap-1">
<GridSnapControl />
@@ -35,8 +35,8 @@ import {
Video,
} from 'lucide-react'
import { useEffect } from 'react'
import { deleteLevelWithFallbackSelection } from '../../../lib/level-selection'
import { runRedo, runUndo } from '../../../lib/history'
import { deleteLevelWithFallbackSelection } from '../../../lib/level-selection'
import { useCommandRegistry } from '../../../store/use-command-registry'
import type { StructureTool } from '../../../store/use-editor'
import useEditor from '../../../store/use-editor'
@@ -297,12 +297,14 @@ export function EditorCommands() {
},
{
id: 'editor.viewer.camera-snapshot',
label: 'Camera Snapshot',
label: 'Take Snapshot',
group: 'Viewer Controls',
icon: <Camera className="h-4 w-4" />,
keywords: ['camera', 'snapshot', 'capture', 'save', 'view', 'bookmark'],
navigate: true,
execute: () => navigateTo('camera-view'),
execute: () => {
setOpen(false)
useEditor.getState().setCaptureMode(true)
},
},
// ── View ─────────────────────────────────────────────────────────────
+4 -255
View File
@@ -27,15 +27,13 @@ interface CommandPaletteStore {
setInputValue: (value: string) => void
navigateTo: (page: string) => void
goBack: () => void
cameraScope: { nodeId: string; label: string } | null
setCameraScope: (scope: { nodeId: string; label: string } | null) => void
}
export const useCommandPalette = create<CommandPaletteStore>((set, get) => ({
open: false,
setOpen: (open) => {
set({ open })
if (!open) set({ pages: [], inputValue: '', cameraScope: null, mode: 'command' })
if (!open) set({ pages: [], inputValue: '', mode: 'command' })
},
mode: 'command',
setMode: (mode) => set({ mode }),
@@ -44,12 +42,8 @@ export const useCommandPalette = create<CommandPaletteStore>((set, get) => ({
setInputValue: (value) => set({ inputValue: value }),
navigateTo: (page) => set((s) => ({ pages: [...s.pages, page], inputValue: '' })),
goBack: () => {
const { pages } = get()
if (pages[pages.length - 1] === 'camera-scope') set({ cameraScope: null })
set((s) => ({ pages: s.pages.slice(0, -1), inputValue: '' }))
},
cameraScope: null,
setCameraScope: (scope) => set({ cameraScope: scope }),
}))
// ---------------------------------------------------------------------------
@@ -157,8 +151,6 @@ const PAGE_LABEL: Record<string, string> = {
'level-mode': 'Level Mode',
'rename-level': 'Rename Level',
'goto-level': 'Go to Level',
'camera-view': 'Camera Snapshot',
'camera-scope': '',
}
// ---------------------------------------------------------------------------
@@ -196,19 +188,8 @@ function EmptyActionItem({ action }: { action: CommandPaletteEmptyAction }) {
// Main component
// ---------------------------------------------------------------------------
export function CommandPalette({ emptyAction }: { emptyAction?: CommandPaletteEmptyAction }) {
const {
open,
setOpen,
mode,
setMode,
pages,
inputValue,
setInputValue,
navigateTo,
goBack,
cameraScope,
setCameraScope,
} = useCommandPalette()
const { open, setOpen, mode, setMode, pages, inputValue, setInputValue, navigateTo, goBack } =
useCommandPalette()
const [meta, setMeta] = useState('⌘')
const [isFullscreen, setIsFullscreen] = useState(false)
@@ -233,11 +214,6 @@ export function CommandPalette({ emptyAction }: { emptyAction?: CommandPaletteEm
),
)
const cameraScopeNode = useScene((s) =>
cameraScope ? s.nodes[cameraScope.nodeId as AnyNodeId] : null,
)
const hasScopeSnapshot = !!(cameraScopeNode as any)?.camera
// Platform detection
useEffect(() => {
setMeta(/Mac|iPhone|iPad|iPod/.test(navigator.platform) ? '⌘' : 'Ctrl')
@@ -279,7 +255,6 @@ export function CommandPalette({ emptyAction }: { emptyAction?: CommandPaletteEm
solo: 'Solo',
}
// Camera snapshot helpers (used by sub-pages registered via EditorCommands)
const confirmRename = () => {
if (!(activeLevelId && inputValue.trim())) return
run(() => {
@@ -287,29 +262,6 @@ export function CommandPalette({ emptyAction }: { emptyAction?: CommandPaletteEm
})
}
const takeSnapshot = () => {
if (!cameraScope) return
import('@pascal-app/core').then(({ emitter }) => {
run(() =>
emitter.emit('camera-controls:capture', { nodeId: cameraScope.nodeId as AnyNodeId }),
)
})
}
const viewSnapshot = () => {
if (!(cameraScope && hasScopeSnapshot)) return
import('@pascal-app/core').then(({ emitter }) => {
run(() => emitter.emit('camera-controls:view', { nodeId: cameraScope.nodeId as AnyNodeId }))
})
}
const clearSnapshot = () => {
if (!(cameraScope && hasScopeSnapshot)) return
run(() => {
useScene.getState().updateNode(cameraScope.nodeId as AnyNodeId, { camera: undefined } as any)
})
}
// ---------------------------------------------------------------------------
// Group registered actions by group (preserving insertion order)
// ---------------------------------------------------------------------------
@@ -363,9 +315,7 @@ export function CommandPalette({ emptyAction }: { emptyAction?: CommandPaletteEm
onClick={goBack}
type="button"
>
{page === 'camera-scope'
? (cameraScope?.label ?? 'Snapshot')
: (PAGE_LABEL[page] ?? views.get(page)?.label ?? page)}
{PAGE_LABEL[page] ?? views.get(page)?.label ?? page}
</button>
)}
<Command.Input
@@ -500,207 +450,6 @@ export function CommandPalette({ emptyAction }: { emptyAction?: CommandPaletteEm
</Command.Item>
</Command.Group>
)}
{/* ── Camera Snapshot: scope picker ─────────────────────────── */}
{page === 'camera-view' && (
<Command.Group heading="Camera Snapshot — Select Scope">
<OptionItem
icon={
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path d="M3 3h18v18H3z" strokeLinecap="round" strokeLinejoin="round" />
<path d="M3 9h18M9 21V9" strokeLinecap="round" strokeLinejoin="round" />
</svg>
}
label="Site"
onSelect={() => {
const { rootNodeIds } = useScene.getState()
const siteId = rootNodeIds[0]
if (siteId) {
setCameraScope({ nodeId: siteId, label: 'Site' })
navigateTo('camera-scope')
}
}}
/>
<OptionItem
icon={
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"
strokeLinecap="round"
strokeLinejoin="round"
/>
<polyline
points="9 22 9 12 15 12 15 22"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
}
label="Building"
onSelect={() => {
const building = Object.values(useScene.getState().nodes).find(
(n) => n.type === 'building',
)
if (building) {
setCameraScope({ nodeId: building.id, label: 'Building' })
navigateTo('camera-scope')
}
}}
/>
<OptionItem
disabled={!activeLevelId}
icon={
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
d="M12 2L2 7l10 5 10-5-10-5z"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M2 17l10 5 10-5M2 12l10 5 10-5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
}
label="Level"
onSelect={() => {
if (activeLevelId) {
setCameraScope({ nodeId: activeLevelId, label: 'Level' })
navigateTo('camera-scope')
}
}}
/>
<OptionItem
disabled={!useViewer.getState().selection.selectedIds.length}
icon={
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path d="M5 3l14 9-14 9V3z" strokeLinecap="round" strokeLinejoin="round" />
</svg>
}
label="Selection"
onSelect={() => {
const firstId = useViewer.getState().selection.selectedIds[0]
if (firstId) {
setCameraScope({ nodeId: firstId, label: 'Selection' })
navigateTo('camera-scope')
}
}}
/>
</Command.Group>
)}
{/* ── Camera Snapshot: actions for selected scope ───────────── */}
{page === 'camera-scope' && cameraScope && (
<Command.Group heading={`${cameraScope.label} Snapshot`}>
<OptionItem
icon={
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"
strokeLinecap="round"
strokeLinejoin="round"
/>
<circle
cx="12"
cy="13"
r="4"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
}
label={hasScopeSnapshot ? 'Update Snapshot' : 'Take Snapshot'}
onSelect={takeSnapshot}
/>
{hasScopeSnapshot && (
<OptionItem
icon={
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"
strokeLinecap="round"
strokeLinejoin="round"
/>
<circle
cx="12"
cy="12"
r="3"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
}
label="View Snapshot"
onSelect={viewSnapshot}
/>
)}
{hasScopeSnapshot && (
<OptionItem
icon={
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<polyline
points="3 6 5 6 21 6"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M19 6l-1 14H6L5 6"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path d="M10 11v6M14 11v6" strokeLinecap="round" strokeLinejoin="round" />
<path d="M9 6V4h6v2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
}
label="Clear Snapshot"
onSelect={clearSnapshot}
/>
)}
</Command.Group>
)}
</Command.List>
{/* Footer hint */}
+7 -5
View File
@@ -5,8 +5,9 @@ import {
getLibraryMaterialIdFromRef,
getMaterialsForCategory,
MATERIAL_CATEGORIES,
toLibraryMaterialRef,
type MaterialSchema,
type MaterialTarget,
toLibraryMaterialRef,
} from '@pascal-app/core'
import { useEffect, useRef, useState } from 'react'
import useEditor from '../../../store/use-editor'
@@ -17,6 +18,8 @@ type MaterialPickerProps = {
onChange?: (material: MaterialSchema) => void
onSelectMaterialPreset?: (materialPreset: string) => void
disabled?: boolean
nodeType?: MaterialTarget
hideSideControl?: boolean
}
export function MaterialPicker({
@@ -48,8 +51,7 @@ export function MaterialPicker({
return
}
const catalogId =
getLibraryMaterialIdFromRef(selectedMaterialPreset) ?? value?.id ?? undefined
const catalogId = getLibraryMaterialIdFromRef(selectedMaterialPreset) ?? value?.id ?? undefined
const selectedCatalogEntry = getCatalogMaterialById(catalogId)
if (selectedCatalogEntry?.category) {
setSelectedCategory(selectedCatalogEntry.category)
@@ -177,7 +179,7 @@ export function MaterialPicker({
title={item.label}
type="button"
>
<div className="pointer-events-none absolute inset-0 rounded-[inherit] ring-1 ring-inset ring-white/12" />
<div className="pointer-events-none absolute inset-0 rounded-[inherit] ring-1 ring-white/12 ring-inset" />
{item.previewThumbnailUrl ? (
<img
alt={item.label}
@@ -193,7 +195,7 @@ export function MaterialPicker({
))}
{selectedCategory === 'other' && onChange ? (
<button
className={`flex h-14 w-14 shrink-0 items-center justify-center rounded-lg border text-[10px] font-medium transition-all ${
className={`flex h-14 w-14 shrink-0 items-center justify-center rounded-lg border font-medium text-[10px] transition-all ${
showCustom
? 'border-blue-500 ring-2 ring-blue-500/30'
: 'border-gray-300 hover:border-gray-400'
+1 -1
View File
@@ -171,7 +171,7 @@ function LevelRow({
{...dragHandleProps}
aria-label={`Reorder ${getLevelDisplayLabel(level)}`}
className={cn(
'ml-0.5 flex h-6 w-4 shrink-0 touch-none cursor-grab items-center justify-center rounded-md text-muted-foreground/35 opacity-0 transition-colors hover:bg-white/5 hover:text-foreground focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 group-hover/level:opacity-100',
'ml-0.5 flex h-6 w-4 shrink-0 cursor-grab touch-none items-center justify-center rounded-md text-muted-foreground/35 opacity-0 transition-colors hover:bg-white/5 hover:text-foreground focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 group-hover/level:opacity-100',
isDragging && 'cursor-grabbing opacity-100',
)}
onClick={(e) => {
@@ -1,5 +1,6 @@
'use client'
import { useIsMobile } from '../../../hooks/use-mobile'
import useEditor from '../../../store/use-editor'
import { BuildingHelper } from './building-helper'
import { CeilingHelper } from './ceiling-helper'
@@ -12,6 +13,10 @@ export function HelperManager() {
const mode = useEditor((s) => s.mode)
const tool = useEditor((s) => s.tool)
const movingNode = useEditor((state) => state.movingNode)
const isMobile = useIsMobile()
// Helpers are keyboard-driven hints (Esc, R, etc.) — irrelevant on touch.
if (isMobile) return null
if (movingNode) {
if (movingNode.type === 'building') return <BuildingHelper showRotate />
File diff suppressed because it is too large Load Diff
@@ -13,13 +13,53 @@ import { cn } from './../../../lib/utils'
import useEditor, { type CatalogCategory } from './../../../store/use-editor'
import { CATALOG_ITEMS } from './catalog-items'
export function ItemCatalog({ category }: { category: CatalogCategory }) {
export function ItemCatalog({
category,
items: itemsOverride,
activePlacementTag = null,
activeFunctionalTag = null,
search = '',
overrideItems,
leadingTile,
emptyState,
}: {
category: CatalogCategory
items?: AssetInput[]
activePlacementTag?: string | null
activeFunctionalTag?: string | null
search?: string
/** When set, bypasses all filtering and displays these items directly (used for server search results) */
overrideItems?: AssetInput[]
/** Rendered as the first grid cell, always visible when there are items. */
leadingTile?: React.ReactNode
/** Rendered when there are no items to show. Replaces the empty grid. */
emptyState?: React.ReactNode
}) {
const selectedItem = useEditor((state) => state.selectedItem)
const setSelectedItem = useEditor((state) => state.setSelectedItem)
const setMode = useEditor((state) => state.setMode)
const setTool = useEditor((state) => state.setTool)
const categoryItems = CATALOG_ITEMS.filter((item) => item.category === category)
const sourceItems = itemsOverride ?? CATALOG_ITEMS
// Server-provided results bypass all local filtering; otherwise filter by category/search/tags
const filteredItems =
overrideItems ??
(() => {
const categoryItems = search
? sourceItems
: sourceItems.filter((item) => item.category === category)
return categoryItems.filter((item) => {
const tags = item.tags ?? []
if (activePlacementTag && !tags.includes(activePlacementTag)) return false
if (activeFunctionalTag && !tags.includes(activeFunctionalTag)) return false
if (search && !item.name.toLowerCase().includes(search.toLowerCase())) return false
return true
})
})()
// Auto-select first item if current selection is not in this category
const categoryItems = filteredItems
// Auto-select first item if current selection is not in the filtered list
useEffect(() => {
const isCurrentItemInCategory = categoryItems.some((item) => item.src === selectedItem?.src)
if (!isCurrentItemInCategory && categoryItems.length > 0) {
@@ -27,58 +67,60 @@ export function ItemCatalog({ category }: { category: CatalogCategory }) {
}
}, [categoryItems, selectedItem?.src, setSelectedItem])
// Get attachment icon based on attachTo type
const getAttachmentIcon = (attachTo: AssetInput['attachTo']) => {
if (attachTo === 'wall' || attachTo === 'wall-side') {
return '/icons/wall.png'
}
if (attachTo === 'ceiling') {
return '/icons/ceiling.png'
}
if (attachTo === 'wall' || attachTo === 'wall-side') return '/icons/wall.png'
if (attachTo === 'ceiling') return '/icons/ceiling.png'
return null
}
if (filteredItems.length === 0 && emptyState) {
return <>{emptyState}</>
}
return (
<div className="-mx-2 -my-2 flex max-w-xl gap-2 overflow-x-auto p-2">
{categoryItems.map((item, index) => {
<div
className="grid gap-2"
style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(90px, 1fr))' }}
>
{leadingTile}
{filteredItems.map((item, index) => {
const isSelected = selectedItem?.src === item?.src
const attachmentIcon = getAttachmentIcon(item?.attachTo)
return (
<Tooltip key={index}>
<TooltipTrigger asChild>
<button
className={cn(
'relative aspect-square h-14 min-h-14 w-14 min-w-14 shrink-0 flex-col gap-px rounded-lg transition-all duration-200 ease-out hover:scale-105 hover:cursor-pointer',
isSelected && 'ring-2 ring-primary-foreground',
)}
onClick={() => setSelectedItem(item)}
type="button"
>
<Image
alt={item.name}
className="rounded-lg object-cover"
fill
loading="eager"
sizes="56px"
src={resolveCdnUrl(item.thumbnail) || ''}
/>
{attachmentIcon && (
<div className="absolute right-0.5 bottom-0.5 flex h-4 w-4 items-center justify-center rounded bg-black/60">
<Image
alt={item.attachTo === 'ceiling' ? 'Ceiling attachment' : 'Wall attachment'}
className="h-4 w-4"
height={16}
src={attachmentIcon}
width={16}
/>
</div>
)}
</button>
</TooltipTrigger>
<TooltipContent className="text-xs" side="top">
<button
className={cn(
'group relative flex flex-col gap-1.5 rounded-xl p-1.5 transition-colors hover:cursor-pointer hover:bg-sidebar-accent',
isSelected && 'bg-sidebar-accent ring-2 ring-primary-foreground',
)}
key={index}
onClick={() => {
setSelectedItem(item)
setTool('item')
setMode('build')
}}
type="button"
>
<div className="relative aspect-square w-full overflow-hidden rounded-lg">
<img
alt={item.name}
className="h-full w-full object-cover"
loading="eager"
src={resolveCdnUrl(item.thumbnail) || ''}
/>
{attachmentIcon && (
<div className="absolute right-1 bottom-1 flex h-4 w-4 items-center justify-center rounded bg-black/60">
<img
alt={item.attachTo === 'ceiling' ? 'Ceiling attachment' : 'Wall attachment'}
className="h-4 w-4"
src={attachmentIcon}
/>
</div>
)}
</div>
<span className="truncate px-0.5 text-left font-medium text-[11px] text-muted-foreground group-hover:text-foreground">
{item.name}
</TooltipContent>
</Tooltip>
</span>
</button>
)
})}
</div>
@@ -2,8 +2,8 @@
import type { LevelNode } from '@pascal-app/core'
import { useEffect, useState } from 'react'
import { cn } from '../../lib/utils'
import type { LevelDuplicatePreset } from '../../lib/level-duplication'
import { cn } from '../../lib/utils'
import {
Dialog,
DialogContent,
@@ -69,9 +69,7 @@ export function LevelDuplicateDialog({
<DialogContent className="sm:max-w-md" showCloseButton={false}>
<DialogHeader>
<DialogTitle>Duplicate Level</DialogTitle>
<DialogDescription>
Choose what to copy from {getLevelLabel(level)}.
</DialogDescription>
<DialogDescription>Choose what to copy from {getLevelLabel(level)}.</DialogDescription>
</DialogHeader>
<div className="grid gap-2">
@@ -95,7 +93,7 @@ export function LevelDuplicateDialog({
<DialogFooter>
<button
className="cursor-pointer rounded-md px-4 py-2 text-sm text-muted-foreground transition-colors hover:bg-accent"
className="cursor-pointer rounded-md px-4 py-2 text-muted-foreground text-sm transition-colors hover:bg-accent"
onClick={() => onOpenChange(false)}
type="button"
>
+2 -3
View File
@@ -120,9 +120,8 @@ export function CeilingPanel() {
const n = polygon.length
for (let i = 0; i < n; i++) {
const j = (i + 1) % n
const current = polygon[i]
const next = polygon[j]
if (!(current && next)) continue
const current = polygon[i]!
const next = polygon[j]!
area += current[0] * next[1]
area -= next[0] * current[1]
}
@@ -11,6 +11,7 @@ import { useViewer } from '@pascal-app/viewer'
import { Move, Trash2 } from 'lucide-react'
import { useCallback } from 'react'
import { sfxEmitter } from '../../../lib/sfx-bus'
import { cn } from '../../../lib/utils'
import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button'
import { PanelSection } from '../controls/panel-section'
@@ -71,10 +72,26 @@ const COLUMN_PROPORTION_PRESETS = {
type ColumnProportionPresetId = keyof typeof COLUMN_PROPORTION_PRESETS
const COLUMN_PROPORTION_OPTIONS = Object.entries(COLUMN_PROPORTION_PRESETS).map(([value, preset]) => ({
value: value as ColumnProportionPresetId,
label: preset.label,
}))
const COLUMN_PROPORTION_OPTIONS = Object.entries(COLUMN_PROPORTION_PRESETS).map(
([value, preset]) => ({
value: value as ColumnProportionPresetId,
label: preset.label,
}),
)
const SUPPORT_STYLE_OPTIONS: Array<{ label: string; value: ColumnNode['supportStyle'] }> = [
{ label: 'Vertical', value: 'vertical' },
{ label: 'A-Frame', value: 'a-frame' },
{ label: 'Y Support', value: 'y-frame' },
{ label: 'V Support', value: 'v-frame' },
{ label: 'X Brace', value: 'x-brace' },
{ label: 'K Brace', value: 'k-brace' },
{ label: 'Single Strut', value: 'single-strut' },
{ label: 'Tripod', value: 'tripod' },
{ label: 'Trestle', value: 'trestle' },
{ label: 'Portal Frame', value: 'portal-frame' },
{ label: 'Box Frame', value: 'box-frame' },
]
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value))
@@ -215,7 +232,12 @@ export function ColumnPanel() {
supportStyle === 'box-frame'
return (
<PanelWrapper icon="/icons/column.png" onClose={handleClose} title={node.name || 'Column'} width={300}>
<PanelWrapper
icon="/icons/column.png"
onClose={handleClose}
title={node.name || 'Column'}
width={300}
>
<PanelSection title="Preset">
<select
className={SELECT_CLASS}
@@ -235,37 +257,39 @@ export function ColumnPanel() {
</PanelSection>
<PanelSection title="Shape">
<select
className={SELECT_CLASS}
onChange={(event) => {
const nextSupportStyle = event.target.value as ColumnNode['supportStyle']
handleUpdate({
supportStyle: nextSupportStyle,
...(nextSupportStyle !== 'vertical'
? {
crossSection: 'rectangular',
width: node.braceWidth ?? node.width,
depth: node.braceDepth ?? node.depth,
baseStyle: 'none',
capitalStyle: 'none',
}
: {}),
})
}}
value={supportStyle}
>
<option value="vertical">Vertical</option>
<option value="a-frame">A-Frame</option>
<option value="y-frame">Y Support</option>
<option value="v-frame">V Support</option>
<option value="x-brace">X Brace</option>
<option value="k-brace">K Brace</option>
<option value="single-strut">Single Strut</option>
<option value="tripod">Tripod Support</option>
<option value="trestle">Trestle Frame</option>
<option value="portal-frame">Portal Frame</option>
<option value="box-frame">Box Frame</option>
</select>
<div className="grid grid-cols-2 gap-1.5 px-1 pt-1">
{SUPPORT_STYLE_OPTIONS.map((option) => {
const isSelected = supportStyle === option.value
return (
<button
className={cn(
'flex min-h-12 items-center rounded-lg border px-2.5 text-left text-xs transition-colors',
isSelected
? 'border-orange-400/60 bg-orange-400/10 text-foreground'
: 'border-border/50 bg-[#2C2C2E] text-muted-foreground hover:bg-[#3e3e3e] hover:text-foreground',
)}
key={option.value}
onClick={() =>
handleUpdate({
supportStyle: option.value,
...(option.value !== 'vertical'
? {
crossSection: 'rectangular',
width: node.braceWidth ?? node.width,
depth: node.braceDepth ?? node.depth,
baseStyle: 'none',
capitalStyle: 'none',
}
: {}),
})
}
type="button"
>
<span className="truncate font-medium">{option.label}</span>
</button>
)
})}
</div>
{isBraceSupport ? (
<>
<SliderControl
@@ -636,7 +660,7 @@ export function ColumnPanel() {
)}
{!isBraceSupport && (
<PanelSection title="Ends">
<PanelSection title="Ends">
<select
className={SELECT_CLASS}
onChange={(event) => {
@@ -647,10 +671,22 @@ export function ColumnPanel() {
? {}
: {
capitalHeight: Math.max(node.capitalHeight, 0.12),
capitalTierCount: capitalStyle === 'stepped' ? Math.max(node.capitalTierCount ?? 3, 3) : node.capitalTierCount,
capitalWidthScale: Math.max(node.capitalWidthScale ?? 1.3, capitalStyle === 'stepped' ? 1.42 : 1.28),
capitalDepthScale: Math.max(node.capitalDepthScale ?? 1.3, capitalStyle === 'stepped' ? 1.42 : 1.28),
capitalStepSpread: capitalStyle === 'stepped' ? Math.max(node.capitalStepSpread ?? 0.34, 0.34) : node.capitalStepSpread,
capitalTierCount:
capitalStyle === 'stepped'
? Math.max(node.capitalTierCount ?? 3, 3)
: node.capitalTierCount,
capitalWidthScale: Math.max(
node.capitalWidthScale ?? 1.3,
capitalStyle === 'stepped' ? 1.42 : 1.28,
),
capitalDepthScale: Math.max(
node.capitalDepthScale ?? 1.3,
capitalStyle === 'stepped' ? 1.42 : 1.28,
),
capitalStepSpread:
capitalStyle === 'stepped'
? Math.max(node.capitalStepSpread ?? 0.34, 0.34)
: node.capitalStepSpread,
}),
})
}}
@@ -732,13 +768,34 @@ export function ColumnPanel() {
? {}
: {
baseHeight: Math.max(node.baseHeight, 0.12),
baseTierCount: baseStyle === 'stepped-square' ? Math.max(node.baseTierCount ?? 3, 3) : node.baseTierCount,
baseWidthScale: Math.max(node.baseWidthScale ?? 1.24, baseStyle === 'stepped-square' ? 1.42 : 1.24),
baseDepthScale: Math.max(node.baseDepthScale ?? 1.24, baseStyle === 'stepped-square' ? 1.42 : 1.24),
baseStepSpread: baseStyle === 'stepped-square' ? Math.max(node.baseStepSpread ?? 0.34, 0.34) : node.baseStepSpread,
basePlinthHeightRatio: baseStyle === 'round-rings' ? (node.basePlinthHeightRatio ?? 0.44) : node.basePlinthHeightRatio,
baseRoundBandScale: baseStyle === 'round-rings' ? (node.baseRoundBandScale ?? 0.92) : node.baseRoundBandScale,
baseNeckScale: baseStyle === 'round-rings' ? (node.baseNeckScale ?? 0.72) : node.baseNeckScale,
baseTierCount:
baseStyle === 'stepped-square'
? Math.max(node.baseTierCount ?? 3, 3)
: node.baseTierCount,
baseWidthScale: Math.max(
node.baseWidthScale ?? 1.24,
baseStyle === 'stepped-square' ? 1.42 : 1.24,
),
baseDepthScale: Math.max(
node.baseDepthScale ?? 1.24,
baseStyle === 'stepped-square' ? 1.42 : 1.24,
),
baseStepSpread:
baseStyle === 'stepped-square'
? Math.max(node.baseStepSpread ?? 0.34, 0.34)
: node.baseStepSpread,
basePlinthHeightRatio:
baseStyle === 'round-rings'
? (node.basePlinthHeightRatio ?? 0.44)
: node.basePlinthHeightRatio,
baseRoundBandScale:
baseStyle === 'round-rings'
? (node.baseRoundBandScale ?? 0.92)
: node.baseRoundBandScale,
baseNeckScale:
baseStyle === 'round-rings'
? (node.baseNeckScale ?? 0.72)
: node.baseNeckScale,
}),
})
}}
@@ -844,7 +901,7 @@ export function ColumnPanel() {
value={node.baseStepSpread ?? 0.34}
/>
)}
</PanelSection>
</PanelSection>
)}
<PanelSection title="Transform">
+260 -237
View File
@@ -12,8 +12,8 @@ import { useViewer } from '@pascal-app/viewer'
import { BookMarked, Copy, DoorOpen, FlipHorizontal2, Move, Trash2 } from 'lucide-react'
import { useCallback, useRef } from 'react'
import { usePresetsAdapter } from '../../../contexts/presets-context'
import { cn } from '../../../lib/utils'
import { sfxEmitter } from '../../../lib/sfx-bus'
import { cn } from '../../../lib/utils'
import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button'
import { MetricControl } from '../controls/metric-control'
@@ -127,8 +127,11 @@ export function DoorPanel() {
const handleUpdate = useCallback(
(updates: Partial<DoorNode>) => {
if (!(selectedId && node)) return
const liveNode = useScene.getState().nodes[selectedId as AnyNodeId]
if (liveNode?.type !== 'door') return
const hasChange = Object.entries(updates).some(([key, value]) => {
const currentValue = node[key as keyof DoorNode]
const currentValue = liveNode[key as keyof DoorNode]
return !isSameDoorValue(currentValue, value)
})
if (!hasChange) return
@@ -137,7 +140,9 @@ export function DoorPanel() {
useInteractive.getState().removeDoorOpenState(selectedId as AnyNodeId)
}
updateNode(selectedId as AnyNode['id'], updates)
useScene.getState().dirtyNodes.add(selectedId as AnyNodeId)
const scene = useScene.getState()
scene.dirtyNodes.add(selectedId as AnyNodeId)
if (liveNode.parentId) scene.dirtyNodes.add(liveNode.parentId as AnyNodeId)
},
[selectedId, node, updateNode],
)
@@ -148,7 +153,13 @@ export function DoorPanel() {
const liveNode = useScene.getState().nodes[selectedId as AnyNodeId]
if (liveNode?.type !== 'door') return
if (!(previewRef.current && previewRef.current.id === selectedId && previewRef.current.key === key)) {
if (
!(
previewRef.current &&
previewRef.current.id === selectedId &&
previewRef.current.key === key
)
) {
previewRef.current = {
id: selectedId as AnyNodeId,
key,
@@ -333,7 +344,8 @@ export function DoorPanel() {
const normHeights = node.segments.map((seg) => seg.heightRatio / hSum)
const isOpening = node.openingKind === 'opening'
const openingShape = node.openingShape ?? 'rectangle'
const doorShape = openingShape === 'arch' || openingShape === 'rounded' ? openingShape : 'rectangle'
const doorShape =
openingShape === 'arch' || openingShape === 'rounded' ? openingShape : 'rectangle'
const openingRadiusMode = node.openingRadiusMode ?? 'all'
const openingTopRadii = node.openingTopRadii ?? [0.15, 0.15]
const cornerRadius = node.cornerRadius ?? 0.15
@@ -348,7 +360,9 @@ export function DoorPanel() {
const isRollupGarageDoor = doorType === 'garage-rollup'
const isTiltupGarageDoor = doorType === 'garage-tiltup'
const typeMode = isOpening ? 'opening' : isGarageDoor ? 'garage' : 'door'
const supportsHandleSide = isSwingDoor
const supportsHingeSide = doorType === 'hinged'
const supportsHandleSide = doorType === 'hinged'
const supportsTopShape = !isGarageDoor
const maxDoorWidth = isGarageDoor ? 6 : 3
const setOpeningTopRadius = (index: number, value: number, commit = false) => {
@@ -395,6 +409,7 @@ export function DoorPanel() {
handleSide: 'right',
trackStyle: 'visible',
operationState: Math.max(node.operationState ?? 0, 0.65),
threshold: false,
contentPadding: [0.03, 0.04],
segments: foldingDoorSegments,
}
@@ -411,6 +426,7 @@ export function DoorPanel() {
trackStyle: 'pocket',
slideDirection: node.slideDirection ?? 'left',
operationState: node.operationState ?? 0,
threshold: false,
contentPadding: [0.035, 0.045],
segments: foldingDoorSegments,
}
@@ -427,6 +443,7 @@ export function DoorPanel() {
trackStyle: 'visible',
slideDirection: node.slideDirection ?? 'left',
operationState: node.operationState ?? 0,
threshold: false,
contentPadding: [0.035, 0.045],
segments: foldingDoorSegments,
}
@@ -443,6 +460,7 @@ export function DoorPanel() {
trackStyle: 'visible',
slideDirection: node.slideDirection ?? 'left',
operationState: node.operationState ?? 0,
threshold: false,
contentPadding: [0.03, 0.04],
segments: frenchDoorSegments,
}
@@ -456,6 +474,7 @@ export function DoorPanel() {
...dimensionUpdates,
handle: false,
threshold: false,
openingShape: 'rectangle',
trackStyle: 'overhead',
operationState: 0,
garagePanelCount: Math.max(3, Math.min(8, node.garagePanelCount ?? 4)),
@@ -472,6 +491,7 @@ export function DoorPanel() {
...dimensionUpdates,
handle: false,
threshold: false,
openingShape: 'rectangle',
trackStyle: 'overhead',
operationState: 0,
garagePanelCount: 4,
@@ -488,6 +508,7 @@ export function DoorPanel() {
...dimensionUpdates,
handle: false,
threshold: false,
openingShape: 'rectangle',
trackStyle: 'overhead',
operationState: 0,
garagePanelCount: 4,
@@ -578,7 +599,8 @@ export function DoorPanel() {
isSelected
? 'border-orange-400/60 bg-orange-400/10 text-foreground'
: 'border-border/50 bg-[#2C2C2E] text-muted-foreground hover:bg-[#3e3e3e] hover:text-foreground',
!option.available && 'cursor-not-allowed opacity-45 hover:bg-[#2C2C2E] hover:text-muted-foreground',
!option.available &&
'cursor-not-allowed opacity-45 hover:bg-[#2C2C2E] hover:text-muted-foreground',
)}
disabled={!option.available}
key={option.value}
@@ -738,7 +760,7 @@ export function DoorPanel() {
/>
</PanelSection>
{!isOpening && (
{!isOpening && supportsTopShape && (
<PanelSection title="Top Shape">
<div className="flex flex-col gap-2 px-1 pb-1">
<SegmentedControl
@@ -990,19 +1012,21 @@ export function DoorPanel() {
{isSwingDoor && (
<PanelSection title="Swing">
<div className="flex flex-col gap-2 px-1 pb-1">
<div className="space-y-1">
<span className="font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
Hinges Side
</span>
<SegmentedControl
onChange={(v) => handleUpdate({ hingesSide: v })}
options={[
{ label: 'Left', value: 'left' },
{ label: 'Right', value: 'right' },
]}
value={node.hingesSide}
/>
</div>
{supportsHingeSide && (
<div className="space-y-1">
<span className="font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
Hinges Side
</span>
<SegmentedControl
onChange={(v) => handleUpdate({ hingesSide: v })}
options={[
{ label: 'Left', value: 'left' },
{ label: 'Right', value: 'right' },
]}
value={node.hingesSide}
/>
</div>
)}
<div className="space-y-1">
<span className="font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
Direction
@@ -1029,245 +1053,244 @@ export function DoorPanel() {
/>
{node.threshold && (
<div className="mt-1 flex flex-col gap-1">
<SliderControl
label="Height"
max={0.1}
min={0.005}
onChange={(v) => handleUpdate({ thresholdHeight: v })}
precision={3}
step={0.005}
unit="m"
value={Math.round(node.thresholdHeight * 1000) / 1000}
/>
</div>
)}
</PanelSection>
)}
{!isGarageDoor && (
<PanelSection title="Handle">
{isSwingDoor && (
<ToggleControl
checked={node.handle}
label="Enable Handle"
onChange={(checked) => handleUpdate({ handle: checked })}
/>
)}
{(node.handle || !isSwingDoor) && (
<div className="mt-1 flex flex-col gap-1">
<SliderControl
label="Height"
max={node.height - 0.1}
min={0.5}
onChange={(v) => handleUpdate({ handleHeight: v })}
precision={2}
step={0.05}
unit="m"
value={Math.round(node.handleHeight * 100) / 100}
/>
{supportsHandleSide && (
<div className="space-y-1">
<span className="font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
Handle Side
</span>
<SegmentedControl
onChange={(v) => handleUpdate({ handleSide: v })}
options={[
{ label: 'Left', value: 'left' },
{ label: 'Right', value: 'right' },
]}
value={node.handleSide}
<SliderControl
label="Height"
max={0.1}
min={0.005}
onChange={(v) => handleUpdate({ thresholdHeight: v })}
precision={3}
step={0.005}
unit="m"
value={Math.round(node.thresholdHeight * 1000) / 1000}
/>
</div>
)}
</div>
</PanelSection>
)}
</PanelSection>
)}
{isSwingDoor && (
<PanelSection title="Hardware">
<ToggleControl
checked={node.doorCloser}
label="Door Closer"
onChange={(checked) => handleUpdate({ doorCloser: checked })}
/>
<ToggleControl
checked={node.panicBar}
label="Panic Bar"
onChange={(checked) => handleUpdate({ panicBar: checked })}
/>
{node.panicBar && (
<div className="mt-1 flex flex-col gap-1">
<SliderControl
label="Bar Height"
max={node.height - 0.1}
min={0.5}
onChange={(v) => handleUpdate({ panicBarHeight: v })}
precision={2}
step={0.05}
unit="m"
value={Math.round(node.panicBarHeight * 100) / 100}
/>
</div>
)}
</PanelSection>
)}
{!isGarageDoor && (
<PanelSection title="Segments">
{node.segments.map((seg, i) => {
const numCols = seg.columnRatios.length
const colSum = seg.columnRatios.reduce((a, b) => a + b, 0)
const normCols = seg.columnRatios.map((r) => r / colSum)
return (
<div className="mb-2 flex flex-col gap-1" key={i}>
<div className="flex items-center justify-between pb-1">
<span className="font-medium text-white/80 text-xs">Segment {i + 1}</span>
</div>
<SegmentedControl
onChange={(t) => {
const updated = node.segments.map((s, idx) =>
idx === i ? { ...s, type: t } : s,
)
handleUpdate({ segments: updated })
}}
options={[
{ label: 'Panel', value: 'panel' },
{ label: 'Glass', value: 'glass' },
{ label: 'Empty', value: 'empty' },
]}
value={seg.type}
{!isGarageDoor && (
<PanelSection title="Handle">
{isSwingDoor && (
<ToggleControl
checked={node.handle}
label="Enable Handle"
onChange={(checked) => handleUpdate({ handle: checked })}
/>
<SliderControl
label="Height"
max={95}
min={5}
onChange={(v) => setSegmentHeightRatio(i, v / 100)}
precision={1}
step={1}
unit="%"
value={Math.round(normHeights[i]! * 100 * 10) / 10}
/>
<SliderControl
label="Columns"
max={8}
min={1}
onChange={(v) => {
const n = Math.max(1, Math.min(8, Math.round(v)))
const updated = node.segments.map((s, idx) =>
idx === i ? { ...s, columnRatios: Array(n).fill(1 / n) } : s,
)
handleUpdate({ segments: updated })
}}
precision={0}
step={1}
value={numCols}
/>
{numCols > 1 && (
<div className="mt-1 border-border/50 border-t pt-1">
{normCols.map((ratio, ci) => (
<SliderControl
key={`c-${ci}`}
label={`C${ci + 1}`}
max={95}
min={5}
onChange={(v) => setSegmentColumnRatio(i, ci, v / 100)}
precision={1}
step={1}
unit="%"
value={Math.round(ratio * 100 * 10) / 10}
)}
{(node.handle || !isSwingDoor) && (
<div className="mt-1 flex flex-col gap-1">
<SliderControl
label="Height"
max={node.height - 0.1}
min={0.5}
onChange={(v) => handleUpdate({ handleHeight: v })}
precision={2}
step={0.05}
unit="m"
value={Math.round(node.handleHeight * 100) / 100}
/>
{supportsHandleSide && (
<div className="space-y-1">
<span className="font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
Handle Side
</span>
<SegmentedControl
onChange={(v) => handleUpdate({ handleSide: v })}
options={[
{ label: 'Left', value: 'left' },
{ label: 'Right', value: 'right' },
]}
value={node.handleSide}
/>
))}
<SliderControl
label="Divider"
max={0.1}
min={0.005}
onChange={(v) => {
const updated = node.segments.map((s, idx) =>
idx === i ? { ...s, dividerThickness: v } : s,
)
handleUpdate({ segments: updated })
}}
precision={3}
step={0.005}
unit="m"
value={Math.round(seg.dividerThickness * 1000) / 1000}
/>
</div>
)}
</div>
)}
</div>
)}
</PanelSection>
)}
{seg.type === 'panel' && (
<div className="mt-1 border-border/50 border-t pt-1">
<SliderControl
label="Inset"
max={0.1}
min={0}
onChange={(v) => {
{isSwingDoor && (
<PanelSection title="Hardware">
<ToggleControl
checked={node.doorCloser}
label="Door Closer"
onChange={(checked) => handleUpdate({ doorCloser: checked })}
/>
<ToggleControl
checked={node.panicBar}
label="Panic Bar"
onChange={(checked) => handleUpdate({ panicBar: checked })}
/>
{node.panicBar && (
<div className="mt-1 flex flex-col gap-1">
<SliderControl
label="Bar Height"
max={node.height - 0.1}
min={0.5}
onChange={(v) => handleUpdate({ panicBarHeight: v })}
precision={2}
step={0.05}
unit="m"
value={Math.round(node.panicBarHeight * 100) / 100}
/>
</div>
)}
</PanelSection>
)}
{!isGarageDoor && (
<PanelSection title="Segments">
{node.segments.map((seg, i) => {
const numCols = seg.columnRatios.length
const colSum = seg.columnRatios.reduce((a, b) => a + b, 0)
const normCols = seg.columnRatios.map((r) => r / colSum)
return (
<div className="mb-2 flex flex-col gap-1" key={i}>
<div className="flex items-center justify-between pb-1">
<span className="font-medium text-white/80 text-xs">Segment {i + 1}</span>
</div>
<SegmentedControl
onChange={(t) => {
const updated = node.segments.map((s, idx) =>
idx === i ? { ...s, panelInset: v } : s,
idx === i ? { ...s, type: t } : s,
)
handleUpdate({ segments: updated })
}}
precision={3}
step={0.005}
unit="m"
value={Math.round(seg.panelInset * 1000) / 1000}
options={[
{ label: 'Panel', value: 'panel' },
{ label: 'Glass', value: 'glass' },
{ label: 'Empty', value: 'empty' },
]}
value={seg.type}
/>
<SliderControl
label="Depth"
max={0.1}
min={0}
label="Height"
max={95}
min={5}
onChange={(v) => setSegmentHeightRatio(i, v / 100)}
precision={1}
step={1}
unit="%"
value={Math.round(normHeights[i]! * 100 * 10) / 10}
/>
<SliderControl
label="Columns"
max={8}
min={1}
onChange={(v) => {
const n = Math.max(1, Math.min(8, Math.round(v)))
const updated = node.segments.map((s, idx) =>
idx === i ? { ...s, panelDepth: v } : s,
idx === i ? { ...s, columnRatios: Array(n).fill(1 / n) } : s,
)
handleUpdate({ segments: updated })
}}
precision={3}
step={0.005}
unit="m"
value={Math.round(seg.panelDepth * 1000) / 1000}
precision={0}
step={1}
value={numCols}
/>
{numCols > 1 && (
<div className="mt-1 border-border/50 border-t pt-1">
{normCols.map((ratio, ci) => (
<SliderControl
key={`c-${ci}`}
label={`C${ci + 1}`}
max={95}
min={5}
onChange={(v) => setSegmentColumnRatio(i, ci, v / 100)}
precision={1}
step={1}
unit="%"
value={Math.round(ratio * 100 * 10) / 10}
/>
))}
<SliderControl
label="Divider"
max={0.1}
min={0.005}
onChange={(v) => {
const updated = node.segments.map((s, idx) =>
idx === i ? { ...s, dividerThickness: v } : s,
)
handleUpdate({ segments: updated })
}}
precision={3}
step={0.005}
unit="m"
value={Math.round(seg.dividerThickness * 1000) / 1000}
/>
</div>
)}
{seg.type === 'panel' && (
<div className="mt-1 border-border/50 border-t pt-1">
<SliderControl
label="Inset"
max={0.1}
min={0}
onChange={(v) => {
const updated = node.segments.map((s, idx) =>
idx === i ? { ...s, panelInset: v } : s,
)
handleUpdate({ segments: updated })
}}
precision={3}
step={0.005}
unit="m"
value={Math.round(seg.panelInset * 1000) / 1000}
/>
<SliderControl
label="Depth"
max={0.1}
min={0}
onChange={(v) => {
const updated = node.segments.map((s, idx) =>
idx === i ? { ...s, panelDepth: v } : s,
)
handleUpdate({ segments: updated })
}}
precision={3}
step={0.005}
unit="m"
value={Math.round(seg.panelDepth * 1000) / 1000}
/>
</div>
)}
</div>
)
})}
<div className="flex gap-1.5 px-1 pt-1">
<ActionButton
label="+ Add Segment"
onClick={() => {
const updated = [
...node.segments,
{
type: 'panel' as const,
heightRatio: 1,
columnRatios: [1],
dividerThickness: 0.03,
panelDepth: 0.01,
panelInset: 0.04,
},
]
handleUpdate({ segments: updated })
}}
/>
{node.segments.length > 1 && (
<ActionButton
className="text-white/60 hover:text-white"
label="- Remove"
onClick={() => handleUpdate({ segments: node.segments.slice(0, -1) })}
/>
)}
</div>
)
})}
<div className="flex gap-1.5 px-1 pt-1">
<ActionButton
label="+ Add Segment"
onClick={() => {
const updated = [
...node.segments,
{
type: 'panel' as const,
heightRatio: 1,
columnRatios: [1],
dividerThickness: 0.03,
panelDepth: 0.01,
panelInset: 0.04,
},
]
handleUpdate({ segments: updated })
}}
/>
{node.segments.length > 1 && (
<ActionButton
className="text-white/60 hover:text-white"
label="- Remove"
onClick={() => handleUpdate({ segments: node.segments.slice(0, -1) })}
/>
)}
</div>
</PanelSection>
)}
</PanelSection>
)}
</>
)}
@@ -1,6 +1,5 @@
'use client'
import {
type AnyNode,
type AnyNodeId,
@@ -87,10 +86,6 @@ export function FencePanel() {
setSelection({ selectedIds: [] })
}, [setSelection])
if (!(node && node.type === 'fence' && selectedId && selectedCount === 1)) return null
const length = getWallCurveLength(node)
@@ -1,9 +1,8 @@
'use client'
import useEditor from '../../../store/use-editor'
import { SliderControl } from '../controls/slider-control'
import { Input } from '../primitives/input'
import { PanelSection } from '../controls/panel-section'
import { Input } from '../primitives/input'
import { PanelWrapper } from './panel-wrapper'
function buildDefaultCustomMaterial() {
@@ -53,11 +52,7 @@ export function PaintPanel() {
}
return (
<PanelWrapper
onClose={() => setPaintPanelOpen(false)}
title="Material"
width={320}
>
<PanelWrapper onClose={() => setPaintPanelOpen(false)} title="Material" width={320}>
<PanelSection title="Custom Material">
<div className="space-y-3">
<div className="space-y-2">
@@ -78,41 +73,71 @@ export function PaintPanel() {
</div>
</div>
<div className="space-y-1">
<label className="block font-medium text-muted-foreground text-xs uppercase tracking-[0.12em]">
Surface
</label>
<div className="space-y-1 rounded-lg border border-border/50 bg-background/40 p-2">
<SliderControl
label="Roughness"
max={1}
min={0}
onChange={(roughness) => updateCustomMaterial({ roughness })}
precision={2}
step={0.01}
value={currentProps.roughness}
/>
<SliderControl
label="Metalness"
max={1}
min={0}
onChange={(metalness) => updateCustomMaterial({ metalness })}
precision={2}
step={0.01}
value={currentProps.metalness}
/>
<SliderControl
label="Opacity"
max={1}
min={0}
onChange={(opacity) =>
updateCustomMaterial({ opacity }, opacity < 1 || currentProps.transparent)
}
precision={2}
step={0.01}
value={currentProps.opacity}
/>
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-[0.12em]">
Roughness
</label>
<span className="font-mono text-muted-foreground text-xs">
{currentProps.roughness.toFixed(2)}
</span>
</div>
<input
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-accent"
max={1}
min={0}
onChange={(e) =>
updateCustomMaterial({ roughness: Number.parseFloat(e.target.value) })
}
step={0.01}
type="range"
value={currentProps.roughness}
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-[0.12em]">
Metalness
</label>
<span className="font-mono text-muted-foreground text-xs">
{currentProps.metalness.toFixed(2)}
</span>
</div>
<input
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-accent"
max={1}
min={0}
onChange={(e) =>
updateCustomMaterial({ metalness: Number.parseFloat(e.target.value) })
}
step={0.01}
type="range"
value={currentProps.metalness}
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-[0.12em]">
Opacity
</label>
<span className="font-mono text-muted-foreground text-xs">
{currentProps.opacity.toFixed(2)}
</span>
</div>
<input
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-accent"
max={1}
min={0}
onChange={(e) => {
const opacity = Number.parseFloat(e.target.value)
updateCustomMaterial({ opacity }, opacity < 1 || currentProps.transparent)
}}
step={0.01}
type="range"
value={currentProps.opacity}
/>
</div>
<div className="space-y-2">
+3 -35
View File
@@ -96,6 +96,8 @@ function panelForType(type: string | null) {
return <StairSegmentPanel />
case 'slab':
return <SlabPanel />
case 'spawn':
return <SpawnPanel />
case 'ceiling':
return <CeilingPanel />
case 'column':
@@ -243,39 +245,5 @@ export function PanelManager() {
return <PaintPanel />
}
// Show appropriate panel based on selected node type
if (selectedNodeType) {
switch (selectedNodeType) {
case 'item':
return <ItemPanel />
case 'roof':
return <RoofPanel />
case 'roof-segment':
return <RoofSegmentPanel />
case 'stair':
return <StairPanel />
case 'stair-segment':
return <StairSegmentPanel />
case 'slab':
return <SlabPanel />
case 'spawn':
return <SpawnPanel />
case 'ceiling':
return <CeilingPanel />
case 'column':
return <ColumnPanel />
case 'wall':
return <WallPanel />
case 'fence':
return <FencePanel />
case 'door':
return <DoorPanel />
case 'elevator':
return <ElevatorPanel />
case 'window':
return <WindowPanel />
}
}
return null
return panelForType(selectedNodeType)
}
@@ -33,7 +33,14 @@ export function PanelWrapper({
className={cn(
isMobile
? 'flex h-full w-full flex-col overflow-hidden bg-transparent dark:text-foreground'
: 'pointer-events-auto fixed top-20 right-4 z-50 flex max-h-[calc(100dvh-100px)] flex-col overflow-hidden rounded-xl border border-border/50 bg-sidebar/95 shadow-2xl backdrop-blur-xl dark:text-foreground',
// Cap height at `100dvh - 154px` so a tall panel's bottom edge
// aligns flush with the top of the floating bottom action bar.
// Combined with `top-20` (80px), the panel's bottom sits at
// `100dvh - 74px` — just clearing the bar without leaving a
// visible gap. The inner `flex-1 overflow-y-auto` content area
// (below) handles vertical scrolling when content exceeds the
// cap.
: 'pointer-events-auto fixed top-20 right-4 z-50 flex max-h-[calc(100dvh-154px)] flex-col overflow-hidden rounded-xl border border-border/50 bg-sidebar/95 shadow-2xl backdrop-blur-xl dark:text-foreground',
className,
)}
style={isMobile ? undefined : { width }}
@@ -4,11 +4,21 @@ import {
type AnyNode,
type GuideNode,
loadAssetUrl,
saveAsset,
type ScanNode,
saveAsset,
useScene,
} from '@pascal-app/core'
import { Eye, EyeOff, LocateFixed, Lock, RotateCcw, Ruler, Trash2, Unlock, Upload } from 'lucide-react'
import {
Eye,
EyeOff,
LocateFixed,
Lock,
RotateCcw,
Ruler,
Trash2,
Unlock,
Upload,
} from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { guideEmitter } from '../../../lib/guide-events'
import { getGuideImageName } from '../../../lib/local-guide-image'
@@ -32,7 +42,9 @@ function getScaleStatus(guide: GuideNode, scaleReferenceVisible: boolean) {
export function ReferencePanel() {
const selectedReferenceId = useEditor((s) => s.selectedReferenceId)
const setSelectedReferenceId = useEditor((s) => s.setSelectedReferenceId)
const guideUi = useEditor((s) => (selectedReferenceId ? s.guideUi[selectedReferenceId] : undefined))
const guideUi = useEditor((s) =>
selectedReferenceId ? s.guideUi[selectedReferenceId] : undefined,
)
const setGuideLocked = useEditor((s) => s.setGuideLocked)
const setGuideScaleReferenceVisible = useEditor((s) => s.setGuideScaleReferenceVisible)
const clearGuideUi = useEditor((s) => s.clearGuideUi)
@@ -77,11 +89,14 @@ export function ReferencePanel() {
try {
const assetUrl = await saveAsset(file)
updateNode(selectedReferenceId as AnyNode['id'], {
name: getGuideImageName(file.name),
url: assetUrl,
scaleReference: null,
} as Partial<GuideNode>)
updateNode(
selectedReferenceId as AnyNode['id'],
{
name: getGuideImageName(file.name),
url: assetUrl,
scaleReference: null,
} as Partial<GuideNode>,
)
setGuideScaleReferenceVisible(selectedReferenceId, true)
} catch {
setReplaceError('Could not replace that image.')
@@ -138,7 +153,7 @@ export function ReferencePanel() {
const isScan = node.type === 'scan'
const guideLocked = !isScan && guideUi?.locked === true
const scaleReferenceVisible = !isScan && guideUi?.scaleReferenceVisible !== false
const scaleStatus = !isScan ? getScaleStatus(node, scaleReferenceVisible) : null
const scaleStatus = isScan ? null : getScaleStatus(node, scaleReferenceVisible)
return (
<PanelWrapper
@@ -165,16 +180,16 @@ export function ReferencePanel() {
<ActionGroup>
<ActionButton
disabled={isReplacing}
icon={<Upload className="h-3.5 w-3.5" />}
label={isReplacing ? 'Replacing...' : 'Replace'}
onClick={() => replaceInputRef.current?.click()}
disabled={isReplacing}
/>
<ActionButton
className="text-destructive hover:bg-destructive/10"
icon={<Trash2 className="h-3.5 w-3.5" />}
label="Delete"
onClick={handleDeleteGuide}
className="text-destructive hover:bg-destructive/10"
/>
</ActionGroup>
@@ -232,16 +247,16 @@ export function ReferencePanel() {
<ActionGroup>
<ActionButton
label={scaleReferenceVisible ? 'Hide Scale' : 'Show Scale'}
disabled={!node.scaleReference}
label={scaleReferenceVisible ? 'Hide Scale' : 'Show Scale'}
onClick={() => {
if (!node.scaleReference) return
setGuideScaleReferenceVisible(node.id, !scaleReferenceVisible)
}}
/>
<ActionButton
label="Clear Scale"
disabled={!node.scaleReference}
label="Clear Scale"
onClick={() => handleUpdate({ scaleReference: null } as Partial<GuideNode>)}
/>
</ActionGroup>
+52 -2
View File
@@ -3,21 +3,25 @@
import {
type AnyNode,
type AnyNodeId,
getEffectiveRoofSurfaceMaterial,
type MaterialSchema,
type RoofNode,
type RoofSurfaceMaterialRole,
RoofNode as RoofNodeSchema,
type RoofSegmentNode,
RoofSegmentNode as RoofSegmentNodeSchema,
type RoofSurfaceMaterialRole,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Copy, Move, Plus, Trash2 } from 'lucide-react'
import { useCallback } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { sfxEmitter } from '../../../lib/sfx-bus'
import { buildRoofSurfaceMaterialPatch } from '../../../lib/material-paint'
import { duplicateRoofSubtree } from '../../../lib/roof-duplication'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button'
import { MaterialPicker } from '../controls/material-picker'
import { PanelSection } from '../controls/panel-section'
import { SliderControl } from '../controls/slider-control'
import { PanelWrapper } from './panel-wrapper'
@@ -28,6 +32,7 @@ export function RoofPanel() {
const updateNode = useScene((s) => s.updateNode)
const createNode = useScene((s) => s.createNode)
const setMovingNode = useEditor((s) => s.setMovingNode)
const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget)
const node = useScene((s) =>
selectedId ? (s.nodes[selectedId as AnyNode['id']] as RoofNode | undefined) : undefined,
@@ -50,6 +55,35 @@ export function RoofPanel() {
[selectedId, updateNode],
)
const materialTargetRole =
selectedMaterialTarget &&
selectedMaterialTarget.nodeId === node?.id &&
(selectedMaterialTarget.role === 'top' ||
selectedMaterialTarget.role === 'edge' ||
selectedMaterialTarget.role === 'wall')
? selectedMaterialTarget.role
: null
const materialPickerValue =
node && materialTargetRole ? getEffectiveRoofSurfaceMaterial(node, materialTargetRole) : {}
const handleTargetedMaterialChange = useCallback(
(material: MaterialSchema) => {
if (!(node && materialTargetRole)) return
handleUpdate(buildRoofSurfaceMaterialPatch(node, materialTargetRole, material, undefined))
},
[handleUpdate, materialTargetRole, node],
)
const handleTargetedMaterialPresetChange = useCallback(
(materialPreset: string) => {
if (!(node && materialTargetRole)) return
handleUpdate(
buildRoofSurfaceMaterialPatch(node, materialTargetRole, undefined, materialPreset),
)
},
[handleUpdate, materialTargetRole, node],
)
const handleClose = useCallback(() => {
setSelection({ selectedIds: [] })
}, [setSelection])
@@ -225,6 +259,22 @@ export function RoofPanel() {
/>
</ActionGroup>
</PanelSection>
<PanelSection title="Material">
{materialTargetRole ? null : (
<div className="mb-3 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 text-[11px] text-muted-foreground">
Click the roof surface you want to edit. Materials apply to one target at a time.
</div>
)}
<MaterialPicker
disabled={!materialTargetRole}
hideSideControl
nodeType="roof"
onChange={handleTargetedMaterialChange}
onSelectMaterialPreset={handleTargetedMaterialPresetChange}
selectedMaterialPreset={materialPickerValue.materialPreset}
value={materialPickerValue.material}
/>
</PanelSection>
</PanelWrapper>
)
}
View File
View File
@@ -87,7 +87,7 @@ export function SpawnPanel() {
if (!(node && node.type === 'spawn' && selectedId)) return null
const rotationDegrees = Math.round((((draftRotation ?? node.rotation) * 180) / Math.PI))
const rotationDegrees = Math.round(((draftRotation ?? node.rotation) * 180) / Math.PI)
const storedRotationDegrees = Math.round((node.rotation * 180) / Math.PI)
return (
@@ -97,7 +97,9 @@ export function SpawnPanel() {
label="X"
max={node.position[0] + 2}
min={node.position[0] - 2}
onChange={(value) => handleUpdate({ position: [value, node.position[1], node.position[2]] })}
onChange={(value) =>
handleUpdate({ position: [value, node.position[1], node.position[2]] })
}
precision={2}
step={0.01}
unit="m"
@@ -107,7 +109,9 @@ export function SpawnPanel() {
label="Y"
max={node.position[1] + 2}
min={node.position[1] - 2}
onChange={(value) => handleUpdate({ position: [node.position[0], value, node.position[2]] })}
onChange={(value) =>
handleUpdate({ position: [node.position[0], value, node.position[2]] })
}
precision={2}
step={0.01}
unit="m"
@@ -117,7 +121,9 @@ export function SpawnPanel() {
label="Z"
max={node.position[2] + 2}
min={node.position[2] - 2}
onChange={(value) => handleUpdate({ position: [node.position[0], node.position[1], value] })}
onChange={(value) =>
handleUpdate({ position: [node.position[0], node.position[1], value] })
}
precision={2}
step={0.01}
unit="m"
@@ -3,25 +3,31 @@
import {
type AnyNode,
type AnyNodeId,
getEffectiveStairSurfaceMaterial,
type LevelNode,
type MaterialSchema,
type StairNode,
StairNode as StairNodeSchema,
type StairRailingMode,
type StairSlabOpeningMode,
type StairTopLandingMode,
type StairType,
type StairSegmentNode,
StairSegmentNode as StairSegmentNodeSchema,
type StairSlabOpeningMode,
type StairSurfaceMaterialRole,
type StairTopLandingMode,
type StairType,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Copy, Move, Plus, Trash2 } from 'lucide-react'
import { useCallback } from 'react'
import { duplicateStairSubtree } from '../../../lib/stair-duplication'
import { useShallow } from 'zustand/react/shallow'
import { buildStairSurfaceMaterialPatch } from '../../../lib/material-paint'
import { sfxEmitter } from '../../../lib/sfx-bus'
import { duplicateStairSubtree } from '../../../lib/stair-duplication'
import useEditor from '../../../store/use-editor'
import { DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE } from '../../tools/stair/stair-defaults'
import { ActionButton, ActionGroup } from '../controls/action-button'
import { MaterialPicker } from '../controls/material-picker'
import { MetricControl } from '../controls/metric-control'
import { PanelSection } from '../controls/panel-section'
import { SegmentedControl } from '../controls/segmented-control'
@@ -59,6 +65,7 @@ export function StairPanel() {
const updateNode = useScene((s) => s.updateNode)
const createNode = useScene((s) => s.createNode)
const setMovingNode = useEditor((s) => s.setMovingNode)
const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget)
const node = useScene((s) =>
selectedId ? (s.nodes[selectedId as AnyNode['id']] as StairNode | undefined) : undefined,
@@ -89,6 +96,35 @@ export function StairPanel() {
[selectedId, updateNode],
)
const materialTargetRole =
selectedMaterialTarget &&
selectedMaterialTarget.nodeId === node?.id &&
(selectedMaterialTarget.role === 'railing' ||
selectedMaterialTarget.role === 'tread' ||
selectedMaterialTarget.role === 'side')
? selectedMaterialTarget.role
: null
const materialPickerValue =
node && materialTargetRole ? getEffectiveStairSurfaceMaterial(node, materialTargetRole) : {}
const handleTargetedMaterialChange = useCallback(
(material: MaterialSchema) => {
if (!(node && materialTargetRole)) return
handleUpdate(buildStairSurfaceMaterialPatch(node, materialTargetRole, material, undefined))
},
[handleUpdate, materialTargetRole, node],
)
const handleTargetedMaterialPresetChange = useCallback(
(materialPreset: string) => {
if (!(node && materialTargetRole)) return
handleUpdate(
buildStairSurfaceMaterialPatch(node, materialTargetRole, undefined, materialPreset),
)
},
[handleUpdate, materialTargetRole, node],
)
const handleClose = useCallback(() => {
setSelection({ selectedIds: [] })
}, [setSelection])
@@ -222,11 +258,11 @@ export function StairPanel() {
/>
<div className="space-y-1.5">
<div className="px-1 text-[11px] uppercase tracking-[0.14em] text-muted-foreground">
<div className="px-1 text-[11px] text-muted-foreground uppercase tracking-[0.14em]">
From Level
</div>
<select
className="h-9 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-sm text-foreground"
className="h-9 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-foreground text-sm"
onChange={(event) => handleUpdate({ fromLevelId: event.target.value })}
value={resolvedFromLevelId ?? ''}
>
@@ -239,11 +275,11 @@ export function StairPanel() {
</div>
<div className="space-y-1.5">
<div className="px-1 text-[11px] uppercase tracking-[0.14em] text-muted-foreground">
<div className="px-1 text-[11px] text-muted-foreground uppercase tracking-[0.14em]">
To Level
</div>
<select
className="h-9 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-sm text-foreground"
className="h-9 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-foreground text-sm"
onChange={(event) => handleUpdate({ toLevelId: event.target.value })}
value={resolvedToLevelId ?? ''}
>
@@ -262,7 +298,7 @@ export function StairPanel() {
/>
{(node.slabOpeningMode ?? 'none') === 'destination' ? (
<SliderControl
<MetricControl
label="Opening Offset"
max={0.5}
min={0}
@@ -308,7 +344,7 @@ export function StairPanel() {
{(node.stairType === 'curved' || node.stairType === 'spiral') && (
<PanelSection title="Geometry">
<SliderControl
<MetricControl
label="Width"
max={10}
min={0.4}
@@ -318,7 +354,7 @@ export function StairPanel() {
unit="m"
value={Math.round((node.width ?? 1) * 100) / 100}
/>
<SliderControl
<MetricControl
label="Rise"
max={10}
min={0.2}
@@ -328,7 +364,7 @@ export function StairPanel() {
unit="m"
value={Math.round((node.totalRise ?? 2.5) * 100) / 100}
/>
<SliderControl
<MetricControl
label="Steps"
max={32}
min={2}
@@ -346,7 +382,7 @@ export function StairPanel() {
/>
)}
{(node.stairType === 'spiral' || !(node.fillToFloor ?? true)) && (
<SliderControl
<MetricControl
label="Thickness"
max={1}
min={0.02}
@@ -357,7 +393,7 @@ export function StairPanel() {
value={Math.round((node.thickness ?? 0.25) * 100) / 100}
/>
)}
<SliderControl
<MetricControl
label="Inner Radius"
max={10}
min={node.stairType === 'spiral' ? 0.05 : 0.2}
@@ -385,7 +421,7 @@ export function StairPanel() {
value={node.topLandingMode ?? 'none'}
/>
{(node.topLandingMode ?? 'none') === 'integrated' && (
<SliderControl
<MetricControl
label="Top Landing"
max={5}
min={0.3}
@@ -520,6 +556,22 @@ export function StairPanel() {
/>
</ActionGroup>
</PanelSection>
<PanelSection title="Material">
{materialTargetRole ? null : (
<div className="mb-3 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 text-[11px] text-muted-foreground">
Click the stair surface you want to edit. Materials apply to one target at a time.
</div>
)}
<MaterialPicker
disabled={!materialTargetRole}
hideSideControl
nodeType="stair"
onChange={handleTargetedMaterialChange}
onSelectMaterialPreset={handleTargetedMaterialPresetChange}
selectedMaterialPreset={materialPickerValue.materialPreset}
value={materialPickerValue.material}
/>
</PanelSection>
</PanelWrapper>
)
}
View File
+146 -125
View File
@@ -12,11 +12,10 @@ import { useViewer } from '@pascal-app/viewer'
import { BookMarked, Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react'
import { useCallback, useRef } from 'react'
import { usePresetsAdapter } from '../../../contexts/presets-context'
import { cn } from '../../../lib/utils'
import { sfxEmitter } from '../../../lib/sfx-bus'
import { cn } from '../../../lib/utils'
import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button'
import { MetricControl } from '../controls/metric-control'
import { PanelSection } from '../controls/panel-section'
import { SegmentedControl } from '../controls/segmented-control'
import { SliderControl } from '../controls/slider-control'
@@ -81,14 +80,16 @@ const windowTypeOptions: Array<{ label: string; value: WindowNode['windowType']
{ label: 'Louvered', value: 'louvered' },
]
const rectangleOnlyWindowTypes = new Set<WindowNode['windowType']>([
'sliding',
'single-hung',
'double-hung',
'bay',
'bow',
const shapedWindowTypes = new Set<WindowNode['windowType']>([
'fixed',
'casement',
'awning',
'hopper',
'louvered',
])
const silllessWindowTypes = new Set<WindowNode['windowType']>(['bay', 'bow'])
export function WindowPanel() {
const selectedId = useViewer((s) => s.selection.selectedIds[0])
const setSelection = useViewer((s) => s.setSelection)
@@ -110,14 +111,19 @@ export function WindowPanel() {
const handleUpdate = useCallback(
(updates: Partial<WindowNode>) => {
if (!(selectedId && node)) return
const liveNode = useScene.getState().nodes[selectedId as AnyNodeId]
if (liveNode?.type !== 'window') return
const hasChange = Object.entries(updates).some(([key, value]) => {
const currentValue = node[key as keyof WindowNode]
const currentValue = liveNode[key as keyof WindowNode]
return !isSameWindowValue(currentValue, value)
})
if (!hasChange) return
updateNode(selectedId as AnyNode['id'], updates)
useScene.getState().dirtyNodes.add(selectedId as AnyNodeId)
const scene = useScene.getState()
scene.dirtyNodes.add(selectedId as AnyNodeId)
if (liveNode.parentId) scene.dirtyNodes.add(liveNode.parentId as AnyNodeId)
},
[selectedId, node, updateNode],
)
@@ -129,7 +135,11 @@ export function WindowPanel() {
if (liveNode?.type !== 'window') return
if (
!(previewRef.current && previewRef.current.id === selectedId && previewRef.current.key === key)
!(
previewRef.current &&
previewRef.current.id === selectedId &&
previewRef.current.key === key
)
) {
previewRef.current = {
id: selectedId as AnyNodeId,
@@ -299,7 +309,8 @@ export function WindowPanel() {
const normRows = node.rowRatios.map((r) => r / rowSum)
const isOpening = node.openingKind === 'opening'
const openingShape = node.openingShape ?? 'rectangle'
const windowShape = openingShape === 'arch' || openingShape === 'rounded' ? openingShape : 'rectangle'
const windowShape =
openingShape === 'arch' || openingShape === 'rounded' ? openingShape : 'rectangle'
const openingRadiusMode = node.openingRadiusMode ?? 'all'
const openingCornerRadii = node.openingCornerRadii ?? [0.15, 0.15, 0.15, 0.15]
const cornerRadius = node.cornerRadius ?? 0.15
@@ -316,6 +327,9 @@ export function WindowPanel() {
node.windowType === 'single-hung' ||
node.windowType === 'double-hung' ||
node.windowType === 'louvered'
const supportsWindowShape = shapedWindowTypes.has(node.windowType ?? 'fixed')
const supportsGrid = node.windowType === 'fixed'
const supportsSill = !silllessWindowTypes.has(node.windowType)
const setOperationState = (value: number) => {
useInteractive.getState().cancelWindowAnimation(node.id)
@@ -340,7 +354,10 @@ export function WindowPanel() {
nextUpdates.openingCornerRadii = nextRadii
}
} else {
const nextRadius = Math.min(Math.max(cornerRadius, 0), getMaxSharedWindowRadius(nextWidth, nextHeight))
const nextRadius = Math.min(
Math.max(cornerRadius, 0),
getMaxSharedWindowRadius(nextWidth, nextHeight),
)
if (Math.abs(nextRadius - cornerRadius) > 1e-6) {
nextUpdates.cornerRadius = nextRadius
}
@@ -464,10 +481,10 @@ export function WindowPanel() {
handleUpdate({
windowType: option.value,
...(option.value === 'awning' ? { awningDirection } : {}),
...(rectangleOnlyWindowTypes.has(option.value)
...(!shapedWindowTypes.has(option.value)
? { openingShape: 'rectangle' }
: {}),
...(option.value === 'bay' || option.value === 'bow' ? { sill: false } : {}),
...(silllessWindowTypes.has(option.value) ? { sill: false } : {}),
})
}
type="button"
@@ -597,7 +614,7 @@ export function WindowPanel() {
/>
</PanelSection>
{!isOpening && !rectangleOnlyWindowTypes.has(node.windowType) && (
{!isOpening && supportsWindowShape && (
<PanelSection title="Corner Shape">
<SegmentedControl
onChange={(value) =>
@@ -814,128 +831,132 @@ export function WindowPanel() {
/>
</PanelSection>
<PanelSection title="Grid">
<SliderControl
label="Columns"
max={8}
min={1}
onChange={(v) => {
const n = Math.max(1, Math.min(8, Math.round(v)))
handleUpdate({ columnRatios: Array(n).fill(1 / n) })
}}
precision={0}
step={1}
value={numCols}
/>
<SliderControl
label="Rows"
max={8}
min={1}
onChange={(v) => {
const n = Math.max(1, Math.min(8, Math.round(v)))
handleUpdate({ rowRatios: Array(n).fill(1 / n) })
}}
precision={0}
step={1}
value={numRows}
/>
{supportsGrid && (
<PanelSection title="Grid">
<SliderControl
label="Columns"
max={8}
min={1}
onChange={(v) => {
const n = Math.max(1, Math.min(8, Math.round(v)))
handleUpdate({ columnRatios: Array(n).fill(1 / n) })
}}
precision={0}
step={1}
value={numCols}
/>
<SliderControl
label="Rows"
max={8}
min={1}
onChange={(v) => {
const n = Math.max(1, Math.min(8, Math.round(v)))
handleUpdate({ rowRatios: Array(n).fill(1 / n) })
}}
precision={0}
step={1}
value={numRows}
/>
{numCols > 1 && (
<div className="mt-2 flex flex-col gap-1">
<div className="mb-1 px-1 font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
Col Widths
{numCols > 1 && (
<div className="mt-2 flex flex-col gap-1">
<div className="mb-1 px-1 font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
Col Widths
</div>
{normCols.map((ratio, i) => (
<SliderControl
key={`c-${i}`}
label={`C${i + 1}`}
max={95}
min={5}
onChange={(v) => setColumnRatio(i, v / 100)}
precision={1}
step={1}
unit="%"
value={Math.round(ratio * 100 * 10) / 10}
/>
))}
<div className="mt-1 border-border/50 border-t pt-1">
<SliderControl
label="Divider"
max={0.1}
min={0.005}
onChange={(v) => handleUpdate({ columnDividerThickness: v })}
precision={3}
step={0.01}
unit="m"
value={Math.round((node.columnDividerThickness ?? 0.03) * 1000) / 1000}
/>
</div>
</div>
{normCols.map((ratio, i) => (
)}
{numRows > 1 && (
<div className="mt-2 flex flex-col gap-1">
<div className="mb-1 px-1 font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
Row Heights
</div>
{normRows.map((ratio, i) => (
<SliderControl
key={`r-${i}`}
label={`R${i + 1}`}
max={95}
min={5}
onChange={(v) => setRowRatio(i, v / 100)}
precision={1}
step={1}
unit="%"
value={Math.round(ratio * 100 * 10) / 10}
/>
))}
<div className="mt-1 border-border/50 border-t pt-1">
<SliderControl
label="Divider"
max={0.1}
min={0.005}
onChange={(v) => handleUpdate({ rowDividerThickness: v })}
precision={3}
step={0.01}
unit="m"
value={Math.round((node.rowDividerThickness ?? 0.03) * 1000) / 1000}
/>
</div>
</div>
)}
</PanelSection>
)}
{supportsSill && (
<PanelSection title="Sill">
<ToggleControl
checked={node.sill}
label="Enable Sill"
onChange={(checked) => handleUpdate({ sill: checked })}
/>
{node.sill && (
<div className="mt-1 flex flex-col gap-1">
<SliderControl
key={`c-${i}`}
label={`C${i + 1}`}
max={95}
min={5}
onChange={(v) => setColumnRatio(i, v / 100)}
precision={1}
step={1}
unit="%"
value={Math.round(ratio * 100 * 10) / 10}
/>
))}
<div className="mt-1 border-border/50 border-t pt-1">
<SliderControl
label="Divider"
max={0.1}
min={0.005}
onChange={(v) => handleUpdate({ columnDividerThickness: v })}
label="Depth"
min={0}
onChange={(v) => handleUpdate({ sillDepth: v })}
precision={3}
step={0.01}
unit="m"
value={Math.round((node.columnDividerThickness ?? 0.03) * 1000) / 1000}
value={Math.round(node.sillDepth * 1000) / 1000}
/>
</div>
</div>
)}
{numRows > 1 && (
<div className="mt-2 flex flex-col gap-1">
<div className="mb-1 px-1 font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
Row Heights
</div>
{normRows.map((ratio, i) => (
<SliderControl
key={`r-${i}`}
label={`R${i + 1}`}
max={95}
min={5}
onChange={(v) => setRowRatio(i, v / 100)}
precision={1}
step={1}
unit="%"
value={Math.round(ratio * 100 * 10) / 10}
/>
))}
<div className="mt-1 border-border/50 border-t pt-1">
<SliderControl
label="Divider"
max={0.1}
min={0.005}
onChange={(v) => handleUpdate({ rowDividerThickness: v })}
label="Thickness"
min={0}
onChange={(v) => handleUpdate({ sillThickness: v })}
precision={3}
step={0.01}
unit="m"
value={Math.round((node.rowDividerThickness ?? 0.03) * 1000) / 1000}
value={Math.round(node.sillThickness * 1000) / 1000}
/>
</div>
</div>
)}
</PanelSection>
<PanelSection title="Sill">
<ToggleControl
checked={node.sill}
label="Enable Sill"
onChange={(checked) => handleUpdate({ sill: checked })}
/>
{node.sill && (
<div className="mt-1 flex flex-col gap-1">
<SliderControl
label="Depth"
min={0}
onChange={(v) => handleUpdate({ sillDepth: v })}
precision={3}
step={0.01}
unit="m"
value={Math.round(node.sillDepth * 1000) / 1000}
/>
<SliderControl
label="Thickness"
min={0}
onChange={(v) => handleUpdate({ sillThickness: v })}
precision={3}
step={0.01}
unit="m"
value={Math.round(node.sillThickness * 1000) / 1000}
/>
</div>
)}
</PanelSection>
)}
</PanelSection>
)}
</>
)}
@@ -144,7 +144,7 @@ export function NumberInput({
}}
/>
<div
className={`relative z-10 flex items-center overflow-hidden rounded-lg border shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] transition-all focus-within:border-primary focus-within:ring-1 focus-within:ring-primary ${isDragging ? 'border-neutral-300 bg-transparent ring-1 ring-neutral-200/60 dark:border-border dark:ring-border/50' : 'border-neutral-200/60 bg-white hover:border-neutral-300 dark:border-border/50 dark:bg-accent/30 dark:hover:border-border/80'}`}
className={`relative z-10 flex items-center overflow-hidden rounded-lg border shadow-elevation-0 transition-all focus-within:border-primary focus-within:ring-1 focus-within:ring-primary ${isDragging ? 'border-neutral-300 bg-transparent ring-1 ring-neutral-200/60 dark:border-border dark:ring-border/50' : 'border-neutral-200/60 bg-white hover:border-neutral-300 dark:border-border/50 dark:bg-accent/30 dark:hover:border-border/80'}`}
>
<div
className={`z-10 select-none truncate py-1.5 pr-1 pl-2 font-barlow font-medium text-muted-foreground text-xs ${
View File
View File
View File
@@ -0,0 +1,330 @@
'use client'
import type { AssetInput } from '@pascal-app/core'
import NextImage from 'next/image'
import { useEffect, useState } from 'react'
import { cn } from '../../../../../lib/utils'
import type { CatalogCategory } from '../../../../../store/use-editor'
import useEditor from '../../../../../store/use-editor'
import { furnishTools } from '../../../action-menu/furnish-tools'
import { CATALOG_ITEMS } from '../../../item-catalog/catalog-items'
import { ItemCatalog } from '../../../item-catalog/item-catalog'
const PLACEMENT_TAGS = new Set(['floor', 'wall', 'ceiling', 'countertop'])
export function ItemsPanel({
items,
onSearchChange,
searchResults,
leadingTile,
emptyState,
}: {
items?: AssetInput[]
/** Called when the search query changes (community edition uses this for server-side search) */
onSearchChange?: (query: string) => void
/** When non-null and search is active, these results bypass local filtering (server search results) */
searchResults?: AssetInput[] | null
/**
* Optional node rendered as the first grid cell, always visible. Used by the
* community edition to inject a "+ Generate with AI" tile.
*/
leadingTile?: React.ReactNode
/**
* Optional node rendered when the grid has no items to show (empty category
* or no search results). Replaces the default "No results" message.
*/
emptyState?: React.ReactNode
}) {
const mode = useEditor((s) => s.mode)
const catalogCategory = useEditor((s) => s.catalogCategory)
const setMode = useEditor((s) => s.setMode)
const setTool = useEditor((s) => s.setTool)
const setCatalogCategory = useEditor((s) => s.setCatalogCategory)
const [activePlacementTag, setActivePlacementTag] = useState<string | null>(null)
const [activeFunctionalTag, setActiveFunctionalTag] = useState<string | null>(null)
// Library / Community / Mine. Default to Library so first-time users see
// the curated catalog rather than every uploaded item; clicking the chip
// again clears the filter (`null` = show everything).
const [activeSource, setActiveSource] = useState<AssetInput['source'] | null>('library')
const [search, setSearch] = useState('')
const isServerSearch = onSearchChange !== undefined
// True when server search is active but results haven't come back yet
const isSearchPending = isServerSearch && search.length > 0 && searchResults === null
// Auto-select the first category when the panel mounts without one
useEffect(() => {
if (!(catalogCategory && furnishTools.some((c) => c.catalogCategory === catalogCategory))) {
setCatalogCategory(furnishTools[0]!.catalogCategory)
}
}, [catalogCategory, setCatalogCategory])
const activeCategory =
furnishTools.find((c) => c.catalogCategory === catalogCategory) ?? furnishTools[0]!
function selectCategory(categoryId: CatalogCategory) {
setCatalogCategory(categoryId)
setTool('item')
setActivePlacementTag(null)
setActiveFunctionalTag(null)
setSearch('')
if (mode !== 'build') setMode('build')
}
// Compute tags for the current category (for filter chips)
const baseItems = items ?? CATALOG_ITEMS
// Apply the Library/Community/Mine filter before any category/tag work.
// Items that don't carry a source field (e.g. seeded built-in catalog
// entries from `CATALOG_ITEMS`) fall under "library".
//
// Community is broader than just other users' uploads: my own *published*
// items show up there too so I can preview my catalog the way other users
// see it. My drafts only appear under Mine.
const matchesSource = (item: AssetInput) => {
if (!activeSource) return true
const itemSource = item.source ?? 'library'
if (activeSource === 'mine') return itemSource === 'mine'
if (activeSource === 'library') return itemSource === 'library'
if (activeSource === 'community') {
if (itemSource === 'community') return true
if (itemSource === 'mine') return !item.isDraft
return false
}
return true
}
const sourceItems = baseItems.filter(matchesSource)
const categoryItems = sourceItems.filter(
(item) => item.category === activeCategory.catalogCategory,
)
// The three source chips are always shown so users can discover the
// filter even before they own any items. Selecting "Mine" with no
// matching items falls through to the empty/no-results state.
const sourceChips: Array<{ id: AssetInput['source']; label: string }> = [
{ id: 'library', label: 'Library' },
{ id: 'community', label: 'Community' },
{ id: 'mine', label: 'Mine' },
]
const allTags = Array.from(new Set(categoryItems.flatMap((item) => item.tags ?? [])))
const placementTags = allTags.filter((t) => PLACEMENT_TAGS.has(t))
const functionalTags = allTags.filter((t) => !PLACEMENT_TAGS.has(t))
const hasFilters = allTags.length > 1
const placementCount = (tag: string | null) =>
categoryItems.filter((item) => {
const tags = item.tags ?? []
if (tag !== null && !tags.includes(tag)) return false
if (activeFunctionalTag && !tags.includes(activeFunctionalTag)) return false
return true
}).length
const functionalCount = (tag: string) =>
categoryItems.filter((item) => {
const tags = item.tags ?? []
if (!tags.includes(tag)) return false
if (activePlacementTag && !tags.includes(activePlacementTag)) return false
return true
}).length
return (
<div className="flex h-full flex-col">
{/* Category tabs */}
<div className="flex shrink-0 gap-1 overflow-x-auto border-border/70 border-b p-2">
{furnishTools.map((cat) => {
const isActive = activeCategory.catalogCategory === cat.catalogCategory
return (
<button
className={cn(
'flex shrink-0 flex-col items-center gap-1 rounded-xl px-3 py-2 transition-colors',
isActive
? 'bg-sidebar-accent text-sidebar-accent-foreground'
: 'text-muted-foreground hover:bg-sidebar-accent/50 hover:text-foreground',
)}
key={cat.catalogCategory}
onClick={() => selectCategory(cat.catalogCategory)}
type="button"
>
<NextImage
alt={cat.label}
className={cn('size-7 object-contain', !isActive && 'opacity-60 grayscale')}
height={28}
src={cat.iconSrc}
width={28}
/>
<span className="font-medium text-[10px] leading-none">{cat.label}</span>
</button>
)
})}
</div>
{/* Search + filters (non-scrollable) */}
<div className="flex shrink-0 flex-col gap-2 border-border/70 border-b p-2">
<div className="flex items-center gap-1.5">
{/* Search and source filter take 50/50 of the row. `min-w-0` on
both sides lets each half shrink to fit when the panel narrows. */}
<input
className="w-1/2 min-w-0 shrink-0 rounded-lg bg-muted px-2.5 py-1.5 text-xs placeholder:text-muted-foreground focus:outline-none"
onChange={(e) => {
setSearch(e.target.value)
onSearchChange?.(e.target.value)
}}
placeholder="Search..."
type="text"
value={search}
/>
{sourceChips.length > 0 && (
<div className="flex w-1/2 min-w-0 shrink-0 rounded-lg bg-muted p-0.5">
{sourceChips.map((chip) => {
const isActive = activeSource === chip.id
return (
<button
className={cn(
'min-w-0 flex-1 truncate rounded-md px-1 py-1 text-center font-medium text-[10px] transition-colors',
isActive
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground',
)}
key={chip.id}
onClick={() => setActiveSource(isActive ? null : chip.id)}
type="button"
>
{chip.label}
</button>
)
})}
</div>
)}
</div>
{hasFilters && !search && !isServerSearch && (
<div className="flex flex-col gap-1.5">
{placementTags.length > 0 && (
<div className="flex flex-wrap gap-1">
<button
className={cn(
'cursor-pointer rounded-md px-2 py-0.5 font-medium text-xs transition-colors',
activePlacementTag === null
? 'bg-blue-500 text-white'
: 'bg-blue-950/50 text-blue-300 hover:bg-blue-900/60 hover:text-blue-200',
)}
onClick={() => setActivePlacementTag(null)}
type="button"
>
All
</button>
{placementTags.map((tag) => {
const count = placementCount(tag)
const isActive = activePlacementTag === tag
const isEmpty = count === 0 && !isActive
return (
<button
className={cn(
'inline-flex cursor-pointer items-center gap-1 rounded-md py-0.5 pr-1.5 pl-2 font-medium text-xs capitalize transition-colors',
isActive
? 'bg-blue-500 text-white'
: isEmpty
? 'cursor-not-allowed bg-zinc-800 text-zinc-500'
: 'bg-blue-950/50 text-blue-300 hover:bg-blue-900/60 hover:text-blue-200',
)}
disabled={isEmpty}
key={tag}
onClick={() => setActivePlacementTag(isActive ? null : tag)}
type="button"
>
{tag}
<span
className={cn(
'text-[10px]',
isActive
? 'text-blue-200'
: isEmpty
? 'text-zinc-600'
: 'text-blue-500/70',
)}
>
{count}
</span>
</button>
)
})}
</div>
)}
{functionalTags.length > 0 && (
<div className="flex flex-wrap gap-1">
{functionalTags.map((tag) => {
const count = functionalCount(tag)
const isActive = activeFunctionalTag === tag
const isEmpty = count === 0 && !isActive
return (
<button
className={cn(
'inline-flex cursor-pointer items-center gap-1 rounded-md py-0.5 pr-1.5 pl-2 font-medium text-xs capitalize transition-colors',
isActive
? 'bg-violet-500 text-white'
: isEmpty
? 'cursor-not-allowed bg-zinc-800 text-zinc-500'
: 'bg-muted text-muted-foreground hover:bg-muted/80 hover:text-foreground',
)}
disabled={isEmpty}
key={tag}
onClick={() => setActiveFunctionalTag(isActive ? null : tag)}
type="button"
>
{tag}
<span
className={cn(
'text-[10px]',
isActive
? 'text-violet-200'
: isEmpty
? 'text-zinc-600'
: 'text-zinc-500/70',
)}
>
{count}
</span>
</button>
)
})}
</div>
)}
</div>
)}
</div>
{/* Item grid */}
<div className="min-h-0 flex-1 overflow-y-auto p-3">
{isSearchPending ? (
<div className="flex h-full items-center justify-center">
<div className="size-5 animate-spin rounded-full border-2 border-muted-foreground/20 border-t-muted-foreground" />
</div>
) : isServerSearch && search && searchResults?.length === 0 ? (
(emptyState ?? (
<div className="flex h-full items-center justify-center text-muted-foreground text-xs">
No results for &ldquo;{search}&rdquo;
</div>
))
) : (
<ItemCatalog
activeFunctionalTag={isServerSearch ? null : activeFunctionalTag}
activePlacementTag={isServerSearch ? null : activePlacementTag}
category={activeCategory.catalogCategory}
emptyState={emptyState}
items={activeSource && items ? items.filter(matchesSource) : items}
key={activeCategory.catalogCategory}
leadingTile={leadingTile}
overrideItems={
isServerSearch && search
? activeSource && searchResults
? searchResults.filter(matchesSource)
: (searchResults ?? undefined)
: undefined
}
search={isServerSearch ? '' : search}
/>
)}
</div>
</div>
)
}

Some files were not shown because too many files have changed in this diff Show More