Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@ const config: Config = {
transformIgnorePatterns: [
'/node_modules/(?!.*mtext-parser)'
],
testPathIgnorePatterns: ['packages/dxf-json/', '/e2e/'],
testPathIgnorePatterns: [
'/e2e/',
'/__tests__/helpers/'
],
moduleNameMapper: {
'^lodash-es$': 'lodash',
'^three/examples/jsm/lines/LineMaterial\\.js$':
Expand Down
6 changes: 3 additions & 3 deletions packages/cad-simple-viewer/__tests__/AcTrLayout.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,14 +104,14 @@ function createLayerInfo(name = '0') {
function createEntity(
objectId: string,
layerName = '0'
): AcTrEntity & { box: THREE.Box3 } {
): AcTrEntity & { wcsBbox: THREE.Box3 } {
return {
objectId,
layerName,
ownerId: 'layout-1',
userData: {},
box: new THREE.Box3(new THREE.Vector3(1, 1, 0), new THREE.Vector3(2, 2, 0))
} as AcTrEntity & { box: THREE.Box3 }
wcsBbox: new THREE.Box3(new THREE.Vector3(1, 1, 0), new THREE.Vector3(2, 2, 0))
} as AcTrEntity & { wcsBbox: THREE.Box3 }
}

describe('AcTrLayout bounding box', () => {
Expand Down
173 changes: 173 additions & 0 deletions packages/cad-simple-viewer/__tests__/AcTrView2d.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
jest.mock('rbush', () => {
class RBushMock<
T extends { minX: number; minY: number; maxX: number; maxY: number }
> {
private items: T[] = []

insert(item: T) {
this.items.push(item)
}

load(items: readonly T[]) {
this.items.push(...items)
}

remove(item: T, equals?: (a: T, b: T) => boolean) {
if (equals) {
const index = this.items.findIndex(entry => equals(entry, item))
if (index >= 0) this.items.splice(index, 1)
return
}
const index = this.items.indexOf(item)
if (index >= 0) this.items.splice(index, 1)
}

clear() {
this.items = []
}

search(bbox: { minX: number; minY: number; maxX: number; maxY: number }) {
return this.items.filter(
item =>
!(
item.maxX < bbox.minX ||
item.minX > bbox.maxX ||
item.maxY < bbox.minY ||
item.minY > bbox.maxY
)
)
}

collides(bbox: { minX: number; minY: number; maxX: number; maxY: number }) {
return this.search(bbox).length > 0
}

all() {
return [...this.items]
}
}

return {
__esModule: true,
default: RBushMock
}
})

import { AcCmColor, AcGeBox2d } from '@mlightcad/data-model'
import type { AcTrEntity } from '@mlightcad/three-renderer'
import * as THREE from 'three'

import {
assertGroupWcsBboxesConsistent,
unionGroupWcsChildBoxes
} from '../src/view/AcTrGroupWcsBboxAssert'
import { AcTrLayout } from '../src/view/AcTrLayout'

function createLayerInfo(name: string) {
return {
name,
isFrozen: false,
isOff: false,
color: new AcCmColor()
}
}

function createHandleGroupEntity(
objectId: string,
layerName: string,
wcsBbox: THREE.Box3,
spatialIndexChildBoxes: Array<{
minX: number
minY: number
maxX: number
maxY: number
id: string
}>
): AcTrEntity {
return {
objectId,
ownerId: 'layout-1',
layerName,
userData: { spatialIndexChildBoxes },
wcsBbox
} as unknown as AcTrEntity
}

describe('AcTrGroupWcsBboxAssert', () => {
it('passes when wcsBbox equals the union of wcsChildBoxes', () => {
const group = {
wcsBbox: new THREE.Box3(new THREE.Vector3(0, 0, 0), new THREE.Vector3(10, 15, 0)),
wcsChildBoxes: [
{ minX: 0, minY: 0, maxX: 10, maxY: 0, id: 'line-a' },
{ minX: 2, minY: 5, maxX: 8, maxY: 15, id: 'line-b' }
]
}

expect(() => assertGroupWcsBboxesConsistent(group)).not.toThrow()
expect(unionGroupWcsChildBoxes(group).min).toMatchObject({ x: 0, y: 0 })
})

it('throws when wcsBbox diverges from wcsChildBoxes', () => {
const group = {
wcsBbox: new THREE.Box3(new THREE.Vector3(999, 999, 0), new THREE.Vector3(10, 5, 0)),
wcsChildBoxes: [{ minX: 0, minY: 0, maxX: 10, maxY: 5, id: 'line-a' }]
}

expect(() => assertGroupWcsBboxesConsistent(group)).toThrow(
/does not match wcsChildBoxes union/
)
})
})

describe('handleGroup WCS spatial index registration', () => {
it('indexes pre-transformed WCS bounds without applying group.matrix again', () => {
const insertWcsBbox = new THREE.Box3(
new THREE.Vector3(100, 200, 0),
new THREE.Vector3(115, 210, 0)
)
const spatialIndexChildBoxes = [
{ minX: 100, minY: 200, maxX: 110, maxY: 200, id: 'line-0' },
{ minX: 105, minY: 205, maxX: 115, maxY: 210, id: 'line-l2' }
]

assertGroupWcsBboxesConsistent({
wcsBbox: insertWcsBbox,
wcsChildBoxes: spatialIndexChildBoxes
})

const entity = createHandleGroupEntity(
'INSERT-1',
'L2',
insertWcsBbox,
spatialIndexChildBoxes
)

const layout = new AcTrLayout()
layout.addLayer(createLayerInfo('L2'))
layout.addEntity(entity)

const queryBox = new AcGeBox2d()
queryBox.min.set(109, 204)
queryBox.max.set(111, 206)

const hits = layout.search(queryBox)

expect(hits).toHaveLength(1)
expect(hits[0].id).toBe('INSERT-1')
expect(hits[0].children?.map(item => item.id)).toEqual(['line-l2'])
})

it('documents that re-applying group.matrix would double-transform WCS bounds', () => {
const wcsBbox = new THREE.Box3(
new THREE.Vector3(100, 200, 0),
new THREE.Vector3(110, 205, 0)
)
const insertMatrix = new THREE.Matrix4().makeTranslation(100, 200, 0)

const wronglyTransformed = wcsBbox.clone()
wronglyTransformed.applyMatrix4(insertMatrix)

expect(wronglyTransformed.min.x).toBeCloseTo(200)
expect(wcsBbox.min.x).toBeCloseTo(100)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -266,13 +266,13 @@ export class AcTrHierarchicalSpatialIndex implements AcTrSpatialIndex {
* Creates or retrieves the child index for a group object.
*
* This is a convenience wrapper around `ensureChildIndex`, using the group's
* `objectId` and `boxes` as id and initialization data.
* `objectId` and {@link AcTrGroup.wcsChildBoxes} as id and initialization data.
*
* @param group Group providing id and child box items.
* @returns Existing or newly created child index, or `undefined` when empty.
*/
createChildIndex(group: AcTrGroup) {
return this.ensureChildIndex(group.objectId, group.boxes)
return this.ensureChildIndex(group.objectId, group.wcsChildBoxes)
}

/**
Expand Down
67 changes: 67 additions & 0 deletions packages/cad-simple-viewer/src/view/AcTrGroupWcsBboxAssert.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import type { AcTrGroup } from '@mlightcad/three-renderer'
import * as THREE from 'three'

const WCS_BBOX_TOLERANCE = 1e-5

export interface AcTrGroupWcsBboxLike {
wcsBbox: THREE.Box3
wcsChildBoxes: ReadonlyArray<{
minX: number
minY: number
maxX: number
maxY: number
}>
}

/**
* Unions per-child WCS boxes into one axis-aligned WCS box.
*/
export function unionGroupWcsChildBoxes(group: AcTrGroupWcsBboxLike): THREE.Box3 {
const union = new THREE.Box3()
for (const box of group.wcsChildBoxes) {
union.union(
new THREE.Box3(
new THREE.Vector3(box.minX, box.minY, 0),
new THREE.Vector3(box.maxX, box.maxY, 0)
)
)
}
return union
}

/**
* Verifies that a block group's aggregate {@link AcTrGroup.wcsBbox} matches the
* union of its per-child {@link AcTrGroup.wcsChildBoxes}.
*
* Called from {@link AcTrView2d.handleGroup} in non-production builds. After
* {@link AcDbRenderingCache.draw} applies the INSERT transform via
* `applyMatrix`, both fields must stay in sync for spatial indexing.
*/
export function assertGroupWcsBboxesConsistent(group: AcTrGroupWcsBboxLike): void {
if (group.wcsChildBoxes.length === 0) {
return
}

const union = unionGroupWcsChildBoxes(group)
const wcs = group.wcsBbox
const cornersMatch =
Math.abs(wcs.min.x - union.min.x) <= WCS_BBOX_TOLERANCE &&
Math.abs(wcs.min.y - union.min.y) <= WCS_BBOX_TOLERANCE &&
Math.abs(wcs.min.z - union.min.z) <= WCS_BBOX_TOLERANCE &&
Math.abs(wcs.max.x - union.max.x) <= WCS_BBOX_TOLERANCE &&
Math.abs(wcs.max.y - union.max.y) <= WCS_BBOX_TOLERANCE &&
Math.abs(wcs.max.z - union.max.z) <= WCS_BBOX_TOLERANCE

if (!cornersMatch) {
throw new Error(
`[AcTrView2d] Group wcsBbox [${wcs.min.x}, ${wcs.min.y}]–[${wcs.max.x}, ${wcs.max.y}] ` +
`does not match wcsChildBoxes union [${union.min.x}, ${union.min.y}]–[${union.max.x}, ${union.max.y}]. ` +
'Ensure AcDbRenderingCache.draw applied the INSERT transform before handleGroup.'
)
}
}

/** @internal Narrow helper for handleGroup dev checks. */
export function assertAcTrGroupWcsBboxesConsistent(group: AcTrGroup): void {
assertGroupWcsBboxesConsistent(group)
}
4 changes: 2 additions & 2 deletions packages/cad-simple-viewer/src/view/AcTrLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ export class AcTrLayout {
* Gets the bounding box that contains all entities in this layout.
*
* Derived from packed batch vertex buffers (same source as GPU draw data),
* not from accumulated {@link AcTrEntity.box} metadata.
* not from accumulated {@link AcTrEntity.wcsBbox} metadata.
*
* @returns The layout's bounding box
*/
Expand Down Expand Up @@ -587,7 +587,7 @@ export class AcTrLayout {
}

private registerEntitySpatialIndex(entity: AcTrEntity) {
const box = entity.box
const box = entity.wcsBbox
this._spatialIndex.insert({
minX: box.min.x,
minY: box.min.y,
Expand Down
50 changes: 18 additions & 32 deletions packages/cad-simple-viewer/src/view/AcTrView2d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import {
} from '../editor/global/AcEdUiColor'
import { AcTrGeometryUtil } from '../util'
import { AcTrEntityDisplayController } from './AcTrEntityDisplayController'
import { assertAcTrGroupWcsBboxesConsistent } from './AcTrGroupWcsBboxAssert'
import { AcTrLayer } from './AcTrLayer'
import { AcTrLayoutView } from './AcTrLayoutView'
import { AcTrLayoutViewManager } from './AcTrLayoutViewManager'
Expand Down Expand Up @@ -1965,37 +1966,22 @@ export class AcTrView2d extends AcEdBaseView {
const renderContext = group.renderContext
const groupObjectId = group.objectId
const groupLayerName = group.layerName
const worldGroupBox = group.box.clone()
worldGroupBox.applyMatrix4(group.matrix)
const groupChildBoxes: AcEdSpatialQueryResultItem[] = group.boxes.map(
box => {
const points = [
new THREE.Vector3(box.minX, box.minY, 0),
new THREE.Vector3(box.maxX, box.minY, 0),
new THREE.Vector3(box.maxX, box.maxY, 0),
new THREE.Vector3(box.minX, box.maxY, 0)
]
for (const point of points) {
point.applyMatrix4(group.matrix)
}
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
for (const point of points) {
minX = Math.min(minX, point.x)
minY = Math.min(minY, point.y)
maxX = Math.max(maxX, point.x)
maxY = Math.max(maxY, point.y)
}
return {
minX,
minY,
maxX,
maxY,
id: box.id
}
}

// AcDbRenderingCache.draw (and similar paths such as AcDbTable) already call
// applyMatrix on the group, which updates wcsBbbox and wcsChildBoxes to WCS.
// Do not multiply group.matrix here — that would double-transform spatial bounds.
if (process.env.NODE_ENV !== 'production') {
assertAcTrGroupWcsBboxesConsistent(group)
}

const groupChildBoxes: AcEdSpatialQueryResultItem[] = group.wcsChildBoxes.map(
box => ({
minX: box.minX,
minY: box.minY,
maxX: box.maxX,
maxY: box.maxY,
id: box.id
})
)
objectsGroupByLayer.forEach((objects, layerName) => {
// AutoCAD block rule: entities authored on layer "0" inherit the INSERT's layer.
Expand All @@ -2018,7 +2004,7 @@ export class AcTrView2d extends AcEdBaseView {
// If block-definition entities are on layer "0", this bucket now uses the layer
// of the block reference itself (effectiveLayerName).
entity.layerName = effectiveLayerName
entity.box = worldGroupBox
entity.wcsBbox = group.wcsBbox.clone()
const entityUserData = entity.userData as {
spatialIndexChildBoxes?: AcEdSpatialQueryResultItem[]
}
Expand Down
Loading