Merge pull request #298 from pascalorg/chore/community-merge

Chore/community merge
This commit is contained in:
Wassim SAMAD
2026-05-09 20:37:12 -04:00
committed by GitHub
197 changed files with 6081 additions and 3968 deletions
+89 -31
View File
@@ -10,7 +10,9 @@ on:
options:
- core
- viewer
- both
- editor
- mcp
- all
bump:
description: "Version bump"
required: true
@@ -51,56 +53,104 @@ jobs:
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- name: Bump & publish core
if: inputs.package == 'core' || inputs.package == 'both'
- name: Bump versions and sync inter-package references
run: |
BUMP=${{ inputs.bump }}
TARGET=${{ inputs.package }}
bump_version() {
local v=$1
IFS='.' read -r MAJ MIN PAT <<< "$v"
if [ "$BUMP" = "major" ]; then MAJ=$((MAJ+1)); MIN=0; PAT=0; fi
if [ "$BUMP" = "minor" ]; then MIN=$((MIN+1)); PAT=0; fi
if [ "$BUMP" = "patch" ]; then PAT=$((PAT+1)); fi
echo "$MAJ.$MIN.$PAT"
}
for pkg in core viewer editor mcp; do
if [ "$TARGET" = "$pkg" ] || [ "$TARGET" = "all" ]; then
CUR=$(jq -r '.version' packages/$pkg/package.json)
NEW=$(bump_version "$CUR")
jq --arg v "$NEW" '.version = $v' packages/$pkg/package.json > tmp.json && mv tmp.json packages/$pkg/package.json
UPPER=$(echo "$pkg" | tr '[:lower:]' '[:upper:]')
echo "${UPPER}_VERSION=$NEW" >> $GITHUB_ENV
echo "Bumped @pascal-app/$pkg: $CUR → $NEW"
fi
done
# Sync inter-package references in peerDependencies and devDependencies.
# Anything that references a bumped @pascal-app/* package is updated to ^NEW.
for pkg in core viewer editor mcp; do
FILE=packages/$pkg/package.json
for dep in core viewer editor mcp; do
UPPER=$(echo "$dep" | tr '[:lower:]' '[:upper:]')
VAR="${UPPER}_VERSION"
VAL="${!VAR}"
[ -z "$VAL" ] && continue
jq --arg name "@pascal-app/$dep" --arg v "^$VAL" '
if .peerDependencies[$name] then .peerDependencies[$name] = $v else . end
| if .devDependencies[$name] then .devDependencies[$name] = $v else . end
' "$FILE" > tmp.json && mv tmp.json "$FILE"
done
done
- name: Build & publish core
if: inputs.package == 'core' || inputs.package == 'all'
working-directory: packages/core
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
BUMP=${{ inputs.bump }}
VERSION=$(jq -r '.version' package.json)
IFS='.' read -r MAJ MIN PAT <<< "$VERSION"
if [ "$BUMP" = "major" ]; then MAJ=$((MAJ+1)); MIN=0; PAT=0; fi
if [ "$BUMP" = "minor" ]; then MIN=$((MIN+1)); PAT=0; fi
if [ "$BUMP" = "patch" ]; then PAT=$((PAT+1)); fi
VERSION="$MAJ.$MIN.$PAT"
jq --arg v "$VERSION" '.version = $v' package.json > tmp.json && mv tmp.json package.json
echo "CORE_VERSION=$VERSION" >> $GITHUB_ENV
bun run build
if [ "${{ inputs.dry-run }}" = "true" ]; then
echo "🏜️ Dry run — would publish @pascal-app/core@$VERSION"
echo "🏜️ Dry run — would publish @pascal-app/core@$CORE_VERSION"
npm publish --dry-run --access public
else
npm publish --access public
echo "📦 Published @pascal-app/core@$VERSION"
echo "📦 Published @pascal-app/core@$CORE_VERSION"
fi
- name: Bump & publish viewer
if: inputs.package == 'viewer' || inputs.package == 'both'
- name: Build & publish viewer
if: inputs.package == 'viewer' || inputs.package == 'all'
working-directory: packages/viewer
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
BUMP=${{ inputs.bump }}
VERSION=$(jq -r '.version' package.json)
IFS='.' read -r MAJ MIN PAT <<< "$VERSION"
if [ "$BUMP" = "major" ]; then MAJ=$((MAJ+1)); MIN=0; PAT=0; fi
if [ "$BUMP" = "minor" ]; then MIN=$((MIN+1)); PAT=0; fi
if [ "$BUMP" = "patch" ]; then PAT=$((PAT+1)); fi
VERSION="$MAJ.$MIN.$PAT"
jq --arg v "$VERSION" '.version = $v' package.json > tmp.json && mv tmp.json package.json
echo "VIEWER_VERSION=$VERSION" >> $GITHUB_ENV
bun run build
if [ "${{ inputs.dry-run }}" = "true" ]; then
echo "🏜️ Dry run — would publish @pascal-app/viewer@$VERSION"
echo "🏜️ Dry run — would publish @pascal-app/viewer@$VIEWER_VERSION"
npm publish --dry-run --access public
else
npm publish --access public
echo "📦 Published @pascal-app/viewer@$VERSION"
echo "📦 Published @pascal-app/viewer@$VIEWER_VERSION"
fi
- name: Publish editor
if: inputs.package == 'editor' || inputs.package == 'all'
working-directory: packages/editor
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
if [ "${{ inputs.dry-run }}" = "true" ]; then
echo "🏜️ Dry run — would publish @pascal-app/editor@$EDITOR_VERSION"
npm publish --dry-run --access public
else
npm publish --access public
echo "📦 Published @pascal-app/editor@$EDITOR_VERSION"
fi
- name: Build & publish mcp
if: inputs.package == 'mcp' || inputs.package == 'all'
working-directory: packages/mcp
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
bun run build
if [ "${{ inputs.dry-run }}" = "true" ]; then
echo "🏜️ Dry run — would publish @pascal-app/mcp@$MCP_VERSION"
npm publish --dry-run --access public
else
npm publish --access public
echo "📦 Published @pascal-app/mcp@$MCP_VERSION"
fi
- name: Commit version bumps & tag
@@ -118,6 +168,14 @@ jobs:
PKGS="$PKGS @pascal-app/viewer@$VIEWER_VERSION"
TAGS="$TAGS @pascal-app/viewer@$VIEWER_VERSION"
fi
if [ -n "$EDITOR_VERSION" ]; then
PKGS="$PKGS @pascal-app/editor@$EDITOR_VERSION"
TAGS="$TAGS @pascal-app/editor@$EDITOR_VERSION"
fi
if [ -n "$MCP_VERSION" ]; then
PKGS="$PKGS @pascal-app/mcp@$MCP_VERSION"
TAGS="$TAGS @pascal-app/mcp@$MCP_VERSION"
fi
git commit -m "release:${PKGS}"
+1 -1
View File
@@ -173,7 +173,7 @@ function parseIfMatch(raw: string | null): number | undefined {
const inner = match ? match[1] : trimmed
if (!inner) return undefined
const n = Number(inner)
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) return undefined
if (!(Number.isFinite(n) && Number.isInteger(n)) || n < 0) return undefined
return n
}
+1
View File
@@ -1,5 +1,6 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "../../../styles/elevation.css";
@source "../../../packages/editor/src";
@custom-variant dark (&:is(.dark *));
+26 -3
View File
@@ -1,13 +1,36 @@
'use client'
import { Editor, type SidebarTab, ViewerToolbarLeft, ViewerToolbarRight } from '@pascal-app/editor'
import {
Editor,
ItemsPanel,
type SidebarTab,
ViewerToolbarLeft,
ViewerToolbarRight,
} from '@pascal-app/editor'
import { Layers, Package, Settings } from 'lucide-react'
import Link from 'next/link'
const SIDEBAR_TABS: (SidebarTab & { component: React.ComponentType })[] = [
const SIDEBAR_TABS = [
{
id: 'site',
label: 'Scene',
component: () => null, // Built-in SitePanel handles this
component: () => null,
mobileDefaultSnap: 0.5,
mobileIcon: <Layers className="h-5 w-5" />,
},
{
id: 'items',
label: 'Items',
component: ItemsPanel,
mobileDefaultSnap: 0.5,
mobileIcon: <Package className="h-5 w-5" />,
},
{
id: 'settings',
label: 'Settings',
component: () => null,
mobileDefaultSnap: 0.5,
mobileIcon: <Settings className="h-5 w-5" />,
},
]
+6 -27
View File
@@ -1,8 +1,8 @@
/**
* Environment variable validation for the editor app.
*
* This file validates that required environment variables are set at runtime.
* Variables are defined in the root .env file.
* This file validates environment variables used by the standalone editor app.
* Values are loaded from the repo root .env.local by package scripts.
*
* @see https://env.t3.gg/docs/nextjs
*/
@@ -13,42 +13,21 @@ export const env = createEnv({
/**
* Server-side environment variables (not exposed to client)
*/
server: {
// Database
POSTGRES_URL: z.string().min(1),
SUPABASE_SERVICE_ROLE_KEY: z.string().min(1),
// Auth
BETTER_AUTH_SECRET: z.string().min(1),
GOOGLE_CLIENT_ID: z.string().optional(),
GOOGLE_CLIENT_SECRET: z.string().optional(),
// Email
RESEND_API_KEY: z.string().optional(),
},
server: {},
/**
* Client-side environment variables (exposed to browser via NEXT_PUBLIC_)
*/
client: {
NEXT_PUBLIC_SUPABASE_URL: z.string().min(1),
NEXT_PUBLIC_SUPABASE_ANON_KEY: z.string().optional(),
NEXT_PUBLIC_ASSETS_CDN_URL: z.string().optional(),
},
/**
* Runtime values - pulls from process.env
*/
runtimeEnv: {
// Server
POSTGRES_URL: process.env.POSTGRES_URL,
SUPABASE_SERVICE_ROLE_KEY: process.env.SUPABASE_SERVICE_ROLE_KEY,
BETTER_AUTH_SECRET: process.env.BETTER_AUTH_SECRET,
GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID,
GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET,
RESEND_API_KEY: process.env.RESEND_API_KEY,
// Client
NEXT_PUBLIC_SUPABASE_URL: process.env.NEXT_PUBLIC_SUPABASE_URL,
NEXT_PUBLIC_SUPABASE_ANON_KEY: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
NEXT_PUBLIC_ASSETS_CDN_URL:
process.env.NEXT_PUBLIC_ASSETS_CDN_URL ?? process.env.NEXT_PUBLIC_EDITOR_ASSETS_CDN_URL,
},
/**
+3
View File
@@ -1,6 +1,9 @@
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
logging: {
browserToTerminal: true,
},
typescript: {
ignoreBuildErrors: true,
},
+5 -4
View File
@@ -4,8 +4,8 @@
"type": "module",
"private": true,
"scripts": {
"dev": "dotenv -e ./.env.local --override -- next dev --port 3002",
"build": "dotenv -e ./.env.local --override -- next build",
"dev": "dotenv -e ../../.env.local -- next dev --port 3002",
"build": "dotenv -e ../../.env.local -- next build",
"start": "next start",
"lint": "biome lint",
"check-types": "next typegen && tsc --noEmit"
@@ -21,6 +21,7 @@
"@tailwindcss/postcss": "^4.2.1",
"clsx": "^2.1.1",
"geist": "^1.7.0",
"lucide-react": "^1.7.0",
"next": "16.2.1",
"postcss": "^8.5.6",
"react": "^19.2.4",
@@ -37,9 +38,9 @@
"@types/react": "19.2.2",
"@types/react-dom": "19.2.2",
"agentation": "^2.3.2",
"react-grab": "^0.1.25",
"react-grab": "^0.1.29",
"react-scan": "^0.5.3",
"tw-animate-css": "^1.4.0",
"typescript": "5.9.3"
"typescript": "6.0.2"
}
}
Binary file not shown.
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="4"/>
<path d="M12 2v2M12 20v2M2 12h2M20 12h2"/>
<path d="M12 8v1M12 15v1M8 12h1M15 12h1"/>
</svg>

After

Width:  |  Height:  |  Size: 290 B

+9
View File
@@ -0,0 +1,9 @@
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"buildCommand": "cd ../.. && npx -y bun@1.3.13 run build --filter=editor",
"installCommand": "cd ../.. && npx -y bun@1.3.13 install --frozen-lockfile",
"outputDirectory": ".next",
"cleanUrls": true,
"trailingSlash": false,
"bunVersion": "1.x"
}
+200 -241
View File
File diff suppressed because it is too large Load Diff
+21 -4
View File
@@ -12,23 +12,40 @@
"check:fix": "biome check --write",
"check-types": "turbo run check-types",
"kill": "lsof -ti:3002 | xargs kill -9 2>/dev/null || echo 'No processes found on port 3002'",
"release": "gh workflow run release.yml -f package=both -f bump=patch",
"release": "gh workflow run release.yml -f package=all -f bump=patch",
"release:viewer": "gh workflow run release.yml -f package=viewer -f bump=patch",
"release:core": "gh workflow run release.yml -f package=core -f bump=patch",
"release:minor": "gh workflow run release.yml -f package=both -f bump=minor",
"release:major": "gh workflow run release.yml -f package=both -f bump=major"
"release:editor": "gh workflow run release.yml -f package=editor -f bump=patch",
"release:mcp": "gh workflow run release.yml -f package=mcp -f bump=patch",
"release:minor": "gh workflow run release.yml -f package=all -f bump=minor",
"release:major": "gh workflow run release.yml -f package=all -f bump=major"
},
"devDependencies": {
"@biomejs/biome": "^2.4.6",
"dotenv-cli": "^11.0.0",
"turbo": "^2.8.15",
"typescript": "5.9.3",
"typescript": "6.0.2",
"ultracite": "^7.2.5"
},
"engines": {
"node": ">=18"
},
"packageManager": "bun@1.3.0",
"overrides": {
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"@types/three": "0.184.0"
},
"optionalDependencies": {
"@tailwindcss/oxide-darwin-arm64": "4.3.0",
"@tailwindcss/oxide-darwin-x64": "4.3.0",
"@tailwindcss/oxide-linux-arm64-gnu": "4.3.0",
"@tailwindcss/oxide-linux-x64-gnu": "4.3.0",
"lightningcss-darwin-arm64": "1.32.0",
"lightningcss-darwin-x64": "1.32.0",
"lightningcss-linux-arm64-gnu": "1.32.0",
"lightningcss-linux-x64-gnu": "1.32.0"
},
"workspaces": [
"apps/*",
"packages/*",
+3 -2
View File
@@ -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
@@ -28,12 +28,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>
}
@@ -66,7 +67,7 @@ export type StairSegmentEvent = NodeEvent<StairSegmentNode>
export type WindowEvent = NodeEvent<WindowNode>
export type DoorEvent = NodeEvent<DoorNode>
// Event suffixes, exported for use in hooks
// Event suffixes - exported for use in hooks
export const eventSuffixes = [
'click',
'move',
@@ -99,7 +100,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
@@ -107,8 +108,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]
+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])
}
+27 -16
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 }
@@ -147,10 +153,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 +222,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 +373,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 +454,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])
@@ -727,7 +736,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),
@@ -29,8 +29,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
- color/style: visual appearance options
+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
}
@@ -122,7 +122,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 +190,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
}
@@ -275,6 +277,7 @@ 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)
})
}
+12 -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 []
}
@@ -511,6 +511,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) {
@@ -701,8 +708,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, LevelNode, SlabNode, StairNode, StairSegmentNode } from '../../schema'
@@ -1,14 +1,12 @@
import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync'
import type {
AnyNode,
AnyNodeId,
CeilingNode,
LevelNode,
SlabNode,
StairNode,
StairSegmentNode,
} from '../../schema'
import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync'
import { DEFAULT_WALL_HEIGHT } from '../wall/wall-footprint'
type Point2D = [number, number]
@@ -36,10 +34,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 +277,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
@@ -426,39 +423,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),
),
Math.abs(totalSweep) * CURVED_STAIR_SLAB_OPENING_RATIO,
Math.abs(totalSweep) / Math.max(stair.stepCount ?? 1, 1),
)
}
function buildArcOpeningPolygon(
stair: StairNode,
innerRadius: number,
outerRadius: number,
startAngle: number,
endAngle: number,
): Point2D[] {
const sweep = endAngle - startAngle
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[] = []
@@ -466,7 +448,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),
)
@@ -474,8 +456,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),
)
@@ -484,39 +465,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
@@ -621,7 +569,7 @@ function getStairOpeningPolygons(
}
if (stair.stairType === 'curved') {
return [getCurvedOpeningPolygon(stair, targetElevation)]
return [getCurvedOpeningPolygon(stair)]
}
if (stair.stairType === 'spiral') {
@@ -775,8 +723,7 @@ export function syncAutoStairOpenings(nodes: Record<string, AnyNode>) {
const nextMetadata = [...manualMetadata, ...stairHoles.map((hole) => hole.metadata)]
if (
!polygonsEqual(existingHoles, nextHoles) ||
!metadataEqual(existingMetadata, nextMetadata)
!(polygonsEqual(existingHoles, nextHoles) && metadataEqual(existingMetadata, nextMetadata))
) {
updates.push({
id: slab.id,
@@ -826,8 +773,7 @@ export function syncAutoStairOpenings(nodes: Record<string, AnyNode>) {
const nextMetadata = [...manualMetadata, ...stairHoles.map((hole) => hole.metadata)]
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,
}
}
+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"]
+5 -5
View File
@@ -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.7.0",
"@pascal-app/viewer": "^0.7.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"
}
}
@@ -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>
@@ -455,14 +455,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={[world.mesh]}
collisionCheckIteration={3}
collisionPushBackDamping={0.1}
collisionPushBackThreshold={0.001}
debug={false}
deceleration={30}
delay={0}
fallGravityFactor={4}
floatCheckType="BOTH"
@@ -473,13 +474,12 @@ export const FirstPersonControls = () => {
floatSpringK={1200}
gravity={9.81}
jumpVel={6}
key="first-person-controller"
maxRunSpeed={5.5}
maxSlope={1.2}
maxWalkSpeed={4}
position={controllerStart.position}
acceleration={26}
airDragFactor={0.3}
deceleration={30}
ref={controllerRef}
/>
</KeyboardControls>
)}
@@ -551,12 +551,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
@@ -591,7 +591,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,7 +763,7 @@ 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 keys = getKeys() ?? presetKeys
const forward = forwardState.current || keys.forward
const backward = backwardState.current || keys.backward
const leftward = leftwardState.current || keys.leftward
@@ -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 />
View File
@@ -4092,7 +4092,6 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
d={path}
fill={isDeleteHovered ? palette.deleteFill : palette.slabFill}
fillRule="evenodd"
opacity={slabFillOpacity}
onClick={
canSelectSlabs
? (event) => {
@@ -4111,9 +4110,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
@@ -4168,7 +4168,6 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
d={path}
fill={isDeleteHovered ? palette.deleteFill : palette.ceilingFill}
fillRule="evenodd"
opacity={ceilingFillOpacity}
onClick={
canSelectCeilings
? (event) => {
@@ -4189,9 +4188,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
@@ -4298,8 +4298,8 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
<polygon
fill={`url(#${wallSelectionHatchId})`}
opacity={1}
points={points}
pointerEvents="none"
points={points}
/>
) : null}
</g>
@@ -4427,8 +4427,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"
@@ -4538,8 +4538,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"
@@ -5218,8 +5218,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"
@@ -5290,8 +5290,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"
/>
@@ -5963,6 +5963,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
@@ -6168,6 +6174,7 @@ const FloorplanNodeLayer = memo(function FloorplanNodeLayer({
}
points={points}
stroke={stroke}
strokeOpacity={1}
strokeWidth={
isSelectionActive ? FLOORPLAN_SELECTED_WALL_STROKE_WIDTH : FLOORPLAN_WALL_STROKE_WIDTH
}
@@ -6217,8 +6224,8 @@ const FloorplanNodeLayer = memo(function FloorplanNodeLayer({
<polygon
fill={`url(#${wallSelectionHatchId})`}
opacity={1}
points={points}
pointerEvents="none"
points={points}
/>
) : null}
{itemDimensionMeasurements.length > 0 ? (
@@ -6308,8 +6315,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}
@@ -7224,6 +7231,11 @@ const FloorplanPolygonHandleLayer = memo(function FloorplanPolygonHandleLayer({
y2={endSvg.y}
/>
<line
onPointerDown={
onEdgePointerDown
? (event) => onEdgePointerDown(nodeId, edgeIndex, event)
: undefined
}
pointerEvents="stroke"
stroke="transparent"
strokeLinecap="round"
@@ -7234,11 +7246,6 @@ const FloorplanPolygonHandleLayer = memo(function FloorplanPolygonHandleLayer({
x2={endSvg.x}
y1={startSvg.y}
y2={endSvg.y}
onPointerDown={
onEdgePointerDown
? (event) => onEdgePointerDown(nodeId, edgeIndex, event)
: undefined
}
/>
</g>
)
@@ -9838,7 +9845,7 @@ export function FloorplanPanel() {
zoneVertexDragState != null ||
isPolygonDraftBuildActive
if (!hasUserAdjustedViewportRef.current && !transientFloorplanFit) {
if (!(hasUserAdjustedViewportRef.current || transientFloorplanFit)) {
setViewport((current) =>
floorplanViewportEquals(current, fittedViewport) ? current : fittedViewport,
)
@@ -10970,6 +10977,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
@@ -10985,14 +11000,6 @@ export function FloorplanPanel() {
return unsubscribe
}, [isItemPlacementPreviewActive, scheduleMovingFloorplanNodeRefresh])
useEffect(() => {
if (!hasPendingItemMeshFootprints) {
return
}
scheduleMovingFloorplanNodeRefresh()
}, [hasPendingItemMeshFootprints, scheduleMovingFloorplanNodeRefresh])
useEffect(() => {
if (!(movingNode?.type === 'door' || movingNode?.type === 'window')) {
return
@@ -16132,17 +16139,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,
@@ -16157,6 +16160,11 @@ export function FloorplanPanel() {
: handleSelectedSlabDelete,
onMove: selectedSlabEditingHole ? handleSelectedSlabHoleMove : handleSelectedSlabMove,
}}
spawn={{
position: selectedSpawnActionMenuPosition,
onDelete: handleSelectedSpawnDelete,
onMove: handleSelectedSpawnMove,
}}
stair={{
position: selectedStairActionMenuPosition,
onDelete: handleSelectedStairDelete,
@@ -16168,7 +16176,6 @@ export function FloorplanPanel() {
onDelete: handleSelectedWallDelete,
onMove: handleSelectedWallMove,
}}
offsetY={FLOORPLAN_ACTION_MENU_OFFSET_Y}
/>
{referenceScaleDraft && (
@@ -16201,7 +16208,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">
@@ -16363,8 +16370,8 @@ export function FloorplanPanel() {
<FloorplanGuideLayer
activeGuideInteractionGuideId={activeGuideInteractionGuideId}
activeGuideInteractionMode={activeGuideInteractionMode}
guideUi={guideUi}
guides={displayGuides}
guideUi={guideUi}
isInteractive={canInteractWithGuides}
onGuideSelect={handleGuideSelect}
onGuideTranslateStart={handleGuideTranslateStart}
@@ -16385,6 +16392,8 @@ export function FloorplanPanel() {
hoveredSlabId={hoveredSlabId}
hoveredWallId={hoveredWallId}
isDeleteMode={isDeleteMode}
isGuideTraceVisible={isGuideTraceVisible}
metersPerUnit={calibratedMetersPerUnit}
onCeilingDoubleClick={handleCeilingDoubleClick}
onCeilingHoverChange={handleCeilingHoverChange}
onCeilingSelect={handleCeilingSelect}
@@ -16401,11 +16410,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}
/>
@@ -16483,8 +16490,8 @@ export function FloorplanPanel() {
<FloorplanReferenceScaleLayer
draft={referenceScaleDraft}
guideUi={guideUi}
guides={displayGuides}
guideUi={guideUi}
palette={palette}
unit={unit}
unitsPerPixel={floorplanUnitsPerPixel}
+65 -50
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,6 +65,7 @@ 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'
@@ -76,12 +78,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 +464,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) => (
@@ -753,7 +755,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 +788,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 +839,7 @@ const ViewerCanvas = memo(function ViewerCanvas({
window.removeEventListener('pointermove', handlePointerMove)
window.removeEventListener('pointerup', handlePointerUp)
}
}, [setFloorplanPaneRatio])
}, [])
useEffect(() => {
setIsCameraControlsHintVisible(!readCameraControlsHintDismissed())
@@ -940,7 +942,9 @@ export default function Editor({
commandPaletteEmptyAction,
}: EditorProps) {
const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode)
useKeyboard({ isVersionPreviewMode, disabled: isFirstPersonMode })
const { isLoadingSceneRef } = useAutoSave({
onSave,
onDirty,
@@ -951,8 +955,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 +973,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 +1034,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 +1141,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>
)}
{!isCaptureMode && (
<div className="pointer-events-auto">
<HelperManager />
</div>
)}
{viewerBanner}
{projectId ? <SnapshotCaptureOverlay projectId={projectId} /> : null}
</>
}
renderTabContent={renderTabContent}
@@ -1159,14 +1168,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 +1231,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>
+2 -2
View File
@@ -341,7 +341,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 ??
@@ -999,7 +999,6 @@ export const SelectionManager = () => {
'roof-segment',
'stair',
'stair-segment',
'spawn',
'window',
'door',
'zone',
@@ -1392,6 +1391,7 @@ export const SelectionManager = () => {
'roof-segment',
'stair',
'stair-segment',
'spawn',
'window',
'door',
'zone',
@@ -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
@@ -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,8 +83,7 @@ export const CurveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
? event.localPosition[2]
: snapScalarToGrid(event.localPosition[2], snapStep)
const offsetFromMidpoint =
-(
const offsetFromMidpoint = -(
(localX - chord.midpoint.x) * chord.normal.x +
(localZ - chord.midpoint.y) * chord.normal.y
)
@@ -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>
@@ -131,10 +131,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
}
@@ -303,8 +305,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
@@ -406,7 +409,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'
@@ -430,7 +433,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)
@@ -90,9 +90,7 @@ function MoveItemContent({ movingNode }: { movingNode: ItemNode }) {
return <>{cursor}</>
}
export const MoveTool: React.FC<{
onSpawnMoved?: (nodeId: SpawnNode['id']) => void
}> = ({ onSpawnMoved }) => {
export const MoveTool: React.FC = () => {
const movingNode = useEditor((state) => state.movingNode)
if (!movingNode) return null
@@ -100,15 +98,14 @@ export const MoveTool: React.FC<{
return <MoveBuildingContent node={movingNode as BuildingNode} />
if (movingNode.type === 'door') return <MoveDoorTool node={movingNode as DoorNode} />
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')
return <MoveSpawnTool node={movingNode as SpawnNode} onCommitted={onSpawnMoved} />
if (movingNode.type === 'spawn') return <MoveSpawnTool node={movingNode as SpawnNode} />
if (movingNode.type === 'stair' || movingNode.type === 'stair-segment')
return <MoveRoofTool node={movingNode as StairNode | StairSegmentNode} />
return <MoveItemContent movingNode={movingNode as ItemNode} />
@@ -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')
}
@@ -192,7 +192,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
@@ -240,22 +250,7 @@ function collectNodeIdsInBounds(bounds: Bounds): string[] {
if (objectBoundsIntersectsBounds(node.id, bounds)) {
result.push(node.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
}
@@ -45,6 +45,7 @@ const tools: Record<Phase, Partial<Record<Tool, React.FC>>> = {
door: DoorTool,
item: ItemTool,
zone: ZoneTool,
spawn: SpawnTool,
window: WindowTool,
},
furnish: {
@@ -63,10 +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 setSelection = useViewer((state) => state.setSelection)
const buildingId = useViewer((state) => state.selection.buildingId)
const activeLevelId = useViewer((state) => state.selection.levelId)
const nodes = useScene((state) => state.nodes)
// Building transform for the local group — all building-relative tools live inside this group
@@ -128,13 +128,13 @@ export const ToolManager: React.FC = () => {
const BuildToolComponent = showBuildTool ? tools[phase]?.[tool] : null
const handlePlacedNodeSelected = (nodeId: AnyNodeId) => {
setSelection({ selectedIds: [nodeId] })
useViewer.getState().setSelection({ selectedIds: [nodeId] })
}
return (
<>
{showSiteBoundaryEditor && <SiteBoundaryEditor />}
{/* World-space tools: site boundary and building movement operate in world coordinates */}
{showSiteBoundaryEditor && <SiteBoundaryEditor />}
{movingNode?.type === 'building' && <MoveTool onSpawnMoved={handlePlacedNodeSelected} />}
{/* Building-local group: all other tools are relative to the selected building.
@@ -162,13 +162,13 @@ export const ToolManager: React.FC = () => {
{movingNode && movingNode.type !== 'building' && (
<MoveTool onSpawnMoved={handlePlacedNodeSelected} />
)}
{!movingNode && showBuildTool && tool === 'spawn' && (
<SpawnTool currentLevelId={selectedLevelId} onPlaced={handlePlacedNodeSelected} />
)}
{!movingNode && showBuildTool && tool === 'column' && (
<ColumnTool currentLevelId={selectedLevelId} onPlaced={handlePlacedNodeSelected} />
)}
{!movingNode && BuildToolComponent && tool !== 'column' && <BuildToolComponent />}
{!movingNode && BuildToolComponent && tool === 'spawn' ? (
<SpawnTool currentLevelId={activeLevelId ?? null} onPlaced={handlePlacedNodeSelected} />
) : !movingNode && showBuildTool && tool === 'column' ? (
<ColumnTool currentLevelId={activeLevelId ?? null} onPlaced={handlePlacedNodeSelected} />
) : !movingNode && BuildToolComponent && tool !== 'column' ? (
<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 =
-(
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>
@@ -63,10 +63,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
}
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 */}
{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" />
<ViewToggles />
<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'
@@ -294,12 +294,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 */}
+8 -5
View File
@@ -4,9 +4,11 @@ import {
getCatalogMaterialById,
getLibraryMaterialIdFromRef,
getMaterialsForCategory,
getMaterialsForTarget,
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 +19,8 @@ type MaterialPickerProps = {
onChange?: (material: MaterialSchema) => void
onSelectMaterialPreset?: (materialPreset: string) => void
disabled?: boolean
nodeType?: MaterialTarget
hideSideControl?: boolean
}
export function MaterialPicker({
@@ -48,8 +52,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 +180,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 +196,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
@@ -162,7 +162,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',
'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',
)}
onClick={() => setSelectedItem(item)}
key={index}
onClick={() => {
setSelectedItem(item)
setTool('item')
setMode('build')
}}
type="button"
>
<Image
<div className="relative aspect-square w-full overflow-hidden rounded-lg">
<img
alt={item.name}
className="rounded-lg object-cover"
fill
className="h-full w-full object-cover"
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
<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"
height={16}
src={attachmentIcon}
width={16}
/>
</div>
)}
</button>
</TooltipTrigger>
<TooltipContent className="text-xs" side="top">
</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]
}
@@ -70,10 +70,12 @@ const COLUMN_PROPORTION_PRESETS = {
type ColumnProportionPresetId = keyof typeof COLUMN_PROPORTION_PRESETS
const COLUMN_PROPORTION_OPTIONS = Object.entries(COLUMN_PROPORTION_PRESETS).map(([value, preset]) => ({
const COLUMN_PROPORTION_OPTIONS = Object.entries(COLUMN_PROPORTION_PRESETS).map(
([value, preset]) => ({
value: value as ColumnProportionPresetId,
label: preset.label,
}))
}),
)
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value))
@@ -201,7 +203,12 @@ export function ColumnPanel() {
const shaftProfile = node.shaftProfile ?? 'straight'
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}
@@ -223,7 +230,9 @@ export function ColumnPanel() {
<PanelSection title="Shape">
<select
className={SELECT_CLASS}
onChange={(event) => handleUpdate({ crossSection: event.target.value as ColumnNode['crossSection'] })}
onChange={(event) =>
handleUpdate({ crossSection: event.target.value as ColumnNode['crossSection'] })
}
value={node.crossSection}
>
<option value="round">Round</option>
@@ -313,7 +322,9 @@ export function ColumnPanel() {
<PanelSection title="Shaft">
<select
className={SELECT_CLASS}
onChange={(event) => handleUpdate(shaftProfileUpdates(event.target.value as ColumnNode['shaftProfile']))}
onChange={(event) =>
handleUpdate(shaftProfileUpdates(event.target.value as ColumnNode['shaftProfile']))
}
value={shaftProfile}
>
<option value="straight">Straight</option>
@@ -487,10 +498,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,
}),
})
}}
@@ -572,13 +595,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,
}),
})
}}
+12 -5
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'
@@ -148,7 +148,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 +339,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
@@ -578,7 +585,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}
@@ -1267,7 +1275,6 @@ export function DoorPanel() {
</div>
</PanelSection>
)}
</>
)}
@@ -1,6 +1,5 @@
'use client'
import {
type AnyNode,
type AnyNodeId,
@@ -86,10 +85,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,42 +73,72 @@ 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
<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>
<div className="space-y-1 rounded-lg border border-border/50 bg-background/40 p-2">
<SliderControl
label="Roughness"
<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={(roughness) => updateCustomMaterial({ roughness })}
precision={2}
onChange={(e) =>
updateCustomMaterial({ roughness: Number.parseFloat(e.target.value) })
}
step={0.01}
type="range"
value={currentProps.roughness}
/>
<SliderControl
label="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]">
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={(metalness) => updateCustomMaterial({ metalness })}
precision={2}
onChange={(e) =>
updateCustomMaterial({ metalness: Number.parseFloat(e.target.value) })
}
step={0.01}
type="range"
value={currentProps.metalness}
/>
<SliderControl
label="Opacity"
</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={(opacity) =>
onChange={(e) => {
const opacity = Number.parseFloat(e.target.value)
updateCustomMaterial({ opacity }, opacity < 1 || currentProps.transparent)
}
precision={2}
}}
step={0.01}
type="range"
value={currentProps.opacity}
/>
</div>
</div>
<div className="space-y-2">
<label className="block font-medium text-muted-foreground text-xs uppercase tracking-[0.12em]">
+3 -32
View File
@@ -92,6 +92,8 @@ function panelForType(type: string | null) {
return <StairSegmentPanel />
case 'slab':
return <SlabPanel />
case 'spawn':
return <SpawnPanel />
case 'ceiling':
return <CeilingPanel />
case 'column':
@@ -238,36 +240,5 @@ export function PanelManager() {
}
// 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 'window':
return <WindowPanel />
}
}
return null
return panelForType(selectedNodeType)
}
@@ -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'], {
updateNode(
selectedReferenceId as AnyNode['id'],
{
name: getGuideImageName(file.name),
url: assetUrl,
scaleReference: null,
} as Partial<GuideNode>)
} 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>
)
}
+97 -1
View File
@@ -4,28 +4,60 @@ import {
type AnyNode,
type AnyNodeId,
getClampedWallCurveOffset,
getEffectiveWallSurfaceMaterial,
getMaxWallCurveOffset,
getWallCurveLength,
getWallSurfaceMaterialSignature,
type MaterialSchema,
normalizeWallCurveOffset,
useScene,
type WallNode,
type WallSurfaceSide,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Move, Spline } from 'lucide-react'
import { useCallback } from 'react'
import { useCallback, useMemo } from 'react'
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'
function buildWallSurfaceMaterialPatch(
node: WallNode,
targetSide: WallSurfaceSide | null,
material: MaterialSchema | undefined,
materialPreset: string | undefined,
): Partial<WallNode> {
const nextSurfaceMaterial = { material, materialPreset }
const nextInterior =
targetSide === null || targetSide === 'interior'
? nextSurfaceMaterial
: getEffectiveWallSurfaceMaterial(node, 'interior')
const nextExterior =
targetSide === null || targetSide === 'exterior'
? nextSurfaceMaterial
: getEffectiveWallSurfaceMaterial(node, 'exterior')
return {
interiorMaterial: nextInterior.material,
interiorMaterialPreset: nextInterior.materialPreset,
exteriorMaterial: nextExterior.material,
exteriorMaterialPreset: nextExterior.materialPreset,
material: undefined,
materialPreset: undefined,
}
}
export function WallPanel() {
const selectedId = useViewer((s) => s.selection.selectedIds[0])
const setSelection = useViewer((s) => s.setSelection)
const updateNode = useScene((s) => s.updateNode)
const setMovingNode = useEditor((s) => s.setMovingNode)
const setCurvingWall = useEditor((s) => s.setCurvingWall)
const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget)
const node = useScene((s) =>
selectedId ? (s.nodes[selectedId as AnyNode['id']] as WallNode | undefined) : undefined,
@@ -56,6 +88,35 @@ export function WallPanel() {
[selectedId, updateNode],
)
const effectiveInteriorMaterial = useMemo(
() => (node ? getEffectiveWallSurfaceMaterial(node, 'interior') : {}),
[node],
)
const effectiveExteriorMaterial = useMemo(
() => (node ? getEffectiveWallSurfaceMaterial(node, 'exterior') : {}),
[node],
)
const surfaceMaterialsMatch = useMemo(
() =>
getWallSurfaceMaterialSignature(effectiveInteriorMaterial) ===
getWallSurfaceMaterialSignature(effectiveExteriorMaterial),
[effectiveExteriorMaterial, effectiveInteriorMaterial],
)
const materialTargetSide =
selectedMaterialTarget &&
selectedMaterialTarget.nodeId === node?.id &&
(selectedMaterialTarget.role === 'interior' || selectedMaterialTarget.role === 'exterior')
? selectedMaterialTarget.role
: null
const materialPickerValue =
materialTargetSide === 'interior'
? effectiveInteriorMaterial
: materialTargetSide === 'exterior'
? effectiveExteriorMaterial
: surfaceMaterialsMatch
? effectiveInteriorMaterial
: {}
const handleUpdateLength = useCallback(
(newLength: number) => {
if (!node || newLength <= 0) return
@@ -79,6 +140,24 @@ export function WallPanel() {
[node, handleUpdate],
)
const handleMaterialPresetChange = useCallback(
(materialPreset: string) => {
if (!(node && materialTargetSide)) return
handleUpdate(
buildWallSurfaceMaterialPatch(node, materialTargetSide, undefined, materialPreset),
)
},
[handleUpdate, materialTargetSide, node],
)
const handleCustomMaterialChange = useCallback(
(material: MaterialSchema) => {
if (!(node && materialTargetSide)) return
handleUpdate(buildWallSurfaceMaterialPatch(node, materialTargetSide, material, undefined))
},
[handleUpdate, materialTargetSide, node],
)
const handleClose = useCallback(() => {
setSelection({ selectedIds: [] })
}, [setSelection])
@@ -160,6 +239,23 @@ export function WallPanel() {
)}
</PanelSection>
<PanelSection title="Material">
{materialTargetSide ? null : (
<div className="mb-3 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 text-[11px] text-muted-foreground">
Click the wall face you want to edit. Materials now apply to one side at a time.
</div>
)}
<MaterialPicker
disabled={!materialTargetSide}
hideSideControl
nodeType="wall"
onChange={handleCustomMaterialChange}
onSelectMaterialPreset={handleMaterialPresetChange}
selectedMaterialPreset={materialPickerValue.materialPreset}
value={materialPickerValue.material}
/>
</PanelSection>
<PanelSection title="Actions">
<ActionGroup>
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />

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