Merge branch 'main' into feat/elevator-system
# Conflicts: # packages/editor/src/components/tools/item/move-tool.tsx # packages/editor/src/components/tools/tool-manager.tsx # packages/editor/src/components/ui/panels/panel-manager.tsx # packages/editor/src/store/use-editor.tsx # packages/viewer/src/components/renderers/site/site-renderer.tsx # packages/viewer/src/components/viewer/ground-occluder.tsx # packages/viewer/src/components/viewer/index.tsx # packages/viewer/src/components/viewer/post-processing.tsx
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@pascal-app/core",
|
||||
"version": "0.6.0",
|
||||
"version": "0.8.0",
|
||||
"description": "Core library for Pascal 3D building editor",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
@@ -73,9 +73,10 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@pascal/typescript-config": "*",
|
||||
"@types/bun": "^1.3.0",
|
||||
"@types/react": "^19.2.2",
|
||||
"typescript": "5.9.3",
|
||||
"@types/three": "^0.184.0"
|
||||
"@types/three": "^0.184.0",
|
||||
"typescript": "6.0.2"
|
||||
},
|
||||
"keywords": [
|
||||
"3d",
|
||||
|
||||
@@ -29,12 +29,13 @@ export interface GridEvent {
|
||||
/** World-space intersection point on the grid plane. */
|
||||
position: [number, number, number]
|
||||
/**
|
||||
* Building-local intersection point, relative to the currently selected building.
|
||||
* Building-local intersection point — relative to the currently selected building.
|
||||
* Equals `position` when no building is selected.
|
||||
* Use this for placing or committing anything that lives inside a building
|
||||
* (walls, slabs, items, etc.).
|
||||
* Use this for placing/committing anything that lives inside a building (walls, slabs, items, etc.).
|
||||
*/
|
||||
localPosition: [number, number, number]
|
||||
faceIndex?: number
|
||||
object: Object3D
|
||||
nativeEvent: ThreeEvent<PointerEvent>
|
||||
}
|
||||
|
||||
@@ -68,7 +69,7 @@ export type WindowEvent = NodeEvent<WindowNode>
|
||||
export type DoorEvent = NodeEvent<DoorNode>
|
||||
export type ElevatorEvent = NodeEvent<ElevatorNode>
|
||||
|
||||
// Event suffixes, exported for use in hooks
|
||||
// Event suffixes - exported for use in hooks
|
||||
export const eventSuffixes = [
|
||||
'click',
|
||||
'move',
|
||||
@@ -101,7 +102,7 @@ export interface ThumbnailGenerateEvent {
|
||||
/**
|
||||
* When true, snap levels to their true positions before capturing (for a
|
||||
* consistent auto-thumbnail angle) and defer the capture if the tab is
|
||||
* hidden, the background auto-save path. Omit for user-driven captures
|
||||
* hidden — the background auto-save path. Omit for user-driven captures
|
||||
* that should fire immediately from the current camera pose.
|
||||
*/
|
||||
snapLevels?: boolean
|
||||
@@ -109,8 +110,10 @@ export interface ThumbnailGenerateEvent {
|
||||
|
||||
export interface CameraControlFitSceneEvent {
|
||||
/**
|
||||
* XZ-plane axis-aligned bounds for camera framing. Omitted values let the
|
||||
* listener choose its default framing pose.
|
||||
* XZ-plane axis-aligned bounds of the scene's geometry, computed from the
|
||||
* scene graph (see `@pascal-app/editor`'s `computeSceneBoundsXZ`). The
|
||||
* viewer's camera-controls listener frames the camera onto this box.
|
||||
* Omitted values fall back to the camera's default pose.
|
||||
*/
|
||||
bounds?: {
|
||||
min: [number, number]
|
||||
|
||||
@@ -46,6 +46,8 @@ export { getRenderableSlabPolygon } from './lib/slab-polygon'
|
||||
export {
|
||||
detectSpacesForLevel,
|
||||
initSpaceDetectionSync,
|
||||
planAutoSlabsForLevel,
|
||||
type AutoSlabSyncPlan,
|
||||
type Space,
|
||||
wallTouchesOthers,
|
||||
} from './lib/space-detection'
|
||||
@@ -122,6 +124,15 @@ export {
|
||||
type WallMiterBoundaryPoints,
|
||||
type WallMiterData,
|
||||
} from './systems/wall/wall-mitering'
|
||||
export {
|
||||
constrainWallMoveDeltaToAxis,
|
||||
getPerpendicularWallMoveAxis,
|
||||
planWallMoveJunctions,
|
||||
type WallMoveBridgePlan,
|
||||
type WallMoveAxis,
|
||||
type WallMoveJunctionPlan,
|
||||
type WallPlanPoint,
|
||||
} from './systems/wall/wall-move'
|
||||
export type { SceneGraph } from './utils/clone-scene-graph'
|
||||
export { cloneLevelSubtree, cloneSceneGraph, forkSceneGraph } from './utils/clone-scene-graph'
|
||||
export { isObject } from './utils/types'
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import {
|
||||
getClampedWallCurveOffset,
|
||||
getWallCurveFrameAt,
|
||||
isCurvedWall,
|
||||
} from '../systems/wall/wall-curve'
|
||||
import { CeilingNode, SlabNode, type CeilingNode as CeilingNodeType, type SlabNode as SlabNodeType, type WallNode } from '../schema'
|
||||
CeilingNode,
|
||||
type CeilingNode as CeilingNodeType,
|
||||
SlabNode,
|
||||
type SlabNode as SlabNodeType,
|
||||
type WallNode,
|
||||
} from '../schema'
|
||||
import {
|
||||
getSceneHistoryPauseDepth,
|
||||
pauseSceneHistory,
|
||||
resumeSceneHistory,
|
||||
} from '../store/history-control'
|
||||
import {
|
||||
getClampedWallCurveOffset,
|
||||
getWallCurveFrameAt,
|
||||
isCurvedWall,
|
||||
} from '../systems/wall/wall-curve'
|
||||
import { simplifyClosedPolygon } from './polygon-geometry'
|
||||
|
||||
type Point2D = { x: number; y: number }
|
||||
@@ -35,6 +41,12 @@ type DetectedRoom = {
|
||||
bbox: ReturnType<typeof bboxOf>
|
||||
}
|
||||
|
||||
export type AutoSlabSyncPlan = {
|
||||
create: SlabNodeType[]
|
||||
update: Array<{ id: SlabNodeType['id']; data: Partial<SlabNodeType> }>
|
||||
delete: Array<SlabNodeType['id']>
|
||||
}
|
||||
|
||||
const DEFAULT_AUTO_SLAB_ELEVATION = 0.05
|
||||
const DEFAULT_AUTO_CEILING_HEIGHT = 2.5
|
||||
const ROOM_CURVE_TOLERANCE = 0.04
|
||||
@@ -147,10 +159,10 @@ function polygonCentroid(points: Point2D[]) {
|
||||
}
|
||||
|
||||
function bboxOf(points: Point2D[]) {
|
||||
let minX = Infinity
|
||||
let minY = Infinity
|
||||
let maxX = -Infinity
|
||||
let maxY = -Infinity
|
||||
let minX = Number.POSITIVE_INFINITY
|
||||
let minY = Number.POSITIVE_INFINITY
|
||||
let maxX = Number.NEGATIVE_INFINITY
|
||||
let maxY = Number.NEGATIVE_INFINITY
|
||||
|
||||
for (const point of points) {
|
||||
minX = Math.min(minX, point.x)
|
||||
@@ -216,7 +228,13 @@ function sampleWallPointsForRoomDetection(
|
||||
return [start, end]
|
||||
}
|
||||
|
||||
const subdivide = (t0: number, p0: Point2D, t1: number, p1: Point2D, depth: number): Point2D[] => {
|
||||
const subdivide = (
|
||||
t0: number,
|
||||
p0: Point2D,
|
||||
t1: number,
|
||||
p1: Point2D,
|
||||
depth: number,
|
||||
): Point2D[] => {
|
||||
const midT = (t0 + t1) / 2
|
||||
const midPoint = getWallCurveFrameAt(wall, midT).point
|
||||
const deviation = pointLineDistance(midPoint, p0, p1)
|
||||
@@ -361,7 +379,7 @@ function extractRoomPolygons(walls: WallNode[]): Point2D[][] {
|
||||
|
||||
const signedArea = polygonArea(polygon)
|
||||
if (signedArea <= 0) continue
|
||||
if (signedArea < 0.5 || signedArea > 10000) continue
|
||||
if (signedArea < 0.5 || signedArea > 10_000) continue
|
||||
|
||||
const signature = polygonSignature(polygon)
|
||||
if (faces.some((face) => polygonSignature(face) === signature)) continue
|
||||
@@ -442,10 +460,7 @@ function nextAutoRoomName(
|
||||
return `Room ${maxIndex + 1} ${suffix}`
|
||||
}
|
||||
|
||||
function sameTuplePolygon(
|
||||
current: Array<[number, number]>,
|
||||
next: Array<[number, number]>,
|
||||
) {
|
||||
function sameTuplePolygon(current: Array<[number, number]>, next: Array<[number, number]>) {
|
||||
return (
|
||||
current.length === next.length &&
|
||||
current.every((point, index) => point[0] === next[index]?.[0] && point[1] === next[index]?.[1])
|
||||
@@ -479,12 +494,10 @@ function buildSpace(levelId: string, polygon: Point2D[]): Space {
|
||||
}
|
||||
}
|
||||
|
||||
function syncAutoSlabsForLevel(
|
||||
levelId: string,
|
||||
export function planAutoSlabsForLevel(
|
||||
roomPolygons: Point2D[][],
|
||||
existingSlabs: SlabNodeType[],
|
||||
sceneStore: any,
|
||||
) {
|
||||
): AutoSlabSyncPlan {
|
||||
const manualSlabs = existingSlabs.filter((slab) => !slab.autoFromWalls)
|
||||
const manualSignatures = new Set(
|
||||
manualSlabs.map((slab) => polygonSignature(slab.polygon.map(pointFromTuple))),
|
||||
@@ -609,16 +622,31 @@ function syncAutoSlabsForLevel(
|
||||
)
|
||||
}
|
||||
|
||||
if (slabsToDelete.length > 0) {
|
||||
sceneStore.getState().deleteNodes(slabsToDelete)
|
||||
return {
|
||||
create: slabsToCreate,
|
||||
update: slabsToUpdate,
|
||||
delete: slabsToDelete,
|
||||
}
|
||||
}
|
||||
|
||||
function syncAutoSlabsForLevel(
|
||||
levelId: string,
|
||||
roomPolygons: Point2D[][],
|
||||
existingSlabs: SlabNodeType[],
|
||||
sceneStore: any,
|
||||
) {
|
||||
const plan = planAutoSlabsForLevel(roomPolygons, existingSlabs)
|
||||
|
||||
if (plan.delete.length > 0) {
|
||||
sceneStore.getState().deleteNodes(plan.delete)
|
||||
}
|
||||
|
||||
if (slabsToUpdate.length > 0) {
|
||||
sceneStore.getState().updateNodes(slabsToUpdate)
|
||||
if (plan.update.length > 0) {
|
||||
sceneStore.getState().updateNodes(plan.update)
|
||||
}
|
||||
|
||||
if (slabsToCreate.length > 0) {
|
||||
sceneStore.getState().createNodes(slabsToCreate.map((node) => ({ node, parentId: levelId })))
|
||||
if (plan.create.length > 0) {
|
||||
sceneStore.getState().createNodes(plan.create.map((node) => ({ node, parentId: levelId })))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -727,7 +755,9 @@ function syncAutoCeilingsForLevel(
|
||||
const polygon = updatesById.get(ceiling.id)
|
||||
if (!polygon) return []
|
||||
|
||||
return sameTuplePolygon(ceiling.polygon, polygon) ? [] : [{ id: ceiling.id, data: { polygon } }]
|
||||
return sameTuplePolygon(ceiling.polygon, polygon)
|
||||
? []
|
||||
: [{ id: ceiling.id, data: { polygon } }]
|
||||
})
|
||||
|
||||
const plannedCeilingsForNaming: Array<{ name?: string }> = [...existingCeilings]
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -13,9 +13,9 @@ export const FenceNode = BaseNode.extend({
|
||||
materialPreset: z.string().optional(),
|
||||
start: z.tuple([z.number(), z.number()]),
|
||||
end: z.tuple([z.number(), z.number()]),
|
||||
curveOffset: z.number().optional(),
|
||||
height: z.number().default(1.8),
|
||||
thickness: z.number().default(0.08),
|
||||
curveOffset: z.number().optional(),
|
||||
baseHeight: z.number().default(0.22),
|
||||
postSpacing: z.number().default(2),
|
||||
postSize: z.number().default(0.1),
|
||||
@@ -30,8 +30,8 @@ export const FenceNode = BaseNode.extend({
|
||||
dedent`
|
||||
Fence node - used to represent a fence segment in the building/site level coordinate system
|
||||
- start/end: fence endpoints in level coordinate system
|
||||
- curveOffset: midpoint sagitta offset used to bend the fence into an arc
|
||||
- height/thickness: overall fence dimensions in meters
|
||||
- curveOffset: midpoint sagitta offset used to bend the fence into an arc
|
||||
- baseHeight/postSpacing/postSize/topRailHeight: exact geometric controls from the plan3D fence model
|
||||
- groundClearance/edgeInset/baseStyle: fence support and inset configuration
|
||||
- showInfill: whether to draw intermediate posts/slats between end posts
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,9 @@ import type { CollectionId } from '../../schema/collections'
|
||||
import type { SceneState } from '../use-scene'
|
||||
|
||||
type AnyContainerNode = AnyNode & { children: string[] }
|
||||
type NodeCreateOp = { node: AnyNode; parentId?: AnyNodeId }
|
||||
type NodeUpdateOp = { id: AnyNodeId; data: Partial<AnyNode> }
|
||||
type NodeDeleteOp = AnyNodeId
|
||||
type WallAttachmentUpdate = { id: AnyNodeId; data: Partial<AnyNode> }
|
||||
type WallMergePlan = {
|
||||
primaryWallId: AnyNodeId
|
||||
@@ -122,7 +125,7 @@ function buildMergedWallAttachmentUpdates(
|
||||
const wallChildren = [...(primary.children ?? []), ...(secondary.children ?? [])] as AnyNodeId[]
|
||||
for (const childId of wallChildren) {
|
||||
const child = nodes[childId]
|
||||
if (!child || !('position' in child) || !Array.isArray(child.position)) {
|
||||
if (!(child && 'position' in child && Array.isArray(child.position))) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -190,10 +193,12 @@ function buildWallMergePlans(
|
||||
})
|
||||
const [primary, secondary] = sortedCandidates
|
||||
if (
|
||||
!primary ||
|
||||
!secondary ||
|
||||
!areWallStylesCompatible(primary, secondary) ||
|
||||
!areWallsCollinearAcrossPoint(primary, secondary, junction)
|
||||
!(
|
||||
primary &&
|
||||
secondary &&
|
||||
areWallStylesCompatible(primary, secondary) &&
|
||||
areWallsCollinearAcrossPoint(primary, secondary, junction)
|
||||
)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
@@ -230,7 +235,7 @@ function buildWallMergePlans(
|
||||
export const createNodesAction = (
|
||||
set: (fn: (state: SceneState) => Partial<SceneState>) => void,
|
||||
get: () => SceneState,
|
||||
ops: { node: AnyNode; parentId?: AnyNodeId }[],
|
||||
ops: NodeCreateOp[],
|
||||
) => {
|
||||
if (get().readOnly) return
|
||||
set((state) => {
|
||||
@@ -275,6 +280,144 @@ export const createNodesAction = (
|
||||
ops.forEach(({ node, parentId }) => {
|
||||
get().markDirty(node.id)
|
||||
if (parentId) get().markDirty(parentId)
|
||||
else if (node.parentId) get().markDirty(node.parentId as AnyNodeId)
|
||||
})
|
||||
}
|
||||
|
||||
export const applyNodeChangesAction = (
|
||||
set: (fn: (state: SceneState) => Partial<SceneState>) => void,
|
||||
get: () => SceneState,
|
||||
changes: { create?: NodeCreateOp[]; update?: NodeUpdateOp[]; delete?: NodeDeleteOp[] },
|
||||
) => {
|
||||
if (get().readOnly) return
|
||||
|
||||
const createOps = changes.create ?? []
|
||||
const updateOps = changes.update ?? []
|
||||
const deleteOps = changes.delete ?? []
|
||||
const nodesToMarkDirty = new Set<AnyNodeId>()
|
||||
const parentsToMarkDirty = new Set<AnyNodeId>()
|
||||
|
||||
set((state) => {
|
||||
const nextNodes = { ...state.nodes }
|
||||
const nextCollections = { ...state.collections }
|
||||
const nextRootIds = [...state.rootNodeIds]
|
||||
let resolvedRootIds = nextRootIds
|
||||
|
||||
for (const { id, data } of updateOps) {
|
||||
const currentNode = nextNodes[id]
|
||||
if (!currentNode) continue
|
||||
|
||||
if (data.parentId !== undefined && data.parentId !== currentNode.parentId) {
|
||||
const oldParentId = currentNode.parentId as AnyNodeId | null
|
||||
if (oldParentId && nextNodes[oldParentId]) {
|
||||
const oldParent = nextNodes[oldParentId] as AnyContainerNode
|
||||
nextNodes[oldParent.id] = {
|
||||
...oldParent,
|
||||
children: oldParent.children.filter((childId) => childId !== id),
|
||||
} as AnyNode
|
||||
parentsToMarkDirty.add(oldParent.id)
|
||||
}
|
||||
|
||||
const newParentId = data.parentId as AnyNodeId | null
|
||||
if (newParentId && nextNodes[newParentId]) {
|
||||
const newParent = nextNodes[newParentId] as AnyContainerNode
|
||||
nextNodes[newParent.id] = {
|
||||
...newParent,
|
||||
children: Array.from(new Set([...newParent.children, id])),
|
||||
} as AnyNode
|
||||
parentsToMarkDirty.add(newParent.id)
|
||||
}
|
||||
}
|
||||
|
||||
nextNodes[id] = { ...currentNode, ...data } as AnyNode
|
||||
nodesToMarkDirty.add(id)
|
||||
}
|
||||
|
||||
for (const { node, parentId } of createOps) {
|
||||
const effectiveParentId = parentId ?? (node.parentId as AnyNodeId | null) ?? null
|
||||
const newNode = {
|
||||
...node,
|
||||
parentId: effectiveParentId,
|
||||
} as AnyNode
|
||||
|
||||
nextNodes[newNode.id as AnyNodeId] = newNode
|
||||
nodesToMarkDirty.add(newNode.id as AnyNodeId)
|
||||
|
||||
if (effectiveParentId && nextNodes[effectiveParentId]) {
|
||||
const parent = nextNodes[effectiveParentId]
|
||||
if ('children' in parent && Array.isArray(parent.children)) {
|
||||
nextNodes[effectiveParentId] = {
|
||||
...parent,
|
||||
children: Array.from(new Set([...parent.children, newNode.id])) as any,
|
||||
}
|
||||
parentsToMarkDirty.add(effectiveParentId)
|
||||
}
|
||||
} else if (!effectiveParentId && !nextRootIds.includes(newNode.id as AnyNodeId)) {
|
||||
nextRootIds.push(newNode.id as AnyNodeId)
|
||||
}
|
||||
}
|
||||
|
||||
const allIdsToDelete = new Set<AnyNodeId>()
|
||||
const collectDelete = (id: AnyNodeId) => {
|
||||
if (allIdsToDelete.has(id)) return
|
||||
allIdsToDelete.add(id)
|
||||
const node = nextNodes[id]
|
||||
if (node && 'children' in node && Array.isArray(node.children)) {
|
||||
for (const childId of node.children) {
|
||||
collectDelete(childId as AnyNodeId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of deleteOps) {
|
||||
collectDelete(id)
|
||||
}
|
||||
|
||||
for (const id of allIdsToDelete) {
|
||||
const node = nextNodes[id]
|
||||
if (!node) continue
|
||||
|
||||
const parentId = node.parentId as AnyNodeId | null
|
||||
if (parentId && nextNodes[parentId] && !allIdsToDelete.has(parentId)) {
|
||||
const parent = nextNodes[parentId] as AnyContainerNode
|
||||
if (parent.children) {
|
||||
nextNodes[parent.id] = {
|
||||
...parent,
|
||||
children: parent.children.filter((childId) => childId !== id),
|
||||
} as AnyNode
|
||||
parentsToMarkDirty.add(parent.id)
|
||||
}
|
||||
}
|
||||
|
||||
resolvedRootIds = resolvedRootIds.filter((rootId) => rootId !== id)
|
||||
|
||||
if ('collectionIds' in node && node.collectionIds) {
|
||||
for (const collectionId of node.collectionIds as CollectionId[]) {
|
||||
const collection = nextCollections[collectionId]
|
||||
if (collection) {
|
||||
nextCollections[collectionId] = {
|
||||
...collection,
|
||||
nodeIds: collection.nodeIds.filter((nodeId) => nodeId !== id),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
delete nextNodes[id]
|
||||
}
|
||||
|
||||
return { nodes: nextNodes, rootNodeIds: resolvedRootIds, collections: nextCollections }
|
||||
})
|
||||
|
||||
nodesToMarkDirty.forEach((id) => get().markDirty(id))
|
||||
parentsToMarkDirty.forEach((id) => {
|
||||
get().markDirty(id)
|
||||
const parent = get().nodes[id]
|
||||
if (parent && 'children' in parent && Array.isArray(parent.children)) {
|
||||
for (const childId of parent.children) {
|
||||
get().markDirty(childId as AnyNodeId)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -111,7 +111,7 @@ function migrateWallSurfaceMaterials(node: Record<string, any>) {
|
||||
materialPreset: typeof node.materialPreset === 'string' ? node.materialPreset : undefined,
|
||||
}
|
||||
|
||||
if (!hasInterior && !hasExterior) {
|
||||
if (!(hasInterior || hasExterior)) {
|
||||
if (legacyFinish.material === undefined && legacyFinish.materialPreset === undefined) {
|
||||
return node
|
||||
}
|
||||
@@ -174,7 +174,7 @@ function migrateStairSurfaceMaterials(node: Record<string, any>) {
|
||||
return legacyFinish
|
||||
}
|
||||
|
||||
if (!hasRailing && !hasTread && !hasSide) {
|
||||
if (!(hasRailing || hasTread || hasSide)) {
|
||||
if (legacyFinish.material === undefined && legacyFinish.materialPreset === undefined) {
|
||||
return node
|
||||
}
|
||||
@@ -236,7 +236,7 @@ function migrateRoofSurfaceMaterials(node: Record<string, any>) {
|
||||
materialPreset: typeof node.materialPreset === 'string' ? node.materialPreset : undefined,
|
||||
}
|
||||
|
||||
if (!hasTop && !hasEdge && !hasWall) {
|
||||
if (!(hasTop || hasEdge || hasWall)) {
|
||||
if (legacyFinish.material === undefined && legacyFinish.materialPreset === undefined) {
|
||||
return node
|
||||
}
|
||||
@@ -350,7 +350,7 @@ function migrateNodes(nodes: Record<string, any>): Record<string, AnyNode> {
|
||||
}
|
||||
|
||||
function getNodeChildIds(node: AnyNode): AnyNodeId[] {
|
||||
if (!('children' in node) || !Array.isArray(node.children)) {
|
||||
if (!('children' in node && Array.isArray(node.children))) {
|
||||
return []
|
||||
}
|
||||
|
||||
@@ -438,6 +438,11 @@ export type SceneState = {
|
||||
|
||||
createNode: (node: AnyNode, parentId?: AnyNodeId) => void
|
||||
createNodes: (ops: { node: AnyNode; parentId?: AnyNodeId }[]) => void
|
||||
applyNodeChanges: (changes: {
|
||||
create?: { node: AnyNode; parentId?: AnyNodeId }[]
|
||||
update?: { id: AnyNodeId; data: Partial<AnyNode> }[]
|
||||
delete?: AnyNodeId[]
|
||||
}) => void
|
||||
|
||||
updateNode: (id: AnyNodeId, data: Partial<AnyNode>) => void
|
||||
updateNodes: (updates: { id: AnyNodeId; data: Partial<AnyNode> }[]) => void
|
||||
@@ -511,6 +516,13 @@ const useScene: UseSceneStore = create<SceneState>()(
|
||||
}
|
||||
}
|
||||
|
||||
set({
|
||||
nodes: cleanedNodes,
|
||||
rootNodeIds,
|
||||
dirtyNodes: new Set<AnyNodeId>(),
|
||||
collections: {},
|
||||
})
|
||||
|
||||
const normalizedRootNodeIds = normalizeRootNodeIds(cleanedNodes, rootNodeIds)
|
||||
const reachableNodeIds = collectReachableNodeIds(cleanedNodes, normalizedRootNodeIds)
|
||||
if (normalizedRootNodeIds.length > 0) {
|
||||
@@ -579,6 +591,7 @@ const useScene: UseSceneStore = create<SceneState>()(
|
||||
|
||||
createNodes: (ops) => nodeActions.createNodesAction(set, get, ops),
|
||||
createNode: (node, parentId) => nodeActions.createNodesAction(set, get, [{ node, parentId }]),
|
||||
applyNodeChanges: (changes) => nodeActions.applyNodeChangesAction(set, get, changes),
|
||||
|
||||
updateNodes: (updates) => nodeActions.updateNodesAction(set, get, updates),
|
||||
updateNode: (id, data) => nodeActions.updateNodesAction(set, get, [{ id, data }]),
|
||||
@@ -701,8 +714,8 @@ let prevFutureLength = 0
|
||||
let prevNodesSnapshot: Record<AnyNodeId, AnyNode> | null = null
|
||||
|
||||
export function clearSceneHistory() {
|
||||
useScene.temporal.getState().clear()
|
||||
resetSceneHistoryPauseDepth()
|
||||
useScene.temporal.getState().clear()
|
||||
prevPastLength = 0
|
||||
prevFutureLength = 0
|
||||
prevNodesSnapshot = null
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// @ts-expect-error — bun:test is provided by the Bun runtime; core does not
|
||||
// depend on @types/bun so the import type is unresolved at compile time.
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import type { AnyNode } from '../../schema'
|
||||
import { BuildingNode, CeilingNode, ElevatorNode, LevelNode, SlabNode } from '../../schema'
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// @ts-expect-error — bun:test is provided by the Bun runtime; core does not
|
||||
// depend on @types/bun so the import type is unresolved at compile time.
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import type { AnyNode } from '../../schema'
|
||||
import {
|
||||
|
||||
@@ -35,10 +35,9 @@ type AxisAlignedRect = {
|
||||
maxZ: number
|
||||
}
|
||||
|
||||
const CURVED_STAIR_SLAB_OPENING_RATIO = 0.9
|
||||
const CURVED_STAIR_SLAB_OPENING_RATIO = 0.8
|
||||
const STRAIGHT_STAIR_TARGET_THRESHOLD_MIN = 0.35
|
||||
const STAIR_SLAB_OPENING_TIGHTENING = 0
|
||||
const CURVED_STAIR_OPENING_STEP_PADDING = 3
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value))
|
||||
@@ -280,7 +279,7 @@ function polygonArea(points: Point2D[]) {
|
||||
for (let index = 0; index < points.length; index += 1) {
|
||||
const current = points[index]
|
||||
const next = points[(index + 1) % points.length]
|
||||
if (!current || !next) continue
|
||||
if (!(current && next)) continue
|
||||
area += current[0] * next[1] - next[0] * current[1]
|
||||
}
|
||||
return area / 2
|
||||
@@ -430,39 +429,24 @@ function buildUnionPolygonsFromRects(rects: AxisAlignedRect[]): Point2D[][] {
|
||||
return polygons
|
||||
}
|
||||
|
||||
function getCurvedOpeningStepCount(
|
||||
stair: StairNode,
|
||||
innerRadius: number,
|
||||
outerRadius: number,
|
||||
totalSweep: number,
|
||||
) {
|
||||
const stepCount = Math.max(2, Math.round(stair.stepCount ?? 10))
|
||||
const stepSweep = Math.abs(totalSweep) / stepCount
|
||||
const midRadius = Math.max((innerRadius + outerRadius) * 0.5, 0.01)
|
||||
const treadDepth = Math.max(stepSweep * midRadius, 0.2)
|
||||
return Math.min(
|
||||
stepCount,
|
||||
function getCurvedOpeningPolygon(stair: StairNode): Point2D[] {
|
||||
const width = Math.max(stair.width ?? 1, 0.4)
|
||||
const innerRadius = Math.max(0.2, stair.innerRadius ?? 0.9)
|
||||
const outerRadius = innerRadius + width
|
||||
const totalSweep = stair.sweepAngle ?? Math.PI / 2
|
||||
const openingSweep =
|
||||
Math.sign(totalSweep || 1) *
|
||||
Math.max(
|
||||
1,
|
||||
Math.ceil(1.8 / treadDepth),
|
||||
Math.ceil(stepCount * CURVED_STAIR_SLAB_OPENING_RATIO),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function buildArcOpeningPolygon(
|
||||
stair: StairNode,
|
||||
innerRadius: number,
|
||||
outerRadius: number,
|
||||
startAngle: number,
|
||||
endAngle: number,
|
||||
): Point2D[] {
|
||||
const sweep = endAngle - startAngle
|
||||
Math.abs(totalSweep) * CURVED_STAIR_SLAB_OPENING_RATIO,
|
||||
Math.abs(totalSweep) / Math.max(stair.stepCount ?? 1, 1),
|
||||
)
|
||||
const startAngle = totalSweep / 2 - openingSweep
|
||||
const endAngle = totalSweep / 2
|
||||
const segmentCount = Math.max(
|
||||
10,
|
||||
Math.min(
|
||||
32,
|
||||
Math.ceil(Math.abs(sweep) / (Math.PI / 24) + Math.max(stair.stepCount ?? 1, 1) * 0.5),
|
||||
Math.ceil(Math.abs(openingSweep) / (Math.PI / 24) + Math.max(stair.stepCount ?? 1, 1) * 0.5),
|
||||
),
|
||||
)
|
||||
const outerPoints: Point2D[] = []
|
||||
@@ -470,7 +454,7 @@ function buildArcOpeningPolygon(
|
||||
|
||||
for (let index = 0; index <= segmentCount; index++) {
|
||||
const t = index / segmentCount
|
||||
const angle = startAngle + sweep * t
|
||||
const angle = startAngle + (endAngle - startAngle) * t
|
||||
outerPoints.push(
|
||||
toWorldPlanPoint(stair, Math.cos(angle) * outerRadius, Math.sin(angle) * outerRadius),
|
||||
)
|
||||
@@ -478,8 +462,7 @@ function buildArcOpeningPolygon(
|
||||
|
||||
for (let index = segmentCount; index >= 0; index--) {
|
||||
const t = index / segmentCount
|
||||
const angle = startAngle + sweep * t
|
||||
|
||||
const angle = startAngle + (endAngle - startAngle) * t
|
||||
innerPoints.push(
|
||||
toWorldPlanPoint(stair, Math.cos(angle) * innerRadius, Math.sin(angle) * innerRadius),
|
||||
)
|
||||
@@ -488,39 +471,6 @@ function buildArcOpeningPolygon(
|
||||
return [...outerPoints, ...innerPoints]
|
||||
}
|
||||
|
||||
function getCurvedOpeningPolygon(stair: StairNode, targetElevation?: number): Point2D[] {
|
||||
const width = Math.max(stair.width ?? 1, 0.4)
|
||||
const innerRadius = Math.max(0.2, stair.innerRadius ?? 0.9)
|
||||
const outerRadius = innerRadius + width
|
||||
const totalSweep = stair.sweepAngle ?? Math.PI / 2
|
||||
const stepCount = Math.max(2, Math.round(stair.stepCount ?? 10))
|
||||
const stepHeight = Math.max(stair.totalRise ?? 2.5, 0.1) / stepCount
|
||||
const stepSweep = totalSweep / stepCount
|
||||
const targetThreshold = Math.max(stepHeight * 2, STRAIGHT_STAIR_TARGET_THRESHOLD_MIN)
|
||||
const endAngle = totalSweep / 2
|
||||
|
||||
const fallbackStartStepIndex = Math.max(
|
||||
0,
|
||||
stepCount - getCurvedOpeningStepCount(stair, innerRadius, outerRadius, totalSweep),
|
||||
)
|
||||
let startStepIndex = fallbackStartStepIndex
|
||||
if (typeof targetElevation === 'number') {
|
||||
for (let index = 0; index < stepCount; index += 1) {
|
||||
const stepTopElevation = stepHeight * (index + 1)
|
||||
if (stepTopElevation >= targetElevation - targetThreshold) {
|
||||
startStepIndex = Math.max(
|
||||
0,
|
||||
Math.min(fallbackStartStepIndex, index - CURVED_STAIR_OPENING_STEP_PADDING),
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const startAngle = -totalSweep / 2 + stepSweep * startStepIndex
|
||||
return buildArcOpeningPolygon(stair, innerRadius, outerRadius, startAngle, endAngle)
|
||||
}
|
||||
|
||||
function getSpiralOpeningPolygon(stair: StairNode): Point2D[] {
|
||||
const radius = Math.max(0.05, stair.innerRadius ?? 0.9) + Math.max(stair.width ?? 1, 0.4)
|
||||
const segmentCount = 48
|
||||
@@ -625,7 +575,7 @@ function getStairOpeningPolygons(
|
||||
}
|
||||
|
||||
if (stair.stairType === 'curved') {
|
||||
return [getCurvedOpeningPolygon(stair, targetElevation)]
|
||||
return [getCurvedOpeningPolygon(stair)]
|
||||
}
|
||||
|
||||
if (stair.stairType === 'spiral') {
|
||||
@@ -784,8 +734,7 @@ export function syncAutoStairOpenings(nodes: Record<string, AnyNode>) {
|
||||
]
|
||||
|
||||
if (
|
||||
!polygonsEqual(existingHoles, nextHoles) ||
|
||||
!metadataEqual(existingMetadata, nextMetadata)
|
||||
!(polygonsEqual(existingHoles, nextHoles) && metadataEqual(existingMetadata, nextMetadata))
|
||||
) {
|
||||
updates.push({
|
||||
id: slab.id,
|
||||
@@ -840,8 +789,7 @@ export function syncAutoStairOpenings(nodes: Record<string, AnyNode>) {
|
||||
]
|
||||
|
||||
if (
|
||||
!polygonsEqual(existingHoles, nextHoles) ||
|
||||
!metadataEqual(existingMetadata, nextMetadata)
|
||||
!(polygonsEqual(existingHoles, nextHoles) && metadataEqual(existingMetadata, nextMetadata))
|
||||
) {
|
||||
updates.push({
|
||||
id: ceiling.id,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
import type { WallNode } from '../../schema'
|
||||
|
||||
const AXIS_EPSILON = 1e-6
|
||||
|
||||
export type WallPlanPoint = [number, number]
|
||||
export type WallMoveAxis = 'x' | 'z'
|
||||
export type WallMoveEndpoint = 'start' | 'end'
|
||||
|
||||
export type WallMoveBridgePlan<TWall extends Pick<WallNode, 'id' | 'start' | 'end'>> = {
|
||||
wall: TWall
|
||||
originalPoint: WallPlanPoint
|
||||
movedEndpoint: WallMoveEndpoint
|
||||
}
|
||||
|
||||
export type WallMoveLinkedWallTargetPlan<
|
||||
TWall extends Pick<WallNode, 'id' | 'start' | 'end'>,
|
||||
> = {
|
||||
wall: TWall
|
||||
originalPoint: WallPlanPoint
|
||||
targetPoint: WallPlanPoint
|
||||
}
|
||||
|
||||
export type WallMoveJunctionPlan<TWall extends Pick<WallNode, 'id' | 'start' | 'end'>> = {
|
||||
linkedWallsToMove: TWall[]
|
||||
linkedWallTargetPlans: Array<WallMoveLinkedWallTargetPlan<TWall>>
|
||||
bridgePlans: Array<WallMoveBridgePlan<TWall>>
|
||||
wallsToDelete: TWall[]
|
||||
}
|
||||
|
||||
export function getPerpendicularWallMoveAxis(
|
||||
start: WallPlanPoint,
|
||||
end: WallPlanPoint,
|
||||
): WallMoveAxis | null {
|
||||
const wallDeltaX = Math.abs(end[0] - start[0])
|
||||
const wallDeltaZ = Math.abs(end[1] - start[1])
|
||||
|
||||
if (wallDeltaX < AXIS_EPSILON && wallDeltaZ < AXIS_EPSILON) return null
|
||||
|
||||
return wallDeltaX >= wallDeltaZ ? 'z' : 'x'
|
||||
}
|
||||
|
||||
export function constrainWallMoveDeltaToAxis(
|
||||
deltaX: number,
|
||||
deltaZ: number,
|
||||
axis: WallMoveAxis | null,
|
||||
): WallPlanPoint {
|
||||
if (axis === 'x') return [deltaX, 0]
|
||||
if (axis === 'z') return [0, deltaZ]
|
||||
return [deltaX, deltaZ]
|
||||
}
|
||||
|
||||
function pointsEqual(a: WallPlanPoint, b: WallPlanPoint) {
|
||||
return Math.abs(a[0] - b[0]) <= AXIS_EPSILON && Math.abs(a[1] - b[1]) <= AXIS_EPSILON
|
||||
}
|
||||
|
||||
function wallTouchesPoint(wall: Pick<WallNode, 'start' | 'end'>, point: WallPlanPoint) {
|
||||
return pointsEqual(wall.start, point) || pointsEqual(wall.end, point)
|
||||
}
|
||||
|
||||
function otherWallEndpoint(wall: Pick<WallNode, 'start' | 'end'>, point: WallPlanPoint) {
|
||||
return pointsEqual(wall.start, point) ? wall.end : wall.start
|
||||
}
|
||||
|
||||
type MoveWallRelation = 'same-direction' | 'opposite-direction' | 'off-axis' | 'stationary'
|
||||
type RelatedWallEntry<TWall extends Pick<WallNode, 'id' | 'start' | 'end'>> = {
|
||||
wall: TWall
|
||||
relation: MoveWallRelation
|
||||
}
|
||||
|
||||
function wallLengthFromPoint(wall: Pick<WallNode, 'start' | 'end'>, point: WallPlanPoint) {
|
||||
const freeEndpoint = otherWallEndpoint(wall, point)
|
||||
return Math.hypot(freeEndpoint[0] - point[0], freeEndpoint[1] - point[1])
|
||||
}
|
||||
|
||||
function getMoveWallRelation(
|
||||
wall: Pick<WallNode, 'start' | 'end'>,
|
||||
sharedPoint: WallPlanPoint,
|
||||
nextPoint: WallPlanPoint,
|
||||
): MoveWallRelation {
|
||||
const moveX = nextPoint[0] - sharedPoint[0]
|
||||
const moveZ = nextPoint[1] - sharedPoint[1]
|
||||
const moveLength = Math.hypot(moveX, moveZ)
|
||||
|
||||
if (moveLength < AXIS_EPSILON) return 'stationary'
|
||||
|
||||
const freeEndpoint = otherWallEndpoint(wall, sharedPoint)
|
||||
const wallX = freeEndpoint[0] - sharedPoint[0]
|
||||
const wallZ = freeEndpoint[1] - sharedPoint[1]
|
||||
const wallLength = Math.hypot(wallX, wallZ)
|
||||
|
||||
if (wallLength < AXIS_EPSILON) return 'stationary'
|
||||
|
||||
const normalizedCross = Math.abs(moveX * wallZ - moveZ * wallX) / (moveLength * wallLength)
|
||||
if (normalizedCross > 1e-4) return 'off-axis'
|
||||
|
||||
const normalizedDot = (moveX * wallX + moveZ * wallZ) / (moveLength * wallLength)
|
||||
return normalizedDot >= 0 ? 'same-direction' : 'opposite-direction'
|
||||
}
|
||||
|
||||
export function planWallMoveJunctions<TWall extends Pick<WallNode, 'id' | 'start' | 'end'>>(
|
||||
linkedWalls: TWall[],
|
||||
originalStart: WallPlanPoint,
|
||||
originalEnd: WallPlanPoint,
|
||||
nextStart: WallPlanPoint,
|
||||
nextEnd: WallPlanPoint,
|
||||
): WallMoveJunctionPlan<TWall> {
|
||||
const linkedWallsToMove = new Map<TWall['id'], TWall>()
|
||||
const linkedWallTargetPlans = new Map<TWall['id'], WallMoveLinkedWallTargetPlan<TWall>>()
|
||||
const bridgePlans = new Map<string, WallMoveBridgePlan<TWall>>()
|
||||
const wallsToDelete = new Map<TWall['id'], TWall>()
|
||||
|
||||
const addStandardEndpointPlan = (
|
||||
endpoint: WallMoveEndpoint,
|
||||
point: WallPlanPoint,
|
||||
nextPoint: WallPlanPoint,
|
||||
relatedWalls: Array<RelatedWallEntry<TWall>>,
|
||||
keySuffix = '',
|
||||
useTargetPlans = false,
|
||||
) => {
|
||||
const hasSideBranch = relatedWalls.some((entry) => entry.relation === 'off-axis')
|
||||
const hasOppositeBridge = relatedWalls.some(
|
||||
(entry) => entry.relation === 'opposite-direction' && hasSideBranch,
|
||||
)
|
||||
|
||||
for (const { wall, relation } of relatedWalls) {
|
||||
if (
|
||||
relation === 'stationary' ||
|
||||
relation === 'same-direction' ||
|
||||
(relation === 'opposite-direction' && !hasSideBranch)
|
||||
) {
|
||||
if (useTargetPlans) {
|
||||
linkedWallTargetPlans.set(wall.id, {
|
||||
wall,
|
||||
originalPoint: point,
|
||||
targetPoint: nextPoint,
|
||||
})
|
||||
} else {
|
||||
linkedWallsToMove.set(wall.id, wall)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (relation === 'off-axis' && hasOppositeBridge) {
|
||||
continue
|
||||
}
|
||||
|
||||
bridgePlans.set(`${wall.id}:${endpoint}${keySuffix}`, {
|
||||
wall,
|
||||
originalPoint: point,
|
||||
movedEndpoint: endpoint,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const addEndpointPlan = (
|
||||
endpoint: WallMoveEndpoint,
|
||||
point: WallPlanPoint,
|
||||
nextPoint: WallPlanPoint,
|
||||
) => {
|
||||
const moveLength = Math.hypot(nextPoint[0] - point[0], nextPoint[1] - point[1])
|
||||
const linkedAtEndpoint = linkedWalls
|
||||
.filter((wall) => wallTouchesPoint(wall, point))
|
||||
.map((wall) => ({
|
||||
wall,
|
||||
relation: getMoveWallRelation(wall, point, nextPoint),
|
||||
}))
|
||||
const consumedSameDirectionWall = linkedAtEndpoint
|
||||
.filter((entry) => entry.relation === 'same-direction')
|
||||
.map((entry) => ({
|
||||
...entry,
|
||||
distance: wallLengthFromPoint(entry.wall, point),
|
||||
}))
|
||||
.filter((entry) => moveLength + AXIS_EPSILON >= entry.distance)
|
||||
.sort((a, b) => a.distance - b.distance)[0]
|
||||
|
||||
if (consumedSameDirectionWall) {
|
||||
const pivotPoint = [...otherWallEndpoint(consumedSameDirectionWall.wall, point)] as WallPlanPoint
|
||||
const bridgeSource = linkedAtEndpoint.find((entry) => entry.relation === 'opposite-direction')
|
||||
|
||||
wallsToDelete.set(consumedSameDirectionWall.wall.id, consumedSameDirectionWall.wall)
|
||||
linkedWallTargetPlans.set(consumedSameDirectionWall.wall.id, {
|
||||
wall: consumedSameDirectionWall.wall,
|
||||
originalPoint: point,
|
||||
targetPoint: pivotPoint,
|
||||
})
|
||||
|
||||
if (bridgeSource) {
|
||||
linkedWallTargetPlans.set(bridgeSource.wall.id, {
|
||||
wall: bridgeSource.wall,
|
||||
originalPoint: point,
|
||||
targetPoint: pivotPoint,
|
||||
})
|
||||
|
||||
bridgePlans.set(`${bridgeSource.wall.id}:${endpoint}:through`, {
|
||||
wall: bridgeSource.wall,
|
||||
originalPoint: pivotPoint,
|
||||
movedEndpoint: endpoint,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const linkedAtPivot = linkedWalls
|
||||
.filter(
|
||||
(wall) => wall.id !== consumedSameDirectionWall.wall.id && wallTouchesPoint(wall, pivotPoint),
|
||||
)
|
||||
.map((wall) => ({
|
||||
wall,
|
||||
relation: getMoveWallRelation(wall, pivotPoint, nextPoint),
|
||||
}))
|
||||
|
||||
addStandardEndpointPlan(endpoint, pivotPoint, nextPoint, linkedAtPivot, ':through-pivot', true)
|
||||
return
|
||||
}
|
||||
|
||||
addStandardEndpointPlan(endpoint, point, nextPoint, linkedAtEndpoint)
|
||||
}
|
||||
|
||||
addEndpointPlan('start', originalStart, nextStart)
|
||||
addEndpointPlan('end', originalEnd, nextEnd)
|
||||
|
||||
return {
|
||||
linkedWallsToMove: Array.from(linkedWallsToMove.values()),
|
||||
linkedWallTargetPlans: Array.from(linkedWallTargetPlans.values()),
|
||||
bridgePlans: Array.from(bridgePlans.values()),
|
||||
wallsToDelete: Array.from(wallsToDelete.values()),
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,8 @@
|
||||
"rootDir": "src",
|
||||
"noEmit": false,
|
||||
"composite": true,
|
||||
"incremental": true
|
||||
"incremental": true,
|
||||
"types": ["bun"]
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
|
||||
Reference in New Issue
Block a user