Merge branch 'main' into feat/upgrade-and-bug-fix
This commit is contained in:
@@ -15,6 +15,36 @@
|
||||
"types": "./dist/utils/clone-scene-graph.d.ts",
|
||||
"import": "./dist/utils/clone-scene-graph.js",
|
||||
"default": "./dist/utils/clone-scene-graph.js"
|
||||
},
|
||||
"./schema": {
|
||||
"types": "./dist/schema/index.d.ts",
|
||||
"import": "./dist/schema/index.js",
|
||||
"default": "./dist/schema/index.js"
|
||||
},
|
||||
"./store": {
|
||||
"types": "./dist/store/use-scene.d.ts",
|
||||
"import": "./dist/store/use-scene.js",
|
||||
"default": "./dist/store/use-scene.js"
|
||||
},
|
||||
"./material-library": {
|
||||
"types": "./dist/material-library.d.ts",
|
||||
"import": "./dist/material-library.js",
|
||||
"default": "./dist/material-library.js"
|
||||
},
|
||||
"./spatial-grid": {
|
||||
"types": "./dist/hooks/spatial-grid/spatial-grid-manager.d.ts",
|
||||
"import": "./dist/hooks/spatial-grid/spatial-grid-manager.js",
|
||||
"default": "./dist/hooks/spatial-grid/spatial-grid-manager.js"
|
||||
},
|
||||
"./wall": {
|
||||
"types": "./dist/systems/wall/wall-footprint.d.ts",
|
||||
"import": "./dist/systems/wall/wall-footprint.js",
|
||||
"default": "./dist/systems/wall/wall-footprint.js"
|
||||
},
|
||||
"./stair-openings": {
|
||||
"types": "./dist/systems/stair/stair-opening-sync.d.ts",
|
||||
"import": "./dist/systems/stair/stair-opening-sync.js",
|
||||
"default": "./dist/systems/stair/stair-opening-sync.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
|
||||
@@ -102,6 +102,21 @@ export interface ThumbnailGenerateEvent {
|
||||
snapLevels?: boolean
|
||||
}
|
||||
|
||||
export interface CameraControlFitSceneEvent {
|
||||
/**
|
||||
* 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]
|
||||
max: [number, number]
|
||||
center: [number, number]
|
||||
size: [number, number]
|
||||
}
|
||||
}
|
||||
|
||||
type CameraControlEvents = {
|
||||
'camera-controls:view': CameraControlEvent
|
||||
'camera-controls:focus': CameraControlEvent
|
||||
@@ -109,6 +124,7 @@ type CameraControlEvents = {
|
||||
'camera-controls:top-view': undefined
|
||||
'camera-controls:orbit-cw': undefined
|
||||
'camera-controls:orbit-ccw': undefined
|
||||
'camera-controls:fit-scene': CameraControlFitSceneEvent
|
||||
'camera-controls:generate-thumbnail': ThumbnailGenerateEvent
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export type {
|
||||
BuildingEvent,
|
||||
CameraControlEvent,
|
||||
CameraControlFitSceneEvent,
|
||||
CeilingEvent,
|
||||
DoorEvent,
|
||||
EventSuffix,
|
||||
@@ -38,7 +39,6 @@ export {
|
||||
type Space,
|
||||
wallTouchesOthers,
|
||||
} from './lib/space-detection'
|
||||
export { baseMaterial, glassMaterial } from './materials'
|
||||
export {
|
||||
getCatalogMaterialById,
|
||||
getLibraryMaterialIdFromRef,
|
||||
@@ -51,6 +51,7 @@ export {
|
||||
type MaterialCatalogItem,
|
||||
toLibraryMaterialRef,
|
||||
} from './material-library'
|
||||
export { baseMaterial, glassMaterial } from './materials'
|
||||
export * from './schema'
|
||||
export {
|
||||
type ControlValue,
|
||||
@@ -64,20 +65,14 @@ export {
|
||||
resumeSceneHistory,
|
||||
} from './store/history-control'
|
||||
export { default as useLiveTransforms, type LiveTransform } from './store/use-live-transforms'
|
||||
export { FenceSystem } from './systems/fence/fence-system'
|
||||
export { clearSceneHistory, default as useScene } from './store/use-scene'
|
||||
export { CeilingSystem } from './systems/ceiling/ceiling-system'
|
||||
export { DoorSystem } from './systems/door/door-system'
|
||||
export { FenceSystem } from './systems/fence/fence-system'
|
||||
export { ItemSystem } from './systems/item/item-system'
|
||||
export { RoofSystem } from './systems/roof/roof-system'
|
||||
export { SlabSystem } from './systems/slab/slab-system'
|
||||
export { StairSystem } from './systems/stair/stair-system'
|
||||
export {
|
||||
DEFAULT_WALL_HEIGHT,
|
||||
DEFAULT_WALL_THICKNESS,
|
||||
getWallPlanFootprint,
|
||||
getWallThickness,
|
||||
} from './systems/wall/wall-footprint'
|
||||
export {
|
||||
getClampedWallCurveOffset,
|
||||
getMaxWallCurveOffset,
|
||||
@@ -91,12 +86,18 @@ export {
|
||||
normalizeWallCurveOffset,
|
||||
sampleWallCenterline,
|
||||
} from './systems/wall/wall-curve'
|
||||
export {
|
||||
DEFAULT_WALL_HEIGHT,
|
||||
DEFAULT_WALL_THICKNESS,
|
||||
getWallPlanFootprint,
|
||||
getWallThickness,
|
||||
} from './systems/wall/wall-footprint'
|
||||
export {
|
||||
calculateLevelMiters,
|
||||
getWallMiterBoundaryPoints,
|
||||
type Point2D,
|
||||
type WallMiterBoundaryPoints,
|
||||
pointToKey,
|
||||
type WallMiterBoundaryPoints,
|
||||
type WallMiterData,
|
||||
} from './systems/wall/wall-mitering'
|
||||
export { WallSystem } from './systems/wall/wall-system'
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
// @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'
|
||||
|
||||
function isValid(url: string): boolean {
|
||||
return AssetUrl.safeParse(url).success
|
||||
}
|
||||
|
||||
describe('AssetUrl', () => {
|
||||
describe('allowed URLs', () => {
|
||||
const cases: Array<[string, string]> = [
|
||||
['asset://abc', 'internal asset handle'],
|
||||
['asset://catalog/items/chair-1', 'nested asset handle'],
|
||||
['blob:http://example.com/uuid-1234', 'blob URL with http inner'],
|
||||
['blob:https://example.com/uuid-5678', 'blob URL with https inner'],
|
||||
['https://cdn.example.com/a.glb', 'https CDN URL'],
|
||||
['https://cdn.example.com/models/chair.glb?v=2', 'https URL with query string'],
|
||||
['http://localhost:3000/x', 'http localhost with port'],
|
||||
['http://localhost/x', 'http localhost without port'],
|
||||
['http://127.0.0.1:8080/texture.png', 'http 127.0.0.1 loopback'],
|
||||
['/public/a.glb', 'app-relative path'],
|
||||
['/material/wood1/albedoMap_basecolor.jpg', 'relative path deep'],
|
||||
['data:image/png;base64,AAA', 'inline PNG data URL'],
|
||||
['data:image/jpeg;base64,/9j/', 'inline JPEG data URL'],
|
||||
['data:image/webp;base64,UklGR', 'inline WebP data URL'],
|
||||
['data:image/svg+xml,%3Csvg%3E', 'inline SVG data URL'],
|
||||
]
|
||||
for (const [url, label] of cases) {
|
||||
test(`accepts ${label}: ${url}`, () => {
|
||||
expect(isValid(url)).toBe(true)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe('rejected URLs', () => {
|
||||
const cases: Array<[string, string]> = [
|
||||
['javascript:alert(1)', 'javascript scheme'],
|
||||
['JAVASCRIPT:alert(1)', 'javascript scheme uppercase'],
|
||||
['file:///etc/passwd', 'file scheme'],
|
||||
['file://C:/Windows/System32/config', 'file scheme Windows'],
|
||||
['http://evil.com/', 'non-loopback http'],
|
||||
['http://example.com:3000/x', 'http on non-loopback host'],
|
||||
['http://169.254.169.254/latest/meta-data/', 'http on link-local (cloud metadata)'],
|
||||
['data:text/html,<script>alert(1)</script>', 'data text/html'],
|
||||
['data:application/javascript,alert(1)', 'data application/javascript'],
|
||||
['data:text/plain,hi', 'data text/plain'],
|
||||
['ftp://a.b.com', 'ftp scheme'],
|
||||
['ws://example.com/', 'websocket scheme'],
|
||||
['vbscript:msgbox', 'vbscript scheme'],
|
||||
['', 'empty string'],
|
||||
['not a url at all', 'non-url string'],
|
||||
['://missing-scheme', 'malformed'],
|
||||
]
|
||||
for (const [url, label] of cases) {
|
||||
test(`rejects ${label}: ${url}`, () => {
|
||||
expect(isValid(url)).toBe(false)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe(`env allowlist via ${ALLOWED_ORIGINS_ENV}`, () => {
|
||||
const g = globalThis as { process?: { env?: Record<string, string | undefined> } }
|
||||
const original = g.process?.env?.[ALLOWED_ORIGINS_ENV]
|
||||
|
||||
beforeEach(() => {
|
||||
if (g.process?.env) delete g.process.env[ALLOWED_ORIGINS_ENV]
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (!g.process?.env) return
|
||||
if (original === undefined) {
|
||||
delete g.process.env[ALLOWED_ORIGINS_ENV]
|
||||
} else {
|
||||
g.process.env[ALLOWED_ORIGINS_ENV] = original
|
||||
}
|
||||
})
|
||||
|
||||
test('single origin allowlist accepts matching https URL', () => {
|
||||
if (!g.process?.env) return // browser-only runtime
|
||||
g.process.env[ALLOWED_ORIGINS_ENV] = 'https://cdn.pascal.app'
|
||||
expect(isValid('https://cdn.pascal.app/a.glb')).toBe(true)
|
||||
expect(isValid('https://cdn.pascal.app/deep/path?q=1')).toBe(true)
|
||||
})
|
||||
|
||||
test('single origin allowlist rejects non-matching https URL', () => {
|
||||
if (!g.process?.env) return
|
||||
g.process.env[ALLOWED_ORIGINS_ENV] = 'https://cdn.pascal.app'
|
||||
expect(isValid('https://cdn.other.com/a.glb')).toBe(false)
|
||||
expect(isValid('https://attacker.example.com/x')).toBe(false)
|
||||
})
|
||||
|
||||
test('multi-origin allowlist accepts any listed origin', () => {
|
||||
if (!g.process?.env) return
|
||||
g.process.env[ALLOWED_ORIGINS_ENV] = 'https://cdn.pascal.app, https://assets.pascal.app'
|
||||
expect(isValid('https://cdn.pascal.app/a.glb')).toBe(true)
|
||||
expect(isValid('https://assets.pascal.app/tex.webp')).toBe(true)
|
||||
expect(isValid('https://third.example.com/x')).toBe(false)
|
||||
})
|
||||
|
||||
test('allowlist ignores trailing / in URL path (origin match only)', () => {
|
||||
if (!g.process?.env) return
|
||||
g.process.env[ALLOWED_ORIGINS_ENV] = 'https://cdn.pascal.app'
|
||||
expect(isValid('https://cdn.pascal.app/')).toBe(true)
|
||||
expect(isValid('https://cdn.pascal.app')).toBe(true)
|
||||
})
|
||||
|
||||
test('empty allowlist behaves like unset', () => {
|
||||
if (!g.process?.env) return
|
||||
g.process.env[ALLOWED_ORIGINS_ENV] = ''
|
||||
expect(isValid('https://cdn.other.com/a.glb')).toBe(true)
|
||||
})
|
||||
|
||||
test('allowlist does not restrict non-https schemes', () => {
|
||||
if (!g.process?.env) return
|
||||
g.process.env[ALLOWED_ORIGINS_ENV] = 'https://cdn.pascal.app'
|
||||
// these should still pass because they match earlier scheme-based branches
|
||||
expect(isValid('asset://x')).toBe(true)
|
||||
expect(isValid('blob:https://example.com/abc')).toBe(true)
|
||||
expect(isValid('data:image/png;base64,AAA')).toBe(true)
|
||||
expect(isValid('/public/a.glb')).toBe(true)
|
||||
expect(isValid('http://localhost:3000/x')).toBe(true)
|
||||
})
|
||||
|
||||
test('allowlist rejects subdomain spoofing', () => {
|
||||
if (!g.process?.env) return
|
||||
g.process.env[ALLOWED_ORIGINS_ENV] = 'https://cdn.pascal.app'
|
||||
expect(isValid('https://cdn.pascal.app.evil.com/x')).toBe(false)
|
||||
expect(isValid('https://evil.com/cdn.pascal.app')).toBe(false)
|
||||
})
|
||||
|
||||
test('allowlist respects ports', () => {
|
||||
if (!g.process?.env) return
|
||||
g.process.env[ALLOWED_ORIGINS_ENV] = 'https://cdn.pascal.app:8443'
|
||||
expect(isValid('https://cdn.pascal.app:8443/x')).toBe(true)
|
||||
expect(isValid('https://cdn.pascal.app/x')).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,79 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
/**
|
||||
* Scheme allowlist for asset-like URLs embedded in scene graphs.
|
||||
*
|
||||
* Phase 3 security audit: `scan.url`, `guide.url`, `material.texture.url`, and
|
||||
* `item.asset.src` were previously bare `z.string()`. That meant an
|
||||
* attacker-crafted scene loaded in the editor could beacon to arbitrary URLs
|
||||
* (e.g. `javascript:`, `file:///etc/passwd`, `http://169.254.169.254/...`).
|
||||
*
|
||||
* This validator rejects URLs that don't match the scheme allowlist below.
|
||||
*/
|
||||
const ALLOWED_SCHEMES = ['asset:', 'blob:', 'https:', 'data:image/'] as const
|
||||
|
||||
/**
|
||||
* Optional environment variable that narrows which `https:` origins are
|
||||
* accepted. Set to a comma-separated list (e.g. `https://cdn.pascal.app`).
|
||||
* When unset, any `https:` origin is permitted.
|
||||
*/
|
||||
export const ALLOWED_ORIGINS_ENV = 'PASCAL_ALLOWED_ASSET_ORIGINS'
|
||||
|
||||
// Narrow access to the environment variable without requiring @types/node in
|
||||
// this package. The core package ships to both browser and Node contexts.
|
||||
function readAllowedOrigins(): readonly string[] | undefined {
|
||||
const g = globalThis as { process?: { env?: Record<string, string | undefined> } }
|
||||
const value = g.process?.env?.[ALLOWED_ORIGINS_ENV]
|
||||
if (!value) return undefined
|
||||
const list = value
|
||||
.split(',')
|
||||
.map((s: string) => s.trim())
|
||||
.filter((s: string) => s.length > 0)
|
||||
return list.length > 0 ? list : undefined
|
||||
}
|
||||
|
||||
function isAllowedAssetUrl(url: string): boolean {
|
||||
if (typeof url !== 'string' || url.length === 0) return false
|
||||
if (url.startsWith('asset://')) return true // internal handle
|
||||
if (url.startsWith('blob:')) return true // in-memory reference
|
||||
if (url.startsWith('data:image/')) return true // inline image only (never data:text/html)
|
||||
if (url.startsWith('/')) return true // app-relative path
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') return false
|
||||
// http is only permitted for localhost development
|
||||
if (parsed.protocol === 'http:' && !['localhost', '127.0.0.1'].includes(parsed.hostname)) {
|
||||
return false
|
||||
}
|
||||
// optional env-driven origin allowlist (only enforced for https URLs)
|
||||
if (parsed.protocol === 'https:') {
|
||||
const allowlist = readAllowedOrigins()
|
||||
if (allowlist) return allowlist.includes(parsed.origin)
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zod validator for asset-style URL fields. Accepts:
|
||||
* - `asset://…` internal handles
|
||||
* - `blob:…` in-memory references
|
||||
* - `data:image/…` inline images (not `data:text/html` or other types)
|
||||
* - `/…` app-relative paths
|
||||
* - `https://…` public URLs (optionally narrowed to an env allowlist)
|
||||
* - `http://localhost[:port]/…` or `http://127.0.0.1/…` for local dev
|
||||
*
|
||||
* Rejects every other scheme, including `javascript:`, `file:`, `ftp:`,
|
||||
* and `data:text/html`, as well as empty strings and non-URL garbage.
|
||||
*/
|
||||
export const AssetUrl = z.string().refine(isAllowedAssetUrl, {
|
||||
message:
|
||||
'URL must be asset://, blob:, data:image/, /path, or https://. http://localhost allowed for dev.',
|
||||
})
|
||||
|
||||
export type AssetUrl = z.infer<typeof AssetUrl>
|
||||
|
||||
// re-export the scheme allowlist for documentation / downstream validators
|
||||
export { ALLOWED_SCHEMES }
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from 'zod'
|
||||
import { AssetUrl } from './asset-url'
|
||||
|
||||
export const MaterialPreset = z.enum([
|
||||
'white',
|
||||
@@ -30,7 +31,7 @@ export const MaterialSchema = z.object({
|
||||
properties: MaterialProperties.optional(),
|
||||
texture: z
|
||||
.object({
|
||||
url: z.string(),
|
||||
url: AssetUrl,
|
||||
repeat: z.tuple([z.number(), z.number()]).optional(),
|
||||
scale: z.number().optional(),
|
||||
})
|
||||
@@ -56,16 +57,16 @@ export const TextureWrapMode = z.enum(['Repeat', 'ClampToEdge', 'MirroredRepeat'
|
||||
export type TextureWrapMode = z.infer<typeof TextureWrapMode>
|
||||
|
||||
export const MaterialMapsSchema = z.object({
|
||||
albedoMap: z.string().optional(),
|
||||
metalnessMap: z.string().optional(),
|
||||
roughnessMap: z.string().optional(),
|
||||
normalMap: z.string().optional(),
|
||||
displacementMap: z.string().optional(),
|
||||
aoMap: z.string().optional(),
|
||||
emissiveMap: z.string().optional(),
|
||||
bumpMap: z.string().optional(),
|
||||
alphaMap: z.string().optional(),
|
||||
lightMap: z.string().optional(),
|
||||
albedoMap: AssetUrl.optional(),
|
||||
metalnessMap: AssetUrl.optional(),
|
||||
roughnessMap: AssetUrl.optional(),
|
||||
normalMap: AssetUrl.optional(),
|
||||
displacementMap: AssetUrl.optional(),
|
||||
aoMap: AssetUrl.optional(),
|
||||
emissiveMap: AssetUrl.optional(),
|
||||
bumpMap: AssetUrl.optional(),
|
||||
alphaMap: AssetUrl.optional(),
|
||||
lightMap: AssetUrl.optional(),
|
||||
})
|
||||
export type MaterialMaps = z.infer<typeof MaterialMapsSchema>
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { z } from 'zod'
|
||||
import { AssetUrl } from '../asset-url'
|
||||
import { BaseNode, nodeType, objectId } from '../base'
|
||||
|
||||
export const GuideNode = BaseNode.extend({
|
||||
id: objectId('guide'),
|
||||
type: nodeType('guide'),
|
||||
url: z.string(),
|
||||
url: AssetUrl,
|
||||
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||
rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||
scale: z.number().default(1),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import dedent from 'dedent'
|
||||
import { z } from 'zod'
|
||||
import { AssetUrl } from '../asset-url'
|
||||
import { BaseNode, nodeType, objectId } from '../base'
|
||||
import type { CollectionId } from '../collections'
|
||||
|
||||
@@ -79,7 +80,7 @@ const assetSchema = z.object({
|
||||
category: z.string(),
|
||||
name: z.string(),
|
||||
thumbnail: z.string(),
|
||||
src: z.string(),
|
||||
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(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
|
||||
@@ -4,6 +4,7 @@ import { BaseNode, nodeType, objectId } from '../base'
|
||||
import { CeilingNode } from './ceiling'
|
||||
import { FenceNode } from './fence'
|
||||
import { GuideNode } from './guide'
|
||||
import { ItemNode } from './item'
|
||||
import { RoofNode } from './roof'
|
||||
import { ScanNode } from './scan'
|
||||
import { SlabNode } from './slab'
|
||||
@@ -20,6 +21,7 @@ export const LevelNode = BaseNode.extend({
|
||||
z.union([
|
||||
WallNode.shape.id,
|
||||
FenceNode.shape.id,
|
||||
ItemNode.shape.id,
|
||||
ZoneNode.shape.id,
|
||||
SlabNode.shape.id,
|
||||
CeilingNode.shape.id,
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { z } from 'zod'
|
||||
import { AssetUrl } from '../asset-url'
|
||||
import { BaseNode, nodeType, objectId } from '../base'
|
||||
|
||||
export const ScanNode = BaseNode.extend({
|
||||
id: objectId('scan'),
|
||||
type: nodeType('scan'),
|
||||
url: z.string(),
|
||||
url: AssetUrl,
|
||||
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||
rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||
scale: z.number().default(1),
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// @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'
|
||||
import { syncAutoStairOpenings } from './stair-opening-sync'
|
||||
|
||||
describe('syncAutoStairOpenings', () => {
|
||||
test('only applies stair holes to destination slabs that contain the opening', () => {
|
||||
const building = BuildingNode.parse({ name: 'Building' })
|
||||
const ground = LevelNode.parse({ name: 'Ground', level: 0, parentId: building.id })
|
||||
const upper = LevelNode.parse({ name: 'Upper', level: 1, parentId: building.id })
|
||||
const landingSlab = SlabNode.parse({
|
||||
name: 'Landing Slab',
|
||||
parentId: upper.id,
|
||||
polygon: [
|
||||
[0, 0],
|
||||
[4, 0],
|
||||
[4, 3],
|
||||
[0, 3],
|
||||
],
|
||||
})
|
||||
const bedroomSlab = SlabNode.parse({
|
||||
name: 'Bedroom Slab',
|
||||
parentId: upper.id,
|
||||
polygon: [
|
||||
[4, 0],
|
||||
[8, 0],
|
||||
[8, 3],
|
||||
[4, 3],
|
||||
],
|
||||
})
|
||||
const segment = StairSegmentNode.parse({
|
||||
parentId: 'stair_main',
|
||||
width: 1,
|
||||
length: 2.6,
|
||||
height: 2.5,
|
||||
stepCount: 12,
|
||||
})
|
||||
const stair = StairNode.parse({
|
||||
id: 'stair_main',
|
||||
name: 'Main Stair',
|
||||
parentId: ground.id,
|
||||
position: [2, 0, 0.2],
|
||||
stairType: 'straight',
|
||||
fromLevelId: ground.id,
|
||||
toLevelId: upper.id,
|
||||
slabOpeningMode: 'destination',
|
||||
children: [segment.id],
|
||||
})
|
||||
const nodes = Object.fromEntries(
|
||||
[
|
||||
building,
|
||||
ground,
|
||||
upper,
|
||||
landingSlab,
|
||||
bedroomSlab,
|
||||
stair,
|
||||
{ ...segment, parentId: stair.id },
|
||||
].map((node) => [node.id, node]),
|
||||
) as Record<string, AnyNode>
|
||||
|
||||
const updates = syncAutoStairOpenings(nodes)
|
||||
const landingUpdate = updates.find((update) => update.id === landingSlab.id)
|
||||
const bedroomUpdate = updates.find((update) => update.id === bedroomSlab.id)
|
||||
|
||||
expect(landingUpdate?.data.holes).toHaveLength(1)
|
||||
expect(landingUpdate?.data.holeMetadata).toEqual([{ source: 'stair', stairId: stair.id }])
|
||||
expect(bedroomUpdate).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -7,7 +7,16 @@ import type {
|
||||
StairNode,
|
||||
StairSegmentNode,
|
||||
} from '../../schema'
|
||||
|
||||
import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync'
|
||||
import type {
|
||||
AnyNode,
|
||||
AnyNodeId,
|
||||
CeilingNode,
|
||||
SlabNode,
|
||||
StairNode,
|
||||
StairSegmentNode,
|
||||
} from '../../schema'
|
||||
import { DEFAULT_WALL_HEIGHT } from '../wall/wall-footprint'
|
||||
|
||||
type Point2D = [number, number]
|
||||
@@ -285,6 +294,36 @@ function polygonArea(points: Point2D[]) {
|
||||
return area / 2
|
||||
}
|
||||
|
||||
function pointOnSegment(point: Point2D, a: Point2D, b: Point2D, tolerance = 1e-6) {
|
||||
const cross = (point[1] - a[1]) * (b[0] - a[0]) - (point[0] - a[0]) * (b[1] - a[1])
|
||||
if (Math.abs(cross) > tolerance) return false
|
||||
const dot = (point[0] - a[0]) * (b[0] - a[0]) + (point[1] - a[1]) * (b[1] - a[1])
|
||||
if (dot < -tolerance) return false
|
||||
const lenSq = (b[0] - a[0]) ** 2 + (b[1] - a[1]) ** 2
|
||||
return dot <= lenSq + tolerance
|
||||
}
|
||||
|
||||
function pointInPolygon(point: Point2D, polygon: Point2D[]) {
|
||||
if (polygon.length < 3) return false
|
||||
let inside = false
|
||||
const [x, z] = point
|
||||
|
||||
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
||||
const a = polygon[i]!
|
||||
const b = polygon[j]!
|
||||
if (pointOnSegment(point, a, b)) return true
|
||||
const intersects =
|
||||
a[1] > z !== b[1] > z && x < ((b[0] - a[0]) * (z - a[1])) / (b[1] - a[1]) + a[0]
|
||||
if (intersects) inside = !inside
|
||||
}
|
||||
|
||||
return inside
|
||||
}
|
||||
|
||||
function polygonContainsPolygon(outer: Point2D[], inner: Point2D[]) {
|
||||
return inner.every((point) => pointInPolygon(point, outer))
|
||||
}
|
||||
|
||||
function getAxisAlignedRectFromPolygon(polygon: Point2D[]): AxisAlignedRect | null {
|
||||
if (polygon.length < 4) return null
|
||||
const xs = polygon.map(([x]) => x)
|
||||
@@ -444,6 +483,7 @@ function buildArcOpeningPolygon(
|
||||
for (let index = segmentCount; index >= 0; index--) {
|
||||
const t = index / segmentCount
|
||||
const angle = startAngle + sweep * t
|
||||
|
||||
innerPoints.push(
|
||||
toWorldPlanPoint(stair, Math.cos(angle) * innerRadius, Math.sin(angle) * innerRadius),
|
||||
)
|
||||
@@ -737,6 +777,7 @@ export function syncAutoStairOpenings(nodes: Record<string, AnyNode>) {
|
||||
},
|
||||
})),
|
||||
)
|
||||
.filter((hole) => polygonContainsPolygon(slab.polygon, hole.polygon))
|
||||
|
||||
const nextHoles = [...manualHoles, ...stairHoles.map((hole) => hole.polygon)]
|
||||
const nextMetadata = [...manualMetadata, ...stairHoles.map((hole) => hole.metadata)]
|
||||
@@ -787,6 +828,7 @@ export function syncAutoStairOpenings(nodes: Record<string, AnyNode>) {
|
||||
},
|
||||
})),
|
||||
)
|
||||
.filter((hole) => polygonContainsPolygon(ceiling.polygon, hole.polygon))
|
||||
|
||||
const nextHoles = [...manualHoles, ...stairHoles.map((hole) => hole.polygon)]
|
||||
const nextMetadata = [...manualMetadata, ...stairHoles.map((hole) => hole.metadata)]
|
||||
|
||||
Reference in New Issue
Block a user